diff --git a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useSaveRecordTableWidgetViews.test.tsx b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useSaveRecordTableWidgetViews.test.tsx new file mode 100644 index 0000000000..2d41b494f5 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useSaveRecordTableWidgetViews.test.tsx @@ -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) => + ({ children }: { children: ReactNode }) => ( + + + {children} + + + ); + +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, + ); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useSaveRecordTableWidgetViews.ts b/packages/twenty-front/src/modules/page-layout/hooks/useSaveRecordTableWidgetViews.ts index dbeaa6f909..4d9b938c82 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useSaveRecordTableWidgetViews.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useSaveRecordTableWidgetViews.ts @@ -137,6 +137,8 @@ export const useSaveRecordTableWidgetViews = () => { positionInViewFilterGroup: filter.positionInViewFilterGroup ?? undefined, subFieldName: filter.subFieldName ?? undefined, + relationTargetFieldMetadataId: + filter.relationTargetFieldMetadataId ?? undefined, })), viewFilterGroups: widgetViewDraft.viewFilterGroups.map( (group) => ({ diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/__tests__/useRecordTableWidgetFilterCallbacks.test.tsx b/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/__tests__/useRecordTableWidgetFilterCallbacks.test.tsx new file mode 100644 index 0000000000..0209738a97 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/__tests__/useRecordTableWidgetFilterCallbacks.test.tsx @@ -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) => + ({ children }: { children: ReactNode }) => ( + {children} + ); + +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, + }), + ]); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useRecordTableWidgetFilterCallbacks.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useRecordTableWidgetFilterCallbacks.ts index c8ddaa9e60..28201e27c6 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useRecordTableWidgetFilterCallbacks.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useRecordTableWidgetFilterCallbacks.ts @@ -61,6 +61,8 @@ export const useRecordTableWidgetFilterCallbacks = ({ positionInViewFilterGroup: recordFilter.positionInRecordFilterGroup ?? null, subFieldName: recordFilter.subFieldName ?? null, + relationTargetFieldMetadataId: + recordFilter.relationTargetFieldMetadataId ?? null, })), viewFilterGroups: currentRecordFilterGroups.map((recordFilterGroup) => mapRecordFilterGroupToViewFilterGroup({