Fix relation-traversal filters showing no options on select fields (#21616)

Relation-traversal filters (e.g. *Company → Industry*) on
`SELECT`/`MULTI_SELECT` fields rendered an empty option picker, so the
filter couldn't be configured.

The value inputs resolved the select options from the **source relation
field** (which has no options) instead of the **relation target field**.

Fixed in both filter UIs:
- Dashboard chart filters and workflow "Find Records"
(`AdvancedFilterSidePanelValueFormInput`)
- Record-index simple & advanced filters and the role-permission filter
builder (`ObjectFilterDropdownOptionSelect`)

Both now resolve the value-input field and its options from
`relationTargetFieldMetadataId` when a filter traverses a relation,
falling back to the source field otherwise.

## Before

<img width="802" height="702" alt="CleanShot 2026-06-15 at 17 27 35@2x"
src="https://github.com/user-attachments/assets/032b0875-8a5a-4e09-a2ad-4c4a8a319f49"
/>

## After

<img width="804" height="796" alt="CleanShot 2026-06-15 at 17 27 05@2x"
src="https://github.com/user-attachments/assets/88248409-5781-49c7-aa9e-3502ff8c68e4"
/>

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21616?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Raphaël Bosi
2026-06-15 18:00:49 +02:00
committed by GitHub
parent f77d8da8a7
commit 504eaa5600
4 changed files with 269 additions and 4 deletions
@@ -1,3 +1,4 @@
import { fieldMetadataItemByIdSelector } from '@/object-metadata/states/fieldMetadataItemByIdSelector';
import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
import { AdvancedFilterSidePanelValueFormCompositeFieldInput } from '@/object-record/advanced-filter/side-panel/components/AdvancedFilterSidePanelValueFormCompositeFieldInput';
import { AdvancedFilterContext } from '@/object-record/advanced-filter/states/context/AdvancedFilterContext';
@@ -20,6 +21,7 @@ import {
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { stringifyRelativeDateFilter } from '@/views/view-filter-value/utils/stringifyRelativeDateFilter';
import { WORKFLOW_TIMEZONE } from '@/workflow/constants/WorkflowTimeZone';
@@ -91,10 +93,23 @@ export const AdvancedFilterSidePanelValueFormInput = ({
dropdownInstanceId,
);
const fieldDefinition = fieldMetadataItemUsedInDropdown
const {
foundFieldMetadataItem: relationTargetFieldMetadataItem,
foundObjectMetadataItem: relationTargetObjectMetadataItem,
} = useAtomFamilySelectorValue(fieldMetadataItemByIdSelector, {
fieldMetadataItemId: recordFilter?.relationTargetFieldMetadataId ?? '',
});
const fieldMetadataItemForValueInput =
relationTargetFieldMetadataItem ?? fieldMetadataItemUsedInDropdown;
const objectMetadataItemForValueInput =
relationTargetObjectMetadataItem ?? objectMetadataItem;
const fieldDefinition = fieldMetadataItemForValueInput
? formatFieldMetadataItemAsFieldDefinition({
field: fieldMetadataItemUsedInDropdown,
objectMetadataItem: objectMetadataItem,
field: fieldMetadataItemForValueInput,
objectMetadataItem: objectMetadataItemForValueInput,
})
: null;
@@ -0,0 +1,118 @@
import { render, waitFor } from '@testing-library/react';
import { useEffect, useState } from 'react';
import { AdvancedFilterSidePanelValueFormInput } from '@/object-record/advanced-filter/side-panel/components/AdvancedFilterSidePanelValueFormInput';
import { AdvancedFilterContext } from '@/object-record/advanced-filter/states/context/AdvancedFilterContext';
import { getAdvancedFilterObjectFilterDropdownComponentInstanceId } from '@/object-record/advanced-filter/utils/getAdvancedFilterObjectFilterDropdownComponentInstanceId';
import { ObjectFilterDropdownComponentInstanceContext } from '@/object-record/object-filter-dropdown/states/contexts/ObjectFilterDropdownComponentInstanceContext';
import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState';
import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { FieldMetadataType, ViewFilterOperand } from 'twenty-shared/types';
import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
jest.mock('@/object-record/record-field/ui/components/FormFieldInput', () => ({
FormFieldInput: () => null,
}));
jest.mock(
'@/object-record/record-field/ui/form-types/components/FormMultiSelectFieldInput',
() => ({
FormMultiSelectFieldInput: ({
options,
}: {
options: { value: string }[];
}) => <div data-testid="multiselect-options-count">{options.length}</div>,
}),
);
const INSTANCE_ID = 'advanced-filter-value-test';
const FILTER_ID = 'relation-select-filter';
const opportunity = getMockObjectMetadataItemOrThrow('opportunity');
const relationField = opportunity.fields.find(
(field) => field.type === FieldMetadataType.RELATION,
);
const selectField = opportunity.fields.find(
(field) =>
field.type === FieldMetadataType.SELECT && (field.options?.length ?? 0) > 0,
);
if (!relationField || !selectField) {
throw new Error('Missing relation or select field in opportunity mock');
}
const relationToSelectFilter: RecordFilter = {
id: FILTER_ID,
fieldMetadataId: relationField.id,
relationTargetFieldMetadataId: selectField.id,
type: FieldMetadataType.SELECT,
operand: ViewFilterOperand.IS,
value: '',
displayValue: '',
label: `${relationField.label}${selectField.label}`,
};
const BaseWrapper = getJestMetadataAndApolloMocksWrapper({ apolloMocks: [] });
const Seed = ({ children }: { children: React.ReactNode }) => {
const setCurrentRecordFilters = useSetAtomComponentState(
currentRecordFiltersComponentState,
);
const setFieldMetadataItemIdUsedInDropdown = useSetAtomComponentState(
fieldMetadataItemIdUsedInDropdownComponentState,
getAdvancedFilterObjectFilterDropdownComponentInstanceId(FILTER_ID),
);
const [isSeeded, setIsSeeded] = useState(false);
useEffect(() => {
setCurrentRecordFilters([relationToSelectFilter]);
setFieldMetadataItemIdUsedInDropdown(relationField.id);
setIsSeeded(true);
}, [setCurrentRecordFilters, setFieldMetadataItemIdUsedInDropdown]);
return isSeeded ? <>{children}</> : null;
};
const Wrapper = () => (
<BaseWrapper>
<AdvancedFilterContext.Provider
value={{ objectMetadataItem: opportunity, isWorkflowFindRecords: false }}
>
<RecordFiltersComponentInstanceContext.Provider
value={{ instanceId: INSTANCE_ID }}
>
<ObjectFilterDropdownComponentInstanceContext.Provider
value={{
instanceId:
getAdvancedFilterObjectFilterDropdownComponentInstanceId(
FILTER_ID,
),
}}
>
<Seed>
<AdvancedFilterSidePanelValueFormInput recordFilterId={FILTER_ID} />
</Seed>
</ObjectFilterDropdownComponentInstanceContext.Provider>
</RecordFiltersComponentInstanceContext.Provider>
</AdvancedFilterContext.Provider>
</BaseWrapper>
);
describe('AdvancedFilterSidePanelValueFormInput', () => {
it('resolves select options from the relation target field for a relation-traversal filter', async () => {
const { getByTestId } = render(<Wrapper />);
await waitFor(() => {
expect(getByTestId('multiselect-options-count').textContent).toBe(
String(selectField.options?.length),
);
});
});
});
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Key } from 'ts-key-enum';
import { fieldMetadataItemByIdSelector } from '@/object-metadata/states/fieldMetadataItemByIdSelector';
import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
import { useOptionsForSelect } from '@/object-record/object-filter-dropdown/hooks/useOptionsForSelect';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
@@ -19,6 +20,7 @@ import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotke
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { MAX_OPTIONS_TO_DISPLAY } from 'twenty-shared/constants';
@@ -76,9 +78,21 @@ export const ObjectFilterDropdownOptionSelect = ({
componentInstanceId,
);
const { foundFieldMetadataItem: relationTargetFieldMetadataItem } =
useAtomFamilySelectorValue(fieldMetadataItemByIdSelector, {
fieldMetadataItemId:
objectFilterDropdownCurrentRecordFilter?.relationTargetFieldMetadataId ??
'',
});
const fieldMetaDataId = fieldMetadataItemUsedInDropdown?.id ?? '';
const { selectOptions } = useOptionsForSelect(fieldMetaDataId);
const { selectOptions: sourceFieldSelectOptions } =
useOptionsForSelect(fieldMetaDataId);
const selectOptions = isDefined(relationTargetFieldMetadataItem)
? relationTargetFieldMetadataItem.options
: sourceFieldSelectOptions;
const [selectableOptions, setSelectableOptions] = useState<
SelectOptionForFilter[]
@@ -0,0 +1,118 @@
import { render } from '@testing-library/react';
import { useEffect, useState } from 'react';
import { ObjectFilterDropdownOptionSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownOptionSelect';
import { ObjectFilterDropdownComponentInstanceContext } from '@/object-record/object-filter-dropdown/states/contexts/ObjectFilterDropdownComponentInstanceContext';
import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState';
import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { FieldMetadataType, ViewFilterOperand } from 'twenty-shared/types';
import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
jest.mock(
'@/object-record/object-filter-dropdown/hooks/useOptionsForSelect',
() => ({
useOptionsForSelect: () => ({ selectOptions: [] }),
}),
);
jest.mock('@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement', () => ({
useHotkeysOnFocusedElement: () => undefined,
}));
jest.mock('@/ui/layout/dropdown/hooks/useCloseDropdown', () => ({
useCloseDropdown: () => ({ closeDropdown: jest.fn() }),
}));
jest.mock('@/ui/layout/selectable-list/hooks/useSelectableList', () => ({
useSelectableList: () => ({ resetSelectedItem: jest.fn() }),
}));
jest.mock(
'@/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownFilterValue',
() => ({
useApplyObjectFilterDropdownFilterValue: () => ({
applyObjectFilterDropdownFilterValue: jest.fn(),
}),
}),
);
const INSTANCE_ID = 'object-filter-dropdown-option-select-test';
const opportunity = getMockObjectMetadataItemOrThrow('opportunity');
const relationField = opportunity.fields.find(
(field) => field.type === FieldMetadataType.RELATION,
);
const selectField = opportunity.fields.find(
(field) =>
field.type === FieldMetadataType.SELECT && (field.options?.length ?? 0) > 0,
);
if (!relationField || !selectField) {
throw new Error('Missing relation or select field in opportunity mock');
}
const relationToSelectFilter: RecordFilter = {
id: 'relation-select-filter',
fieldMetadataId: relationField.id,
relationTargetFieldMetadataId: selectField.id,
type: FieldMetadataType.SELECT,
operand: ViewFilterOperand.IS,
value: '',
displayValue: '',
label: `${relationField.label}${selectField.label}`,
};
const BaseWrapper = getJestMetadataAndApolloMocksWrapper({ apolloMocks: [] });
const Seed = ({ children }: { children: React.ReactNode }) => {
const setFieldMetadataItemIdUsedInDropdown = useSetAtomComponentState(
fieldMetadataItemIdUsedInDropdownComponentState,
);
const setObjectFilterDropdownCurrentRecordFilter = useSetAtomComponentState(
objectFilterDropdownCurrentRecordFilterComponentState,
);
const [isSeeded, setIsSeeded] = useState(false);
useEffect(() => {
setFieldMetadataItemIdUsedInDropdown(relationField.id);
setObjectFilterDropdownCurrentRecordFilter(relationToSelectFilter);
setIsSeeded(true);
}, [
setFieldMetadataItemIdUsedInDropdown,
setObjectFilterDropdownCurrentRecordFilter,
]);
return isSeeded ? <>{children}</> : null;
};
const Wrapper = () => (
<BaseWrapper>
<ObjectFilterDropdownComponentInstanceContext.Provider
value={{ instanceId: INSTANCE_ID }}
>
<Seed>
<ObjectFilterDropdownOptionSelect focusId="test-focus" />
</Seed>
</ObjectFilterDropdownComponentInstanceContext.Provider>
</BaseWrapper>
);
describe('ObjectFilterDropdownOptionSelect', () => {
it('renders the relation target field options for a relation-traversal filter', async () => {
const { findByText, getByText } = render(<Wrapper />);
const [firstOption, ...otherOptions] = selectField.options ?? [];
await findByText(firstOption.label);
for (const option of otherOptions) {
expect(getByText(option.label)).toBeInTheDocument();
}
});
});