diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/side-panel/components/AdvancedFilterSidePanelValueFormInput.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/side-panel/components/AdvancedFilterSidePanelValueFormInput.tsx index c86d1feb58..837f928317 100644 --- a/packages/twenty-front/src/modules/object-record/advanced-filter/side-panel/components/AdvancedFilterSidePanelValueFormInput.tsx +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/side-panel/components/AdvancedFilterSidePanelValueFormInput.tsx @@ -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; diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/side-panel/components/__tests__/AdvancedFilterSidePanelValueFormInput.test.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/side-panel/components/__tests__/AdvancedFilterSidePanelValueFormInput.test.tsx new file mode 100644 index 0000000000..8714174662 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/side-panel/components/__tests__/AdvancedFilterSidePanelValueFormInput.test.tsx @@ -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 }[]; + }) =>
{options.length}
, + }), +); + +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 = () => ( + + + + + + + + + + + +); + +describe('AdvancedFilterSidePanelValueFormInput', () => { + it('resolves select options from the relation target field for a relation-traversal filter', async () => { + const { getByTestId } = render(); + + await waitFor(() => { + expect(getByTestId('multiselect-options-count').textContent).toBe( + String(selectField.options?.length), + ); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOptionSelect.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOptionSelect.tsx index 39e2e32f84..6947b25111 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOptionSelect.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOptionSelect.tsx @@ -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[] diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/__tests__/ObjectFilterDropdownOptionSelect.test.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/__tests__/ObjectFilterDropdownOptionSelect.test.tsx new file mode 100644 index 0000000000..b961d84910 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/__tests__/ObjectFilterDropdownOptionSelect.test.tsx @@ -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 = () => ( + + + + + + + +); + +describe('ObjectFilterDropdownOptionSelect', () => { + it('renders the relation target field options for a relation-traversal filter', async () => { + const { findByText, getByText } = render(); + + const [firstOption, ...otherOptions] = selectField.options ?? []; + + await findByText(firstOption.label); + + for (const option of otherOptions) { + expect(getByText(option.label)).toBeInTheDocument(); + } + }); +});