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
@@ -356,6 +356,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"viewFilterGroupUniversalIdentifier",
"positionInViewFilterGroup",
"subFieldName",
"relationTargetFieldMetadataUniversalIdentifier",
],
"propertiesToStringify": [
"value",
@@ -669,6 +669,11 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toStringify: false,
universalProperty: undefined,
},
relationTargetFieldMetadataId: {
toCompare: true,
toStringify: false,
universalProperty: 'relationTargetFieldMetadataUniversalIdentifier',
},
createdAt: {
toCompare: false,
toStringify: false,
@@ -132,6 +132,9 @@ export const ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY = {
view: {
foreignKey: 'viewId',
},
relationTargetFieldMetadata: {
foreignKey: 'relationTargetFieldMetadataId',
},
viewFilterGroup: {
foreignKey: 'viewFilterGroupId',
},
@@ -241,6 +241,13 @@ export const ALL_MANY_TO_ONE_METADATA_RELATIONS = {
isNullable: false,
universalForeignKey: 'viewUniversalIdentifier',
},
relationTargetFieldMetadata: {
metadataName: 'fieldMetadata',
foreignKey: 'relationTargetFieldMetadataId',
inverseOneToManyProperty: null,
isNullable: true,
universalForeignKey: 'relationTargetFieldMetadataUniversalIdentifier',
},
viewFilterGroup: {
metadataName: 'viewFilterGroup',
foreignKey: 'viewFilterGroupId',
@@ -4,10 +4,10 @@ exports[`sortMetadataNamesChildrenFirst should return metadata names sorted with
[
"rowLevelPermissionPredicate",
"navigationMenuItem",
"viewFilter",
"commandMenuItem",
"fieldPermission",
"viewField",
"viewFilter",
"objectPermission",
"pageLayoutWidget",
"viewSort",
@@ -7,4 +7,5 @@ export const FLAT_VIEW_FILTER_EDITABLE_PROPERTIES = [
'viewFilterGroupId',
'positionInViewFilterGroup',
'subFieldName',
'relationTargetFieldMetadataId',
] as const satisfies MetadataEntityPropertyName<'viewFilter'>[];
@@ -31,6 +31,7 @@ export const fromCreateViewFilterInputToFlatViewFilterToCreate = ({
'viewFilterGroupId',
'operand',
'subFieldName',
'relationTargetFieldMetadataId',
],
);
@@ -41,12 +42,15 @@ export const fromCreateViewFilterInputToFlatViewFilterToCreate = ({
fieldMetadataUniversalIdentifier,
viewUniversalIdentifier,
viewFilterGroupUniversalIdentifier,
relationTargetFieldMetadataUniversalIdentifier,
} = resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewFilter',
foreignKeyValues: {
fieldMetadataId,
viewId,
viewFilterGroupId: createViewFilterInput.viewFilterGroupId,
relationTargetFieldMetadataId:
createViewFilterInput.relationTargetFieldMetadataId,
},
flatEntityMaps: {
flatFieldMetadataMaps,
@@ -69,6 +73,7 @@ export const fromCreateViewFilterInputToFlatViewFilterToCreate = ({
positionInViewFilterGroup:
createViewFilterInput.positionInViewFilterGroup ?? null,
subFieldName: createViewFilterInput.subFieldName ?? null,
relationTargetFieldMetadataUniversalIdentifier,
applicationUniversalIdentifier: flatApplication.universalIdentifier,
};
};
@@ -87,5 +87,22 @@ export const fromUpdateViewFilterInputToFlatViewFilterToUpdateOrThrow = ({
viewFilterGroupUniversalIdentifier;
}
if (
updatedEditableFieldProperties.relationTargetFieldMetadataId !== undefined
) {
const { relationTargetFieldMetadataUniversalIdentifier } =
resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewFilter',
foreignKeyValues: {
relationTargetFieldMetadataId:
flatViewFilterToUpdate.relationTargetFieldMetadataId,
},
flatEntityMaps: { flatFieldMetadataMaps },
});
flatViewFilterToUpdate.relationTargetFieldMetadataUniversalIdentifier =
relationTargetFieldMetadataUniversalIdentifier;
}
return flatViewFilterToUpdate;
};
@@ -69,6 +69,22 @@ export const fromViewFilterEntityToFlatViewFilter = ({
);
}
let relationTargetFieldMetadataUniversalIdentifier: string | null = null;
if (isDefined(viewFilterEntity.relationTargetFieldMetadataId)) {
relationTargetFieldMetadataUniversalIdentifier =
fieldMetadataIdToUniversalIdentifierMap.get(
viewFilterEntity.relationTargetFieldMetadataId,
) ?? null;
if (!isDefined(relationTargetFieldMetadataUniversalIdentifier)) {
throw new FlatEntityMapsException(
`FieldMetadata with id ${viewFilterEntity.relationTargetFieldMetadataId} not found for viewFilter ${viewFilterEntity.id} (relation target)`,
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
);
}
}
return {
...viewFilterEntityWithoutRelations,
createdAt: viewFilterEntity.createdAt.toISOString(),
@@ -77,6 +93,7 @@ export const fromViewFilterEntityToFlatViewFilter = ({
universalIdentifier: viewFilterEntityWithoutRelations.universalIdentifier,
applicationUniversalIdentifier,
fieldMetadataUniversalIdentifier,
relationTargetFieldMetadataUniversalIdentifier,
viewFilterGroupUniversalIdentifier,
viewUniversalIdentifier,
};
@@ -49,6 +49,11 @@ export class CreateViewFilterInput {
@Field({ nullable: true })
subFieldName?: string;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
relationTargetFieldMetadataId?: string;
@IsUUID()
@Field(() => UUIDScalarType, { nullable: false })
viewId: string;
@@ -46,6 +46,11 @@ class UpdateViewFilterInputUpdates {
@IsString()
@Field({ nullable: true })
subFieldName?: string;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
relationTargetFieldMetadataId?: string;
}
@InputType()
@@ -34,6 +34,9 @@ export class ViewFilterDTO {
@Field(() => String, { nullable: true })
subFieldName?: string | null;
@Field(() => UUIDScalarType, { nullable: true })
relationTargetFieldMetadataId?: string | null;
@Field(() => UUIDScalarType, { nullable: false })
viewId: string;
@@ -23,6 +23,11 @@ import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-enti
@Index('IDX_VIEW_FILTER_WORKSPACE_ID_VIEW_ID', ['workspaceId', 'viewId'])
@Index('IDX_VIEW_FILTER_VIEW_ID', ['viewId'])
@Index('IDX_VIEW_FILTER_FIELD_METADATA_ID', ['fieldMetadataId'])
@Index(
'IDX_VIEW_FILTER_RELATION_TARGET_FIELD_METADATA_ID',
['relationTargetFieldMetadataId'],
{ where: '"relationTargetFieldMetadataId" IS NOT NULL' },
)
export class ViewFilterEntity
extends SyncableEntity
implements Required<ViewFilterEntity>
@@ -59,6 +64,16 @@ export class ViewFilterEntity
@Column({ nullable: true, type: 'text', default: null })
subFieldName: string | null;
@Column({ nullable: true, type: 'uuid', default: null })
relationTargetFieldMetadataId: string | null;
@ManyToOne(() => FieldMetadataEntity, {
onDelete: 'CASCADE',
nullable: true,
})
@JoinColumn({ name: 'relationTargetFieldMetadataId' })
relationTargetFieldMetadata: Relation<FieldMetadataEntity> | null;
@Column({ nullable: false, type: 'uuid' })
viewId: string;
@@ -48,4 +48,9 @@ export class UpsertViewWidgetViewFilterInput {
@IsString()
@Field({ nullable: true })
subFieldName?: string;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
relationTargetFieldMetadataId?: string;
}
@@ -267,5 +267,83 @@ describe('ViewQueryParamsService', () => {
// Filter should be effectively empty because the field was deleted
expect(result.filter).toEqual({ and: [] });
});
it('should resolve relation-traversal filters against the target field', async () => {
const relationFieldId = 'relation-field-id';
const targetFieldId = 'target-field-id';
const mockFilterGroupId = 'filter-group-id';
const flatFieldMetadataMapsWithRelation = {
byUniversalIdentifier: {
'relation-universal-id': {
id: relationFieldId,
name: 'company',
type: FieldMetadataType.RELATION,
label: 'Company',
options: null,
universalIdentifier: 'relation-universal-id',
},
'target-universal-id': {
id: targetFieldId,
name: 'name',
type: FieldMetadataType.TEXT,
label: 'Name',
options: null,
universalIdentifier: 'target-universal-id',
},
},
universalIdentifierById: {
[relationFieldId]: 'relation-universal-id',
[targetFieldId]: 'target-universal-id',
},
universalIdentifiersByApplicationId: {},
};
const mockView = {
id: mockViewId,
name: 'People at Acme',
objectMetadataId: mockObjectMetadataId,
type: ViewType.TABLE,
visibility: ViewVisibility.WORKSPACE,
viewFilters: [
{
id: 'filter-id',
fieldMetadataId: relationFieldId,
operand: ViewFilterOperand.CONTAINS,
value: 'Acme',
viewFilterGroupId: mockFilterGroupId,
subFieldName: null,
relationTargetFieldMetadataId: targetFieldId,
},
],
viewFilterGroups: [
{
id: mockFilterGroupId,
parentViewFilterGroupId: null,
logicalOperator: ViewFilterGroupLogicalOperator.AND,
},
],
viewSorts: [],
};
viewService.findByIdWithRelations.mockResolvedValue(mockView as any);
flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue(
{
flatObjectMetadataMaps: mockFlatObjectMetadataMaps,
flatFieldMetadataMaps: flatFieldMetadataMapsWithRelation,
} as any,
);
const result = await viewQueryParamsService.resolveViewToQueryParams(
mockViewId,
mockWorkspaceId,
);
// Filter is nested under the relation field name, not flattened
// against the FK column.
expect(result.filter).toEqual({
and: [{ company: { name: { ilike: '%Acme%' } } }],
});
});
});
});
@@ -90,6 +90,8 @@ export class ViewQueryParamsService {
recordFilterGroupId: viewFilter.viewFilterGroupId,
operand: viewFilter.operand,
subFieldName: viewFilter.subFieldName,
relationTargetFieldMetadataId:
viewFilter.relationTargetFieldMetadataId ?? null,
} as RecordFilter;
})
.filter(isDefined);
@@ -105,10 +107,16 @@ export class ViewQueryParamsService {
: RecordFilterGroupLogicalOperator.AND,
}));
const fields = recordFilters
.map((filter) => {
const filterFieldMetadataIds = recordFilters.flatMap((filter) =>
isDefined(filter.relationTargetFieldMetadataId)
? [filter.fieldMetadataId, filter.relationTargetFieldMetadataId]
: [filter.fieldMetadataId],
);
const fields = filterFieldMetadataIds
.map((fieldMetadataId) => {
const field = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: filter.fieldMetadataId,
flatEntityId: fieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
});
@@ -675,12 +675,15 @@ export class ViewWidgetUpsertService {
fieldMetadataUniversalIdentifier,
viewUniversalIdentifier,
viewFilterGroupUniversalIdentifier,
relationTargetFieldMetadataUniversalIdentifier,
} = resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewFilter',
foreignKeyValues: {
fieldMetadataId: inputFilter.fieldMetadataId,
viewId,
viewFilterGroupId: inputFilter.viewFilterGroupId,
relationTargetFieldMetadataId:
inputFilter.relationTargetFieldMetadataId,
},
flatEntityMaps: {
flatFieldMetadataMaps,
@@ -706,6 +709,9 @@ export class ViewWidgetUpsertService {
positionInViewFilterGroup:
inputFilter.positionInViewFilterGroup ?? null,
subFieldName: inputFilter.subFieldName ?? null,
relationTargetFieldMetadataId:
inputFilter.relationTargetFieldMetadataId ?? null,
relationTargetFieldMetadataUniversalIdentifier,
createdAt: now,
updatedAt: now,
deletedAt: null,
@@ -719,17 +725,22 @@ export class ViewWidgetUpsertService {
existingFilter.viewFilterGroupId !== inputFilter.viewFilterGroupId ||
existingFilter.positionInViewFilterGroup !==
inputFilter.positionInViewFilterGroup ||
existingFilter.subFieldName !== inputFilter.subFieldName;
existingFilter.subFieldName !== inputFilter.subFieldName ||
existingFilter.relationTargetFieldMetadataId !==
(inputFilter.relationTargetFieldMetadataId ?? null);
if (hasChanged) {
const {
fieldMetadataUniversalIdentifier,
viewFilterGroupUniversalIdentifier,
relationTargetFieldMetadataUniversalIdentifier,
} = resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewFilter',
foreignKeyValues: {
fieldMetadataId: inputFilter.fieldMetadataId,
viewFilterGroupId: inputFilter.viewFilterGroupId,
relationTargetFieldMetadataId:
inputFilter.relationTargetFieldMetadataId,
},
flatEntityMaps: {
flatFieldMetadataMaps,
@@ -751,6 +762,9 @@ export class ViewWidgetUpsertService {
existingFilter.positionInViewFilterGroup,
subFieldName:
inputFilter.subFieldName ?? existingFilter.subFieldName,
relationTargetFieldMetadataId:
inputFilter.relationTargetFieldMetadataId ?? null,
relationTargetFieldMetadataUniversalIdentifier,
updatedAt: now,
});
}