Create a cancel action to leave the dashboard edit mode without saving (#14650)

Closes https://github.com/twentyhq/core-team-issues/issues/1523
This commit is contained in:
Raphaël Bosi
2025-09-23 10:36:37 +02:00
committed by GitHub
parent 1a1ef9f254
commit 3d27a1e48a
8 changed files with 220 additions and 30 deletions
@@ -1,3 +1,4 @@
import { CancelDashboardSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/dashboard-actions/components/CancelDashboardSingleRecordAction';
import { EditDashboardSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/dashboard-actions/components/EditDashboardSingleRecordAction';
import { DashboardSingleRecordActionKeys } from '@/action-menu/actions/record-actions/single-record/dashboard-actions/types/DashboardSingleRecordActionKeys';
import { SingleRecordActionKeys } from '@/action-menu/actions/record-actions/single-record/types/SingleRecordActionsKey';
@@ -7,7 +8,7 @@ import { ActionType } from '@/action-menu/actions/types/ActionType';
import { ActionViewType } from '@/action-menu/actions/types/ActionViewType';
import { msg } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconPencil } from 'twenty-ui/display';
import { IconPencil, IconX } from 'twenty-ui/display';
export const DASHBOARD_ACTIONS_CONFIG = inheritActionsFromDefaultConfig({
config: {
@@ -28,6 +29,23 @@ export const DASHBOARD_ACTIONS_CONFIG = inheritActionsFromDefaultConfig({
availableOn: [ActionViewType.SHOW_PAGE],
component: <EditDashboardSingleRecordAction />,
},
[DashboardSingleRecordActionKeys.CANCEL_DASHBOARD_EDITION]: {
key: DashboardSingleRecordActionKeys.CANCEL_DASHBOARD_EDITION,
label: msg`Cancel Edition`,
shortLabel: msg`Cancel`,
isPinned: true,
position: 2,
Icon: IconX,
type: ActionType.Standard,
scope: ActionScope.RecordSelection,
shouldBeRegistered: ({ selectedRecord }) =>
isDefined(selectedRecord) &&
!selectedRecord?.isRemote &&
!isDefined(selectedRecord?.deletedAt) &&
isDefined(selectedRecord?.pageLayoutId),
availableOn: [ActionViewType.SHOW_PAGE],
component: <CancelDashboardSingleRecordAction />,
},
},
actionKeys: [
SingleRecordActionKeys.ADD_TO_FAVORITES,
@@ -41,33 +59,33 @@ export const DASHBOARD_ACTIONS_CONFIG = inheritActionsFromDefaultConfig({
],
propertiesToOverwrite: {
[SingleRecordActionKeys.ADD_TO_FAVORITES]: {
position: 2,
},
[SingleRecordActionKeys.REMOVE_FROM_FAVORITES]: {
position: 3,
},
[SingleRecordActionKeys.DELETE]: {
[SingleRecordActionKeys.REMOVE_FROM_FAVORITES]: {
position: 4,
},
[SingleRecordActionKeys.DELETE]: {
position: 5,
label: msg`Delete dashboard`,
},
[SingleRecordActionKeys.EXPORT_FROM_RECORD_SHOW]: {
position: 5,
position: 6,
label: msg`Export dashboard`,
},
[SingleRecordActionKeys.DESTROY]: {
position: 6,
position: 7,
label: msg`Permanently destroy dashboard`,
},
[SingleRecordActionKeys.RESTORE]: {
position: 7,
position: 8,
label: msg`Restore dashboard`,
},
[SingleRecordActionKeys.NAVIGATE_TO_PREVIOUS_RECORD]: {
position: 8,
position: 9,
label: msg`Navigate to previous dashboard`,
},
[SingleRecordActionKeys.NAVIGATE_TO_NEXT_RECORD]: {
position: 9,
position: 10,
label: msg`Navigate to next dashboard`,
},
},
@@ -0,0 +1,27 @@
import { Action } from '@/action-menu/actions/components/Action';
import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow';
import { useResetDraftPageLayoutToPersistedPageLayout } from '@/dashboards/hooks/useResetDraftPageLayoutToPersistedPageLayout';
import { useSetIsDashboardInEditMode } from '@/dashboards/hooks/useSetDashboardInEditMode';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useRecoilValue } from 'recoil';
export const CancelDashboardSingleRecordAction = () => {
const recordId = useSelectedRecordIdOrThrow();
const selectedRecord = useRecoilValue(recordStoreFamilyState(recordId));
const pageLayoutId = selectedRecord?.pageLayoutId;
const { setIsDashboardInEditMode } =
useSetIsDashboardInEditMode(pageLayoutId);
const { resetDraftPageLayoutToPersistedPageLayout } =
useResetDraftPageLayoutToPersistedPageLayout(pageLayoutId);
const handleClick = () => {
resetDraftPageLayoutToPersistedPageLayout();
setIsDashboardInEditMode(false);
};
return <Action onClick={handleClick} />;
};
@@ -1,3 +1,4 @@
export enum DashboardSingleRecordActionKeys {
EDIT_DASHBOARD = 'edit-dashboard-single-record',
CANCEL_DASHBOARD_EDITION = 'cancel-dashboard-edition-single-record',
}
@@ -0,0 +1,64 @@
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
import { convertPageLayoutToTabLayouts } from '@/page-layout/utils/convertPageLayoutToTabLayouts';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const useResetDraftPageLayoutToPersistedPageLayout = (
pageLayoutIdFromProps?: string,
) => {
const componentInstanceId = useAvailableComponentInstanceIdOrThrow(
PageLayoutComponentInstanceContext,
pageLayoutIdFromProps,
);
const pageLayoutDraftState = useRecoilComponentCallbackState(
pageLayoutDraftComponentState,
componentInstanceId,
);
const pageLayoutPersistedState = useRecoilComponentCallbackState(
pageLayoutPersistedComponentState,
componentInstanceId,
);
const pageLayoutCurrentLayoutsState = useRecoilComponentCallbackState(
pageLayoutCurrentLayoutsComponentState,
componentInstanceId,
);
const resetDraftPageLayoutToPersistedPageLayout = useRecoilCallback(
({ set, snapshot }) =>
() => {
const pageLayoutPersisted = snapshot
.getLoadable(pageLayoutPersistedState)
.getValue();
if (isDefined(pageLayoutPersisted)) {
set(pageLayoutDraftState, {
id: pageLayoutPersisted.id,
name: pageLayoutPersisted.name,
type: pageLayoutPersisted.type,
objectMetadataId: pageLayoutPersisted.objectMetadataId,
tabs: pageLayoutPersisted.tabs,
});
const tabLayouts = convertPageLayoutToTabLayouts(pageLayoutPersisted);
set(pageLayoutCurrentLayoutsState, tabLayouts);
}
},
[
pageLayoutDraftState,
pageLayoutPersistedState,
pageLayoutCurrentLayoutsState,
],
);
return {
resetDraftPageLayoutToPersistedPageLayout,
};
};
@@ -28,6 +28,7 @@ export const useSetIsDashboardInEditMode = (pageLayoutIdFromProps: string) => {
set(forceRegisteredActionsByKeyState, (prev) => ({
...prev,
[DashboardSingleRecordActionKeys.EDIT_DASHBOARD]: !value,
[DashboardSingleRecordActionKeys.CANCEL_DASHBOARD_EDITION]: value,
}));
},
[forceRegisteredActionsByKeyState, isPageLayoutInEditModeState],
@@ -3,7 +3,7 @@ import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pag
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
import { type PageLayoutWithData } from '@/page-layout/types/pageLayoutTypes';
import { type TabLayouts } from '@/page-layout/types/tab-layouts';
import { convertPageLayoutToTabLayouts } from '@/page-layout/utils/convertPageLayoutToTabLayouts';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { useQuery } from '@apollo/client';
@@ -59,25 +59,8 @@ export const PageLayoutInitializationQueryEffect = ({
tabs: layout.tabs,
});
if (layout.tabs.length > 0) {
const tabLayouts: TabLayouts = {};
layout.tabs.forEach((tab) => {
const layouts = tab.widgets.map((w) => ({
i: w.id,
x: w.gridPosition.column,
y: w.gridPosition.row,
w: w.gridPosition.columnSpan,
h: w.gridPosition.rowSpan,
}));
tabLayouts[tab.id] = {
desktop: layouts,
mobile: layouts.map((l) => ({ ...l, w: 1, x: 0 })),
};
});
set(pageLayoutCurrentLayoutsComponentCallbackState, tabLayouts);
} else {
set(pageLayoutCurrentLayoutsComponentCallbackState, {});
}
const tabLayouts = convertPageLayoutToTabLayouts(layout);
set(pageLayoutCurrentLayoutsComponentCallbackState, tabLayouts);
}
},
[
@@ -0,0 +1,67 @@
import { type PageLayoutWithData } from '@/page-layout/types/pageLayoutTypes';
import { convertPageLayoutToTabLayouts } from '@/page-layout/utils/convertPageLayoutToTabLayouts';
import { PageLayoutType, WidgetType } from '~/generated/graphql';
describe('convertPageLayoutToTabLayouts', () => {
it('should convert page layout to tab layouts', () => {
const pageLayout: PageLayoutWithData = {
id: 'page-layout-1',
name: 'Page Layout 1',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: 'object-metadata-1',
tabs: [
{
id: 'tab-1',
title: 'Tab 1',
position: 0,
pageLayoutId: 'page-layout-1',
widgets: [
{
id: 'widget-1',
pageLayoutTabId: 'tab-1',
title: 'Widget 1',
type: WidgetType.GRAPH,
gridPosition: { row: 0, column: 0, rowSpan: 2, columnSpan: 2 },
objectMetadataId: 'object-metadata-1',
createdAt: '2025-01-01T00:00:00.000Z',
updatedAt: '2025-01-01T00:00:00.000Z',
deletedAt: null,
},
{
id: 'widget-2',
pageLayoutTabId: 'tab-1',
title: 'Widget 2',
type: WidgetType.GRAPH,
gridPosition: { row: 2, column: 0, rowSpan: 2, columnSpan: 2 },
objectMetadataId: 'object-metadata-1',
createdAt: '2025-01-01T00:00:00.000Z',
updatedAt: '2025-01-01T00:00:00.000Z',
deletedAt: null,
},
],
createdAt: '2025-01-01T00:00:00.000Z',
updatedAt: '2025-01-01T00:00:00.000Z',
deletedAt: null,
},
],
createdAt: '2025-01-01T00:00:00.000Z',
updatedAt: '2025-01-01T00:00:00.000Z',
deletedAt: null,
};
const result = convertPageLayoutToTabLayouts(pageLayout);
expect(result).toEqual({
'tab-1': {
desktop: [
{ i: 'widget-1', x: 0, y: 0, w: 2, h: 2 },
{ i: 'widget-2', x: 0, y: 2, w: 2, h: 2 },
],
mobile: [
{ i: 'widget-1', x: 0, y: 0, w: 1, h: 2 },
{ i: 'widget-2', x: 0, y: 2, w: 1, h: 2 },
],
},
});
});
});
@@ -0,0 +1,29 @@
import { type PageLayoutWithData } from '@/page-layout/types/pageLayoutTypes';
import { type TabLayouts } from '@/page-layout/types/tab-layouts';
export const convertPageLayoutToTabLayouts = (
pageLayout: PageLayoutWithData,
): TabLayouts => {
if (pageLayout.tabs.length === 0) {
return {};
}
const tabLayouts: TabLayouts = {};
pageLayout.tabs.forEach((tab) => {
const layouts = tab.widgets.map((widget) => ({
i: widget.id,
x: widget.gridPosition.column,
y: widget.gridPosition.row,
w: widget.gridPosition.columnSpan,
h: widget.gridPosition.rowSpan,
}));
tabLayouts[tab.id] = {
desktop: layouts,
mobile: layouts.map((layout) => ({ ...layout, w: 1, x: 0 })),
};
});
return tabLayouts;
};