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:
+300
@@ -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 { useDuplicateFieldsWidgetForPageLayout } from '@/page-layout/hooks/useDuplicateFieldsWidgetForPageLayout';
|
||||||
|
import { useDuplicateRecordTableWidgetForPageLayout } from '@/page-layout/hooks/useDuplicateRecordTableWidgetForPageLayout';
|
||||||
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
|
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
|
||||||
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
|
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
|
||||||
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
|
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
|
||||||
@@ -62,6 +63,11 @@ export const useDuplicatePageLayoutTab = ({
|
|||||||
pageLayoutId,
|
pageLayoutId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { duplicateRecordTableWidget } =
|
||||||
|
useDuplicateRecordTableWidgetForPageLayout({
|
||||||
|
pageLayoutId,
|
||||||
|
});
|
||||||
|
|
||||||
const duplicateTab = useCallback(
|
const duplicateTab = useCallback(
|
||||||
(tabId: string): string => {
|
(tabId: string): string => {
|
||||||
const currentPageLayoutDraft = store.get(pageLayoutDraft);
|
const currentPageLayoutDraft = store.get(pageLayoutDraft);
|
||||||
@@ -84,15 +90,20 @@ export const useDuplicatePageLayoutTab = ({
|
|||||||
const newWidgetId = uuidv4();
|
const newWidgetId = uuidv4();
|
||||||
widgetOldIdNewIdMap.set(widget.id, newWidgetId);
|
widgetOldIdNewIdMap.set(widget.id, newWidgetId);
|
||||||
|
|
||||||
const fieldsWidgetCopyResult = duplicateFieldsWidget({
|
const widgetViewCopyResult =
|
||||||
sourceWidget: widget,
|
duplicateFieldsWidget({
|
||||||
newWidgetId,
|
sourceWidget: widget,
|
||||||
});
|
newWidgetId,
|
||||||
|
}) ??
|
||||||
|
duplicateRecordTableWidget({
|
||||||
|
sourceWidget: widget,
|
||||||
|
newWidgetId,
|
||||||
|
});
|
||||||
|
|
||||||
const clonedConfiguration = isDefined(fieldsWidgetCopyResult)
|
const clonedConfiguration = isDefined(widgetViewCopyResult)
|
||||||
? {
|
? {
|
||||||
...widget.configuration,
|
...widget.configuration,
|
||||||
viewId: fieldsWidgetCopyResult.newViewId,
|
viewId: widgetViewCopyResult.newViewId,
|
||||||
}
|
}
|
||||||
: widget.configuration;
|
: widget.configuration;
|
||||||
|
|
||||||
@@ -168,6 +179,7 @@ export const useDuplicatePageLayoutTab = ({
|
|||||||
[
|
[
|
||||||
closeSidePanelMenu,
|
closeSidePanelMenu,
|
||||||
duplicateFieldsWidget,
|
duplicateFieldsWidget,
|
||||||
|
duplicateRecordTableWidget,
|
||||||
navigatePageLayoutSidePanel,
|
navigatePageLayoutSidePanel,
|
||||||
pageLayoutCurrentLayouts,
|
pageLayoutCurrentLayouts,
|
||||||
pageLayoutDraft,
|
pageLayoutDraft,
|
||||||
|
|||||||
+12
-6
@@ -1,4 +1,5 @@
|
|||||||
import { useDuplicateFieldsWidgetForPageLayout } from '@/page-layout/hooks/useDuplicateFieldsWidgetForPageLayout';
|
import { useDuplicateFieldsWidgetForPageLayout } from '@/page-layout/hooks/useDuplicateFieldsWidgetForPageLayout';
|
||||||
|
import { useDuplicateRecordTableWidgetForPageLayout } from '@/page-layout/hooks/useDuplicateRecordTableWidgetForPageLayout';
|
||||||
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
|
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
|
||||||
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
|
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
|
||||||
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
|
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
|
||||||
@@ -50,6 +51,11 @@ export const useDuplicatePageLayoutWidget = (
|
|||||||
pageLayoutId,
|
pageLayoutId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { duplicateRecordTableWidget } =
|
||||||
|
useDuplicateRecordTableWidgetForPageLayout({
|
||||||
|
pageLayoutId,
|
||||||
|
});
|
||||||
|
|
||||||
const duplicateWidget = useCallback(
|
const duplicateWidget = useCallback(
|
||||||
(widgetId: string): string => {
|
(widgetId: string): string => {
|
||||||
const pageLayoutDraft = store.get(pageLayoutDraftState);
|
const pageLayoutDraft = store.get(pageLayoutDraftState);
|
||||||
@@ -76,15 +82,14 @@ export const useDuplicatePageLayoutWidget = (
|
|||||||
|
|
||||||
const newWidgetId = uuidv4();
|
const newWidgetId = uuidv4();
|
||||||
|
|
||||||
const fieldsWidgetCopyResult = duplicateFieldsWidget({
|
const widgetViewCopyResult =
|
||||||
sourceWidget,
|
duplicateFieldsWidget({ sourceWidget, newWidgetId }) ??
|
||||||
newWidgetId,
|
duplicateRecordTableWidget({ sourceWidget, newWidgetId });
|
||||||
});
|
|
||||||
|
|
||||||
const clonedConfiguration = isDefined(fieldsWidgetCopyResult)
|
const clonedConfiguration = isDefined(widgetViewCopyResult)
|
||||||
? {
|
? {
|
||||||
...sourceWidget.configuration,
|
...sourceWidget.configuration,
|
||||||
viewId: fieldsWidgetCopyResult.newViewId,
|
viewId: widgetViewCopyResult.newViewId,
|
||||||
}
|
}
|
||||||
: sourceWidget.configuration;
|
: sourceWidget.configuration;
|
||||||
|
|
||||||
@@ -156,6 +161,7 @@ export const useDuplicatePageLayoutWidget = (
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
duplicateFieldsWidget,
|
duplicateFieldsWidget,
|
||||||
|
duplicateRecordTableWidget,
|
||||||
pageLayoutCurrentLayoutsState,
|
pageLayoutCurrentLayoutsState,
|
||||||
pageLayoutDraftState,
|
pageLayoutDraftState,
|
||||||
setPageLayoutEditingWidgetId,
|
setPageLayoutEditingWidgetId,
|
||||||
|
|||||||
+89
@@ -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 };
|
||||||
|
};
|
||||||
+2
-43
@@ -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 { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
|
||||||
import { recordTableWidgetViewPersistedComponentState } from '@/page-layout/states/recordTableWidgetViewPersistedComponentState';
|
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 { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
|
||||||
import { type View } from '@/views/types/View';
|
import { type View } from '@/views/types/View';
|
||||||
import { useStore } from 'jotai';
|
import { useStore } from 'jotai';
|
||||||
@@ -41,44 +37,7 @@ export const useInitializeRecordTableWidgetViewDraft = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const snapshot = buildRecordTableWidgetViewSnapshotFromView(view);
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
store.set(recordTableWidgetViewDraftState, (prev) => ({
|
store.set(recordTableWidgetViewDraftState, (prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|||||||
+134
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+50
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
+51
@@ -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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user