feat: add command menu item to layout customization (#18764)
figma https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=93630-365000&t=QesAkY3JOyI9D3UU-0 https://github.com/user-attachments/assets/cf3b354d-0b08-41ae-91ae-386054b5cb2e https://github.com/user-attachments/assets/73bfd676-f823-44c9-a70d-107c20523664 --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
+64
@@ -0,0 +1,64 @@
|
||||
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
|
||||
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
|
||||
import { PAGE_HEADER_SIDE_PANEL_BUTTON_CLICK_OUTSIDE_ID } from '@/ui/layout/page-header/constants/PageHeaderSidePanelButtonClickOutsideId';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
AppTooltip,
|
||||
IconLayoutSidebarRightExpand,
|
||||
IconX,
|
||||
TooltipDelay,
|
||||
TooltipPosition,
|
||||
} from 'twenty-ui/display';
|
||||
import { AnimatedButton } from 'twenty-ui/input';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { AnimatedIconCrossfade, useIsMobile } from 'twenty-ui/utilities';
|
||||
|
||||
const StyledTooltipWrapper = styled.div`
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
`;
|
||||
|
||||
export const CommandMenuItemMoreActionsButton = () => {
|
||||
const { toggleSidePanelMenu } = useSidePanelMenu();
|
||||
const isSidePanelOpened = useAtomStateValue(isSidePanelOpenedState);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const ariaLabel = isSidePanelOpened
|
||||
? t`Close side panel`
|
||||
: t`Open side panel`;
|
||||
|
||||
return (
|
||||
<div id="toggle-side-panel-button">
|
||||
<AnimatedButton
|
||||
animatedSvg={
|
||||
<AnimatedIconCrossfade
|
||||
isActive={isSidePanelOpened}
|
||||
ActiveIcon={IconX}
|
||||
InactiveIcon={IconLayoutSidebarRightExpand}
|
||||
/>
|
||||
}
|
||||
dataClickOutsideId={PAGE_HEADER_SIDE_PANEL_BUTTON_CLICK_OUTSIDE_ID}
|
||||
dataTestId="page-header-side-panel-button"
|
||||
size={isMobile ? 'medium' : 'small'}
|
||||
variant="secondary"
|
||||
accent="default"
|
||||
title={t`More`}
|
||||
ariaLabel={ariaLabel}
|
||||
onClick={toggleSidePanelMenu}
|
||||
/>
|
||||
<StyledTooltipWrapper>
|
||||
<AppTooltip
|
||||
anchorSelect="#toggle-side-panel-button"
|
||||
content={i18n._(ariaLabel)}
|
||||
delay={TooltipDelay.longDelay}
|
||||
place={TooltipPosition.Bottom}
|
||||
offset={5}
|
||||
noArrow
|
||||
/>
|
||||
</StyledTooltipWrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import { CommandMenuItemComponent } from '@/command-menu-item/display/components/CommandMenuItemComponent';
|
||||
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
|
||||
import { PinnedCommandMenuItemsInlineMeasurements } from '@/command-menu-item/server-items/display/components/PinnedCommandMenuItemsInlineMeasurements';
|
||||
import { PINNED_COMMAND_MENU_ITEMS_GAP } from '@/command-menu-item/server-items/display/constants/PinnedCommandMenuItemsGap';
|
||||
import { usePinnedCommandMenuItemsInlineLayout } from '@/command-menu-item/server-items/display/hooks/usePinnedCommandMenuItemsInlineLayout';
|
||||
import { NodeDimension } from '@/ui/utilities/dimensions/components/NodeDimension';
|
||||
import { styled } from '@linaria/react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useContext, useMemo } from 'react';
|
||||
import { ThemeContext } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledActionContainer = styled(motion.div)`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledItemsContainer = styled.div`
|
||||
display: flex;
|
||||
gap: ${PINNED_COMMAND_MENU_ITEMS_GAP}px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
export const PinnedCommandMenuItemButtons = () => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const { commandMenuItems } = useContext(CommandMenuContext);
|
||||
|
||||
const pinnedCommandMenuItems = useMemo(
|
||||
() =>
|
||||
commandMenuItems.filter(
|
||||
(commandMenuItem) => commandMenuItem.isPinned === true,
|
||||
),
|
||||
[commandMenuItems],
|
||||
);
|
||||
|
||||
const {
|
||||
pinnedInlineCommandMenuItems,
|
||||
pinnedOverflowCommandMenuItems,
|
||||
onContainerDimensionChange,
|
||||
onCommandMenuItemDimensionChange,
|
||||
} = usePinnedCommandMenuItemsInlineLayout({
|
||||
pinnedCommandMenuItems,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PinnedCommandMenuItemsInlineMeasurements
|
||||
pinnedCommandMenuItems={[
|
||||
...pinnedInlineCommandMenuItems,
|
||||
...pinnedOverflowCommandMenuItems,
|
||||
]}
|
||||
onPinnedCommandMenuItemDimensionChange={
|
||||
onCommandMenuItemDimensionChange
|
||||
}
|
||||
/>
|
||||
<StyledWrapper>
|
||||
<NodeDimension onDimensionChange={onContainerDimensionChange}>
|
||||
<StyledContainer>
|
||||
<StyledItemsContainer>
|
||||
{pinnedInlineCommandMenuItems.map(
|
||||
(pinnedInlineCommandMenuItem) => (
|
||||
<StyledActionContainer
|
||||
key={pinnedInlineCommandMenuItem.key}
|
||||
layout
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: 'unset', opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={{
|
||||
duration: theme.animation.duration.instant,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
>
|
||||
<CommandMenuItemComponent
|
||||
action={pinnedInlineCommandMenuItem}
|
||||
/>
|
||||
</StyledActionContainer>
|
||||
),
|
||||
)}
|
||||
</StyledItemsContainer>
|
||||
</StyledContainer>
|
||||
</NodeDimension>
|
||||
</StyledWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { CommandMenuButton } from '@/command-menu/components/CommandMenuButton';
|
||||
import { type CommandMenuItemConfig } from '@/command-menu-item/types/CommandMenuItemConfig';
|
||||
import { PINNED_COMMAND_MENU_ITEMS_GAP } from '@/command-menu-item/server-items/display/constants/PinnedCommandMenuItemsGap';
|
||||
import { NodeDimension } from '@/ui/utilities/dimensions/components/NodeDimension';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
type ElementDimensions = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type PinnedCommandMenuItemsInlineMeasurementsProps = {
|
||||
pinnedCommandMenuItems: CommandMenuItemConfig[];
|
||||
onPinnedCommandMenuItemDimensionChange: (
|
||||
commandMenuItemKey: string,
|
||||
) => (dimensions: ElementDimensions) => void;
|
||||
};
|
||||
|
||||
const StyledHiddenMeasurementsContainer = styled.div`
|
||||
display: flex;
|
||||
gap: ${PINNED_COMMAND_MENU_ITEMS_GAP}px;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: -9999px;
|
||||
visibility: hidden;
|
||||
`;
|
||||
|
||||
export const PinnedCommandMenuItemsInlineMeasurements = ({
|
||||
pinnedCommandMenuItems,
|
||||
onPinnedCommandMenuItemDimensionChange,
|
||||
}: PinnedCommandMenuItemsInlineMeasurementsProps) => {
|
||||
return (
|
||||
<StyledHiddenMeasurementsContainer>
|
||||
{pinnedCommandMenuItems.map((pinnedCommandMenuItem) => (
|
||||
<NodeDimension
|
||||
key={pinnedCommandMenuItem.key}
|
||||
onDimensionChange={onPinnedCommandMenuItemDimensionChange(
|
||||
pinnedCommandMenuItem.key,
|
||||
)}
|
||||
>
|
||||
<CommandMenuButton
|
||||
command={{
|
||||
key: `${pinnedCommandMenuItem.key}-inline-measurement`,
|
||||
label: pinnedCommandMenuItem.label,
|
||||
shortLabel: pinnedCommandMenuItem.shortLabel,
|
||||
Icon: pinnedCommandMenuItem.Icon,
|
||||
isPrimaryCTA: pinnedCommandMenuItem.isPrimaryCTA,
|
||||
}}
|
||||
/>
|
||||
</NodeDimension>
|
||||
))}
|
||||
</StyledHiddenMeasurementsContainer>
|
||||
);
|
||||
};
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
|
||||
import { PINNED_COMMAND_MENU_ITEMS_GAP } from '@/command-menu-item/server-items/display/constants/PinnedCommandMenuItemsGap';
|
||||
import { commandMenuPinnedInlineLayoutState } from '@/command-menu-item/server-items/display/states/commandMenuPinnedInlineLayoutState';
|
||||
import { getVisibleCommandMenuItemCountForContainerWidth } from '@/command-menu-item/server-items/display/utils/getVisibleCommandMenuItemCountForContainerWidth';
|
||||
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
|
||||
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
|
||||
import { SidePanelList } from '@/side-panel/components/SidePanelList';
|
||||
import { SIDE_PANEL_PREVIOUS_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelPreviousComponentInstanceId';
|
||||
import { SIDE_PANEL_RESET_CONTEXT_TO_SELECTION } from '@/side-panel/constants/SidePanelResetContextToSelection';
|
||||
import { SidePanelResetContextToSelectionButton } from '@/side-panel/pages/root/components/SidePanelResetContextToSelectionButton';
|
||||
import { useFilterActionsWithSidePanelSearch } from '@/side-panel/pages/root/hooks/useFilterActionsWithSidePanelSearch';
|
||||
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
|
||||
import { type SidePanelCommandMenuItemGroupConfig } from '@/side-panel/types/SidePanelCommandMenuItemGroupConfig';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useContext } from 'react';
|
||||
import { isNumber } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const SidePanelCommandMenuItemDisplayPage = () => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const sidePanelSearch = useAtomStateValue(sidePanelSearchState);
|
||||
const { commandMenuItems } = useContext(CommandMenuContext);
|
||||
const commandMenuPinnedInlineLayout = useAtomStateValue(
|
||||
commandMenuPinnedInlineLayoutState,
|
||||
);
|
||||
|
||||
const { filterActionsWithSidePanelSearch } =
|
||||
useFilterActionsWithSidePanelSearch({
|
||||
sidePanelSearch,
|
||||
});
|
||||
|
||||
const pinnedCommandMenuItems = commandMenuItems
|
||||
.filter((commandMenuItem) => commandMenuItem.isPinned === true)
|
||||
.sort(
|
||||
(firstPinnedCommandMenuItem, secondPinnedCommandMenuItem) =>
|
||||
firstPinnedCommandMenuItem.position -
|
||||
secondPinnedCommandMenuItem.position,
|
||||
);
|
||||
|
||||
const unpinnedCommandMenuItems = commandMenuItems
|
||||
.filter((commandMenuItem) => commandMenuItem.isPinned !== true)
|
||||
.sort(
|
||||
(firstUnpinnedCommandMenuItem, secondUnpinnedCommandMenuItem) =>
|
||||
firstUnpinnedCommandMenuItem.position -
|
||||
secondUnpinnedCommandMenuItem.position,
|
||||
);
|
||||
|
||||
const pinnedCommandMenuItemKeysInDisplayOrder = pinnedCommandMenuItems.map(
|
||||
(pinnedCommandMenuItem) => pinnedCommandMenuItem.key,
|
||||
);
|
||||
|
||||
const visiblePinnedCommandMenuItemCount =
|
||||
getVisibleCommandMenuItemCountForContainerWidth({
|
||||
commandMenuItemKeysInDisplayOrder:
|
||||
pinnedCommandMenuItemKeysInDisplayOrder,
|
||||
commandMenuItemWidthsByKey:
|
||||
commandMenuPinnedInlineLayout.commandMenuItemWidthsByKey,
|
||||
commandMenuItemsContainerWidth:
|
||||
commandMenuPinnedInlineLayout.containerWidth,
|
||||
commandMenuItemsGapWidth: PINNED_COMMAND_MENU_ITEMS_GAP,
|
||||
});
|
||||
|
||||
const hasKnownPinnedInlineLayout =
|
||||
commandMenuPinnedInlineLayout.containerWidth > 0 &&
|
||||
pinnedCommandMenuItemKeysInDisplayOrder.every((commandMenuItemKey) =>
|
||||
isNumber(
|
||||
commandMenuPinnedInlineLayout.commandMenuItemWidthsByKey[
|
||||
commandMenuItemKey
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
const pinnedOverflowCommandMenuItems = hasKnownPinnedInlineLayout
|
||||
? pinnedCommandMenuItems.slice(visiblePinnedCommandMenuItemCount)
|
||||
: pinnedCommandMenuItems;
|
||||
|
||||
const matchingPinnedItems = filterActionsWithSidePanelSearch(
|
||||
pinnedOverflowCommandMenuItems,
|
||||
);
|
||||
const matchingOtherItems = filterActionsWithSidePanelSearch(
|
||||
unpinnedCommandMenuItems,
|
||||
);
|
||||
|
||||
const noResults = !matchingPinnedItems.length && !matchingOtherItems.length;
|
||||
|
||||
const commandGroups: SidePanelCommandMenuItemGroupConfig[] = [
|
||||
{
|
||||
heading: t`Pinned`,
|
||||
items: matchingPinnedItems,
|
||||
},
|
||||
{
|
||||
heading: t`Other`,
|
||||
items: matchingOtherItems,
|
||||
},
|
||||
];
|
||||
|
||||
const selectableItems = commandGroups.flatMap((group) => group.items ?? []);
|
||||
const selectableItemIds = selectableItems.map((item) => item.key);
|
||||
|
||||
// oxlint-disable-next-line twenty/matching-state-variable
|
||||
const previousContextStoreCurrentObjectMetadataItemId =
|
||||
useAtomComponentStateValue(
|
||||
contextStoreCurrentObjectMetadataItemIdComponentState,
|
||||
SIDE_PANEL_PREVIOUS_COMPONENT_INSTANCE_ID,
|
||||
);
|
||||
|
||||
if (isDefined(previousContextStoreCurrentObjectMetadataItemId)) {
|
||||
selectableItemIds.unshift(SIDE_PANEL_RESET_CONTEXT_TO_SELECTION);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidePanelList
|
||||
commandGroups={commandGroups}
|
||||
selectableItemIds={selectableItemIds}
|
||||
noResults={noResults}
|
||||
>
|
||||
{isDefined(previousContextStoreCurrentObjectMetadataItemId) && (
|
||||
<SidePanelGroup heading={t`Context`}>
|
||||
<SidePanelResetContextToSelectionButton />
|
||||
</SidePanelGroup>
|
||||
)}
|
||||
</SidePanelList>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user