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:
@@ -43,6 +43,8 @@ describe('mapViewFiltersToFilters', () => {
|
||||
type: FieldMetadataType.FULL_NAME,
|
||||
positionInRecordFilterGroup: undefined,
|
||||
recordFilterGroupId: undefined,
|
||||
subFieldName: undefined,
|
||||
relationTargetFieldMetadataId: null,
|
||||
},
|
||||
];
|
||||
expect(
|
||||
|
||||
@@ -12,6 +12,7 @@ export const areViewFiltersEqual = (
|
||||
'value',
|
||||
'operand',
|
||||
'subFieldName',
|
||||
'relationTargetFieldMetadataId',
|
||||
];
|
||||
|
||||
return propertiesToCompare.every((property) =>
|
||||
|
||||
+9
-1
@@ -1,7 +1,9 @@
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
|
||||
import { type RecordFilterGroup } from '@/object-record/record-filter-group/types/RecordFilterGroup';
|
||||
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
|
||||
import { augmentFieldsWithRelationTargets } from '@/object-record/record-filter/utils/augmentFieldsWithRelationTargets';
|
||||
import { type RecordSort } from '@/object-record/record-sort/types/RecordSort';
|
||||
import { type RecordFilterValueDependencies } from 'twenty-shared/types';
|
||||
import { computeRecordGqlOperationFilter } from 'twenty-shared/utils';
|
||||
@@ -12,6 +14,7 @@ export const getQueryVariablesFromFiltersAndSorts = ({
|
||||
recordSorts,
|
||||
objectMetadataItem,
|
||||
objectMetadataItems = [],
|
||||
flattenedFieldMetadataItems,
|
||||
filterValueDependencies,
|
||||
}: {
|
||||
recordFilterGroups: RecordFilterGroup[];
|
||||
@@ -19,10 +22,15 @@ export const getQueryVariablesFromFiltersAndSorts = ({
|
||||
recordSorts: RecordSort[];
|
||||
objectMetadataItem: EnrichedObjectMetadataItem;
|
||||
objectMetadataItems?: EnrichedObjectMetadataItem[];
|
||||
flattenedFieldMetadataItems: FieldMetadataItem[];
|
||||
filterValueDependencies: RecordFilterValueDependencies;
|
||||
}) => {
|
||||
const filter = computeRecordGqlOperationFilter({
|
||||
fields: objectMetadataItem?.fields ?? [],
|
||||
fields: augmentFieldsWithRelationTargets({
|
||||
baseFields: objectMetadataItem?.fields ?? [],
|
||||
recordFilters,
|
||||
allFieldMetadataItems: flattenedFieldMetadataItems,
|
||||
}),
|
||||
filterValueDependencies,
|
||||
recordFilterGroups,
|
||||
recordFilters,
|
||||
|
||||
@@ -13,5 +13,7 @@ export const mapRecordFilterToViewFilter = (
|
||||
positionInViewFilterGroup: recordFilter.positionInRecordFilterGroup,
|
||||
viewFilterGroupId: recordFilter.recordFilterGroupId,
|
||||
subFieldName: recordFilter.subFieldName,
|
||||
relationTargetFieldMetadataId:
|
||||
recordFilter.relationTargetFieldMetadataId ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -14,27 +14,50 @@ import { type ViewFilter } from '@/views/types/ViewFilter';
|
||||
|
||||
export const mapViewFiltersToFilters = (
|
||||
viewFilters: ViewFilter[] | GqlViewFilter[],
|
||||
availableFieldMetadataItems: FieldMetadataItem[],
|
||||
fieldMetadataItems: FieldMetadataItem[],
|
||||
): RecordFilter[] => {
|
||||
return viewFilters
|
||||
.map((viewFilter) => {
|
||||
const availableFieldMetadataItem = availableFieldMetadataItems.find(
|
||||
const sourceFieldMetadataItem = fieldMetadataItems.find(
|
||||
(fieldMetadataItem) =>
|
||||
fieldMetadataItem.id === viewFilter.fieldMetadataId,
|
||||
);
|
||||
|
||||
if (!isDefined(availableFieldMetadataItem)) {
|
||||
// Todo: we we don't throw an error yet as we have race condition on view change
|
||||
if (!isDefined(sourceFieldMetadataItem)) {
|
||||
// Todo: we don't throw an error yet as we have race condition on view change
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const filterType = getFilterTypeFromFieldType(
|
||||
availableFieldMetadataItem.type,
|
||||
);
|
||||
const relationTargetFieldMetadataItem = isDefined(
|
||||
viewFilter.relationTargetFieldMetadataId,
|
||||
)
|
||||
? fieldMetadataItems.find(
|
||||
(fieldMetadataItem) =>
|
||||
fieldMetadataItem.id === viewFilter.relationTargetFieldMetadataId,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const label = isSystemSearchVectorField(availableFieldMetadataItem.name)
|
||||
// A relation-traversal filter is meaningful only if both ends
|
||||
// resolve — drop it otherwise (same race-condition handling as the
|
||||
// source field above) instead of rendering a chip whose filterType
|
||||
// and label fall back to the relation source, since the dispatcher
|
||||
// would silently drop the GraphQL filter anyway.
|
||||
if (
|
||||
isDefined(viewFilter.relationTargetFieldMetadataId) &&
|
||||
!isDefined(relationTargetFieldMetadataItem)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const filterType = isDefined(relationTargetFieldMetadataItem)
|
||||
? getFilterTypeFromFieldType(relationTargetFieldMetadataItem.type)
|
||||
: getFilterTypeFromFieldType(sourceFieldMetadataItem.type);
|
||||
|
||||
const label = isSystemSearchVectorField(sourceFieldMetadataItem.name)
|
||||
? 'Search'
|
||||
: availableFieldMetadataItem.label;
|
||||
: isDefined(relationTargetFieldMetadataItem)
|
||||
? `${sourceFieldMetadataItem.label} → ${relationTargetFieldMetadataItem.label}`
|
||||
: sourceFieldMetadataItem.label;
|
||||
|
||||
const operand = viewFilter.operand;
|
||||
|
||||
@@ -54,6 +77,8 @@ export const mapViewFiltersToFilters = (
|
||||
label,
|
||||
type: filterType,
|
||||
subFieldName: viewFilter.subFieldName as CompositeFieldSubFieldName,
|
||||
relationTargetFieldMetadataId:
|
||||
viewFilter.relationTargetFieldMetadataId ?? null,
|
||||
} satisfies RecordFilter;
|
||||
})
|
||||
.filter(isDefined);
|
||||
|
||||
Reference in New Issue
Block a user