Fix widget view save paths dropping relationTargetFieldMetadataId (#23814)

## Context

`viewFilter.relationTargetFieldMetadataId` (relation traversal, added in
2.6.0) is accepted and persisted by the `upsertViewWidget` mutation, and
`mapViewFiltersToFilters` restores it when loading a widget view. But
two frontend mappers silently dropped it, so any relation-traversal
filter on a record table widget view was lost the moment the layout was
saved (or the moment the user edited the widget's filters in the side
panel):

- `useSaveRecordTableWidgetViews` omitted the field when building the
`upsertViewWidget` input
- `useRecordTableWidgetFilterCallbacks` omitted it when syncing current
record filters back into the widget view draft

## Changes

- Carry `relationTargetFieldMetadataId` through both mappers
- Add regression tests for both hooks (they fail without the fix)

This is a prerequisite for nested relation field widgets (see follow-up
PR), which rely on a traversal filter surviving the widget view save
path.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Xp3AgGtc4kSP8PpgpKMWLQ)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23814?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Félix Malfait
2026-08-05 16:08:25 +02:00
committed by GitHub
parent 5b6734691c
commit 3f9137bd4e
4 changed files with 333 additions and 0 deletions
@@ -0,0 +1,188 @@
import { type FlatViewFilter } from '@/metadata-store/types/FlatViewFilter';
import { UPSERT_VIEW_WIDGET } from '@/page-layout/graphql/mutations/upsertViewWidget';
import { useSaveRecordTableWidgetViews } from '@/page-layout/hooks/useSaveRecordTableWidgetViews';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import {
makeDraft,
makeTab,
makeWidget,
} from '@/page-layout/testing/pageLayoutDraftFixtures';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { buildRecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/utils/buildRecordTableWidgetViewSnapshot';
import { type MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing/react';
import { act, renderHook } from '@testing-library/react';
import { createStore, Provider as JotaiProvider } from 'jotai';
import { type ReactNode } from 'react';
import { ViewFilterOperand } from 'twenty-shared/types';
import {
WidgetConfigurationType,
WidgetType,
type UpsertViewWidgetInput,
} from '~/generated-metadata/graphql';
import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow';
const PAGE_LAYOUT_ID = 'page-layout-id';
const WIDGET_ID = 'widget-id';
const SOURCE_FIELD_METADATA_ID = '20202020-51cf-4b06-b1d3-a836e857f9dd';
const RELATION_TARGET_FIELD_METADATA_ID =
'20202020-1af7-4b09-a58a-b18aaaa12b83';
const snapshot = buildRecordTableWidgetViewSnapshot(
getMockObjectMetadataItemOrThrow('company'),
);
const relationTraversalViewFilter: FlatViewFilter = {
id: 'view-filter-id',
fieldMetadataId: SOURCE_FIELD_METADATA_ID,
operand: ViewFilterOperand.IS,
value: JSON.stringify({
isCurrentRecordSelected: true,
selectedRecordIds: [],
}),
viewFilterGroupId: null,
positionInViewFilterGroup: null,
subFieldName: null,
relationTargetFieldMetadataId: RELATION_TARGET_FIELD_METADATA_ID,
viewId: snapshot.view.id,
};
const recordTableWidget = {
...makeWidget(WIDGET_ID, 0),
type: WidgetType.RECORD_TABLE,
configuration: {
__typename: 'RecordTableConfiguration' as const,
configurationType: WidgetConfigurationType.RECORD_TABLE,
viewId: snapshot.view.id,
},
} as unknown as PageLayoutWidget;
const upsertViewWidgetResult = jest.fn(
(variables: { input: UpsertViewWidgetInput }) => ({
data: {
upsertViewWidget: {
__typename: 'View',
id: variables.input.widgetId,
viewGroups: [],
},
},
}),
);
const mocks: MockedResponse[] = [
{
request: {
query: UPSERT_VIEW_WIDGET,
variables: () => true,
},
maxUsageCount: Number.POSITIVE_INFINITY,
result: upsertViewWidgetResult,
},
];
const getWrapper =
(store: ReturnType<typeof createStore>) =>
({ children }: { children: ReactNode }) => (
<JotaiProvider store={store}>
<MockedProvider
mocks={mocks}
defaultOptions={{ mutate: { fetchPolicy: 'no-cache' } }}
>
{children}
</MockedProvider>
</JotaiProvider>
);
describe('useSaveRecordTableWidgetViews', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should include relationTargetFieldMetadataId in the upsert view filters input', async () => {
const store = createStore();
store.set(
pageLayoutDraftComponentState.atomFamily({
instanceId: PAGE_LAYOUT_ID,
}),
makeDraft([makeTab('tab-1', [recordTableWidget])]),
);
store.set(
recordTableWidgetViewDraftComponentState.atomFamily({
instanceId: PAGE_LAYOUT_ID,
}),
{
[WIDGET_ID]: {
...snapshot,
viewFilters: [relationTraversalViewFilter],
},
},
);
const { result } = renderHook(() => useSaveRecordTableWidgetViews(), {
wrapper: getWrapper(store),
});
await act(async () => {
await result.current.saveRecordTableWidgetViews(PAGE_LAYOUT_ID);
});
expect(upsertViewWidgetResult).toHaveBeenCalledTimes(1);
const { input } = upsertViewWidgetResult.mock.calls[0][0];
expect(input.widgetId).toBe(WIDGET_ID);
expect(input.viewFilters).toEqual([
expect.objectContaining({
id: relationTraversalViewFilter.id,
fieldMetadataId: SOURCE_FIELD_METADATA_ID,
relationTargetFieldMetadataId: RELATION_TARGET_FIELD_METADATA_ID,
}),
]);
});
it('should send undefined relationTargetFieldMetadataId for direct filters', async () => {
const store = createStore();
store.set(
pageLayoutDraftComponentState.atomFamily({
instanceId: PAGE_LAYOUT_ID,
}),
makeDraft([makeTab('tab-1', [recordTableWidget])]),
);
store.set(
recordTableWidgetViewDraftComponentState.atomFamily({
instanceId: PAGE_LAYOUT_ID,
}),
{
[WIDGET_ID]: {
...snapshot,
viewFilters: [
{
...relationTraversalViewFilter,
relationTargetFieldMetadataId: null,
},
],
},
},
);
const { result } = renderHook(() => useSaveRecordTableWidgetViews(), {
wrapper: getWrapper(store),
});
await act(async () => {
await result.current.saveRecordTableWidgetViews(PAGE_LAYOUT_ID);
});
expect(upsertViewWidgetResult).toHaveBeenCalledTimes(1);
const { input } = upsertViewWidgetResult.mock.calls[0][0];
expect(input.viewFilters?.[0].relationTargetFieldMetadataId).toBe(
undefined,
);
});
});
@@ -137,6 +137,8 @@ export const useSaveRecordTableWidgetViews = () => {
positionInViewFilterGroup:
filter.positionInViewFilterGroup ?? undefined,
subFieldName: filter.subFieldName ?? undefined,
relationTargetFieldMetadataId:
filter.relationTargetFieldMetadataId ?? undefined,
})),
viewFilterGroups: widgetViewDraft.viewFilterGroups.map(
(group) => ({
@@ -0,0 +1,141 @@
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { useRecordTableWidgetFilterCallbacks } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetFilterCallbacks';
import { buildRecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/utils/buildRecordTableWidgetViewSnapshot';
import { act, renderHook } from '@testing-library/react';
import { createStore, Provider as JotaiProvider } from 'jotai';
import { type ReactNode } from 'react';
import { ViewFilterOperand } from 'twenty-shared/types';
import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow';
const PAGE_LAYOUT_ID = 'page-layout-id';
const WIDGET_ID = 'widget-id';
const RECORD_INDEX_ID = 'record-index-id';
const SOURCE_FIELD_METADATA_ID = '20202020-51cf-4b06-b1d3-a836e857f9dd';
const RELATION_TARGET_FIELD_METADATA_ID =
'20202020-1af7-4b09-a58a-b18aaaa12b83';
const snapshot = buildRecordTableWidgetViewSnapshot(
getMockObjectMetadataItemOrThrow('company'),
);
const relationTraversalRecordFilter: RecordFilter = {
id: 'record-filter-id',
fieldMetadataId: SOURCE_FIELD_METADATA_ID,
value: JSON.stringify({
isCurrentRecordSelected: true,
selectedRecordIds: [],
}),
displayValue: '',
type: 'RELATION',
operand: ViewFilterOperand.IS,
label: 'Company',
relationTargetFieldMetadataId: RELATION_TARGET_FIELD_METADATA_ID,
};
const getWrapper =
(store: ReturnType<typeof createStore>) =>
({ children }: { children: ReactNode }) => (
<JotaiProvider store={store}>{children}</JotaiProvider>
);
describe('useRecordTableWidgetFilterCallbacks', () => {
it('should carry relationTargetFieldMetadataId into the widget view draft filters', () => {
const store = createStore();
store.set(
recordTableWidgetViewDraftComponentState.atomFamily({
instanceId: PAGE_LAYOUT_ID,
}),
{ [WIDGET_ID]: snapshot },
);
store.set(
currentRecordFiltersComponentState.atomFamily({
instanceId: RECORD_INDEX_ID,
}),
[relationTraversalRecordFilter],
);
const { result } = renderHook(
() =>
useRecordTableWidgetFilterCallbacks({
pageLayoutId: PAGE_LAYOUT_ID,
widgetId: WIDGET_ID,
viewId: snapshot.view.id,
recordIndexId: RECORD_INDEX_ID,
}),
{ wrapper: getWrapper(store) },
);
act(() => {
result.current.handleFilterUpdate();
});
const updatedDraft = store.get(
recordTableWidgetViewDraftComponentState.atomFamily({
instanceId: PAGE_LAYOUT_ID,
}),
);
expect(updatedDraft[WIDGET_ID].viewFilters).toEqual([
expect.objectContaining({
id: relationTraversalRecordFilter.id,
fieldMetadataId: SOURCE_FIELD_METADATA_ID,
relationTargetFieldMetadataId: RELATION_TARGET_FIELD_METADATA_ID,
subFieldName: null,
}),
]);
});
it('should keep relationTargetFieldMetadataId null for direct filters', () => {
const store = createStore();
store.set(
recordTableWidgetViewDraftComponentState.atomFamily({
instanceId: PAGE_LAYOUT_ID,
}),
{ [WIDGET_ID]: snapshot },
);
store.set(
currentRecordFiltersComponentState.atomFamily({
instanceId: RECORD_INDEX_ID,
}),
[
{
...relationTraversalRecordFilter,
relationTargetFieldMetadataId: undefined,
},
],
);
const { result } = renderHook(
() =>
useRecordTableWidgetFilterCallbacks({
pageLayoutId: PAGE_LAYOUT_ID,
widgetId: WIDGET_ID,
viewId: snapshot.view.id,
recordIndexId: RECORD_INDEX_ID,
}),
{ wrapper: getWrapper(store) },
);
act(() => {
result.current.handleFilterUpdate();
});
const updatedDraft = store.get(
recordTableWidgetViewDraftComponentState.atomFamily({
instanceId: PAGE_LAYOUT_ID,
}),
);
expect(updatedDraft[WIDGET_ID].viewFilters).toEqual([
expect.objectContaining({
fieldMetadataId: SOURCE_FIELD_METADATA_ID,
relationTargetFieldMetadataId: null,
}),
]);
});
});
@@ -61,6 +61,8 @@ export const useRecordTableWidgetFilterCallbacks = ({
positionInViewFilterGroup:
recordFilter.positionInRecordFilterGroup ?? null,
subFieldName: recordFilter.subFieldName ?? null,
relationTargetFieldMetadataId:
recordFilter.relationTargetFieldMetadataId ?? null,
})),
viewFilterGroups: currentRecordFilterGroups.map((recordFilterGroup) =>
mapRecordFilterGroupToViewFilterGroup({