[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:
+5
@@ -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);
|
||||
};
|
||||
|
||||
+4
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
+9
-71
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+76
-67
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+16
-5
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+65
@@ -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"
|
||||
|
||||
+4
-1
@@ -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)`;
|
||||
};
|
||||
+5
@@ -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
|
||||
|
||||
+95
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+70
-64
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user