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:
+1
@@ -356,6 +356,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
|
||||
"viewFilterGroupUniversalIdentifier",
|
||||
"positionInViewFilterGroup",
|
||||
"subFieldName",
|
||||
"relationTargetFieldMetadataUniversalIdentifier",
|
||||
],
|
||||
"propertiesToStringify": [
|
||||
"value",
|
||||
|
||||
+5
@@ -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,
|
||||
|
||||
+3
@@ -132,6 +132,9 @@ export const ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY = {
|
||||
view: {
|
||||
foreignKey: 'viewId',
|
||||
},
|
||||
relationTargetFieldMetadata: {
|
||||
foreignKey: 'relationTargetFieldMetadataId',
|
||||
},
|
||||
viewFilterGroup: {
|
||||
foreignKey: 'viewFilterGroupId',
|
||||
},
|
||||
|
||||
+7
@@ -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',
|
||||
|
||||
+1
-1
@@ -4,10 +4,10 @@ exports[`sortMetadataNamesChildrenFirst should return metadata names sorted with
|
||||
[
|
||||
"rowLevelPermissionPredicate",
|
||||
"navigationMenuItem",
|
||||
"viewFilter",
|
||||
"commandMenuItem",
|
||||
"fieldPermission",
|
||||
"viewField",
|
||||
"viewFilter",
|
||||
"objectPermission",
|
||||
"pageLayoutWidget",
|
||||
"viewSort",
|
||||
|
||||
+1
@@ -7,4 +7,5 @@ export const FLAT_VIEW_FILTER_EDITABLE_PROPERTIES = [
|
||||
'viewFilterGroupId',
|
||||
'positionInViewFilterGroup',
|
||||
'subFieldName',
|
||||
'relationTargetFieldMetadataId',
|
||||
] as const satisfies MetadataEntityPropertyName<'viewFilter'>[];
|
||||
|
||||
+5
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+17
@@ -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;
|
||||
};
|
||||
|
||||
+17
@@ -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,
|
||||
};
|
||||
|
||||
+5
@@ -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;
|
||||
|
||||
+5
@@ -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;
|
||||
|
||||
|
||||
+15
@@ -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;
|
||||
|
||||
|
||||
+5
@@ -48,4 +48,9 @@ export class UpsertViewWidgetViewFilterInput {
|
||||
@IsString()
|
||||
@Field({ nullable: true })
|
||||
subFieldName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
relationTargetFieldMetadataId?: string;
|
||||
}
|
||||
|
||||
+78
@@ -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%' } } }],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+11
-3
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
+15
-1
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user