fix(filters): make filter dispatcher own relation-target resolution (#20670)
## Summary Two relation-traversal bugs surfaced post-merge of #20533, both rooted in the same architectural smell: the GraphQL filter dispatcher took a flat `fields: FieldShared[]` array and silently dropped any filter whose `relationTargetFieldMetadataId` wasn't in that array. Callers had to remember to pre-augment the list with relation targets — and 16+ call sites did not all know this. This PR fixes both bugs and removes the smell. ### Bug 1 — Save as new view loses the relation target `useCreateViewFromCurrentView` built the create-filter input without `relationTargetFieldMetadataId`. The saved view's filter persisted without the traversal — on reload the chip showed "Company contains 'air'" instead of "Company → Name contains 'air'". Discarded at save time, not at read time. Fix: include `relationTargetFieldMetadataId` in the create input. (Commit 1.) ### Bug 2 — Workflow Search Records drops one-hop traversals `FindRecordsWorkflowAction` built its fields list from `flatObjectMetadata.fieldIds` only (source object's fields). The shared dispatcher then couldn't resolve the relation target field on the related object and silently dropped the filter — a configured "People where Company → Name Contains 'Airbnb'" came through as `{ and: [] }`. This was the same shape as bugs already fixed in 5 other call sites (chart filters, view filters, record table, etc.). The pattern was: caller forgets to augment fields → dispatcher silently drops the filter. Fix (commit 2): change the dispatcher to take a `findFieldMetadataItemById: (id) => FieldShared | undefined` resolver callback. Both source-field and relation-target-field lookups go through the same resolver, so callers no longer need to know about the augmentation requirement. Frontend callers pass a workspace-wide resolver built from `flattenedFieldMetadataItemsSelector`; server callers wrap `findFlatEntityByIdInFlatEntityMaps` on `flatFieldMetadataMaps`. In both cases relation-target lookups just work, because the resolver can see fields on related objects. ## Why this matters Before: "if you call the dispatcher, pre-augment your fields list with relation targets, or filters get silently dropped." An invariant only enforceable by code review, broken often enough to ship two user-visible bugs in one week. After: the dispatcher resolves field ids itself. There's no list to forget to augment. The failure mode (filter silently dropped) becomes structurally impossible at the dispatcher boundary. Net diff: 240 insertions, 319 deletions. Removed `augmentFieldsWithRelationTargets` (frontend) and the workflow whack-a-mole code (server). ## Test plan - [ ] Save view: create an advanced filter using a one-hop relation traversal, click "Save as new view", reload, confirm the chip still reads "Source → Target operator value" - [ ] Workflow: configure a Search Records action with a relation-traversal filter, run the workflow, confirm the filter is actually applied - [ ] Dashboard chart: configure a chart with a relation-traversal filter, confirm the chart data respects it - [ ] Record table, group-by, calendar, total count, footer aggregates: all continue to work with both plain and relation-traversal filters
This commit is contained in:
-1
@@ -93,7 +93,6 @@ export class ChartDataQueryService {
|
||||
}: ExecuteGroupByQueryParams): Promise<GroupByRawResult[]> {
|
||||
const gqlOperationFilter = convertChartFilterToGqlOperationFilter({
|
||||
filter,
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
userTimezone,
|
||||
});
|
||||
|
||||
+7
-33
@@ -2,7 +2,6 @@ import {
|
||||
type ChartFilter,
|
||||
type CompositeFieldSubFieldName,
|
||||
type FilterableAndTSVectorFieldType,
|
||||
type PartialFieldMetadataItem,
|
||||
type RecordFilterGroupLogicalOperator,
|
||||
type ViewFilterOperand,
|
||||
} from 'twenty-shared/types';
|
||||
@@ -18,18 +17,15 @@ import { type ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
|
||||
type ConvertChartFilterToGqlOperationFilterParams = {
|
||||
filter: ChartFilter | undefined;
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
userTimezone: string;
|
||||
};
|
||||
|
||||
export const convertChartFilterToGqlOperationFilter = ({
|
||||
filter,
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
userTimezone,
|
||||
}: ConvertChartFilterToGqlOperationFilterParams): ObjectRecordFilter => {
|
||||
@@ -44,34 +40,6 @@ export const convertChartFilterToGqlOperationFilter = ({
|
||||
return {};
|
||||
}
|
||||
|
||||
const fieldIds = flatObjectMetadata.fieldIds ?? [];
|
||||
const fields: PartialFieldMetadataItem[] = fieldIds
|
||||
.map((fieldId: string) => {
|
||||
const field = findFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityId: fieldId,
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
if (!isDefined(field)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: field.id,
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
label: field.label,
|
||||
options: field.options?.map((opt) => ({
|
||||
id: opt.id ?? '',
|
||||
label: opt.label,
|
||||
value: opt.value,
|
||||
color: 'color' in opt ? opt.color : undefined,
|
||||
position: opt.position,
|
||||
})),
|
||||
};
|
||||
})
|
||||
.filter(isDefined);
|
||||
|
||||
const convertedRecordFilters: Omit<RecordFilter, 'id'>[] = recordFilters.map(
|
||||
(recordFilter) => {
|
||||
const field = findFlatEntityByIdInFlatEntityMaps({
|
||||
@@ -90,6 +58,8 @@ export const convertChartFilterToGqlOperationFilter = ({
|
||||
subFieldName: (recordFilter.subFieldName ?? undefined) as
|
||||
| CompositeFieldSubFieldName
|
||||
| undefined,
|
||||
relationTargetFieldMetadataId:
|
||||
recordFilter.relationTargetFieldMetadataId ?? null,
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -104,7 +74,11 @@ export const convertChartFilterToGqlOperationFilter = ({
|
||||
}));
|
||||
|
||||
return computeRecordGqlOperationFilter({
|
||||
fields,
|
||||
findFieldMetadataItemById: (id) =>
|
||||
findFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityId: id,
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
}),
|
||||
recordFilters: convertedRecordFilters,
|
||||
recordFilterGroups: convertedRecordFilterGroups,
|
||||
filterValueDependencies: {
|
||||
|
||||
+6
-33
@@ -1,12 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
type FieldMetadataComplexOption,
|
||||
type FieldMetadataDefaultOption,
|
||||
} from 'twenty-shared/types';
|
||||
import {
|
||||
computeRecordGqlOperationFilter,
|
||||
isDefined,
|
||||
isRecordFilterValueValid,
|
||||
resolveInput,
|
||||
} from 'twenty-shared/utils';
|
||||
@@ -63,38 +58,12 @@ export class FindRecordsWorkflowAction implements WorkflowAction {
|
||||
const executionContext =
|
||||
await this.workflowExecutionContextService.getExecutionContext(runInfo);
|
||||
|
||||
const { flatObjectMetadata, flatFieldMetadataMaps } =
|
||||
const { flatFieldMetadataMaps } =
|
||||
await this.workflowCommonWorkspaceService.getObjectMetadataInfo(
|
||||
workflowActionInput.objectName,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const fields = flatObjectMetadata.fieldIds
|
||||
.map((fieldId) => {
|
||||
const field = findFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityId: fieldId,
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
if (!field) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: field.id,
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
label: field.label,
|
||||
// Note: force cast is required until we deprecate the CreateFieldInput and UpdateFieldInput
|
||||
// type derivation from the FieldMetadataDto
|
||||
options: field.options as
|
||||
| (FieldMetadataDefaultOption & { id: string })[]
|
||||
| (FieldMetadataComplexOption & { id: string })[]
|
||||
| null,
|
||||
};
|
||||
})
|
||||
.filter(isDefined);
|
||||
|
||||
if (workflowActionInput.filter?.recordFilters) {
|
||||
for (const filter of workflowActionInput.filter.recordFilters) {
|
||||
if (!isRecordFilterValueValid(filter)) {
|
||||
@@ -110,7 +79,11 @@ export class FindRecordsWorkflowAction implements WorkflowAction {
|
||||
workflowActionInput.filter?.recordFilters &&
|
||||
workflowActionInput.filter?.recordFilterGroups
|
||||
? computeRecordGqlOperationFilter({
|
||||
fields,
|
||||
findFieldMetadataItemById: (id) =>
|
||||
findFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityId: id,
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
}),
|
||||
recordFilters: workflowActionInput.filter.recordFilters,
|
||||
recordFilterGroups: workflowActionInput.filter.recordFilterGroups,
|
||||
filterValueDependencies: {
|
||||
|
||||
Reference in New Issue
Block a user