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
@@ -1,8 +1,8 @@
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useCommandMenuContextApi } from '@/command-menu-item/server-items/hooks/useCommandMenuContextApi';
import { type CommandMenuContextType } from '@/command-menu-item/contexts/CommandMenuContext';
import { useCommandMenuContextApi } from '@/command-menu-item/server-items/common/hooks/useCommandMenuContextApi';
import { CommandMenuContextProviderServerItemsContent } from './CommandMenuContextProviderServerItemsContent';
import { CommandMenuContextProviderServerItemsWithWorkflowEnrichment } from './CommandMenuContextProviderServerItemsWithWorkflowEnrichment';
@@ -2,7 +2,7 @@ import {
CommandMenuContext,
type CommandMenuContextType,
} from '@/command-menu-item/contexts/CommandMenuContext';
import { useCommandMenuItemsFromBackend } from '@/command-menu-item/server-items/hooks/useCommandMenuItemsFromBackend';
import { useCommandMenuItemsFromBackend } from '@/command-menu-item/server-items/common/hooks/useCommandMenuItemsFromBackend';
import { type CommandMenuContextApi } from 'twenty-shared/types';
type CommandMenuContextProviderServerItemsContentProps = {
@@ -2,7 +2,7 @@ import { type CommandMenuContextApi } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type CommandMenuContextType } from '@/command-menu-item/contexts/CommandMenuContext';
import { useWorkflowsWithCurrentVersions } from '@/command-menu-item/server-items/hooks/useWorkflowsWithCurrentVersions';
import { useWorkflowsWithCurrentVersions } from '@/command-menu-item/server-items/common/hooks/useWorkflowsWithCurrentVersions';
import { CommandMenuContextProviderServerItemsContent } from './CommandMenuContextProviderServerItemsContent';
@@ -0,0 +1,11 @@
import { COMMAND_MENU_ITEM_FRAGMENT } from '@/command-menu-item/graphql/fragments/commandMenuItemFragment';
import { gql } from '@apollo/client';
export const UPDATE_COMMAND_MENU_ITEM = gql`
${COMMAND_MENU_ITEM_FRAGMENT}
mutation UpdateCommandMenuItem($input: UpdateCommandMenuItemInput!) {
updateCommandMenuItem(input: $input) {
...CommandMenuItemFields
}
}
`;
@@ -1,6 +1,6 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { objectPermissionsFamilySelector } from '@/auth/states/objectPermissionsFamilySelector';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState';
@@ -13,13 +13,14 @@ import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPe
import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView';
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
import { recordStoreRecordsSelector } from '@/object-record/record-store/states/selectors/recordStoreRecordsSelector';
import { SIDE_PANEL_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelComponentInstanceId';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { isNonEmptyArray } from '@sniptt/guards';
import { useStore } from 'jotai';
import { useContext } from 'react';
import {
CommandMenuContextApiPageType,
type CommandMenuContextApi,
@@ -29,7 +30,11 @@ import { isDefined } from 'twenty-shared/utils';
export const useCommandMenuContextApi = (): CommandMenuContextApi => {
const store = useStore();
const { isInSidePanel } = useContext(CommandMenuContext);
const contextStoreInstanceId = useAvailableComponentInstanceIdOrThrow(
ContextStoreComponentInstanceContext,
);
const isInSidePanel =
contextStoreInstanceId === SIDE_PANEL_COMPONENT_INSTANCE_ID;
const contextStoreCurrentObjectMetadataItemId = useAtomComponentStateValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
@@ -39,6 +44,10 @@ export const useCommandMenuContextApi = (): CommandMenuContextApi => {
contextStoreTargetedRecordsRuleComponentState,
);
const contextStoreNumberOfSelectedRecords = useAtomComponentStateValue(
contextStoreNumberOfSelectedRecordsComponentState,
);
const { objectMetadataItems } = useObjectMetadataItems();
const objectMetadataItem = objectMetadataItems.find(
@@ -52,19 +61,16 @@ export const useCommandMenuContextApi = (): CommandMenuContextApi => {
? contextStoreTargetedRecordsRule.selectedRecordIds
: undefined;
const favoriteRecordIds = (() => {
if (!isNonEmptyArray(recordIds) || !isDefined(objectMetadataItem)) {
return [];
}
return recordIds.filter((recordId) =>
navigationMenuItems?.some(
(item) =>
item.targetRecordId === recordId &&
item.targetObjectMetadataId === objectMetadataItem.id,
),
);
})();
const favoriteRecordIds =
!isNonEmptyArray(recordIds) || !isDefined(objectMetadataItem)
? []
: recordIds.filter((recordId) =>
navigationMenuItems?.some(
(item) =>
item.targetRecordId === recordId &&
item.targetObjectMetadataId === objectMetadataItem.id,
),
);
const selectedRecords = useAtomFamilySelectorValue(
recordStoreRecordsSelector,
@@ -107,10 +113,6 @@ export const useCommandMenuContextApi = (): CommandMenuContextApi => {
? CommandMenuContextApiPageType.RECORD_PAGE
: CommandMenuContextApiPageType.INDEX_PAGE;
const contextStoreNumberOfSelectedRecords = useAtomComponentStateValue(
contextStoreNumberOfSelectedRecordsComponentState,
);
const isSelectAll = contextStoreTargetedRecordsRule.mode === 'exclusion';
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
@@ -0,0 +1,34 @@
import { isDefined } from 'twenty-shared/utils';
import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState';
import { commandMenuItemsSelector } from '@/command-menu-item/server-items/common/states/commandMenuItemsSelector';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const useCommandMenuItemsDraftState = () => {
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
const commandMenuItemsDraft = useAtomStateValue(commandMenuItemsDraftState);
const isDirty =
isDefined(commandMenuItemsDraft) &&
!isDeeplyEqual(
commandMenuItemsDraft.map(({ id, isPinned, position, shortLabel }) => ({
id,
isPinned,
position,
shortLabel,
})),
commandMenuItems.map(({ id, isPinned, position, shortLabel }) => ({
id,
isPinned,
position,
shortLabel,
})),
);
return {
commandMenuItemsDraft,
isDirty,
};
};
@@ -0,0 +1,238 @@
import { FrontComponentCommandMenuItem } from '@/command-menu-item/display/components/FrontComponentCommandMenuItem';
import { HeadlessCommandMenuItem } from '@/command-menu-item/display/components/HeadlessCommandMenuItem';
import { commandMenuItemsSelector } from '@/command-menu-item/server-items/common/states/commandMenuItemsSelector';
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/server-items/common/utils/doesCommandMenuItemMatchObjectMetadataId';
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
import { type CommandMenuContextApi } from 'twenty-shared/types';
import {
evaluateConditionalAvailabilityExpression,
interpolateCommandMenuItemLabel,
isDefined,
} from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { COMMAND_MENU_DEFAULT_ICON } from '@/workflow/workflow-trigger/constants/CommandMenuDefaultIcon';
import {
CommandMenuItemAvailabilityType,
type CommandMenuItemFieldsFragment,
} from '~/generated-metadata/graphql';
type CommandMenuItemWithFrontComponent = CommandMenuItemFieldsFragment & {
frontComponentId: string;
conditionalAvailabilityExpression?: string | null;
};
type CommandMenuItemWithSource = CommandMenuItemFieldsFragment & {
conditionalAvailabilityExpression?: string | null;
};
type BuildCommandMenuItemFromFrontComponentParams = {
item: CommandMenuItemWithFrontComponent;
type?: CommandMenuItemType;
scope: CommandMenuItemScope;
isPinned: boolean;
getIcon: ReturnType<typeof useIcons>['getIcon'];
commandMenuContextApi: CommandMenuContextApi;
};
const buildCommandMenuItemFromFrontComponent = ({
item,
type = CommandMenuItemType.FrontComponent,
scope,
isPinned,
getIcon,
commandMenuContextApi,
}: BuildCommandMenuItemFromFrontComponentParams) => {
const displayLabel = interpolateCommandMenuItemLabel({
label: item.label,
context: commandMenuContextApi,
});
const displayShortLabel = interpolateCommandMenuItemLabel({
label: item.shortLabel,
context: commandMenuContextApi,
});
const Icon = getIcon(item.icon, COMMAND_MENU_DEFAULT_ICON);
const isHeadless = item.frontComponent?.isHeadless === true;
return {
type,
key: `command-menu-item-front-component-${item.id}`,
id: item.id,
scope,
label: displayLabel,
shortLabel: displayShortLabel,
position: item.position,
isPinned,
Icon,
hotKeys: item.hotKeys,
shouldBeRegistered: () =>
evaluateConditionalAvailabilityExpression(
item.conditionalAvailabilityExpression,
commandMenuContextApi,
),
component: isHeadless ? (
<HeadlessCommandMenuItem item={item} />
) : (
<FrontComponentCommandMenuItem frontComponentId={item.frontComponentId} />
),
};
};
type BuildCommandMenuItemFromStandardKeyParams = {
item: CommandMenuItemWithSource;
type?: CommandMenuItemType;
scope: CommandMenuItemScope;
isPinned: boolean;
getIcon: ReturnType<typeof useIcons>['getIcon'];
commandMenuContextApi: CommandMenuContextApi;
};
const buildCommandItemFromEngineKey = ({
item,
type = CommandMenuItemType.Standard,
scope,
isPinned,
getIcon,
commandMenuContextApi,
}: BuildCommandMenuItemFromStandardKeyParams) => {
const Icon = getIcon(item.icon, COMMAND_MENU_DEFAULT_ICON);
return {
type,
key: `command-menu-item-engine-${item.id}`,
id: item.id,
scope,
label: interpolateCommandMenuItemLabel({
label: item.label,
context: commandMenuContextApi,
}),
shortLabel: interpolateCommandMenuItemLabel({
label: item.shortLabel,
context: commandMenuContextApi,
}),
position: item.position,
isPinned,
Icon,
hotKeys: item.hotKeys,
shouldBeRegistered: () =>
evaluateConditionalAvailabilityExpression(
item.conditionalAvailabilityExpression,
commandMenuContextApi,
),
component: <HeadlessCommandMenuItem item={item} />,
};
};
export const useCommandMenuItemsFromBackend = (
commandMenuContextApi: CommandMenuContextApi,
) => {
const { getIcon } = useIcons();
const currentObjectMetadataItemId =
commandMenuContextApi.objectMetadataItem.id;
const hasRecordSelection = commandMenuContextApi.numberOfSelectedRecords >= 1;
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
const itemsWithObjectMatches = commandMenuItems.filter(
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
);
const buildCommandMenuItem = ({
item,
scope,
isPinned,
typeOverride,
}: {
item: CommandMenuItemFieldsFragment;
scope: CommandMenuItemScope;
isPinned: boolean;
typeOverride?: CommandMenuItemType;
}) => {
if (isDefined(item.engineComponentKey)) {
return buildCommandItemFromEngineKey({
item,
type: typeOverride,
scope,
isPinned,
getIcon,
commandMenuContextApi,
});
}
if (isDefined(item.frontComponentId)) {
return buildCommandMenuItemFromFrontComponent({
item: item as CommandMenuItemWithFrontComponent,
type: typeOverride,
scope,
isPinned,
getIcon,
commandMenuContextApi,
});
}
return null;
};
const globalItems = itemsWithObjectMatches.filter(
(item) => item.availabilityType === CommandMenuItemAvailabilityType.GLOBAL,
);
const recordScopedItems = itemsWithObjectMatches.filter(
(item) =>
item.availabilityType ===
CommandMenuItemAvailabilityType.RECORD_SELECTION,
);
const fallbackItems = itemsWithObjectMatches.filter(
(item) =>
item.availabilityType === CommandMenuItemAvailabilityType.FALLBACK,
);
const globalCommandMenuItems = globalItems
.map((item) =>
buildCommandMenuItem({
item,
scope: CommandMenuItemScope.Global,
isPinned: item.isPinned,
}),
)
.filter(isDefined);
const recordScopedCommandMenuItems = hasRecordSelection
? recordScopedItems
.map((item) =>
buildCommandMenuItem({
item,
scope: CommandMenuItemScope.RecordSelection,
isPinned: item.isPinned,
}),
)
.filter(isDefined)
: [];
const fallbackCommandMenuItems = fallbackItems
.map((item) =>
buildCommandMenuItem({
item,
scope: CommandMenuItemScope.Global,
isPinned: false,
typeOverride: CommandMenuItemType.Fallback,
}),
)
.filter(isDefined);
return [
...globalCommandMenuItems,
...recordScopedCommandMenuItems,
...fallbackCommandMenuItems,
]
.filter((item) => item.shouldBeRegistered())
.sort((a, b) => a.position - b.position);
};
@@ -0,0 +1,14 @@
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
export const commandMenuItemsSelector = createAtomSelector<
CommandMenuItemFieldsFragment[]
>({
key: 'commandMenuItemsSelector',
get: ({ get }) => {
const storeItem = get(metadataStoreState, 'commandMenuItems');
return storeItem.current as CommandMenuItemFieldsFragment[];
},
});
@@ -0,0 +1,7 @@
import { isDefined } from 'twenty-shared/utils';
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
export const doesCommandMenuItemMatchObjectMetadataId =
(objectMetadataItemId: unknown) => (item: CommandMenuItemFieldsFragment) =>
!isDefined(item.availabilityObjectMetadataId) ||
item.availabilityObjectMetadataId === objectMetadataItemId;
@@ -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>
);
};
@@ -0,0 +1 @@
export const PINNED_COMMAND_MENU_ITEMS_GAP = 8;
@@ -0,0 +1,114 @@
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 { commandMenuPinnedInlineLayoutState } from '@/command-menu-item/server-items/display/states/commandMenuPinnedInlineLayoutState';
import { getVisibleCommandMenuItemCountForContainerWidth } from '@/command-menu-item/server-items/display/utils/getVisibleCommandMenuItemCountForContainerWidth';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useCallback, useMemo } from 'react';
type ElementDimensions = {
width: number;
height: number;
};
type UsePinnedCommandMenuItemsInlineLayoutParams = {
pinnedCommandMenuItems: CommandMenuItemConfig[];
};
export const usePinnedCommandMenuItemsInlineLayout = ({
pinnedCommandMenuItems,
}: UsePinnedCommandMenuItemsInlineLayoutParams) => {
const [commandMenuPinnedInlineLayout, setCommandMenuPinnedInlineLayout] =
useAtomState(commandMenuPinnedInlineLayoutState);
const pinnedCommandMenuItemsSortedByPosition = useMemo(
() =>
[...pinnedCommandMenuItems].sort(
(firstPinnedCommandMenuItem, secondPinnedCommandMenuItem) =>
firstPinnedCommandMenuItem.position -
secondPinnedCommandMenuItem.position,
),
[pinnedCommandMenuItems],
);
const pinnedCommandMenuItemKeysInDisplayOrder = useMemo(
() =>
pinnedCommandMenuItemsSortedByPosition.map(
(pinnedCommandMenuItem) => pinnedCommandMenuItem.key,
),
[pinnedCommandMenuItemsSortedByPosition],
);
const visiblePinnedCommandMenuItemCount = useMemo(
() =>
getVisibleCommandMenuItemCountForContainerWidth({
commandMenuItemKeysInDisplayOrder:
pinnedCommandMenuItemKeysInDisplayOrder,
commandMenuItemWidthsByKey:
commandMenuPinnedInlineLayout.commandMenuItemWidthsByKey,
commandMenuItemsContainerWidth:
commandMenuPinnedInlineLayout.containerWidth,
commandMenuItemsGapWidth: PINNED_COMMAND_MENU_ITEMS_GAP,
}),
[commandMenuPinnedInlineLayout, pinnedCommandMenuItemKeysInDisplayOrder],
);
const pinnedInlineCommandMenuItems = useMemo(
() =>
pinnedCommandMenuItemsSortedByPosition.slice(
0,
visiblePinnedCommandMenuItemCount,
),
[pinnedCommandMenuItemsSortedByPosition, visiblePinnedCommandMenuItemCount],
);
const pinnedOverflowCommandMenuItems = useMemo(
() =>
pinnedCommandMenuItemsSortedByPosition.slice(
visiblePinnedCommandMenuItemCount,
),
[pinnedCommandMenuItemsSortedByPosition, visiblePinnedCommandMenuItemCount],
);
const onContainerDimensionChange = useCallback(
(dimensions: ElementDimensions) => {
setCommandMenuPinnedInlineLayout(
(previousCommandMenuPinnedInlineLayout) =>
previousCommandMenuPinnedInlineLayout.containerWidth !==
dimensions.width
? {
...previousCommandMenuPinnedInlineLayout,
containerWidth: dimensions.width,
}
: previousCommandMenuPinnedInlineLayout,
);
},
[setCommandMenuPinnedInlineLayout],
);
const onCommandMenuItemDimensionChange = useCallback(
(commandMenuItemKey: string) => (dimensions: ElementDimensions) => {
setCommandMenuPinnedInlineLayout(
(previousCommandMenuPinnedInlineLayout) =>
previousCommandMenuPinnedInlineLayout.commandMenuItemWidthsByKey[
commandMenuItemKey
] !== dimensions.width
? {
...previousCommandMenuPinnedInlineLayout,
commandMenuItemWidthsByKey: {
...previousCommandMenuPinnedInlineLayout.commandMenuItemWidthsByKey,
[commandMenuItemKey]: dimensions.width,
},
}
: previousCommandMenuPinnedInlineLayout,
);
},
[setCommandMenuPinnedInlineLayout],
);
return {
pinnedInlineCommandMenuItems,
pinnedOverflowCommandMenuItems,
onContainerDimensionChange,
onCommandMenuItemDimensionChange,
};
};
@@ -0,0 +1,11 @@
import { type CommandMenuPinnedInlineLayout } from '@/command-menu-item/server-items/display/types/CommandMenuPinnedInlineLayout';
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const commandMenuPinnedInlineLayoutState =
createAtomState<CommandMenuPinnedInlineLayout>({
key: 'commandMenuPinnedInlineLayoutState',
defaultValue: {
containerWidth: 0,
commandMenuItemWidthsByKey: {},
},
});
@@ -0,0 +1,4 @@
export type CommandMenuPinnedInlineLayout = {
containerWidth: number;
commandMenuItemWidthsByKey: Record<string, number>;
};
@@ -0,0 +1,39 @@
import { getVisibleCommandMenuItemCountForContainerWidth } from '@/command-menu-item/server-items/display/utils/getVisibleCommandMenuItemCountForContainerWidth';
describe('getVisibleCommandMenuItemCountForContainerWidth', () => {
it('should return all items when container width is zero', () => {
const visibleCommandMenuItemCount =
getVisibleCommandMenuItemCountForContainerWidth({
commandMenuItemKeysInDisplayOrder: ['a', 'b', 'c'],
commandMenuItemWidthsByKey: { a: 20, b: 20, c: 20 },
commandMenuItemsContainerWidth: 0,
commandMenuItemsGapWidth: 8,
});
expect(visibleCommandMenuItemCount).toBe(3);
});
it('should return all items when at least one width is missing', () => {
const visibleCommandMenuItemCount =
getVisibleCommandMenuItemCountForContainerWidth({
commandMenuItemKeysInDisplayOrder: ['a', 'b'],
commandMenuItemWidthsByKey: { a: 20 },
commandMenuItemsContainerWidth: 40,
commandMenuItemsGapWidth: 8,
});
expect(visibleCommandMenuItemCount).toBe(2);
});
it('should stop before exceeding the container width with gaps', () => {
const visibleCommandMenuItemCount =
getVisibleCommandMenuItemCountForContainerWidth({
commandMenuItemKeysInDisplayOrder: ['a', 'b', 'c'],
commandMenuItemWidthsByKey: { a: 40, b: 40, c: 40 },
commandMenuItemsContainerWidth: 100,
commandMenuItemsGapWidth: 8,
});
expect(visibleCommandMenuItemCount).toBe(2);
});
});
@@ -0,0 +1,42 @@
type GetVisibleCommandMenuItemCountForContainerWidthParams = {
commandMenuItemKeysInDisplayOrder: string[];
commandMenuItemWidthsByKey: Record<string, number>;
commandMenuItemsContainerWidth: number;
commandMenuItemsGapWidth: number;
};
export const getVisibleCommandMenuItemCountForContainerWidth = ({
commandMenuItemKeysInDisplayOrder,
commandMenuItemWidthsByKey,
commandMenuItemsContainerWidth,
commandMenuItemsGapWidth,
}: GetVisibleCommandMenuItemCountForContainerWidthParams): number => {
if (commandMenuItemsContainerWidth <= 0) {
return commandMenuItemKeysInDisplayOrder.length;
}
let usedWidth = 0;
let visibleCommandMenuItemCount = 0;
for (const commandMenuItemKey of commandMenuItemKeysInDisplayOrder) {
const commandMenuItemWidth = commandMenuItemWidthsByKey[commandMenuItemKey];
if (typeof commandMenuItemWidth !== 'number') {
return commandMenuItemKeysInDisplayOrder.length;
}
const nextWidth =
visibleCommandMenuItemCount === 0
? commandMenuItemWidth
: usedWidth + commandMenuItemsGapWidth + commandMenuItemWidth;
if (nextWidth > commandMenuItemsContainerWidth) {
break;
}
usedWidth = nextWidth;
visibleCommandMenuItemCount += 1;
}
return visibleCommandMenuItemCount;
};
@@ -0,0 +1,72 @@
import { AnimatedIconCrossfade } from 'twenty-ui/utilities';
import { commandMenuItemEditSelectionModeState } from '@/command-menu-item/server-items/edit/states/commandMenuItemEditSelectionModeState';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useLingui } from '@lingui/react/macro';
import { useStore } from 'jotai';
import { SidePanelPages } from 'twenty-shared/types';
import { IconPencil, IconX } from 'twenty-ui/display';
import { AnimatedButton } from 'twenty-ui/input';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
export const CommandMenuItemEditButton = () => {
const { t } = useLingui();
const store = useStore();
const { navigateSidePanel } = useNavigateSidePanel();
const { closeSidePanelMenu } = useSidePanelMenu();
const isLayoutCustomizationModeEnabled = useAtomStateValue(
isLayoutCustomizationModeEnabledState,
);
const isSidePanelOpened = useAtomStateValue(isSidePanelOpenedState);
const sidePanelPage = useAtomStateValue(sidePanelPageState);
const isCommandMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED,
);
const isCommandMenuEditPageActive =
isSidePanelOpened && sidePanelPage === SidePanelPages.CommandMenuEdit;
if (!isLayoutCustomizationModeEnabled || !isCommandMenuItemEnabled) {
return null;
}
const handleClick = () => {
if (isCommandMenuEditPageActive) {
closeSidePanelMenu();
return;
}
store.set(commandMenuItemEditSelectionModeState.atom, 'selection');
navigateSidePanel({
page: SidePanelPages.CommandMenuEdit,
pageTitle: t`Edit actions`,
pageIcon: IconPencil,
resetNavigationStack: true,
});
};
return (
<AnimatedButton
animatedSvg={
<AnimatedIconCrossfade
isActive={isCommandMenuEditPageActive}
ActiveIcon={IconX}
InactiveIcon={IconPencil}
/>
}
title={t`Edit actions`}
variant="secondary"
size="small"
onClick={handleClick}
/>
);
};
@@ -0,0 +1,107 @@
import { COMMAND_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/command-menu-item/constants/CommandMenuDropdownClickOutsideId';
import { commandMenuItemEditSelectionModeState } from '@/command-menu-item/server-items/edit/states/commandMenuItemEditSelectionModeState';
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 { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import {
IconChevronDown,
IconSquareCheck,
IconSquareX,
} from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const DROPDOWN_ID = 'command-menu-edit-record-selection-dropdown';
const StyledClickableArea = styled.div`
align-items: center;
background-color: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.sm};
cursor: pointer;
display: flex;
gap: ${themeCssVariables.spacing[1]};
height: 24px;
padding-left: ${themeCssVariables.spacing[2]};
padding-right: ${themeCssVariables.spacing[1]};
`;
const StyledLabel = styled.span`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.md};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledDropdownMenuContainer = styled.div`
width: 100%;
`;
export const CommandMenuItemEditRecordSelectionDropdown = () => {
const { t } = useLingui();
const { closeDropdown } = useCloseDropdown();
const commandMenuItemEditSelectionMode = useAtomStateValue(
commandMenuItemEditSelectionModeState,
);
const setCommandMenuItemEditSelectionMode = useSetAtomState(
commandMenuItemEditSelectionModeState,
);
const isNoneSelected = commandMenuItemEditSelectionMode === 'none';
const handleSelectMode = (mode: 'none' | 'selection') => {
setCommandMenuItemEditSelectionMode(mode);
closeDropdown(DROPDOWN_ID);
};
const TriggerIcon = isNoneSelected ? IconSquareX : IconSquareCheck;
const triggerLabel = isNoneSelected
? t`No record selected`
: t`Records selected`;
return (
<Dropdown
dropdownId={DROPDOWN_ID}
clickableComponent={
<StyledClickableArea
data-click-outside-id={COMMAND_MENU_DROPDOWN_CLICK_OUTSIDE_ID}
>
<TriggerIcon size={16} />
<StyledLabel>{triggerLabel}</StyledLabel>
<IconChevronDown size={16} />
</StyledClickableArea>
}
dropdownPlacement="bottom-start"
dropdownComponents={
<DropdownContent widthInPixels={GenericDropdownContentWidth.Medium}>
<StyledDropdownMenuContainer
data-click-outside-id={COMMAND_MENU_DROPDOWN_CLICK_OUTSIDE_ID}
>
<DropdownMenuItemsContainer>
<MenuItemSelect
LeftIcon={IconSquareX}
text={t`No record selected`}
selected={isNoneSelected}
onClick={() => handleSelectMode('none')}
/>
<MenuItemSelect
LeftIcon={IconSquareCheck}
text={t`Records selected`}
selected={!isNoneSelected}
onClick={() => handleSelectMode('selection')}
/>
</DropdownMenuItemsContainer>
</StyledDropdownMenuContainer>
</DropdownContent>
}
/>
);
};
@@ -0,0 +1,85 @@
import { useUpdateCommandMenuItemInDraft } from '@/command-menu-item/server-items/edit/hooks/useUpdateCommandMenuItemInDraft';
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 { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useLingui } from '@lingui/react/macro';
import { type ReactElement } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconRefresh, IconTag } from 'twenty-ui/display';
import { MenuItem, MenuItemToggle } from 'twenty-ui/navigation';
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
type CommandMenuItemOptionsDropdownProps = Pick<
CommandMenuItemFieldsFragment,
'shortLabel'
> & {
itemId: string;
serverShortLabel: string | null | undefined;
iconButton: ReactElement;
};
const getCommandMenuItemOptionsDropdownId = (itemId: string) =>
`command-menu-item-options-${itemId}`;
export const CommandMenuItemOptionsDropdown = ({
itemId,
shortLabel,
serverShortLabel,
iconButton,
}: CommandMenuItemOptionsDropdownProps) => {
const { t } = useLingui();
const dropdownId = getCommandMenuItemOptionsDropdownId(itemId);
const { closeDropdown } = useCloseDropdown();
const { updateCommandMenuItemInDraft } = useUpdateCommandMenuItemInDraft();
const normalizedServerShortLabel = serverShortLabel ?? null;
const normalizedShortLabel = shortLabel ?? null;
const isLabelHidden =
normalizedShortLabel === null && isDefined(normalizedServerShortLabel);
const hasShortLabelOverride =
normalizedShortLabel !== normalizedServerShortLabel;
const handleToggleHideLabel = (toggled: boolean) => {
updateCommandMenuItemInDraft(itemId, {
shortLabel: toggled ? null : normalizedServerShortLabel,
});
};
const handleResetLabelToDefault = () => {
updateCommandMenuItemInDraft(itemId, {
shortLabel: normalizedServerShortLabel,
});
closeDropdown(dropdownId);
};
return (
<Dropdown
dropdownId={dropdownId}
clickableComponent={iconButton}
dropdownPlacement="bottom-end"
dropdownComponents={
<DropdownContent widthInPixels={GenericDropdownContentWidth.Medium}>
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconTag}
text={t`Hide label`}
toggled={isLabelHidden}
onToggleChange={handleToggleHideLabel}
toggleSize="small"
/>
<MenuItem
LeftIcon={IconRefresh}
onClick={handleResetLabelToDefault}
accent="default"
text={t`Reset label to default`}
disabled={!hasShortLabelOverride}
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
);
};
@@ -0,0 +1,172 @@
import { CommandMenuButton } from '@/command-menu/components/CommandMenuButton';
import { useCommandMenuContextApi } from '@/command-menu-item/server-items/common/hooks/useCommandMenuContextApi';
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/server-items/common/utils/doesCommandMenuItemMatchObjectMetadataId';
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 { commandMenuItemEditSelectionModeState } from '@/command-menu-item/server-items/edit/states/commandMenuItemEditSelectionModeState';
import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState';
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
import { type CommandMenuItemConfig } from '@/command-menu-item/types/CommandMenuItemConfig';
import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
import { NodeDimension } from '@/ui/utilities/dimensions/components/NodeDimension';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { COMMAND_MENU_DEFAULT_ICON } from '@/workflow/workflow-trigger/constants/CommandMenuDefaultIcon';
import { styled } from '@linaria/react';
import { motion } from 'framer-motion';
import { useContext, useMemo } from 'react';
import { interpolateCommandMenuItemLabel } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { ThemeContext } from 'twenty-ui/theme-constants';
import {
CommandMenuItemAvailabilityType,
type CommandMenuItemFieldsFragment,
} from '~/generated-metadata/graphql';
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 PinnedCommandMenuItemButtonsEditMode = () => {
const { theme } = useContext(ThemeContext);
const { getIcon } = useIcons();
const commandMenuContextApi = useCommandMenuContextApi();
const currentObjectMetadataItemId =
commandMenuContextApi.objectMetadataItem.id;
const commandMenuItemsDraft =
useAtomStateValue(commandMenuItemsDraftState) ?? [];
const commandMenuItemEditSelectionMode = useAtomStateValue(
commandMenuItemEditSelectionModeState,
);
const allowedAvailabilityTypes = useMemo(
() =>
new Set<CommandMenuItemAvailabilityType>([
CommandMenuItemAvailabilityType.GLOBAL,
commandMenuItemEditSelectionMode === 'selection'
? CommandMenuItemAvailabilityType.RECORD_SELECTION
: CommandMenuItemAvailabilityType.FALLBACK,
]),
[commandMenuItemEditSelectionMode],
);
const interpolateLabel = (rawLabel: string | null | undefined) =>
interpolateCommandMenuItemLabel({
label: rawLabel,
context: commandMenuContextApi,
});
const pinnedCommandMenuItems: CommandMenuItemConfig[] = useMemo(
() =>
commandMenuItemsDraft
.filter(
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
)
.filter((item) => allowedAvailabilityTypes.has(item.availabilityType))
.filter((item) => item.isPinned)
.map((item) => {
const Icon = getIcon(item.icon, COMMAND_MENU_DEFAULT_ICON);
const label = interpolateLabel(item.label) ?? item.label;
const shortLabel = interpolateLabel(item.shortLabel);
const key = `edit-preview-${item.id}`;
return {
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.Global,
key,
label,
shortLabel,
position: item.position,
Icon,
isPinned: true,
shouldBeRegistered: () => true,
component: (
<CommandMenuButton
command={{ key, label, shortLabel, Icon }}
disabled
/>
),
};
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
commandMenuItemsDraft,
currentObjectMetadataItemId,
allowedAvailabilityTypes,
commandMenuContextApi,
getIcon,
],
);
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',
}}
>
{pinnedInlineCommandMenuItem.component}
</StyledActionContainer>
),
)}
</StyledItemsContainer>
</StyledContainer>
</NodeDimension>
</StyledWrapper>
</>
);
};
@@ -0,0 +1,339 @@
import { useCommandMenuContextApi } from '@/command-menu-item/server-items/common/hooks/useCommandMenuContextApi';
import { commandMenuItemsSelector } from '@/command-menu-item/server-items/common/states/commandMenuItemsSelector';
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/server-items/common/utils/doesCommandMenuItemMatchObjectMetadataId';
import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState';
import { CommandMenuItemEditRecordSelectionDropdown } from '@/command-menu-item/server-items/edit/components/CommandMenuItemEditRecordSelectionDropdown';
import { CommandMenuItemOptionsDropdown } from '@/command-menu-item/server-items/edit/components/CommandMenuItemOptionsDropdown';
import { useReorderCommandMenuItemsInDraft } from '@/command-menu-item/server-items/edit/hooks/useReorderCommandMenuItemsInDraft';
import { useResetCommandMenuItemsDraft } from '@/command-menu-item/server-items/edit/hooks/useResetCommandMenuItemsDraft';
import { commandMenuItemEditSelectionModeState } from '@/command-menu-item/server-items/edit/states/commandMenuItemEditSelectionModeState';
import { useUpdateCommandMenuItemInDraft } from '@/command-menu-item/server-items/edit/hooks/useUpdateCommandMenuItemInDraft';
import { COMMAND_MENU_CLICK_OUTSIDE_ID } from '@/command-menu/constants/CommandMenuClickOutsideId';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { SidePanelFooter } from '@/ui/layout/side-panel/components/SidePanelFooter';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { type DropResult } from '@hello-pangea/dnd';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import {
interpolateCommandMenuItemLabel,
isDefined,
} from 'twenty-shared/utils';
import {
IconDotsVertical,
IconPin,
IconPinnedOff,
IconRefresh,
useIcons,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { MenuItem, MenuItemDraggable } from 'twenty-ui/navigation';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
CommandMenuItemAvailabilityType,
type CommandMenuItemFieldsFragment,
} from '~/generated-metadata/graphql';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
const partitionByPinned = (items: CommandMenuItemFieldsFragment[]) => {
const pinned = items
.filter((item) => item.isPinned)
.sort((a, b) => a.position - b.position);
const other = items
.filter((item) => !item.isPinned)
.sort((a, b) => a.position - b.position);
return { pinned, other };
};
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
`;
const StyledViewbar = styled.div`
align-items: center;
backdrop-filter: blur(5px);
border-bottom: 1px solid ${themeCssVariables.border.color.light};
display: flex;
flex-shrink: 0;
height: 40px;
padding: ${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[2]};
`;
const StyledContent = styled.div`
flex: 1;
overflow: auto;
`;
export const SidePanelCommandMenuItemEditPage = () => {
const { t } = useLingui();
const { getIcon } = useIcons();
const commandMenuContextApi = useCommandMenuContextApi();
const currentObjectMetadataItemId =
commandMenuContextApi.objectMetadataItem.id;
const commandMenuItemEditSelectionMode = useAtomStateValue(
commandMenuItemEditSelectionModeState,
);
const sidePanelSearch = useAtomStateValue(sidePanelSearchState);
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
const serverItemsById = new Map(
commandMenuItems.map((item) => [item.id, item]),
);
const commandMenuItemsDraft =
useAtomStateValue(commandMenuItemsDraftState) ?? [];
const { updateCommandMenuItemInDraft } = useUpdateCommandMenuItemInDraft();
const { reorderCommandMenuItemInDraft } = useReorderCommandMenuItemsInDraft();
const { resetCommandMenuItemsDraft } = useResetCommandMenuItemsDraft();
const allowedAvailabilityTypes = new Set<CommandMenuItemAvailabilityType>([
CommandMenuItemAvailabilityType.GLOBAL,
commandMenuItemEditSelectionMode === 'selection'
? CommandMenuItemAvailabilityType.RECORD_SELECTION
: CommandMenuItemAvailabilityType.FALLBACK,
]);
const filteredCommandMenuItems = commandMenuItemsDraft
.filter(
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
)
.filter((item) => allowedAvailabilityTypes.has(item.availabilityType));
const filteredCommandMenuItemIds = new Set(
filteredCommandMenuItems.map((item) => item.id),
);
const getDisplayLabel = (item: CommandMenuItemFieldsFragment) =>
interpolateCommandMenuItemLabel({
label: item.label,
context: commandMenuContextApi,
}) ?? item.label;
const { pinned: allPinnedItems, other: allOtherItems } = partitionByPinned(
filteredCommandMenuItems,
);
const normalizedSearch =
sidePanelSearch.length > 0
? normalizeSearchText(sidePanelSearch)
: undefined;
const matchesSearch = (item: CommandMenuItemFieldsFragment) =>
normalizedSearch === undefined ||
normalizeSearchText(getDisplayLabel(item)).includes(normalizedSearch);
const displayedPinnedItems = allPinnedItems.filter(matchesSearch);
const displayedOtherItems = allOtherItems.filter(matchesSearch);
const selectableItemIds = [
...displayedPinnedItems.map((item) => item.id),
...displayedOtherItems.map((item) => item.id),
];
const handleTogglePin = (itemId: string, currentlyPinned: boolean) => {
if (currentlyPinned) {
const nextOtherPosition =
allOtherItems.length === 0
? 0
: allOtherItems[allOtherItems.length - 1].position + 1;
updateCommandMenuItemInDraft(itemId, {
isPinned: false,
position: nextOtherPosition,
});
return;
}
const nextPinnedPosition =
allPinnedItems.length === 0
? 0
: allPinnedItems[allPinnedItems.length - 1].position + 1;
updateCommandMenuItemInDraft(itemId, {
isPinned: true,
position: nextPinnedPosition,
});
};
const makeOptionsDropdownWrapper =
(item: Pick<CommandMenuItemFieldsFragment, 'id' | 'shortLabel'>) =>
({ iconButton }: { iconButton: React.ReactElement }) => (
<CommandMenuItemOptionsDropdown
itemId={item.id}
shortLabel={item.shortLabel}
serverShortLabel={serverItemsById.get(item.id)?.shortLabel ?? null}
iconButton={iconButton}
/>
);
const handlePinnedDragEnd = (result: DropResult) => {
const { source, destination, draggableId } = result;
if (!isDefined(destination)) {
return;
}
if (source.index === destination.index) {
return;
}
const displayedPinnedItemIdsWithoutSource = displayedPinnedItems
.map((item) => item.id)
.filter((itemId) => itemId !== draggableId);
const allPinnedItemsWithoutSource = allPinnedItems.filter(
(item) => item.id !== draggableId,
);
const nextDisplayedPinnedItemId =
displayedPinnedItemIdsWithoutSource[destination.index];
const previousDisplayedPinnedItemId =
displayedPinnedItemIdsWithoutSource[destination.index - 1];
let destinationIndexInAllPinnedItemsWithoutSource: number;
if (isDefined(nextDisplayedPinnedItemId)) {
destinationIndexInAllPinnedItemsWithoutSource =
allPinnedItemsWithoutSource.findIndex(
(item) => item.id === nextDisplayedPinnedItemId,
);
} else if (isDefined(previousDisplayedPinnedItemId)) {
const previousIndex = allPinnedItemsWithoutSource.findIndex(
(item) => item.id === previousDisplayedPinnedItemId,
);
if (previousIndex === -1) {
return;
}
destinationIndexInAllPinnedItemsWithoutSource = previousIndex + 1;
} else {
return;
}
if (destinationIndexInAllPinnedItemsWithoutSource === -1) {
return;
}
reorderCommandMenuItemInDraft(
draggableId,
destinationIndexInAllPinnedItemsWithoutSource,
'pinned',
filteredCommandMenuItemIds,
);
};
return (
<StyledContainer data-click-outside-id={COMMAND_MENU_CLICK_OUTSIDE_ID}>
<StyledViewbar>
<CommandMenuItemEditRecordSelectionDropdown />
</StyledViewbar>
<StyledContent>
<SidePanelList commandGroups={[]} selectableItemIds={selectableItemIds}>
<SidePanelGroup heading={t`Pinned`}>
<DraggableList
onDragEnd={handlePinnedDragEnd}
draggableItems={displayedPinnedItems.map((item, index) => {
const ItemIcon = isDefined(item.icon)
? getIcon(item.icon)
: undefined;
return (
<DraggableItem
key={item.id}
draggableId={item.id}
index={index}
itemComponent={
<SelectableListItem
itemId={item.id}
onEnter={() => handleTogglePin(item.id, true)}
>
<MenuItemDraggable
withIconContainer
LeftIcon={ItemIcon}
text={getDisplayLabel(item)}
gripMode="onHover"
isIconDisplayedOnHoverOnly={false}
iconButtons={[
{
Icon: IconPinnedOff,
onClick: (event) => {
event.stopPropagation();
handleTogglePin(item.id, true);
},
},
{
Icon: IconDotsVertical,
Wrapper: makeOptionsDropdownWrapper(item),
onClick: () => {},
},
]}
/>
</SelectableListItem>
}
/>
);
})}
/>
</SidePanelGroup>
<SidePanelGroup heading={t`Other`}>
{displayedOtherItems.map((item) => {
const ItemIcon = isDefined(item.icon)
? getIcon(item.icon)
: undefined;
return (
<SelectableListItem
key={item.id}
itemId={item.id}
onEnter={() => handleTogglePin(item.id, false)}
>
<MenuItem
withIconContainer
LeftIcon={ItemIcon}
text={getDisplayLabel(item)}
isIconDisplayedOnHoverOnly={false}
iconButtons={[
{
Icon: IconPin,
onClick: (event) => {
event.stopPropagation();
handleTogglePin(item.id, false);
},
},
]}
/>
</SelectableListItem>
);
})}
</SidePanelGroup>
</SidePanelList>
</StyledContent>
<SidePanelFooter
actions={[
<Button
key="reset"
Icon={IconRefresh}
title={t`Reset to default`}
variant="secondary"
accent="default"
size="small"
onClick={resetCommandMenuItemsDraft}
/>,
]}
/>
</StyledContainer>
);
};
@@ -0,0 +1,92 @@
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState';
import { computeInsertPositionFromBounds } from '@/command-menu-item/server-items/edit/utils/computeInsertPositionFromBounds';
import { getPositionBoundsAtInsertionPoint } from '@/command-menu-item/server-items/edit/utils/getPositionBoundsAtInsertionPoint';
export const useReorderCommandMenuItemsInDraft = () => {
const store = useStore();
const reorderCommandMenuItemInDraft = useCallback(
(
sourceId: string,
destinationIndex: number,
targetSection: 'pinned' | 'other',
contextualItemIds?: ReadonlySet<string>,
) => {
const draft = store.get(commandMenuItemsDraftState.atom);
if (!isDefined(draft)) {
return;
}
const isPinned = targetSection === 'pinned';
const fullSectionItems = draft
.filter((item) => item.isPinned === isPinned)
.sort((a, b) => a.position - b.position);
const contextualSectionItems = isDefined(contextualItemIds)
? fullSectionItems.filter((item) => contextualItemIds.has(item.id))
: fullSectionItems;
const fullSectionItemsWithoutSource = fullSectionItems.filter(
(item) => item.id !== sourceId,
);
const contextualSectionItemsWithoutSource = contextualSectionItems.filter(
(item) => item.id !== sourceId,
);
const clampedDestinationIndex = Math.max(
0,
Math.min(destinationIndex, contextualSectionItemsWithoutSource.length),
);
const nextContextualItem =
contextualSectionItemsWithoutSource[clampedDestinationIndex];
const previousContextualItem =
contextualSectionItemsWithoutSource[clampedDestinationIndex - 1];
if (
!isDefined(nextContextualItem) &&
!isDefined(previousContextualItem)
) {
return;
}
const positionBounds = isDefined(nextContextualItem)
? getPositionBoundsAtInsertionPoint(
nextContextualItem.id,
'before',
fullSectionItemsWithoutSource,
)
: getPositionBoundsAtInsertionPoint(
previousContextualItem!.id,
'after',
fullSectionItemsWithoutSource,
);
if (!isDefined(positionBounds)) {
return;
}
const newPosition = computeInsertPositionFromBounds(
positionBounds.previousPosition,
positionBounds.nextPosition,
);
const updatedDraft = draft.map((item) =>
item.id === sourceId
? { ...item, isPinned, position: newPosition }
: item,
);
store.set(commandMenuItemsDraftState.atom, updatedDraft);
},
[store],
);
return { reorderCommandMenuItemInDraft };
};
@@ -0,0 +1,21 @@
import { useCallback } from 'react';
import { useStore } from 'jotai';
import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState';
import { commandMenuItemsSelector } from '@/command-menu-item/server-items/common/states/commandMenuItemsSelector';
// Resets the draft to the current server state, discarding all user edits.
export const useResetCommandMenuItemsDraft = () => {
const store = useStore();
const resetCommandMenuItemsDraft = useCallback(() => {
const serverItems = store.get(commandMenuItemsSelector.atom);
store.set(
commandMenuItemsDraftState.atom,
serverItems.map((item) => ({ ...item })),
);
}, [store]);
return { resetCommandMenuItemsDraft };
};
@@ -0,0 +1,57 @@
import { useCallback } from 'react';
import { useStore } from 'jotai';
import { useMutation } from '@apollo/client/react';
import { isDefined } from 'twenty-shared/utils';
import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState';
import { UPDATE_COMMAND_MENU_ITEM } from '@/command-menu-item/server-items/common/graphql/mutations/updateCommandMenuItem';
import { commandMenuItemsSelector } from '@/command-menu-item/server-items/common/states/commandMenuItemsSelector';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { type UpdateCommandMenuItemInput } from '~/generated-metadata/graphql';
export const useSaveCommandMenuItemsDraft = () => {
const store = useStore();
const [updateCommandMenuItem] = useMutation(UPDATE_COMMAND_MENU_ITEM);
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
const saveCommandMenuItemsDraft = useCallback(async () => {
const draft = store.get(commandMenuItemsDraftState.atom);
if (!isDefined(draft)) {
return;
}
const serverItemsById = new Map(
commandMenuItems.map((item) => [item.id, item]),
);
const changedItems = draft.filter((draftItem) => {
const serverItem = serverItemsById.get(draftItem.id);
if (!isDefined(serverItem)) {
return false;
}
return (
draftItem.isPinned !== serverItem.isPinned ||
draftItem.position !== serverItem.position ||
draftItem.shortLabel !== serverItem.shortLabel
);
});
await Promise.all(
changedItems.map((item) => {
const input: UpdateCommandMenuItemInput = {
id: item.id,
isPinned: item.isPinned,
position: item.position,
shortLabel: item.shortLabel,
};
return updateCommandMenuItem({ variables: { input } });
}),
);
}, [store, commandMenuItems, updateCommandMenuItem]);
return { saveCommandMenuItemsDraft };
};
@@ -0,0 +1,29 @@
import { useCallback } from 'react';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState';
import { type CommandMenuItemEditableFields } from '@/command-menu-item/server-items/edit/types/CommandMenuItemEditableFields';
export const useUpdateCommandMenuItemInDraft = () => {
const store = useStore();
const updateCommandMenuItemInDraft = useCallback(
(id: string, fields: Partial<CommandMenuItemEditableFields>) => {
const draft = store.get(commandMenuItemsDraftState.atom);
if (!isDefined(draft)) {
return;
}
const updatedDraft = draft.map((item) =>
item.id === id ? { ...item, ...fields } : item,
);
store.set(commandMenuItemsDraftState.atom, updatedDraft);
},
[store],
);
return { updateCommandMenuItemInDraft };
};
@@ -0,0 +1,9 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export type CommandMenuItemEditSelectionMode = 'none' | 'selection';
export const commandMenuItemEditSelectionModeState =
createAtomState<CommandMenuItemEditSelectionMode>({
key: 'commandMenuItemEditSelectionModeState',
defaultValue: 'selection',
});
@@ -0,0 +1,10 @@
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const commandMenuItemsDraftState = createAtomState<
CommandMenuItemFieldsFragment[] | null
>({
key: 'commandMenuItemsDraftState',
defaultValue: null,
});
@@ -0,0 +1,6 @@
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
export type CommandMenuItemEditableFields = Pick<
CommandMenuItemFieldsFragment,
'isPinned' | 'position' | 'shortLabel'
>;
@@ -0,0 +1,39 @@
import { computeInsertPositionFromBounds } from '@/command-menu-item/server-items/edit/utils/computeInsertPositionFromBounds';
describe('computeInsertPositionFromBounds', () => {
it('returns midpoint when both bounds are defined', () => {
expect(computeInsertPositionFromBounds(2, 4)).toBe(3);
});
it('returns midpoint for non-integer result', () => {
expect(computeInsertPositionFromBounds(1, 2)).toBe(1.5);
});
it('returns previous - 1 when only next is undefined', () => {
expect(computeInsertPositionFromBounds(5, undefined)).toBe(6);
});
it('returns next - 1 when only previous is undefined', () => {
expect(computeInsertPositionFromBounds(undefined, 3)).toBe(2);
});
it('returns 0 when both bounds are undefined', () => {
expect(computeInsertPositionFromBounds(undefined, undefined)).toBe(0);
});
it('returns previous - 1 when bounds are equal', () => {
expect(computeInsertPositionFromBounds(5, 5)).toBe(4);
});
it('handles negative positions', () => {
expect(computeInsertPositionFromBounds(-4, -2)).toBe(-3);
});
it('handles zero as previous position', () => {
expect(computeInsertPositionFromBounds(0, 2)).toBe(1);
});
it('handles zero as next position', () => {
expect(computeInsertPositionFromBounds(-2, 0)).toBe(-1);
});
});
@@ -0,0 +1,105 @@
import { getPositionBoundsAtInsertionPoint } from '@/command-menu-item/server-items/edit/utils/getPositionBoundsAtInsertionPoint';
const makeItems = (positions: number[]) =>
positions.map((position, index) => ({
id: `item-${index}`,
position,
}));
describe('getPositionBoundsAtInsertionPoint', () => {
const items = makeItems([10, 20, 30, 40, 50]);
describe('insert before', () => {
it('returns previous=undefined and next=10 when inserting before the first item', () => {
expect(
getPositionBoundsAtInsertionPoint('item-0', 'before', items),
).toEqual({
previousPosition: undefined,
nextPosition: 10,
});
});
it('returns previous=10 and next=20 when inserting before the second item', () => {
expect(
getPositionBoundsAtInsertionPoint('item-1', 'before', items),
).toEqual({
previousPosition: 10,
nextPosition: 20,
});
});
it('returns previous=40 and next=50 when inserting before the last item', () => {
expect(
getPositionBoundsAtInsertionPoint('item-4', 'before', items),
).toEqual({
previousPosition: 40,
nextPosition: 50,
});
});
});
describe('insert after', () => {
it('returns previous=10 and next=20 when inserting after the first item', () => {
expect(
getPositionBoundsAtInsertionPoint('item-0', 'after', items),
).toEqual({
previousPosition: 10,
nextPosition: 20,
});
});
it('returns previous=50 and next=undefined when inserting after the last item', () => {
expect(
getPositionBoundsAtInsertionPoint('item-4', 'after', items),
).toEqual({
previousPosition: 50,
nextPosition: undefined,
});
});
it('returns previous=30 and next=40 when inserting after the middle item', () => {
expect(
getPositionBoundsAtInsertionPoint('item-2', 'after', items),
).toEqual({
previousPosition: 30,
nextPosition: 40,
});
});
});
describe('edge cases', () => {
it('returns undefined when anchor item is not found', () => {
expect(
getPositionBoundsAtInsertionPoint('nonexistent', 'before', items),
).toBeUndefined();
});
it('handles single-item list inserting before', () => {
const singleItem = makeItems([5]);
expect(
getPositionBoundsAtInsertionPoint('item-0', 'before', singleItem),
).toEqual({
previousPosition: undefined,
nextPosition: 5,
});
});
it('handles single-item list inserting after', () => {
const singleItem = makeItems([5]);
expect(
getPositionBoundsAtInsertionPoint('item-0', 'after', singleItem),
).toEqual({
previousPosition: 5,
nextPosition: undefined,
});
});
it('returns undefined for empty list', () => {
expect(
getPositionBoundsAtInsertionPoint('item-0', 'before', []),
).toBeUndefined();
});
});
});
@@ -0,0 +1,24 @@
import { isDefined } from 'twenty-shared/utils';
export const computeInsertPositionFromBounds = (
previousPosition: number | undefined,
nextPosition: number | undefined,
): number => {
if (!isDefined(previousPosition) && isDefined(nextPosition)) {
return nextPosition - 1;
}
if (isDefined(previousPosition) && !isDefined(nextPosition)) {
return previousPosition + 1;
}
if (isDefined(previousPosition) && isDefined(nextPosition)) {
if (previousPosition === nextPosition) {
return previousPosition - 1;
}
return (previousPosition + nextPosition) / 2;
}
return 0;
};
@@ -0,0 +1,23 @@
type PositionedItem = { id: string; position: number };
export const getPositionBoundsAtInsertionPoint = (
anchorItemId: string,
insertionSide: 'before' | 'after',
sectionItems: PositionedItem[],
) => {
const anchorIndex = sectionItems.findIndex(
(item) => item.id === anchorItemId,
);
if (anchorIndex === -1) {
return undefined;
}
const insertIndex =
insertionSide === 'before' ? anchorIndex : anchorIndex + 1;
return {
previousPosition: sectionItems[insertIndex - 1]?.position,
nextPosition: sectionItems[insertIndex]?.position,
};
};
@@ -1,39 +0,0 @@
import { useConvertBackendItemToCommandMenuItemConfig } from '@/command-menu-item/server-items/hooks/useConvertBackendItemToCommandMenuItemConfig';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useQuery } from '@apollo/client/react';
import { type CommandMenuContextApi } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
type CommandMenuItemFieldsFragment,
FindManyCommandMenuItemsDocument,
} from '~/generated-metadata/graphql';
export const useCommandMenuItemsFromBackend = (
commandMenuContextApi: CommandMenuContextApi,
) => {
const { convertBackendItemToCommandMenuItemConfig } =
useConvertBackendItemToCommandMenuItemConfig();
const contextStoreCurrentObjectMetadataItemId = useAtomComponentStateValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
);
const { data } = useQuery(FindManyCommandMenuItemsDocument);
const allItems = data?.commandMenuItems ?? [];
const objectMatches = (item: CommandMenuItemFieldsFragment) =>
!isDefined(item.availabilityObjectMetadataId) ||
item.availabilityObjectMetadataId ===
contextStoreCurrentObjectMetadataItemId;
return allItems
.filter(objectMatches)
.map((item) =>
convertBackendItemToCommandMenuItemConfig(item, commandMenuContextApi),
)
.filter(isDefined)
.filter((item) => item.shouldBeRegistered())
.sort((a, b) => a.position - b.position);
};