diff --git a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useDuplicatePageLayoutTab.test.tsx b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useDuplicatePageLayoutTab.test.tsx new file mode 100644 index 0000000000..3383f59183 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/useDuplicatePageLayoutTab.test.tsx @@ -0,0 +1,300 @@ +import { metadataStoreState } from '@/metadata-store/states/metadataStoreState'; +import { useDuplicatePageLayoutTab } from '@/page-layout/hooks/useDuplicatePageLayoutTab'; +import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; +import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState'; +import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; +import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab'; +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { type RecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot'; +import { getTabListInstanceIdFromPageLayoutId } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutId'; +import { act, renderHook } from '@testing-library/react'; +import { createStore } from 'jotai'; +import { type ReactNode } from 'react'; +import { + PageLayoutTabLayoutMode, + PageLayoutType, + ViewOpenRecordIn, + ViewType, + ViewVisibility, + WidgetConfigurationType, + WidgetType, +} from '~/generated-metadata/graphql'; + +import { + PAGE_LAYOUT_TEST_INSTANCE_ID, + PageLayoutTestWrapper, +} from './PageLayoutTestWrapper'; + +jest.mock('uuid', () => ({ + v4: jest.fn(), +})); + +jest.mock('@/side-panel/hooks/useSidePanelMenu', () => ({ + useSidePanelMenu: () => ({ + closeSidePanelMenu: jest.fn(), + }), +})); + +jest.mock( + '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel', + () => ({ + useNavigatePageLayoutSidePanel: () => ({ + navigatePageLayoutSidePanel: jest.fn(), + }), + }), +); + +const SOURCE_TAB_ID = 'source-tab-id'; +const SOURCE_WIDGET_ID = 'source-widget-id'; +const SOURCE_VIEW_ID = 'source-view-id'; + +const makeRecordTableWidget = (): PageLayoutWidget => + ({ + __typename: 'PageLayoutWidget', + id: SOURCE_WIDGET_ID, + applicationId: 'application-id', + pageLayoutTabId: SOURCE_TAB_ID, + title: 'Companies', + type: WidgetType.RECORD_TABLE, + objectMetadataId: 'object-metadata-id', + gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 }, + position: { + __typename: 'PageLayoutWidgetGridPosition', + layoutMode: PageLayoutTabLayoutMode.GRID, + row: 0, + column: 0, + rowSpan: 4, + columnSpan: 4, + }, + configuration: { + __typename: 'RecordTableConfiguration', + configurationType: WidgetConfigurationType.RECORD_TABLE, + viewId: SOURCE_VIEW_ID, + }, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + deletedAt: null, + isActive: true, + }) as PageLayoutWidget; + +const makeSourceTab = (widgets: PageLayoutWidget[]): PageLayoutTab => + ({ + __typename: 'PageLayoutTab', + id: SOURCE_TAB_ID, + applicationId: 'application-id', + title: 'Companies', + icon: null, + position: 0, + layoutMode: PageLayoutTabLayoutMode.GRID, + pageLayoutId: PAGE_LAYOUT_TEST_INSTANCE_ID, + widgets, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + deletedAt: null, + isActive: true, + }) as PageLayoutTab; + +const makeDraftPageLayout = (tabs: PageLayoutTab[]): DraftPageLayout => ({ + id: PAGE_LAYOUT_TEST_INSTANCE_ID, + name: 'Test Layout', + type: PageLayoutType.DASHBOARD, + objectMetadataId: null, + tabs, +}); + +const sourceRecordTableViewSnapshot: RecordTableWidgetViewSnapshot = { + view: { + id: SOURCE_VIEW_ID, + name: 'Companies Table', + icon: 'IconTable', + objectMetadataId: 'object-metadata-id', + type: ViewType.TABLE_WIDGET, + isCompact: false, + position: 0, + openRecordIn: ViewOpenRecordIn.RECORD_PAGE, + visibility: ViewVisibility.WORKSPACE, + shouldHideEmptyGroups: false, + isActive: true, + }, + viewFields: [ + { + id: 'source-view-field-id', + viewId: SOURCE_VIEW_ID, + fieldMetadataId: 'field-metadata-id', + position: 0, + size: 180, + isVisible: true, + isActive: true, + }, + ], + viewFilterGroups: [], + viewFilters: [], + viewSorts: [], +}; + +describe('useDuplicatePageLayoutTab', () => { + const getWrapper = + (store = createStore()) => + ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + const getPageLayoutDraftAtom = () => + pageLayoutDraftComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }); + + const getPageLayoutCurrentLayoutsAtom = () => + pageLayoutCurrentLayoutsComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }); + + const getRecordTableWidgetViewDraftAtom = () => + recordTableWidgetViewDraftComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should clone record-table widget views when duplicating a tab', () => { + const uuidModule = require('uuid'); + uuidModule.v4 + .mockReturnValueOnce('new-tab-id') + .mockReturnValueOnce('new-widget-id') + .mockReturnValueOnce('new-view-id') + .mockReturnValueOnce('new-view-field-id'); + + const store = createStore(); + const recordTableWidget = makeRecordTableWidget(); + + store.set( + getPageLayoutDraftAtom(), + makeDraftPageLayout([makeSourceTab([recordTableWidget])]), + ); + store.set(getPageLayoutCurrentLayoutsAtom(), { + [SOURCE_TAB_ID]: { + desktop: [{ i: SOURCE_WIDGET_ID, x: 0, y: 0, w: 4, h: 4 }], + mobile: [{ i: SOURCE_WIDGET_ID, x: 0, y: 0, w: 1, h: 4 }], + }, + }); + store.set(getRecordTableWidgetViewDraftAtom(), { + [SOURCE_WIDGET_ID]: sourceRecordTableViewSnapshot, + }); + + const { result } = renderHook( + () => + useDuplicatePageLayoutTab({ + pageLayoutId: PAGE_LAYOUT_TEST_INSTANCE_ID, + tabListInstanceId: getTabListInstanceIdFromPageLayoutId( + PAGE_LAYOUT_TEST_INSTANCE_ID, + ), + }), + { wrapper: getWrapper(store) }, + ); + + act(() => { + result.current.duplicateTab(SOURCE_TAB_ID); + }); + + const pageLayoutDraft = store.get(getPageLayoutDraftAtom()); + const duplicatedTab = pageLayoutDraft.tabs.find( + (tab) => tab.id === 'new-tab-id', + ); + const duplicatedWidget = duplicatedTab?.widgets[0]; + const recordTableWidgetViewDraft = store.get( + getRecordTableWidgetViewDraftAtom(), + ); + + expect(duplicatedWidget?.id).toBe('new-widget-id'); + expect(duplicatedWidget?.configuration).toMatchObject({ + viewId: 'new-view-id', + }); + expect(recordTableWidgetViewDraft['new-widget-id'].view.id).toBe( + 'new-view-id', + ); + expect(recordTableWidgetViewDraft['new-widget-id'].viewFields[0]).toEqual( + expect.objectContaining({ + id: 'new-view-field-id', + viewId: 'new-view-id', + }), + ); + }); + + it('should clone record-table widget views from metadata when source draft is not initialized', () => { + const uuidModule = require('uuid'); + uuidModule.v4 + .mockReturnValueOnce('new-tab-id') + .mockReturnValueOnce('new-widget-id') + .mockReturnValueOnce('new-view-id') + .mockReturnValueOnce('new-view-field-id'); + + const store = createStore(); + const recordTableWidget = makeRecordTableWidget(); + + store.set( + getPageLayoutDraftAtom(), + makeDraftPageLayout([makeSourceTab([recordTableWidget])]), + ); + store.set(getPageLayoutCurrentLayoutsAtom(), { + [SOURCE_TAB_ID]: { + desktop: [{ i: SOURCE_WIDGET_ID, x: 0, y: 0, w: 4, h: 4 }], + mobile: [{ i: SOURCE_WIDGET_ID, x: 0, y: 0, w: 1, h: 4 }], + }, + }); + store.set(metadataStoreState.atomFamily('views'), { + current: [sourceRecordTableViewSnapshot.view], + draft: [], + status: 'up-to-date', + }); + store.set(metadataStoreState.atomFamily('viewFields'), { + current: sourceRecordTableViewSnapshot.viewFields, + draft: [], + status: 'up-to-date', + }); + + const { result } = renderHook( + () => + useDuplicatePageLayoutTab({ + pageLayoutId: PAGE_LAYOUT_TEST_INSTANCE_ID, + tabListInstanceId: getTabListInstanceIdFromPageLayoutId( + PAGE_LAYOUT_TEST_INSTANCE_ID, + ), + }), + { wrapper: getWrapper(store) }, + ); + + act(() => { + result.current.duplicateTab(SOURCE_TAB_ID); + }); + + const pageLayoutDraft = store.get(getPageLayoutDraftAtom()); + const duplicatedTab = pageLayoutDraft.tabs.find( + (tab) => tab.id === 'new-tab-id', + ); + const duplicatedWidget = duplicatedTab?.widgets[0]; + const recordTableWidgetViewDraft = store.get( + getRecordTableWidgetViewDraftAtom(), + ); + + expect(duplicatedWidget?.configuration).toMatchObject({ + viewId: 'new-view-id', + }); + expect(recordTableWidgetViewDraft['new-widget-id'].view.id).toBe( + 'new-view-id', + ); + expect(recordTableWidgetViewDraft['new-widget-id'].viewFields[0]).toEqual( + expect.objectContaining({ + id: 'new-view-field-id', + viewId: 'new-view-id', + }), + ); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useDuplicatePageLayoutTab.ts b/packages/twenty-front/src/modules/page-layout/hooks/useDuplicatePageLayoutTab.ts index 7941e44127..f047e9cdef 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useDuplicatePageLayoutTab.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useDuplicatePageLayoutTab.ts @@ -1,4 +1,5 @@ import { useDuplicateFieldsWidgetForPageLayout } from '@/page-layout/hooks/useDuplicateFieldsWidgetForPageLayout'; +import { useDuplicateRecordTableWidgetForPageLayout } from '@/page-layout/hooks/useDuplicateRecordTableWidgetForPageLayout'; import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState'; import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; @@ -62,6 +63,11 @@ export const useDuplicatePageLayoutTab = ({ pageLayoutId, }); + const { duplicateRecordTableWidget } = + useDuplicateRecordTableWidgetForPageLayout({ + pageLayoutId, + }); + const duplicateTab = useCallback( (tabId: string): string => { const currentPageLayoutDraft = store.get(pageLayoutDraft); @@ -84,15 +90,20 @@ export const useDuplicatePageLayoutTab = ({ const newWidgetId = uuidv4(); widgetOldIdNewIdMap.set(widget.id, newWidgetId); - const fieldsWidgetCopyResult = duplicateFieldsWidget({ - sourceWidget: widget, - newWidgetId, - }); + const widgetViewCopyResult = + duplicateFieldsWidget({ + sourceWidget: widget, + newWidgetId, + }) ?? + duplicateRecordTableWidget({ + sourceWidget: widget, + newWidgetId, + }); - const clonedConfiguration = isDefined(fieldsWidgetCopyResult) + const clonedConfiguration = isDefined(widgetViewCopyResult) ? { ...widget.configuration, - viewId: fieldsWidgetCopyResult.newViewId, + viewId: widgetViewCopyResult.newViewId, } : widget.configuration; @@ -168,6 +179,7 @@ export const useDuplicatePageLayoutTab = ({ [ closeSidePanelMenu, duplicateFieldsWidget, + duplicateRecordTableWidget, navigatePageLayoutSidePanel, pageLayoutCurrentLayouts, pageLayoutDraft, diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useDuplicatePageLayoutWidget.ts b/packages/twenty-front/src/modules/page-layout/hooks/useDuplicatePageLayoutWidget.ts index d683c6c20a..74448d7fd8 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useDuplicatePageLayoutWidget.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useDuplicatePageLayoutWidget.ts @@ -1,4 +1,5 @@ import { useDuplicateFieldsWidgetForPageLayout } from '@/page-layout/hooks/useDuplicateFieldsWidgetForPageLayout'; +import { useDuplicateRecordTableWidgetForPageLayout } from '@/page-layout/hooks/useDuplicateRecordTableWidgetForPageLayout'; import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState'; import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; @@ -50,6 +51,11 @@ export const useDuplicatePageLayoutWidget = ( pageLayoutId, }); + const { duplicateRecordTableWidget } = + useDuplicateRecordTableWidgetForPageLayout({ + pageLayoutId, + }); + const duplicateWidget = useCallback( (widgetId: string): string => { const pageLayoutDraft = store.get(pageLayoutDraftState); @@ -76,15 +82,14 @@ export const useDuplicatePageLayoutWidget = ( const newWidgetId = uuidv4(); - const fieldsWidgetCopyResult = duplicateFieldsWidget({ - sourceWidget, - newWidgetId, - }); + const widgetViewCopyResult = + duplicateFieldsWidget({ sourceWidget, newWidgetId }) ?? + duplicateRecordTableWidget({ sourceWidget, newWidgetId }); - const clonedConfiguration = isDefined(fieldsWidgetCopyResult) + const clonedConfiguration = isDefined(widgetViewCopyResult) ? { ...sourceWidget.configuration, - viewId: fieldsWidgetCopyResult.newViewId, + viewId: widgetViewCopyResult.newViewId, } : sourceWidget.configuration; @@ -156,6 +161,7 @@ export const useDuplicatePageLayoutWidget = ( }, [ duplicateFieldsWidget, + duplicateRecordTableWidget, pageLayoutCurrentLayoutsState, pageLayoutDraftState, setPageLayoutEditingWidgetId, diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useDuplicateRecordTableWidgetForPageLayout.ts b/packages/twenty-front/src/modules/page-layout/hooks/useDuplicateRecordTableWidgetForPageLayout.ts new file mode 100644 index 0000000000..ed20dc4437 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/hooks/useDuplicateRecordTableWidgetForPageLayout.ts @@ -0,0 +1,89 @@ +import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState'; +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { getWidgetConfigurationViewId } from '@/page-layout/utils/getWidgetConfigurationViewId'; +import { buildRecordTableWidgetViewSnapshotFromView } from '@/page-layout/widgets/record-table/utils/buildRecordTableWidgetViewSnapshotFromView'; +import { cloneRecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/utils/cloneRecordTableWidgetViewSnapshot'; +import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; +import { viewFromViewIdFamilySelector } from '@/views/states/selectors/viewFromViewIdFamilySelector'; +import { useStore } from 'jotai'; +import { useCallback } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { WidgetType } from '~/generated-metadata/graphql'; + +type DuplicateRecordTableWidgetParams = { + sourceWidget: PageLayoutWidget; + newWidgetId: string; +}; + +type DuplicateRecordTableWidgetResult = { + newViewId: string; +}; + +export const useDuplicateRecordTableWidgetForPageLayout = ({ + pageLayoutId, +}: { + pageLayoutId: string; +}) => { + const store = useStore(); + + const recordTableWidgetViewDraftState = useAtomComponentStateCallbackState( + recordTableWidgetViewDraftComponentState, + pageLayoutId, + ); + + const duplicateRecordTableWidget = useCallback( + ({ + sourceWidget, + newWidgetId, + }: DuplicateRecordTableWidgetParams): DuplicateRecordTableWidgetResult | null => { + if (sourceWidget.type !== WidgetType.RECORD_TABLE) { + return null; + } + + const sourceSnapshotFromDraft = store.get( + recordTableWidgetViewDraftState, + )[sourceWidget.id]; + + const sourceViewId = getWidgetConfigurationViewId( + sourceWidget.configuration, + ); + + const sourceViewFromMetadataStore = + !isDefined(sourceSnapshotFromDraft) && isDefined(sourceViewId) + ? store.get( + viewFromViewIdFamilySelector.selectorFamily({ + viewId: sourceViewId, + }), + ) + : undefined; + + const sourceSnapshotFromMetadataStore = isDefined( + sourceViewFromMetadataStore, + ) + ? buildRecordTableWidgetViewSnapshotFromView( + sourceViewFromMetadataStore, + ) + : undefined; + + const snapshotToClone = + sourceSnapshotFromDraft ?? sourceSnapshotFromMetadataStore; + + if (!isDefined(snapshotToClone)) { + return null; + } + + const clonedSnapshot = + cloneRecordTableWidgetViewSnapshot(snapshotToClone); + + store.set(recordTableWidgetViewDraftState, (previousDraft) => ({ + ...previousDraft, + [newWidgetId]: clonedSnapshot, + })); + + return { newViewId: clonedSnapshot.view.id }; + }, + [recordTableWidgetViewDraftState, store], + ); + + return { duplicateRecordTableWidget }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useInitializeRecordTableWidgetViewDraft.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useInitializeRecordTableWidgetViewDraft.ts index 8f67244ad8..2f3f77dbc2 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useInitializeRecordTableWidgetViewDraft.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useInitializeRecordTableWidgetViewDraft.ts @@ -1,10 +1,6 @@ -import { type FlatViewField } from '@/metadata-store/types/FlatViewField'; -import { type FlatViewFilter } from '@/metadata-store/types/FlatViewFilter'; -import { type FlatViewFilterGroup } from '@/metadata-store/types/FlatViewFilterGroup'; -import { type FlatViewSort } from '@/metadata-store/types/FlatViewSort'; import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState'; import { recordTableWidgetViewPersistedComponentState } from '@/page-layout/states/recordTableWidgetViewPersistedComponentState'; -import { type RecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot'; +import { buildRecordTableWidgetViewSnapshotFromView } from '@/page-layout/widgets/record-table/utils/buildRecordTableWidgetViewSnapshotFromView'; import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; import { type View } from '@/views/types/View'; import { useStore } from 'jotai'; @@ -41,44 +37,7 @@ export const useInitializeRecordTableWidgetViewDraft = ({ return; } - const { - viewFields, - viewFilters, - viewFilterGroups, - viewSorts, - viewGroups: _viewGroups, - ...viewProps - } = view; - - const flatViewFields: FlatViewField[] = viewFields.map((field) => ({ - ...field, - viewId: view.id, - })); - - const flatViewFilters: FlatViewFilter[] = viewFilters.map((filter) => ({ - ...filter, - viewId: view.id, - })); - - const flatViewFilterGroups: FlatViewFilterGroup[] = ( - viewFilterGroups ?? [] - ).map((group) => ({ - ...group, - viewId: view.id, - })); - - const flatViewSorts: FlatViewSort[] = viewSorts.map((sort) => ({ - ...sort, - viewId: view.id, - })); - - const snapshot: RecordTableWidgetViewSnapshot = { - view: viewProps, - viewFields: flatViewFields, - viewFilters: flatViewFilters, - viewFilterGroups: flatViewFilterGroups, - viewSorts: flatViewSorts, - }; + const snapshot = buildRecordTableWidgetViewSnapshotFromView(view); store.set(recordTableWidgetViewDraftState, (prev) => ({ ...prev, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/cloneRecordTableWidgetViewSnapshot.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/cloneRecordTableWidgetViewSnapshot.test.ts new file mode 100644 index 0000000000..1488861228 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/cloneRecordTableWidgetViewSnapshot.test.ts @@ -0,0 +1,134 @@ +import { type RecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot'; +import { cloneRecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/utils/cloneRecordTableWidgetViewSnapshot'; +import { ViewFilterOperand } from 'twenty-shared/types'; +import { + ViewFilterGroupLogicalOperator, + ViewOpenRecordIn, + ViewSortDirection, + ViewType, + ViewVisibility, +} from '~/generated-metadata/graphql'; + +const SOURCE_VIEW_ID = 'source-view-id'; +const PARENT_FILTER_GROUP_ID = 'parent-filter-group-id'; +const CHILD_FILTER_GROUP_ID = 'child-filter-group-id'; + +const sourceSnapshot: RecordTableWidgetViewSnapshot = { + view: { + id: SOURCE_VIEW_ID, + name: 'Companies Table', + icon: 'IconTable', + objectMetadataId: 'object-metadata-id', + type: ViewType.TABLE_WIDGET, + isCompact: false, + position: 0, + openRecordIn: ViewOpenRecordIn.RECORD_PAGE, + visibility: ViewVisibility.WORKSPACE, + shouldHideEmptyGroups: false, + isActive: true, + }, + viewFields: [ + { + id: 'view-field-id', + viewId: SOURCE_VIEW_ID, + fieldMetadataId: 'field-metadata-id', + position: 0, + size: 180, + isVisible: true, + isActive: true, + }, + ], + viewFilterGroups: [ + { + id: PARENT_FILTER_GROUP_ID, + viewId: SOURCE_VIEW_ID, + logicalOperator: ViewFilterGroupLogicalOperator.AND, + parentViewFilterGroupId: null, + positionInViewFilterGroup: null, + }, + { + id: CHILD_FILTER_GROUP_ID, + viewId: SOURCE_VIEW_ID, + logicalOperator: ViewFilterGroupLogicalOperator.OR, + parentViewFilterGroupId: PARENT_FILTER_GROUP_ID, + positionInViewFilterGroup: 0, + }, + ], + viewFilters: [ + { + id: 'view-filter-id', + viewId: SOURCE_VIEW_ID, + fieldMetadataId: 'field-metadata-id', + operand: ViewFilterOperand.IS, + value: 'Acme', + viewFilterGroupId: CHILD_FILTER_GROUP_ID, + positionInViewFilterGroup: 0, + subFieldName: null, + }, + ], + viewSorts: [ + { + id: 'view-sort-id', + viewId: SOURCE_VIEW_ID, + fieldMetadataId: 'field-metadata-id', + direction: ViewSortDirection.ASC, + }, + ], +}; + +describe('cloneRecordTableWidgetViewSnapshot', () => { + it('should assign a new view id and re-point every nested viewId to it', () => { + const clonedSnapshot = cloneRecordTableWidgetViewSnapshot(sourceSnapshot); + + expect(clonedSnapshot.view.id).not.toBe(SOURCE_VIEW_ID); + + const newViewId = clonedSnapshot.view.id; + + expect(clonedSnapshot.viewFields[0].viewId).toBe(newViewId); + expect(clonedSnapshot.viewFilterGroups[0].viewId).toBe(newViewId); + expect(clonedSnapshot.viewFilters[0].viewId).toBe(newViewId); + expect(clonedSnapshot.viewSorts[0].viewId).toBe(newViewId); + }); + + it('should regenerate row ids so the duplicate persists without colliding with the source', () => { + const clonedSnapshot = cloneRecordTableWidgetViewSnapshot(sourceSnapshot); + + expect(clonedSnapshot.viewFields[0].id).not.toBe('view-field-id'); + expect(clonedSnapshot.viewFilters[0].id).not.toBe('view-filter-id'); + expect(clonedSnapshot.viewSorts[0].id).not.toBe('view-sort-id'); + expect(clonedSnapshot.viewFilterGroups[0].id).not.toBe( + PARENT_FILTER_GROUP_ID, + ); + expect(clonedSnapshot.viewFilterGroups[1].id).not.toBe( + CHILD_FILTER_GROUP_ID, + ); + }); + + it('should re-point filter-group references to the regenerated group ids', () => { + const clonedSnapshot = cloneRecordTableWidgetViewSnapshot(sourceSnapshot); + + const [parentGroup, childGroup] = clonedSnapshot.viewFilterGroups; + + expect(childGroup.parentViewFilterGroupId).toBe(parentGroup.id); + expect(clonedSnapshot.viewFilters[0].viewFilterGroupId).toBe(childGroup.id); + }); + + it('should preserve filter, sort and field content', () => { + const clonedSnapshot = cloneRecordTableWidgetViewSnapshot(sourceSnapshot); + + expect(clonedSnapshot.viewFilters[0]).toMatchObject({ + fieldMetadataId: 'field-metadata-id', + operand: ViewFilterOperand.IS, + value: 'Acme', + }); + expect(clonedSnapshot.viewSorts[0]).toMatchObject({ + fieldMetadataId: 'field-metadata-id', + direction: ViewSortDirection.ASC, + }); + expect(clonedSnapshot.viewFields[0]).toMatchObject({ + fieldMetadataId: 'field-metadata-id', + position: 0, + size: 180, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildRecordTableWidgetViewSnapshotFromView.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildRecordTableWidgetViewSnapshotFromView.ts new file mode 100644 index 0000000000..7c2b25609b --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildRecordTableWidgetViewSnapshotFromView.ts @@ -0,0 +1,50 @@ +import { type FlatViewField } from '@/metadata-store/types/FlatViewField'; +import { type FlatViewFilter } from '@/metadata-store/types/FlatViewFilter'; +import { type FlatViewFilterGroup } from '@/metadata-store/types/FlatViewFilterGroup'; +import { type FlatViewSort } from '@/metadata-store/types/FlatViewSort'; +import { type RecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot'; +import { type View } from '@/views/types/View'; + +export const buildRecordTableWidgetViewSnapshotFromView = ( + view: View, +): RecordTableWidgetViewSnapshot => { + const { + viewFields, + viewFilters, + viewFilterGroups, + viewSorts, + viewGroups: _viewGroups, + viewFieldGroups: _viewFieldGroups, + ...viewProps + } = view; + + const flatViewFields: FlatViewField[] = viewFields.map((field) => ({ + ...field, + viewId: view.id, + })); + + const flatViewFilters: FlatViewFilter[] = viewFilters.map((filter) => ({ + ...filter, + viewId: view.id, + })); + + const flatViewFilterGroups: FlatViewFilterGroup[] = ( + viewFilterGroups ?? [] + ).map((group) => ({ + ...group, + viewId: view.id, + })); + + const flatViewSorts: FlatViewSort[] = viewSorts.map((sort) => ({ + ...sort, + viewId: view.id, + })); + + return { + view: viewProps, + viewFields: flatViewFields, + viewFilters: flatViewFilters, + viewFilterGroups: flatViewFilterGroups, + viewSorts: flatViewSorts, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/cloneRecordTableWidgetViewSnapshot.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/cloneRecordTableWidgetViewSnapshot.ts new file mode 100644 index 0000000000..732cf42a26 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/cloneRecordTableWidgetViewSnapshot.ts @@ -0,0 +1,51 @@ +import { type RecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot'; +import { isDefined } from 'twenty-shared/utils'; +import { v4 as uuidv4 } from 'uuid'; + +export const cloneRecordTableWidgetViewSnapshot = ( + sourceSnapshot: RecordTableWidgetViewSnapshot, +): RecordTableWidgetViewSnapshot => { + const newViewId = uuidv4(); + + const previousToNewFilterGroupId = new Map( + sourceSnapshot.viewFilterGroups.map((filterGroup) => [ + filterGroup.id, + uuidv4(), + ]), + ); + + return { + view: { + ...sourceSnapshot.view, + id: newViewId, + }, + viewFields: sourceSnapshot.viewFields.map((viewField) => ({ + ...viewField, + id: uuidv4(), + viewId: newViewId, + })), + viewFilterGroups: sourceSnapshot.viewFilterGroups.map((filterGroup) => ({ + ...filterGroup, + id: previousToNewFilterGroupId.get(filterGroup.id) ?? uuidv4(), + viewId: newViewId, + parentViewFilterGroupId: isDefined(filterGroup.parentViewFilterGroupId) + ? (previousToNewFilterGroupId.get( + filterGroup.parentViewFilterGroupId, + ) ?? null) + : filterGroup.parentViewFilterGroupId, + })), + viewFilters: sourceSnapshot.viewFilters.map((viewFilter) => ({ + ...viewFilter, + id: uuidv4(), + viewId: newViewId, + viewFilterGroupId: isDefined(viewFilter.viewFilterGroupId) + ? (previousToNewFilterGroupId.get(viewFilter.viewFilterGroupId) ?? null) + : viewFilter.viewFilterGroupId, + })), + viewSorts: sourceSnapshot.viewSorts.map((viewSort) => ({ + ...viewSort, + id: uuidv4(), + viewId: newViewId, + })), + }; +};