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:
+188
@@ -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) => ({
|
||||
|
||||
+141
@@ -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,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
+2
@@ -61,6 +61,8 @@ export const useRecordTableWidgetFilterCallbacks = ({
|
||||
positionInViewFilterGroup:
|
||||
recordFilter.positionInRecordFilterGroup ?? null,
|
||||
subFieldName: recordFilter.subFieldName ?? null,
|
||||
relationTargetFieldMetadataId:
|
||||
recordFilter.relationTargetFieldMetadataId ?? null,
|
||||
})),
|
||||
viewFilterGroups: currentRecordFilterGroups.map((recordFilterGroup) =>
|
||||
mapRecordFilterGroupToViewFilterGroup({
|
||||
|
||||
Reference in New Issue
Block a user