From 1e336dbad1f1cbcd732f11e52ad65b71f3544c61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Tue, 2 Jun 2026 15:52:08 +0200 Subject: [PATCH] feat: allow many-to-one relations as advanced filter leaves (#21147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Lets a many-to-one relation be selected as the **leaf** of an advanced (nested) filter. Previously the nested-field submenu excluded relations, so you could filter `Opportunities WHERE company.Name contains X` but not `Opportunities WHERE company.accountOwner = me`. ## How it works Selecting a relation leaf filters by its **foreign key** — `company.accountOwnerId = X` — a single hop the backend already resolves on the joined table (`{ company: { accountOwnerId: { in: [...] } } }`). It is **not** a multi-hop traversal: filtering on a *scalar field of* the related record (e.g. `company.accountOwner.name`) stays excluded, since that needs a second join the backend caps at one hop. Two changes: - **`AdvancedFilterRelationTargetFieldSelectMenu`** — stop excluding many-to-one relations from the nested-field submenu. - **`ObjectFilterDropdownRecordSelect`** — resolve the record picker's object from the *leaf* relation's target (e.g. WorkspaceMember, including the "Me" pin) rather than the source relation's object. The source-field fallback applies only when there is no leaf. ## Testing - Added `turnRecordFilterIntoRecordGqlOperationFilter` unit cases asserting a relation leaf (and `= me`) compiles to the FK form — 59/59. - typecheck + lint green (twenty-front, twenty-shared). Seeding an onboarding view that uses this filter will follow in a separate PR. --- ...cedFilterRelationTargetFieldSelectMenu.tsx | 10 +-- .../ObjectFilterDropdownRecordSelect.tsx | 89 +++++++++++++++---- ...RecordFilterIntoGqlOperationFilter.test.ts | 59 ++++++++++++ 3 files changed, 131 insertions(+), 27 deletions(-) diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRelationTargetFieldSelectMenu.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRelationTargetFieldSelectMenu.tsx index 6467a32277..eca26dd428 100644 --- a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRelationTargetFieldSelectMenu.tsx +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRelationTargetFieldSelectMenu.tsx @@ -62,17 +62,9 @@ export const AdvancedFilterRelationTargetFieldSelectMenu = ({ ? sourceFieldMetadataItem.relation.targetObjectMetadata.id : null; - const { filterableFieldMetadataItems: allTargetFields } = + const { filterableFieldMetadataItems: relationTargetFields } = useFilterableFieldMetadataItems(targetObjectMetadataId ?? ''); - // The backend supports a single hop only. Exclude many-to-one relations - // from the target list so the user can't compose multi-hop traversals - // (e.g. Person → Company → ParentCompany) that the dispatcher would - // collapse back to a filter-by-id on the intermediate relation. - const relationTargetFields = allTargetFields.filter( - (field) => !isManyToOneRelationField(field), - ); - if ( !isDefined(sourceFieldMetadataItem) || !isManyToOneRelationField(sourceFieldMetadataItem) diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordSelect.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordSelect.tsx index 0fa41e9b5d..6d8604c925 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordSelect.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordSelect.tsx @@ -1,11 +1,14 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector'; import { getRelationObjectMetadataNameSingular } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions'; +import { getFieldMetadataItemById } from '@/object-metadata/utils/getFieldMetadataItemById'; import { ObjectFilterDropdownRecordPinnedItems } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordPinnedItems'; import { CURRENT_WORKSPACE_MEMBER_SELECTABLE_ITEM_ID } from '@/object-record/object-filter-dropdown/constants/CurrentWorkspaceMemberSelectableItemId'; import { useApplyObjectFilterDropdownFilterValue } from '@/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownFilterValue'; import { useObjectFilterDropdownFilterValue } from '@/object-record/object-filter-dropdown/hooks/useObjectFilterDropdownFilterValue'; import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector'; import { objectFilterDropdownSearchInputComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputComponentState'; +import { relationTargetFieldMetadataIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/relationTargetFieldMetadataIdUsedInDropdownComponentState'; import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState'; import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; import { MultipleSelectDropdown } from '@/object-record/select/components/MultipleSelectDropdown'; @@ -36,10 +39,19 @@ type ObjectFilterDropdownRecordSelectProps = { dropdownId: string; }; -export const ObjectFilterDropdownRecordSelect = ({ +type ObjectFilterDropdownRecordSelectContentProps = { + objectNameSingular: string; + recordFilterId?: string; + dropdownId: string; +}; + +// Owns the record-picker hooks. Rendered only once an object to pick from has +// been resolved, so its hooks always run in the same order. +const ObjectFilterDropdownRecordSelectContent = ({ + objectNameSingular, recordFilterId, dropdownId, -}: ObjectFilterDropdownRecordSelectProps) => { +}: ObjectFilterDropdownRecordSelectContentProps) => { const fieldMetadataItemUsedInFilterDropdown = useAtomComponentSelectorValue( fieldMetadataItemUsedInDropdownComponentSelector, ); @@ -75,28 +87,12 @@ export const ObjectFilterDropdownRecordSelect = ({ }) .parse(objectFilterDropdownFilterValue); - if (!isDefined(fieldMetadataItemUsedInFilterDropdown)) { - throw new Error('fieldMetadataItemUsedInFilterDropdown is not defined'); - } - - const objectNameSingular = getRelationObjectMetadataNameSingular({ - field: fieldMetadataItemUsedInFilterDropdown, - }); - - if (!isDefined(objectNameSingular)) { - throw new Error('relationObjectMetadataNameSingular is not defined'); - } - const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular, }); const objectLabelPlural = objectMetadataItem?.labelPlural; - if (!isDefined(objectNameSingular)) { - throw new Error('objectNameSingular is not defined'); - } - const firstSimpleRecordFilterForFieldMetadataItemUsedInDropdown = currentRecordFilters.find( (filter) => @@ -242,3 +238,60 @@ export const ObjectFilterDropdownRecordSelect = ({ ); }; + +export const ObjectFilterDropdownRecordSelect = ({ + recordFilterId, + dropdownId, +}: ObjectFilterDropdownRecordSelectProps) => { + const fieldMetadataItemUsedInFilterDropdown = useAtomComponentSelectorValue( + fieldMetadataItemUsedInDropdownComponentSelector, + ); + + const relationTargetFieldMetadataIdUsedInDropdown = + useAtomComponentStateValue( + relationTargetFieldMetadataIdUsedInDropdownComponentState, + ); + + const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector); + + if (!isDefined(fieldMetadataItemUsedInFilterDropdown)) { + throw new Error('fieldMetadataItemUsedInFilterDropdown is not defined'); + } + + // Nested relation filters pick records from the leaf relation's target object + // (company → accountOwner ⇒ WorkspaceMember); direct filters use the source. + const relationTargetFieldMetadataItem = isDefined( + relationTargetFieldMetadataIdUsedInDropdown, + ) + ? getFieldMetadataItemById({ + fieldMetadataId: relationTargetFieldMetadataIdUsedInDropdown, + objectMetadataItems, + }).fieldMetadataItem + : undefined; + + const effectiveFieldMetadataItem = isDefined( + relationTargetFieldMetadataIdUsedInDropdown, + ) + ? relationTargetFieldMetadataItem + : fieldMetadataItemUsedInFilterDropdown; + + const objectNameSingular = isDefined(effectiveFieldMetadataItem) + ? getRelationObjectMetadataNameSingular({ + field: effectiveFieldMetadataItem, + }) + : undefined; + + // A stale filter whose relation-target field was deleted can't resolve an + // object to pick records from — render nothing rather than crash. + if (!isDefined(objectNameSingular)) { + return null; + } + + return ( + + ); +}; diff --git a/packages/twenty-shared/src/utils/filter/__tests__/turnRecordFilterIntoGqlOperationFilter.test.ts b/packages/twenty-shared/src/utils/filter/__tests__/turnRecordFilterIntoGqlOperationFilter.test.ts index 85bc8db4e7..95d70bf481 100644 --- a/packages/twenty-shared/src/utils/filter/__tests__/turnRecordFilterIntoGqlOperationFilter.test.ts +++ b/packages/twenty-shared/src/utils/filter/__tests__/turnRecordFilterIntoGqlOperationFilter.test.ts @@ -49,6 +49,12 @@ const fields = [ type: FieldMetadataType.RELATION, label: 'Company', }, + { + id: 'f-relation-account-owner', + name: 'accountOwner', + type: FieldMetadataType.RELATION, + label: 'Account Owner', + }, { id: 'f-bool', name: 'isActive', @@ -1038,5 +1044,58 @@ describe('turnRecordFilterIntoRecordGqlOperationFilter', () => { expect(result).toHaveProperty('companyId.in'); }); + + // A relation leaf compiles to a single-hop FK compare on the joined table + // (company.accountOwnerId), not a second-hop `company.accountOwner.`. + it('should resolve a relation target field to its foreign key', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: { + ...makeFilter( + 'f-relation', + RecordFilterOperand.IS, + '["550e8400-e29b-41d4-a716-446655440000"]', + 'RELATION', + ), + relationTargetFieldMetadataId: 'f-relation-account-owner', + } as RecordFilter, + fieldMetadataItemById, + }); + + expect(result).toEqual({ + company: { + accountOwnerId: { in: ['550e8400-e29b-41d4-a716-446655440000'] }, + }, + }); + }); + + // "= me" (the current workspace member) resolves into that same FK compare. + it('should resolve a relation target field set to the current workspace member', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies: { + ...filterValueDependencies, + currentWorkspaceMemberId: '11111111-1111-4111-8111-111111111111', + }, + recordFilter: { + ...makeFilter( + 'f-relation', + RecordFilterOperand.IS, + JSON.stringify({ + isCurrentWorkspaceMemberSelected: true, + selectedRecordIds: [], + }), + 'RELATION', + ), + relationTargetFieldMetadataId: 'f-relation-account-owner', + } as RecordFilter, + fieldMetadataItemById, + }); + + expect(result).toEqual({ + company: { + accountOwnerId: { in: ['11111111-1111-4111-8111-111111111111'] }, + }, + }); + }); }); });