[DASHBOARDS] Widget duplication (#15979)

## Widget duplication

- Created a shared component for the option menu shared by the record
page, the dashboards and the workflows
- Fix arrow selection in the option menu in workflows, which wasn't
working
- Scroll to the new widget after creation and open the new widget
settings


https://github.com/user-attachments/assets/47b8dade-44bd-4ba2-a81c-01b09e8718d3
This commit is contained in:
Raphaël Bosi
2025-11-24 14:38:18 +01:00
committed by GitHub
parent 8923d2fa0a
commit 2b80d9e015
18 changed files with 526 additions and 217 deletions
@@ -1,5 +1,6 @@
import { Action } from '@/action-menu/actions/components/Action';
import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useResetDraftPageLayoutToPersistedPageLayout } from '@/page-layout/hooks/useResetDraftPageLayoutToPersistedPageLayout';
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
@@ -12,6 +13,8 @@ export const CancelDashboardSingleRecordAction = () => {
const pageLayoutId = selectedRecord?.pageLayoutId;
const { closeCommandMenu } = useCommandMenu();
const { setIsPageLayoutInEditMode } =
useSetIsPageLayoutInEditMode(pageLayoutId);
@@ -19,6 +22,8 @@ export const CancelDashboardSingleRecordAction = () => {
useResetDraftPageLayoutToPersistedPageLayout(pageLayoutId);
const handleClick = () => {
closeCommandMenu();
resetDraftPageLayoutToPersistedPageLayout();
setIsPageLayoutInEditMode(false);
};
@@ -1,5 +1,6 @@
import { Action } from '@/action-menu/actions/components/Action';
import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useSavePageLayout } from '@/page-layout/hooks/useSavePageLayout';
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
@@ -17,8 +18,11 @@ export const SaveDashboardSingleRecordAction = () => {
const { setIsPageLayoutInEditMode } =
useSetIsPageLayoutInEditMode(pageLayoutId);
const { closeCommandMenu } = useCommandMenu();
const handleClick = async () => {
await savePageLayout();
closeCommandMenu();
setIsPageLayoutInEditMode(false);
};
@@ -3,20 +3,9 @@ import { ActionScope } from '@/action-menu/actions/types/ActionScope';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { getRightDrawerActionMenuDropdownIdFromActionMenuId } from '@/action-menu/utils/getRightDrawerActionMenuDropdownIdFromActionMenuId';
import { SIDE_PANEL_FOCUS_ID } from '@/command-menu/constants/SidePanelFocusId';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useToggleDropdown } from '@/ui/layout/dropdown/hooks/useToggleDropdown';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { OptionsDropdownMenu } from '@/ui/layout/dropdown/components/OptionsDropdownMenu';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useTheme } from '@emotion/react';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { Button } from 'twenty-ui/input';
import { getOsControlSymbol } from 'twenty-ui/utilities';
export const CommandMenuActionMenuDropdown = () => {
const { actions } = useContext(ActionMenuContext);
@@ -25,31 +14,8 @@ export const CommandMenuActionMenuDropdown = () => {
ActionMenuComponentInstanceContext,
);
const theme = useTheme();
const dropdownId =
getRightDrawerActionMenuDropdownIdFromActionMenuId(actionMenuId);
const { toggleDropdown } = useToggleDropdown();
const hotkeysConfig = {
keys: ['ctrl+o', 'meta+o'],
callback: () => {
toggleDropdown({
dropdownComponentInstanceIdFromProps: dropdownId,
});
},
dependencies: [toggleDropdown],
};
useHotkeysOnFocusedElement({
...hotkeysConfig,
focusId: SIDE_PANEL_FOCUS_ID,
});
useHotkeysOnFocusedElement({
...hotkeysConfig,
focusId: dropdownId,
});
const recordSelectionActions = actions.filter(
(action) => action.scope === ActionScope.RecordSelection,
@@ -59,43 +25,15 @@ export const CommandMenuActionMenuDropdown = () => {
(action) => action.key,
);
const { setSelectedItemId } = useSelectableList(actionMenuId);
return (
<Dropdown
<OptionsDropdownMenu
dropdownId={dropdownId}
data-select-disable
clickableComponent={
<Button
size="small"
title={t`Options`}
hotkeys={[getOsControlSymbol(), 'O']}
/>
}
dropdownPlacement="top-end"
dropdownOffset={{ y: parseInt(theme.spacing(2), 10) }}
globalHotkeysConfig={{
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: false,
}}
onOpen={() => {
setSelectedItemId(selectableItemIdArray[0]);
}}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={actionMenuId}
focusId={dropdownId}
selectableItemIdArray={selectableItemIdArray}
>
{recordSelectionActions.map((action) => (
<ActionComponent action={action} key={action.key} />
))}
</SelectableList>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
selectableListId={actionMenuId}
selectableItemIdArray={selectableItemIdArray}
>
{recordSelectionActions.map((action) => (
<ActionComponent action={action} key={action.key} />
))}
</OptionsDropdownMenu>
);
};
@@ -33,6 +33,13 @@ import {
type PageLayoutWidget,
} from '~/generated/graphql';
const StyledCommandMenuContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
`;
const StyledSidePanelInformationBanner = styled(SidePanelInformationBanner)`
margin-top: ${({ theme }) => theme.spacing(2)};
`;
@@ -137,78 +144,80 @@ export const ChartSettings = ({ widget }: { widget: PageLayoutWidget }) => {
primaryAxisField?.type === FieldMetadataType.DATE_TIME;
return (
<CommandMenuList commandGroups={[]} selectableItemIds={visibleItemIds}>
<ChartTypeSelectionSection
currentGraphType={currentGraphType}
setCurrentGraphType={handleGraphTypeChange}
/>
{hasWidgetTooManyGroups && (
<StyledSidePanelInformationBanner
message={
currentGraphType === GraphType.LINE
? t`Undisplayed data: max ${LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS} data points per chart.`
: t`Undisplayed data: max ${BAR_CHART_MAXIMUM_NUMBER_OF_BARS} bars per chart.`
}
tooltipMessage={
isPrimaryAxisDate
? t`Consider adding a filter or changing the date granularity to display more data.`
: t`Consider adding a filter to display more data.`
}
variant="warning"
<StyledCommandMenuContainer>
<CommandMenuList commandGroups={[]} selectableItemIds={visibleItemIds}>
<ChartTypeSelectionSection
currentGraphType={currentGraphType}
setCurrentGraphType={handleGraphTypeChange}
/>
)}
{chartSettings.map((group) => {
const visibleItems = group.items.filter(
(item) =>
!shouldHideChartSetting(
item,
widget.objectMetadataId,
isGroupByEnabled as boolean,
configuration,
objectMetadataItem,
),
);
{hasWidgetTooManyGroups && (
<StyledSidePanelInformationBanner
message={
currentGraphType === GraphType.LINE
? t`Undisplayed data: max ${LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS} data points per chart.`
: t`Undisplayed data: max ${BAR_CHART_MAXIMUM_NUMBER_OF_BARS} bars per chart.`
}
tooltipMessage={
isPrimaryAxisDate
? t`Consider adding a filter or changing the date granularity to display more data.`
: t`Consider adding a filter to display more data.`
}
variant="warning"
/>
)}
{chartSettings.map((group) => {
const visibleItems = group.items.filter(
(item) =>
!shouldHideChartSetting(
item,
widget.objectMetadataId,
isGroupByEnabled as boolean,
configuration,
objectMetadataItem,
),
);
return (
<CommandGroup key={group.heading} heading={group.heading}>
{visibleItems.map((item) => {
const handleItemToggleChange = () => {
setSelectedItemId(item.id);
updateChartSettingToggle(item.id);
};
return (
<CommandGroup key={group.heading} heading={group.heading}>
{visibleItems.map((item) => {
const handleItemToggleChange = () => {
setSelectedItemId(item.id);
updateChartSettingToggle(item.id);
};
const handleItemInputChange = (value: number | null) => {
updateChartSettingInput(item.id, value);
};
const handleItemInputChange = (value: number | null) => {
updateChartSettingInput(item.id, value);
};
const handleItemDropdownOpen = () => {
openDropdown({
dropdownComponentInstanceIdFromProps: item.id,
});
};
const handleItemDropdownOpen = () => {
openDropdown({
dropdownComponentInstanceIdFromProps: item.id,
});
};
const handleFilterClick = () => {
navigatePageLayoutCommandMenu({
commandMenuPage: CommandMenuPages.PageLayoutGraphFilter,
});
};
const handleFilterClick = () => {
navigatePageLayoutCommandMenu({
commandMenuPage: CommandMenuPages.PageLayoutGraphFilter,
});
};
return (
<ChartSettingItem
key={item.id}
item={item}
configuration={configuration}
getChartSettingsValues={getChartSettingsValues}
onToggleChange={handleItemToggleChange}
onInputChange={handleItemInputChange}
onDropdownOpen={handleItemDropdownOpen}
onFilterClick={handleFilterClick}
/>
);
})}
</CommandGroup>
);
})}
</CommandMenuList>
return (
<ChartSettingItem
key={item.id}
item={item}
configuration={configuration}
getChartSettingsValues={getChartSettingsValues}
onToggleChange={handleItemToggleChange}
onInputChange={handleItemInputChange}
onDropdownOpen={handleItemDropdownOpen}
onFilterClick={handleFilterClick}
/>
);
})}
</CommandGroup>
);
})}
</CommandMenuList>
</StyledCommandMenuContainer>
);
};
@@ -1,11 +1,19 @@
import { ChartSettings } from '@/command-menu/pages/page-layout/components/ChartSettings';
import { WidgetSettingsFooter } from '@/command-menu/pages/page-layout/components/WidgetSettingsFooter';
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { GraphWidgetComponentInstanceContext } from '@/page-layout/widgets/graph/states/contexts/GraphWidgetComponentInstanceContext';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import styled from '@emotion/styled';
import { isDefined } from 'twenty-shared/utils';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
`;
export const CommandMenuPageLayoutGraphTypeSelect = () => {
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
@@ -41,10 +49,13 @@ export const CommandMenuPageLayoutGraphTypeSelect = () => {
}
return (
<GraphWidgetComponentInstanceContext.Provider
value={{ instanceId: widgetInEditMode.id }}
>
<ChartSettings widget={widgetInEditMode} />
</GraphWidgetComponentInstanceContext.Provider>
<StyledContainer>
<GraphWidgetComponentInstanceContext.Provider
value={{ instanceId: widgetInEditMode.id }}
>
<ChartSettings widget={widgetInEditMode} />
<WidgetSettingsFooter />
</GraphWidgetComponentInstanceContext.Provider>
</StyledContainer>
);
};
@@ -0,0 +1,65 @@
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { useDuplicatePageLayoutWidget } from '@/page-layout/hooks/useDuplicatePageLayoutWidget';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { OptionsDropdownMenu } from '@/ui/layout/dropdown/components/OptionsDropdownMenu';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useLingui } from '@lingui/react/macro';
import { useId } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconCopyPlus } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
export const WidgetSettingsFooter = () => {
const dropdownId = useId();
const { t } = useLingui();
const { closeDropdown } = useCloseDropdown();
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
const { duplicateWidget } = useDuplicatePageLayoutWidget(pageLayoutId);
const editingWidgetId = useRecoilComponentValue(
pageLayoutEditingWidgetIdComponentState,
pageLayoutId,
);
const handleDuplicateWidget = () => {
if (isDefined(editingWidgetId)) {
duplicateWidget(editingWidgetId);
}
closeDropdown(dropdownId);
};
const selectedItemId = useRecoilComponentValue(
selectedItemIdComponentState,
dropdownId,
);
return (
<RightDrawerFooter
actions={[
<OptionsDropdownMenu
key="options"
dropdownId={dropdownId}
selectableListId={dropdownId}
selectableItemIdArray={['duplicate-widget']}
>
<SelectableListItem
itemId="duplicate-widget"
onEnter={handleDuplicateWidget}
>
<MenuItem
focused={selectedItemId === 'duplicate-widget'}
onClick={handleDuplicateWidget}
text={t`Duplicate widget`}
LeftIcon={IconCopyPlus}
/>
</SelectableListItem>
</OptionsDropdownMenu>,
]}
/>
);
};
@@ -7,6 +7,8 @@ import {
PAGE_LAYOUT_CONFIG,
type PageLayoutBreakpoint,
} from '@/page-layout/constants/PageLayoutBreakpoints';
import { PAGE_LAYOUT_GRID_MARGIN } from '@/page-layout/constants/PageLayoutGridMargin';
import { PAGE_LAYOUT_GRID_ROW_HEIGHT } from '@/page-layout/constants/PageLayoutGridRowHeight';
import { usePageLayoutHandleLayoutChange } from '@/page-layout/hooks/usePageLayoutHandleLayoutChange';
import { usePageLayoutTabWithVisibleWidgetsOrThrow } from '@/page-layout/hooks/usePageLayoutTabWithVisibleWidgetsOrThrow';
import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState';
@@ -153,10 +155,10 @@ export const PageLayoutGridLayout = ({ tabId }: PageLayoutGridLayoutProps) => {
layouts={layouts}
breakpoints={PAGE_LAYOUT_CONFIG.breakpoints}
cols={PAGE_LAYOUT_CONFIG.columns}
rowHeight={55}
rowHeight={PAGE_LAYOUT_GRID_ROW_HEIGHT}
maxCols={12}
containerPadding={[0, 0]}
margin={[8, 8]}
margin={[PAGE_LAYOUT_GRID_MARGIN, PAGE_LAYOUT_GRID_MARGIN]}
isDraggable={isPageLayoutInEditMode}
isResizable={isPageLayoutInEditMode}
draggableHandle=".drag-handle"
@@ -10,6 +10,7 @@ import { useReorderPageLayoutTabs } from '@/page-layout/hooks/useReorderPageLayo
import { PageLayoutMainContent } from '@/page-layout/PageLayoutMainContent';
import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState';
import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState';
import { getScrollWrapperInstanceIdFromPageLayoutId } from '@/page-layout/utils/getScrollWrapperInstanceIdFromPageLayoutId';
import { getTabListInstanceIdFromPageLayoutId } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutId';
import { getTabsByDisplayMode } from '@/page-layout/utils/getTabsByDisplayMode';
import { getTabsWithVisibleWidgets } from '@/page-layout/utils/getTabsWithVisibleWidgets';
@@ -133,7 +134,9 @@ export const PageLayoutRendererContent = () => {
<PageLayoutTabHeader />
<StyledScrollWrapper
componentInstanceId={`scroll-wrapper-page-layout-${currentPageLayout.id}`}
componentInstanceId={getScrollWrapperInstanceIdFromPageLayoutId(
currentPageLayout.id,
)}
defaultEnableXScroll={false}
>
{isDefined(activeTabId) && (
@@ -0,0 +1 @@
export const PAGE_LAYOUT_GRID_MARGIN = 8;
@@ -0,0 +1 @@
export const PAGE_LAYOUT_GRID_ROW_HEIGHT = 55;
@@ -16,6 +16,8 @@ import { pageLayoutCurrentLayoutsComponentState } from '../states/pageLayoutCurr
import { pageLayoutDraftComponentState } from '../states/pageLayoutDraftComponentState';
import { pageLayoutTabSettingsOpenTabIdComponentState } from '../states/pageLayoutTabSettingsOpenTabIdComponentState';
import { type PageLayoutTab } from '../types/PageLayoutTab';
import { generateDuplicatedTimestamps } from '../utils/generateDuplicatedTimestamps';
import { getDuplicatedTitle } from '../utils/getDuplicatedTitle';
export const useDuplicatePageLayoutTab = (pageLayoutIdFromProps?: string) => {
const pageLayoutId = useAvailableComponentInstanceIdOrThrow(
@@ -76,8 +78,7 @@ export const useDuplicatePageLayoutTab = (pageLayoutIdFromProps?: string) => {
...widget,
id: newWidgetId,
pageLayoutTabId: newTabId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...generateDuplicatedTimestamps(),
};
});
@@ -93,13 +94,10 @@ export const useDuplicatePageLayoutTab = (pageLayoutIdFromProps?: string) => {
const newTab: PageLayoutTab = {
...sourceTab,
id: newTabId,
title: sourceTab.title.endsWith('(Copy)')
? sourceTab.title
: `${sourceTab.title} (Copy)`,
title: getDuplicatedTitle(sourceTab.title),
position: newTabPosition,
widgets: clonedWidgets,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...generateDuplicatedTimestamps(),
};
const sourceLayouts = allTabLayouts[tabId] ?? {
@@ -0,0 +1,151 @@
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { addWidgetToTab } from '@/page-layout/utils/addWidgetToTab';
import { getScrollWrapperInstanceIdFromPageLayoutId } from '@/page-layout/utils/getScrollWrapperInstanceIdFromPageLayoutId';
import { getUpdatedTabLayouts } from '@/page-layout/utils/getUpdatedTabLayouts';
import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement';
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 { type PageLayoutWidget } from '~/generated/graphql';
import { generateDuplicatedTimestamps } from '../utils/generateDuplicatedTimestamps';
import { getDuplicatedTitle } from '../utils/getDuplicatedTitle';
export const useDuplicatePageLayoutWidget = (
pageLayoutIdFromProps?: string,
) => {
const pageLayoutId = useAvailableComponentInstanceIdOrThrow(
PageLayoutComponentInstanceContext,
pageLayoutIdFromProps,
);
const pageLayoutDraftState = useRecoilComponentCallbackState(
pageLayoutDraftComponentState,
pageLayoutId,
);
const pageLayoutCurrentLayoutsState = useRecoilComponentCallbackState(
pageLayoutCurrentLayoutsComponentState,
pageLayoutId,
);
const setEditingWidgetId = useSetRecoilComponentState(
pageLayoutEditingWidgetIdComponentState,
pageLayoutId,
);
const { getScrollWrapperElement } = useScrollWrapperHTMLElement(
getScrollWrapperInstanceIdFromPageLayoutId(pageLayoutId),
);
const duplicateWidget = useRecoilCallback(
({ snapshot, set }) =>
(widgetId: string): string => {
const pageLayoutDraft = snapshot
.getLoadable(pageLayoutDraftState)
.getValue();
const allTabLayouts = snapshot
.getLoadable(pageLayoutCurrentLayoutsState)
.getValue();
const sourceWidget = pageLayoutDraft.tabs
.flatMap((tab) => tab.widgets)
.find((widget) => widget.id === widgetId);
if (!isDefined(sourceWidget)) {
throw new Error(`Widget with id ${widgetId} not found`);
}
const sourceTab = pageLayoutDraft.tabs.find(
(tab) => tab.id === sourceWidget.pageLayoutTabId,
);
if (!isDefined(sourceTab)) {
throw new Error(
`Tab with id ${sourceWidget.pageLayoutTabId} not found`,
);
}
const newWidgetId = uuidv4();
const clonedWidget: PageLayoutWidget = {
...sourceWidget,
id: newWidgetId,
title: getDuplicatedTitle(sourceWidget.title),
...generateDuplicatedTimestamps(),
};
const currentTabLayouts = allTabLayouts[sourceTab.id] || {
desktop: [],
mobile: [],
};
const sourceLayout = currentTabLayouts.desktop.find(
(layout) => layout.i === widgetId,
);
if (!isDefined(sourceLayout)) {
throw new Error(`Layout for widget ${widgetId} not found`);
}
const maxY = currentTabLayouts.desktop.reduce(
(max, layout) => Math.max(max, layout.y + layout.h),
0,
);
const newLayout = {
...sourceLayout,
i: newWidgetId,
y: maxY,
};
const updatedLayouts = getUpdatedTabLayouts(
allTabLayouts,
sourceTab.id,
newLayout,
);
set(pageLayoutCurrentLayoutsState, updatedLayouts);
set(pageLayoutDraftState, (prev) => ({
...prev,
tabs: addWidgetToTab(prev.tabs, sourceTab.id, clonedWidget),
}));
setEditingWidgetId(newWidgetId);
const { scrollWrapperElement } = getScrollWrapperElement();
if (isDefined(scrollWrapperElement)) {
requestAnimationFrame(() => {
const widgetElement = scrollWrapperElement.querySelector(
`[data-widget-id="${newWidgetId}"]`,
);
if (isDefined(widgetElement)) {
widgetElement.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
}
});
}
return newWidgetId;
},
[
pageLayoutCurrentLayoutsState,
pageLayoutDraftState,
setEditingWidgetId,
getScrollWrapperElement,
],
);
return { duplicateWidget };
};
@@ -0,0 +1,11 @@
export const generateDuplicatedTimestamps = (): {
createdAt: string;
updatedAt: string;
} => {
const now = new Date().toISOString();
return {
createdAt: now,
updatedAt: now,
};
};
@@ -0,0 +1,3 @@
export const getDuplicatedTitle = (title: string): string => {
return title.endsWith('(Copy)') ? title : `${title} (Copy)`;
};
@@ -0,0 +1,5 @@
export const getScrollWrapperInstanceIdFromPageLayoutId = (
pageLayoutId: string,
) => {
return `scroll-wrapper-page-layout-${pageLayoutId}`;
};
@@ -103,6 +103,7 @@ export const WidgetRenderer = ({ widget }: WidgetRendererProps) => {
isResizing={isResizing}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
data-widget-id={widget.id}
>
{showHeader && (
<WidgetCardHeader
@@ -0,0 +1,95 @@
import { SIDE_PANEL_FOCUS_ID } from '@/command-menu/constants/SidePanelFocusId';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useToggleDropdown } from '@/ui/layout/dropdown/hooks/useToggleDropdown';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useTheme } from '@emotion/react';
import { useLingui } from '@lingui/react/macro';
import { type ReactNode, useId } from 'react';
import { Button } from 'twenty-ui/input';
import { getOsControlSymbol } from 'twenty-ui/utilities';
type OptionsDropdownMenuProps = {
dropdownId?: string;
selectableListId?: string;
selectableItemIdArray?: string[];
onOpen?: () => void;
children: ReactNode;
};
export const OptionsDropdownMenu = ({
dropdownId: dropdownIdFromProps,
selectableListId,
selectableItemIdArray = [],
onOpen,
children,
}: OptionsDropdownMenuProps) => {
const generatedDropdownId = useId();
const dropdownId = dropdownIdFromProps ?? generatedDropdownId;
const { t } = useLingui();
const theme = useTheme();
const { toggleDropdown } = useToggleDropdown();
const listId = selectableListId ?? dropdownId;
const { setSelectedItemId } = useSelectableList(listId);
const handleOpen = () => {
if (selectableItemIdArray.length > 0) {
setSelectedItemId(selectableItemIdArray[0]);
}
onOpen?.();
};
const hotkeysConfig = {
keys: ['ctrl+o', 'meta+o'],
callback: () => {
toggleDropdown({
dropdownComponentInstanceIdFromProps: dropdownId,
});
},
dependencies: [toggleDropdown, dropdownId],
};
useHotkeysOnFocusedElement({
...hotkeysConfig,
focusId: SIDE_PANEL_FOCUS_ID,
});
useHotkeysOnFocusedElement({
...hotkeysConfig,
focusId: dropdownId,
});
return (
<Dropdown
dropdownId={dropdownId}
data-select-disable
clickableComponent={
<Button title={t`Options`} hotkeys={[getOsControlSymbol(), 'O']} />
}
dropdownPlacement="top-end"
dropdownOffset={{ y: parseInt(theme.spacing(2), 10) }}
globalHotkeysConfig={{
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: false,
}}
onOpen={handleOpen}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={listId}
focusId={dropdownId}
selectableItemIdArray={selectableItemIdArray}
>
{children}
</SelectableList>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
);
};
@@ -1,14 +1,13 @@
import { useWorkflowCommandMenu } from '@/command-menu/hooks/useWorkflowCommandMenu';
import { useCommandMenuWorkflowIdOrThrow } from '@/command-menu/pages/workflow/hooks/useCommandMenuWorkflowIdOrThrow';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { OptionsDropdownMenu } from '@/ui/layout/dropdown/components/OptionsDropdownMenu';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useDeleteStep } from '@/workflow/workflow-steps/hooks/useDeleteStep';
import { useDuplicateStep } from '@/workflow/workflow-steps/hooks/useDuplicateStep';
import { useTheme } from '@emotion/react';
import { useLingui } from '@lingui/react/macro';
import { useId } from 'react';
import { isDefined } from 'twenty-shared/utils';
@@ -16,7 +15,6 @@ import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { IconCopyPlus, IconPencil, IconTrash } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
import { getOsControlSymbol } from 'twenty-ui/utilities';
export const WorkflowStepFooter = ({
stepId,
@@ -27,7 +25,6 @@ export const WorkflowStepFooter = ({
}) => {
const dropdownId = useId();
const { t } = useLingui();
const theme = useTheme();
const { duplicateStep } = useDuplicateStep();
const { closeDropdown } = useCloseDropdown();
const workflowId = useCommandMenuWorkflowIdOrThrow();
@@ -39,66 +36,75 @@ export const WorkflowStepFooter = ({
const shouldPinDeleteButton =
!isDefined(additionalActions) || additionalActions.length === 0;
const selectableItemIdArray = [
'change-node-type',
...(stepId !== TRIGGER_STEP_ID ? ['duplicate'] : []),
...(!shouldPinDeleteButton ? ['delete'] : []),
];
const handleChangeNodeType = () => {
closeDropdown(dropdownId);
if (stepId === TRIGGER_STEP_ID) {
openWorkflowTriggerTypeInCommandMenu(workflowId);
} else {
openWorkflowEditStepTypeInCommandMenu(workflowId);
}
};
const handleDuplicateNode = () => {
closeDropdown(dropdownId);
duplicateStep({ stepId });
};
const handleDeleteNode = () => {
closeDropdown(dropdownId);
deleteStep(stepId);
};
const selectedItemId = useRecoilComponentValue(
selectedItemIdComponentState,
dropdownId,
);
const OptionsDropdown = (
<Dropdown
<OptionsDropdownMenu
dropdownId={dropdownId}
data-select-disable
clickableComponent={
<Button
size="small"
title={t`Options`}
hotkeys={[getOsControlSymbol(), 'O']}
selectableListId={dropdownId}
selectableItemIdArray={selectableItemIdArray}
>
<SelectableListItem
itemId="change-node-type"
onEnter={handleChangeNodeType}
>
<MenuItem
focused={selectedItemId === 'change-node-type'}
onClick={handleChangeNodeType}
text={t`Change node type`}
LeftIcon={IconPencil}
/>
}
dropdownPlacement="top-end"
dropdownOffset={{ y: parseInt(theme.spacing(2), 10) }}
globalHotkeysConfig={{
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: false,
}}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={['change-node-type', 'duplicate']}
>
<MenuItem
onClick={() => {
closeDropdown(dropdownId);
stepId === TRIGGER_STEP_ID
? openWorkflowTriggerTypeInCommandMenu(workflowId)
: openWorkflowEditStepTypeInCommandMenu(workflowId);
}}
text={t`Change node type`}
LeftIcon={IconPencil}
/>
{stepId !== TRIGGER_STEP_ID && (
<MenuItem
onClick={() => {
closeDropdown(dropdownId);
duplicateStep({ stepId });
}}
text={t`Duplicate node`}
LeftIcon={IconCopyPlus}
/>
)}
{!shouldPinDeleteButton && (
<MenuItem
onClick={() => {
closeDropdown(dropdownId);
deleteStep(stepId);
}}
text={t`Delete node`}
LeftIcon={IconTrash}
/>
)}
</SelectableList>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
</SelectableListItem>
{stepId !== TRIGGER_STEP_ID && (
<SelectableListItem itemId="duplicate" onEnter={handleDuplicateNode}>
<MenuItem
focused={selectedItemId === 'duplicate'}
onClick={handleDuplicateNode}
text={t`Duplicate node`}
LeftIcon={IconCopyPlus}
/>
</SelectableListItem>
)}
{!shouldPinDeleteButton && (
<SelectableListItem itemId="delete" onEnter={handleDeleteNode}>
<MenuItem
focused={selectedItemId === 'delete'}
onClick={handleDeleteNode}
text={t`Delete node`}
LeftIcon={IconTrash}
/>
</SelectableListItem>
)}
</OptionsDropdownMenu>
);
const deleteButton = (