feat: allow many-to-one relations as advanced filter leaves (#21147)

## 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.
This commit is contained in:
Félix Malfait
2026-06-02 15:52:08 +02:00
committed by GitHub
parent c14422473b
commit 1e336dbad1
3 changed files with 131 additions and 27 deletions
@@ -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)
@@ -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 (
<ObjectFilterDropdownRecordSelectContent
objectNameSingular={objectNameSingular}
recordFilterId={recordFilterId}
dropdownId={dropdownId}
/>
);
};