From eeca9cd42e8caa4ee0c7e5516aefdc85ca8a7931 Mon Sep 17 00:00:00 2001
From: nitin <142569587+ehconitin@users.noreply.github.com>
Date: Mon, 22 Jun 2026 16:27:01 +0530
Subject: [PATCH] fix(front): isolate record table dashboard widget filters on
duplicate (#21936)
closes
https://discord.com/channels/1130383047699738754/1518291134382608394
https://github.com/user-attachments/assets/931e4e88-44e8-4634-a7f9-e0564bd80fff
---
.../useDuplicatePageLayoutTab.test.tsx | 300 ++++++++++++++++++
.../hooks/useDuplicatePageLayoutTab.ts | 24 +-
.../hooks/useDuplicatePageLayoutWidget.ts | 18 +-
...DuplicateRecordTableWidgetForPageLayout.ts | 89 ++++++
...useInitializeRecordTableWidgetViewDraft.ts | 45 +--
...cloneRecordTableWidgetViewSnapshot.test.ts | 134 ++++++++
...ldRecordTableWidgetViewSnapshotFromView.ts | 50 +++
.../cloneRecordTableWidgetViewSnapshot.ts | 51 +++
8 files changed, 656 insertions(+), 55 deletions(-)
create mode 100644 packages/twenty-front/src/modules/page-layout/hooks/__tests__/useDuplicatePageLayoutTab.test.tsx
create mode 100644 packages/twenty-front/src/modules/page-layout/hooks/useDuplicateRecordTableWidgetForPageLayout.ts
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/cloneRecordTableWidgetViewSnapshot.test.ts
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildRecordTableWidgetViewSnapshotFromView.ts
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/cloneRecordTableWidgetViewSnapshot.ts
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,
+ })),
+ };
+};