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:
+18
-3
@@ -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;
|
||||
|
||||
|
||||
+118
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+15
-1
@@ -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[]
|
||||
|
||||
+118
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user