[DASHBOARDS] Tab duplication (#15906)

Closes https://github.com/twentyhq/core-team-issues/issues/1878

- Allow tab duplication
- Add autofocus on the tiltle input upon the creation and duplication of
tabs and the creation of widgets
- Fix a bug where cancelling the edition while on a new tab, by
resetting the active tab id if it's not in the persisted tabs


https://github.com/user-attachments/assets/a4dc2f0b-1a56-406a-bde7-cca17f9cdbc1
This commit is contained in:
Raphaël Bosi
2025-11-19 10:57:37 +01:00
committed by GitHub
parent d4e85376ea
commit 5b52ac992e
14 changed files with 368 additions and 36 deletions
@@ -0,0 +1,156 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useNavigatePageLayoutCommandMenu } from '@/command-menu/pages/page-layout/hooks/useNavigatePageLayoutCommandMenu';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { calculateNewPosition } from '@/favorites/utils/calculateNewPosition';
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { getTabListInstanceIdFromPageLayoutId } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutId';
import { sortTabsByPosition } from '@/page-layout/utils/sortTabsByPosition';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import { pageLayoutCurrentLayoutsComponentState } from '../states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '../states/pageLayoutDraftComponentState';
import { pageLayoutTabSettingsOpenTabIdComponentState } from '../states/pageLayoutTabSettingsOpenTabIdComponentState';
import { type PageLayoutTab } from '../types/PageLayoutTab';
export const useDuplicatePageLayoutTab = (pageLayoutIdFromProps?: string) => {
const pageLayoutId = useAvailableComponentInstanceIdOrThrow(
PageLayoutComponentInstanceContext,
pageLayoutIdFromProps,
);
const pageLayoutDraftState = useRecoilComponentCallbackState(
pageLayoutDraftComponentState,
pageLayoutId,
);
const pageLayoutCurrentLayoutsState = useRecoilComponentCallbackState(
pageLayoutCurrentLayoutsComponentState,
pageLayoutId,
);
const tabListInstanceId = getTabListInstanceIdFromPageLayoutId(pageLayoutId);
const setActiveTabId = useSetRecoilComponentState(
activeTabIdComponentState,
tabListInstanceId,
);
const setTabSettingsOpenTabId = useSetRecoilComponentState(
pageLayoutTabSettingsOpenTabIdComponentState,
pageLayoutId,
);
const { navigatePageLayoutCommandMenu } = useNavigatePageLayoutCommandMenu();
const { closeCommandMenu } = useCommandMenu();
const duplicateTab = useRecoilCallback(
({ snapshot, set }) =>
(tabId: string): string => {
const pageLayoutDraft = snapshot
.getLoadable(pageLayoutDraftState)
.getValue();
const allTabLayouts = snapshot
.getLoadable(pageLayoutCurrentLayoutsState)
.getValue();
const sourceTab = pageLayoutDraft.tabs.find((t) => t.id === tabId);
if (!isDefined(sourceTab)) {
throw new Error(`Tab with id ${tabId} not found`);
}
const newTabId = uuidv4();
const widgetOldIdNewIdMap = new Map<string, string>();
const clonedWidgets = sourceTab.widgets.map((widget) => {
const newWidgetId = uuidv4();
widgetOldIdNewIdMap.set(widget.id, newWidgetId);
return {
...widget,
id: newWidgetId,
pageLayoutTabId: newTabId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
});
const sortedTabs = sortTabsByPosition(pageLayoutDraft.tabs);
const sourceIndex = sortedTabs.findIndex((t) => t.id === tabId);
const newTabPosition = calculateNewPosition({
items: sortedTabs,
destinationIndex: sourceIndex + 1,
sourceIndex,
});
const newTab: PageLayoutTab = {
...sourceTab,
id: newTabId,
title: sourceTab.title.endsWith('(Copy)')
? sourceTab.title
: `${sourceTab.title} (Copy)`,
position: newTabPosition,
widgets: clonedWidgets,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const sourceLayouts = allTabLayouts[tabId] ?? {
desktop: [],
mobile: [],
};
const newLayouts = {
desktop: sourceLayouts.desktop.map((layout) => ({
...layout,
i: widgetOldIdNewIdMap.get(layout.i) || layout.i,
})),
mobile: sourceLayouts.mobile.map((layout) => ({
...layout,
i: widgetOldIdNewIdMap.get(layout.i) || layout.i,
})),
};
set(pageLayoutCurrentLayoutsState, {
...allTabLayouts,
[newTabId]: newLayouts,
});
set(pageLayoutDraftState, (prev) => ({
...prev,
tabs: [...prev.tabs, newTab],
}));
closeCommandMenu();
setActiveTabId(newTabId);
setTabSettingsOpenTabId(newTabId);
navigatePageLayoutCommandMenu({
commandMenuPage: CommandMenuPages.PageLayoutTabSettings,
pageTitle: newTab.title,
focusTitleInput: true,
});
return newTabId;
},
[
closeCommandMenu,
navigatePageLayoutCommandMenu,
pageLayoutCurrentLayoutsState,
pageLayoutDraftState,
setActiveTabId,
setTabSettingsOpenTabId,
],
);
return { duplicateTab };
};