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



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21936?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:
nitin
2026-06-22 16:27:01 +05:30
committed by GitHub
parent 8b9e3a0fc3
commit eeca9cd42e
8 changed files with 656 additions and 55 deletions
@@ -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 }) => (
<PageLayoutTestWrapper
store={store}
instanceId={PAGE_LAYOUT_TEST_INSTANCE_ID}
>
{children}
</PageLayoutTestWrapper>
);
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',
}),
);
});
});
@@ -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,
@@ -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,
@@ -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 };
};
@@ -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,
@@ -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,
});
});
});
@@ -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,
};
};
@@ -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,
})),
};
};