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
@@ -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.<field>`.
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'] },
},
});
});
});
});