nitin
2026-03-26 20:34:03 +05:30
committed by GitHub
parent c7f6036a47
commit afeef8e04a
74 changed files with 2518 additions and 239 deletions
@@ -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>
);
};
@@ -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>
</>
);
};
@@ -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>
);
};
@@ -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>
);
};