fix(views): make relation traversal filters survive serialize + reload

Four bugs caught in PR review of the one-hop relation filter feature:

- turnRecordFilterIntoRecordGqlOperationFilter (twenty-shared) looks up
  the target field in the same `fieldMetadataItems` list it received,
  but every frontend caller was passing only the current object's
  fields. The target field never resolved, so the serializer fell
  through to the legacy RELATION case and threw a ZodError trying to
  parse the text value as record IDs. Fix: feed every call site the
  `flattenedFieldMetadataItemsSelector` value (a memoized flat-map of
  every object's fields). `turnAnyFieldFilterIntoRecordGqlFilter`
  iterates and is left on the current-object list.

- mapViewFiltersToFilters derived `type` and `label` from the source
  RELATION field on load, so a saved `Company → Name` filter came back
  as type=RELATION / label=Company and the advanced-filter UI reopened
  it with record-picker semantics. Add an optional
  `allFieldMetadataItems` param, resolve the target field through it,
  and use the target's filterType / build a `source → target` label.
  Callers updated to pass the flattened list.

- view-filter.entity.ts comment promised "graceful fallback to
  relation-by-record semantics" on ON DELETE SET NULL, but nothing
  rewrites the persisted operand/value to fit the new shape — load
  path was the implicit dead-letter office. Rewrite the comment to
  describe the actual contract (row survives, caller drops the
  filter); no behavior change.

- relationTargetFieldMetadataIdUsedInDropdownComponentState had a
  writer in the advanced-filter field-select hook and a reader in the
  simple operand dropdown (chip-edit), but the contexts never met:
  chip-edit init didn't restore the state, so editing a saved
  traversal filter via its chip showed the source field's operand
  list. Wire setEditableFilterChipDropdownStates to also restore
  relationTargetFieldMetadataId from the loaded recordFilter.

Also unfreezes the viewMapFunctions test that was already failing on
the branch (the persisted relation-target field wasn't covered in the
expected shape).
This commit is contained in:
Félix Malfait
2026-05-14 15:05:25 +02:00
parent 8461ddbadd
commit a90a6e3bc6
14 changed files with 121 additions and 22 deletions

View File

@@ -1,5 +1,6 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
@@ -9,6 +10,7 @@ import { useCurrentRecordGroupDefinition } from '@/object-record/record-group/ho
import { useRecordGroupFilter } from '@/object-record/record-group/hooks/useRecordGroupFilter';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import {
combineFilters,
computeRecordGqlOperationFilter,
@@ -47,8 +49,12 @@ export const useFindManyRecordIndexTableParams = (
const { filterValueDependencies } = useFilterValueDependencies();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const currentFilters = computeRecordGqlOperationFilter({
fields: objectMetadataItem?.fields ?? [],
fields: flattenedFieldMetadataItems,
recordFilterGroups: currentRecordFilterGroups,
recordFilters: currentRecordFilters,
filterValueDependencies,

View File

@@ -2,6 +2,7 @@ import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/s
import { useGetFieldMetadataItemByIdOrThrow } from '@/object-metadata/hooks/useGetFieldMetadataItemById';
import { availableFieldMetadataItemsForFilterFamilySelector } from '@/object-metadata/states/availableFieldMetadataItemsForFilterFamilySelector';
import { availableFieldMetadataItemsForSortFamilySelector } from '@/object-metadata/states/availableFieldMetadataItemsForSortFamilySelector';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition';
import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField';
@@ -129,9 +130,13 @@ export const useLoadRecordIndexStates = () => {
.filter(isDefined);
const allFilterableFields = getFilterableFields(objectMetadataItem);
const flattenedFieldMetadataItems = store.get(
flattenedFieldMetadataItemsSelector,
);
const recordFilters = mapViewFiltersToFilters(
view.viewFilters,
allFilterableFields,
flattenedFieldMetadataItems,
);
const recordFilterGroups = mapViewFilterGroupsToRecordFilterGroups(
@@ -141,6 +146,7 @@ export const useLoadRecordIndexStates = () => {
const contextStoreFilters = mapViewFiltersToFilters(
view.viewFilters,
filterableFieldMetadataItems,
flattenedFieldMetadataItems,
);
let recordIndexGroupFieldMetadataItemValue = undefined;

View File

@@ -1,4 +1,5 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { useRelevantRecordsGqlFields } from '@/object-record/record-field/hooks/useRelevantRecordsGqlFields';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
@@ -12,6 +13,7 @@ import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import {
combineFilters,
computeRecordGqlOperationFilter,
@@ -36,11 +38,15 @@ export const useRecordIndexGroupCommonQueryVariables = () => {
const { filterValueDependencies } = useFilterValueDependencies();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const requestFilters = computeRecordGqlOperationFilter({
filterValueDependencies,
recordFilters: currentRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
fields: objectMetadataItem.fields,
fields: flattenedFieldMetadataItems,
});
const anyFieldFilterValue = useAtomComponentStateValue(

View File

@@ -1,4 +1,5 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { EMPTY_QUERY } from '@/object-record/constants/EmptyQuery';
@@ -12,6 +13,7 @@ import { useAggregateGqlFieldsFromRecordIndexGroupAggregates } from '@/object-re
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { buildGroupByFieldObject } from '@/page-layout/widgets/graph/utils/buildGroupByFieldObject';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useQuery } from '@apollo/client/react';
import { useMemo } from 'react';
import { type Nullable } from 'twenty-shared/types';
@@ -46,11 +48,15 @@ export const useRecordIndexGroupsAggregatesGroupBy = ({
const { filterValueDependencies } = useFilterValueDependencies();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const requestFilters = computeRecordGqlOperationFilter({
filterValueDependencies,
recordFilters: currentRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
fields: objectMetadataItem.fields,
fields: flattenedFieldMetadataItems,
});
const { recordAggregateGqlField } =

View File

@@ -1,5 +1,6 @@
import { useListenToObjectRecordOperationBrowserEvent } from '@/browser-event/hooks/useListenToObjectRecordOperationBrowserEvent';
import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
@@ -13,6 +14,7 @@ import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEvent
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useStore } from 'jotai';
import { useCallback, useMemo } from 'react';
@@ -50,6 +52,10 @@ export const RecordTableEmptyHasNewRecordEffect = () => {
currentRecordFilterGroupsComponentState,
);
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const queryId = `record-table-empty-${objectMetadataItem.nameSingular}`;
const operationSignature = useMemo(
@@ -57,7 +63,7 @@ export const RecordTableEmptyHasNewRecordEffect = () => {
objectNameSingular: objectMetadataItem.nameSingular,
variables: {
filter: computeRecordGqlOperationFilter({
fields: objectMetadataItem.fields,
fields: flattenedFieldMetadataItems,
recordFilters: currentRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
filterValueDependencies,
@@ -67,6 +73,7 @@ export const RecordTableEmptyHasNewRecordEffect = () => {
}),
[
objectMetadataItem,
flattenedFieldMetadataItems,
currentRecordFilters,
currentRecordFilterGroups,
filterValueDependencies,

View File

@@ -1,3 +1,4 @@
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { useAggregateRecords } from '@/object-record/hooks/useAggregateRecords';
import { transformAggregateRawValueIntoAggregateDisplayValue } from '@/object-record/record-aggregate/utils/transformAggregateRawValueIntoAggregateDisplayValue';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
@@ -45,10 +46,14 @@ export const useAggregateRecordsForRecordTableColumnFooter = (
const dateLocale = useAtomStateValue(dateLocaleState);
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const { filterValueDependencies } = useFilterValueDependencies();
const requestFilters = computeRecordGqlOperationFilter({
fields: objectMetadataItem.fields,
fields: flattenedFieldMetadataItems,
filterValueDependencies,
recordFilterGroups: currentRecordFilterGroups,
recordFilters: currentRecordFilters,

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
@@ -8,6 +9,7 @@ import { useRecordIndexContextOrThrow } from '@/object-record/record-index/conte
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { computeRecordGqlOperationFilter } from 'twenty-shared/utils';
export const RecordTableVirtualizedSSESubscribeEffect = () => {
@@ -26,6 +28,10 @@ export const RecordTableVirtualizedSSESubscribeEffect = () => {
currentRecordFilterGroupsComponentState,
);
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const queryId = `record-table-virtualized-${objectMetadataItem.nameSingular}`;
const operationSignature = useMemo(
@@ -33,7 +39,7 @@ export const RecordTableVirtualizedSSESubscribeEffect = () => {
objectNameSingular: objectMetadataItem.nameSingular,
variables: {
filter: computeRecordGqlOperationFilter({
fields: objectMetadataItem.fields,
fields: flattenedFieldMetadataItems,
recordFilters: currentRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
filterValueDependencies,
@@ -43,6 +49,7 @@ export const RecordTableVirtualizedSSESubscribeEffect = () => {
}),
[
objectMetadataItem,
flattenedFieldMetadataItems,
currentRecordFilters,
currentRecordFilterGroups,
filterValueDependencies,

View File

@@ -1,5 +1,7 @@
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import {
computeRecordGqlOperationFilter,
isDefined,
@@ -38,8 +40,12 @@ export const useGraphWidgetQueryCommon = ({
const { filterValueDependencies } = useFilterValueDependencies();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const gqlOperationFilter = computeRecordGqlOperationFilter({
fields: objectMetadataItem.fields,
fields: flattenedFieldMetadataItems,
filterValueDependencies,
recordFilters: configuration.filter?.recordFilters ?? [],
recordFilterGroups: configuration.filter?.recordFilterGroups ?? [],

View File

@@ -1,8 +1,10 @@
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { useSetAdvancedFilterDropdownStates } from '@/object-record/advanced-filter/hooks/useSetAdvancedFilterDropdownAllRowsStates';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { type View } from '@/views/types/View';
import { getFilterableFields } from '@/views/utils/getFilterableFields';
@@ -39,6 +41,10 @@ export const RecordTableSettingsFiltersInitializeStateEffect = ({
currentRecordFilterGroupsComponentState,
);
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const [hasInitializedFilters, setHasInitializedFilters] = useState(false);
const stateAlreadyHasFilters =
@@ -58,6 +64,7 @@ export const RecordTableSettingsFiltersInitializeStateEffect = ({
const recordFilters = mapViewFiltersToFilters(
view.viewFilters,
filterableFields,
flattenedFieldMetadataItems,
);
setCurrentRecordFilters(recordFilters);
@@ -72,6 +79,7 @@ export const RecordTableSettingsFiltersInitializeStateEffect = ({
}, [
view,
objectMetadataItem,
flattenedFieldMetadataItems,
hasInitializedFilters,
stateAlreadyHasFilters,
setCurrentRecordFilters,

View File

@@ -1,4 +1,6 @@
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { type ViewFilter as GqlViewFilter } from '~/generated-metadata/graphql';
import { type ViewFilter } from '@/views/types/ViewFilter';
import { getFilterableFields } from '@/views/utils/getFilterableFields';
@@ -7,12 +9,20 @@ import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters';
export const useMapViewFiltersToFilters = () => {
const { objectMetadataItem } = useRecordIndexContextOrThrow();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const mapViewFiltersToRecordFilters = (
viewFilters: ViewFilter[] | GqlViewFilter[],
) => {
const filterableFieldMetadataItems =
getFilterableFields(objectMetadataItem);
return mapViewFiltersToFilters(viewFilters, filterableFieldMetadataItems);
return mapViewFiltersToFilters(
viewFilters,
filterableFieldMetadataItems,
flattenedFieldMetadataItems,
);
};
return { mapViewFiltersToRecordFilters };

View File

@@ -1,5 +1,6 @@
import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState';
import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState';
import { relationTargetFieldMetadataIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/relationTargetFieldMetadataIdUsedInDropdownComponentState';
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/subFieldNameUsedInDropdownComponentState';
import { useFilterableFieldMetadataItemsInRecordIndexContext } from '@/object-record/record-filter/hooks/useFilterableFieldMetadataItemsInRecordIndexContext';
@@ -59,6 +60,15 @@ export const useSetEditableFilterChipDropdownStates = () => {
}),
recordFilter.subFieldName,
);
store.set(
relationTargetFieldMetadataIdUsedInDropdownComponentState.atomFamily({
instanceId: getEditableChipObjectFilterDropdownComponentInstanceId({
recordFilterId: recordFilter.id,
}),
}),
recordFilter.relationTargetFieldMetadataId,
);
},
[store, filterableFieldMetadataItems],
);

View File

@@ -43,6 +43,8 @@ describe('mapViewFiltersToFilters', () => {
type: FieldMetadataType.FULL_NAME,
positionInRecordFilterGroup: undefined,
recordFilterGroupId: undefined,
subFieldName: undefined,
relationTargetFieldMetadataId: null,
},
];
expect(

View File

@@ -15,6 +15,10 @@ import { type ViewFilter } from '@/views/types/ViewFilter';
export const mapViewFiltersToFilters = (
viewFilters: ViewFilter[] | GqlViewFilter[],
availableFieldMetadataItems: FieldMetadataItem[],
// All field metadata items across every object, used to resolve relation
// traversal targets that live on a different object than the source field.
// Defaults to `availableFieldMetadataItems` for non-traversal callers.
allFieldMetadataItems: FieldMetadataItem[] = availableFieldMetadataItems,
): RecordFilter[] => {
return viewFilters
.map((viewFilter) => {
@@ -28,18 +32,6 @@ export const mapViewFiltersToFilters = (
return undefined;
}
const filterType = getFilterTypeFromFieldType(
availableFieldMetadataItem.type,
);
const label = isSystemSearchVectorField(availableFieldMetadataItem.name)
? 'Search'
: availableFieldMetadataItem.label;
const operand = viewFilter.operand;
const stringValue = convertViewFilterValueToString(viewFilter.value);
// The codegen-generated `GqlViewFilter` and the local `ViewFilter`
// type don't both expose this field; `in` narrows safely across both.
const relationTargetFieldMetadataId =
@@ -47,6 +39,32 @@ export const mapViewFiltersToFilters = (
? (viewFilter.relationTargetFieldMetadataId ?? null)
: null;
const relationTargetFieldMetadataItem = isDefined(
relationTargetFieldMetadataId,
)
? allFieldMetadataItems.find(
(fieldMetadataItem) =>
fieldMetadataItem.id === relationTargetFieldMetadataId,
)
: undefined;
// For relation traversal, the operand picker / value input must match
// the target field's type, and the label must reflect both hops so the
// filter is recognizable in the UI (e.g. "Company → Name").
const filterType = isDefined(relationTargetFieldMetadataItem)
? getFilterTypeFromFieldType(relationTargetFieldMetadataItem.type)
: getFilterTypeFromFieldType(availableFieldMetadataItem.type);
const label = isSystemSearchVectorField(availableFieldMetadataItem.name)
? 'Search'
: isDefined(relationTargetFieldMetadataItem)
? `${availableFieldMetadataItem.label}${relationTargetFieldMetadataItem.label}`
: availableFieldMetadataItem.label;
const operand = viewFilter.operand;
const stringValue = convertViewFilterValueToString(viewFilter.value);
return {
id: viewFilter.id,
fieldMetadataId: viewFilter.fieldMetadataId,

View File

@@ -71,8 +71,10 @@ export class ViewFilterEntity
@Column({ nullable: true, type: 'uuid', default: null })
relationTargetFieldMetadataId: string | null;
// ON DELETE SET NULL — if the target field is deleted the filter survives
// and falls back to relation-by-record semantics rather than being lost.
// ON DELETE SET NULL keeps the row when the target field is deleted, but
// the operand/value stay shaped for the original target. The load path
// therefore drops any filter where `relationTargetFieldMetadataId` has
// become null after a non-null persisted value (deferred to caller).
@ManyToOne(() => FieldMetadataEntity, {
onDelete: 'SET NULL',
nullable: true,