feat(twenty-front): relation traversal in filter dropdown (stacked) (#20533)

**Stacked on #20527** 




https://github.com/user-attachments/assets/48995655-401a-4c35-8094-e88da8408bdd





## Summary

Surfaces the one-hop relation traversal added in #20527 through the
existing **composite sub-field dropdown pattern**. Clicking a
MANY_TO_ONE relation field in the "+ Filter" picker now opens the same
second-level dropdown that composite fields (FULL_NAME, ADDRESS,
CURRENCY, etc.) already use — populated with the target object's
filterable fields. Picking one (e.g. `Company → Name`) builds a filter
that serializes to the nested GraphQL filter the backend now accepts: `{
company: { name: { ilike: "%X%" } } }`.

No new components. The whole feature reuses
`AdvancedFilterSubFieldSelectMenu` + the existing
`subFieldNameUsedInDropdownComponentState` + the existing `MenuItem
hasSubMenu` indicator. Only the conditions that gate the sub-menu (and
the sub-menu's content for relations) were broadened.

## What landed

| File | Change |
|---|---|
| `ObjectFilterDropdownFilterSelectMenuItem` | Sub-menu chevron now
shows on MANY_TO_ONE relations (`isManyToOneRelationField` util). |
| `AdvancedFilterFieldSelectMenu` | Relation clicks open the sub-menu
alongside composite clicks. |
| `AdvancedFilterSubFieldSelectMenu` | New branch: when the sub-menu
type is `'RELATION'`, render the target object's filterable fields via
`useFilterableFieldMetadataItems(targetObjectMetadataId)`. Composite
logic untouched. |
| `objectFilterDropdownSubMenuFieldType` state | Widened to accept a
`'RELATION'` sentinel. Role-permissions sub-field menu narrows it back
out (it doesn't traverse relations). |
| `useSelectFieldUsedInAdvancedFilterDropdown` | New optional
`targetFieldMetadataItem` arg. When present, the stored RecordFilter's
`type` is the target field's type so the operand picker and value input
render the target's operands (`'TEXT'` operators when filtering
`company.name`, etc.). |
| `turnRecordFilterIntoGqlOperationFilter` (shared) | When the filter
targets a `RELATION` field with a `subFieldName`, synthesize a
field-metadata for the target, recurse to build the inner filter, then
wrap it under the relation field's name → `{ relationName: {
targetFieldName: { ...operator } } }`. |

`RecordFilter.subFieldName` stays narrowly typed as
`CompositeFieldSubFieldName` so the wide downstream consumers
(`shouldShowFilterTextInput`, composite handlers in the serializer,
etc.) don't change. The relation target field's name is stored through a
narrowly-scoped cast at the dropdown's storage point — the serializer
checks `filter.type === 'RELATION'` before interpreting it as a target
field name, so the cast can't be mis-read by composite-only code paths.

## Test plan

- [ ] Open a table view on People, click "+ Filter", click "Company" →
sub-menu opens with Company's filterable fields
- [ ] Pick "Name" → operand picker shows TEXT operators (Contains,
Equals, …)
- [ ] Type "Airbnb" → filter applies, table shows people whose company
name contains "Airbnb"
- [ ] Verify network tab: the GraphQL filter variable is `{ company: {
name: { ilike: "%Airbnb%" } } }`
- [ ] Same flow with a composite target field (e.g. `Company →
annualRecurringRevenue → amountMicros`) — should work end-to-end
(backend supports composite-within-relation; #20527 has an integration
test covering this)
- [ ] Composite fields (FULL_NAME, ADDRESS) still open their normal
sub-menu and filter correctly — no regression
- [ ] Role-permissions field-select sub-field menu is unaffected (it
bails out early on the RELATION sentinel)

## Out of scope

- ONE_TO_MANY traversal (no backend support yet)
- Aggregates (`people.count > 5`)
- Persisting relation-traversal filters into a saved view (ViewFilter
has no `relationPath` column yet; that's a separate slice)
- REST API DSL changes
- AI Tools

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-05-15 16:42:47 +02:00
committed by GitHub
parent eca92ca559
commit c938fbf4d6
88 changed files with 1809 additions and 414 deletions
@@ -7,7 +7,8 @@ import { CoreObjectNameSingular, FieldMetadataType } from 'twenty-shared/types';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { AdvancedFilterFieldSelectSearchInput } from '@/object-record/advanced-filter/components/AdvancedFilterFieldSelectSearchInput';
import { useAdvancedFilterFieldSelectDropdown } from '@/object-record/advanced-filter/hooks/useAdvancedFilterFieldSelectDropdown';
import { useSelectFieldUsedInAdvancedFilterDropdown } from '@/object-record/advanced-filter/hooks/useSelectFieldUsedInAdvancedFilterDropdown';
import { useApplyAdvancedFilterSourceField } from '@/object-record/advanced-filter/hooks/useApplyAdvancedFilterSourceField';
import { usePushFocusForLeafFieldValuePicker } from '@/object-record/advanced-filter/hooks/usePushFocusForLeafFieldValuePicker';
import { AdvancedFilterContext } from '@/object-record/advanced-filter/states/context/AdvancedFilterContext';
import { ObjectFilterDropdownFilterSelectMenuItem } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelectMenuItem';
import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState';
@@ -73,8 +74,8 @@ export const SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectF
advancedFilterFieldSelectDropdownId,
);
const { selectFieldUsedInAdvancedFilterDropdown } =
useSelectFieldUsedInAdvancedFilterDropdown();
const { applyAdvancedFilterSourceField } =
useApplyAdvancedFilterSourceField();
const [, setObjectFilterDropdownSubMenuFieldType] = useAtomComponentState(
objectFilterDropdownSubMenuFieldTypeComponentState,
@@ -89,6 +90,9 @@ export const SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectF
fieldMetadataItemIdUsedInDropdownComponentState,
);
const { pushFocusForLeafFieldValuePicker } =
usePushFocusForLeafFieldValuePicker();
const handleFieldSelect = (
selectedFieldMetadataItem: FieldMetadataItem,
) => {
@@ -102,13 +106,17 @@ export const SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectF
setObjectFilterDropdownSubMenuFieldType(filterType);
setFieldMetadataItemIdUsedInDropdown(selectedFieldMetadataItem.id);
setObjectFilterDropdownIsSelectingCompositeField(true);
} else {
selectFieldUsedInAdvancedFilterDropdown({
fieldMetadataItemId: selectedFieldMetadataItem.id,
recordFilterId,
});
closeAdvancedFilterFieldSelectDropdown();
return;
}
applyAdvancedFilterSourceField({
sourceFieldMetadataItem: selectedFieldMetadataItem,
recordFilterId,
});
pushFocusForLeafFieldValuePicker(selectedFieldMetadataItem);
closeAdvancedFilterFieldSelectDropdown();
};
const selectableItemIdArray = filteredFieldMetadataItems.map(
@@ -10,7 +10,7 @@ import { MenuItem } from 'twenty-ui/navigation';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useAdvancedFilterFieldSelectDropdown } from '@/object-record/advanced-filter/hooks/useAdvancedFilterFieldSelectDropdown';
import { useSelectFieldUsedInAdvancedFilterDropdown } from '@/object-record/advanced-filter/hooks/useSelectFieldUsedInAdvancedFilterDropdown';
import { useApplyAdvancedFilterCompositeSubField } from '@/object-record/advanced-filter/hooks/useApplyAdvancedFilterCompositeSubField';
import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector';
import { objectFilterDropdownIsSelectingCompositeFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingCompositeFieldComponentState';
import { objectFilterDropdownSubMenuFieldTypeComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSubMenuFieldTypeComponentState';
@@ -61,8 +61,8 @@ export const SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectS
const { closeAdvancedFilterFieldSelectDropdown } =
useAdvancedFilterFieldSelectDropdown(recordFilterId);
const { selectFieldUsedInAdvancedFilterDropdown } =
useSelectFieldUsedInAdvancedFilterDropdown();
const { applyAdvancedFilterCompositeSubField } =
useApplyAdvancedFilterCompositeSubField();
const handleSelectFilter = (
selectedFieldMetadataItem: FieldMetadataItem | null | undefined,
@@ -72,10 +72,10 @@ export const SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectS
return;
}
selectFieldUsedInAdvancedFilterDropdown({
fieldMetadataItemId: selectedFieldMetadataItem.id,
applyAdvancedFilterCompositeSubField({
sourceFieldMetadataItem: selectedFieldMetadataItem,
subFieldName: subFieldName ?? null,
recordFilterId,
subFieldName,
});
closeAdvancedFilterFieldSelectDropdown();