[Command Menu] Fix record-selection command filtering in edit mode (#20034)

https://github.com/user-attachments/assets/fe1461c7-0d5c-4c6f-8c2e-2cf569e7de90

## What

Fix `RECORD_SELECTION` items leaking into the command menu when nothing
is selected, and unify how the menu renders in normal vs edit mode.

## The bug

`RECORD_SELECTION`-availability items were showing up even when
`numberOfSelectedRecords === 0`. New util
`doesCommandMenuItemMatchSelectionState` gates them, applied
consistently in the runtime provider and the editor.

## The refactor

`PinnedCommandMenuItemButtonsEditMode` was a 140-line near-duplicate of
`PinnedCommandMenuItemButtons` with its own (drifting) filter logic.
Killed it. Edit mode now flows through the same
`CommandMenuContextProvider` with a new `isInPreviewMode` flag — one
filter chain, one rendering path.

## Behavior in edit mode

**Header (pinned buttons in page header):**
- Runs the full filter chain — object metadata, page type, selection
state, page layout, *and the conditional availability expression*
- Buttons render at full styling but are inert via `pointer-events:
none` + `cursor: not-allowed`
- Preview now reflects exactly what users will see on the live page (not
a grayed-out approximation)

**Side panel editor:**
- New `useEditableCommandMenuItems` hook
- Same filters as runtime *minus* the conditional availability
expression and `FALLBACK` items — so it surfaces everything that's
actually configurable for this page context
- Still gates on selection state — if no records selected,
`RECORD_SELECTION` items are hidden from the editor too. Open to
feedback if we'd rather always show them so users can pin them ahead of
time.

## Misc

- `usePinnedCommandMenuItemsInlineLayout` — visible count now waits
until every item is measured before committing. Fixes a flash of wrong
counts on mount/resize
- Renamed `useCommandMenuContextApi` → `useCurrentCommandMenuContextApi`
— name now conveys it reads from the *current* scoped context store
- Copy: "Records selected" → "Record(s) selected"
This commit is contained in:
nitin
2026-04-29 19:04:28 +05:30
committed by GitHub
parent 51384bc085
commit 4649736d49
20 changed files with 200 additions and 218 deletions
@@ -1,8 +1,7 @@
import { PinnedCommandMenuItemButtons } from '@/command-menu-item/display/components/PinnedCommandMenuItemButtons';
import { RecordIndexCommandMenuDropdown } from '@/command-menu-item/components/RecordIndexCommandMenuDropdown';
import { CommandMenuContextProvider } from '@/command-menu-item/contexts/CommandMenuContextProvider';
import { PinnedCommandMenuItemButtons } from '@/command-menu-item/display/components/PinnedCommandMenuItemButtons';
import { CommandMenuItemEditButton } from '@/command-menu-item/edit/components/CommandMenuItemEditButton';
import { PinnedCommandMenuItemButtonsEditMode } from '@/command-menu-item/edit/components/PinnedCommandMenuItemButtonsEditMode';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
@@ -21,23 +20,18 @@ export const RecordIndexCommandMenu = () => {
isLayoutCustomizationModeEnabledState,
);
const showEditModePinnedButtons = isLayoutCustomizationModeEnabled;
return (
<>
{contextStoreCurrentObjectMetadataItemId && (
<>
{!isMobile && showEditModePinnedButtons ? (
<PinnedCommandMenuItemButtonsEditMode />
) : (
<CommandMenuContextProvider
isInSidePanel={false}
displayType="button"
containerType="index-page-header"
>
{!isMobile && <PinnedCommandMenuItemButtons />}
</CommandMenuContextProvider>
)}
<CommandMenuContextProvider
isInSidePanel={false}
displayType="button"
containerType="index-page-header"
isInPreviewMode={isLayoutCustomizationModeEnabled}
>
{!isMobile && <PinnedCommandMenuItemButtons />}
</CommandMenuContextProvider>
<CommandMenuContextProvider
isInSidePanel={false}
displayType="dropdownItem"
@@ -4,7 +4,9 @@ import { CommandMenuItemEditButton } from '@/command-menu-item/edit/components/C
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsMobile } from 'twenty-ui/utilities';
export const RecordShowCommandMenu = () => {
@@ -23,6 +25,9 @@ export const RecordShowCommandMenu = () => {
contextStoreTargetedRecordsRule.selectedRecordIds.length === 1;
const isMobile = useIsMobile();
const isLayoutCustomizationModeEnabled = useAtomStateValue(
isLayoutCustomizationModeEnabledState,
);
return (
<>
@@ -32,6 +37,7 @@ export const RecordShowCommandMenu = () => {
isInSidePanel={false}
displayType="button"
containerType="show-page-header"
isInPreviewMode={isLayoutCustomizationModeEnabled}
>
{!isMobile && <PinnedCommandMenuItemButtons />}
</CommandMenuContextProvider>
@@ -125,6 +125,7 @@ export const StandalonePageCommandMenu = () => {
containerType: 'standalone-page-header',
commandMenuItems: filteredCommandMenuItems,
commandMenuContextApi,
isInPreviewMode: false,
}}
>
{!isMobile && <PinnedCommandMenuItemButtons />}
@@ -45,6 +45,7 @@ const meta: Meta<typeof RecordIndexCommandMenuDropdown> = {
containerType: 'index-page-dropdown',
commandMenuItems: createMockCommandMenuItems(),
commandMenuContextApi: EMPTY_COMMAND_MENU_CONTEXT_API,
isInPreviewMode: false,
}}
>
<Story />
@@ -3,13 +3,13 @@ import { Provider as JotaiProvider } from 'jotai';
import { userEvent, within } from 'storybook/test';
import { RecordPageSidePanelCommandMenuDropdown } from '@/command-menu-item/components/RecordPageSidePanelCommandMenuDropdown';
import { EMPTY_COMMAND_MENU_CONTEXT_API } from '@/command-menu-item/constants/EmptyCommandMenuContextApi';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { createMockCommandMenuItems } from '@/command-menu-item/mock/command-menu-items.mock';
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { EMPTY_COMMAND_MENU_CONTEXT_API } from '@/command-menu-item/constants/EmptyCommandMenuContextApi';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
import { ContextStoreDecorator } from '~/testing/decorators/ContextStoreDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
@@ -44,6 +44,7 @@ const meta: Meta<typeof RecordPageSidePanelCommandMenuDropdown> = {
...EMPTY_COMMAND_MENU_CONTEXT_API,
isInSidePanel: true,
},
isInPreviewMode: false,
}}
>
<Story />
@@ -9,6 +9,7 @@ export type CommandMenuContextType = {
containerType: CommandMenuItemContainerType;
commandMenuItems: CommandMenuItemFieldsFragment[];
commandMenuContextApi: CommandMenuContextApi;
isInPreviewMode: boolean;
};
export const CommandMenuContext = createContext<CommandMenuContextType>({
@@ -16,4 +17,5 @@ export const CommandMenuContext = createContext<CommandMenuContextType>({
displayType: 'button',
commandMenuItems: [],
commandMenuContextApi: EMPTY_COMMAND_MENU_CONTEXT_API,
isInPreviewMode: false,
});
@@ -2,7 +2,7 @@ import { CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type CommandMenuContextType } from '@/command-menu-item/contexts/CommandMenuContext';
import { useCommandMenuContextApi } from '@/command-menu-item/hooks/useCommandMenuContextApi';
import { useCurrentCommandMenuContextApi } from '@/command-menu-item/hooks/useCurrentCommandMenuContextApi';
import { CommandMenuContextProviderContent } from './CommandMenuContextProviderContent';
import { CommandMenuContextProviderWithWorkflowEnrichment } from './CommandMenuContextProviderWithWorkflowEnrichment';
@@ -12,6 +12,7 @@ type CommandMenuContextProviderProps = {
displayType: CommandMenuContextType['displayType'];
containerType: CommandMenuContextType['containerType'];
children: React.ReactNode;
isInPreviewMode?: boolean;
};
export const CommandMenuContextProvider = ({
@@ -19,8 +20,9 @@ export const CommandMenuContextProvider = ({
displayType,
containerType,
children,
isInPreviewMode = false,
}: CommandMenuContextProviderProps) => {
const commandMenuContextApiFromHook = useCommandMenuContextApi();
const commandMenuContextApiFromHook = useCurrentCommandMenuContextApi();
const commandMenuContextApi = isInSidePanel
? { ...commandMenuContextApiFromHook, isInSidePanel: true }
@@ -45,6 +47,7 @@ export const CommandMenuContextProvider = ({
containerType={containerType}
commandMenuContextApi={commandMenuContextApi}
selectedWorkflowRecordIds={selectedWorkflowRecordIds}
isInPreviewMode={isInPreviewMode}
>
{children}
</CommandMenuContextProviderWithWorkflowEnrichment>
@@ -56,6 +59,7 @@ export const CommandMenuContextProvider = ({
displayType={displayType}
containerType={containerType}
commandMenuContextApi={commandMenuContextApi}
isInPreviewMode={isInPreviewMode}
>
{children}
</CommandMenuContextProviderContent>
@@ -2,10 +2,12 @@ import {
CommandMenuContext,
type CommandMenuContextType,
} from '@/command-menu-item/contexts/CommandMenuContext';
import { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/commandMenuItemsDraftState';
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/utils/doesCommandMenuItemMatchObjectMetadataId';
import { doesCommandMenuItemMatchPageLayoutId } from '@/command-menu-item/utils/doesCommandMenuItemMatchPageLayoutId';
import { doesCommandMenuItemMatchPageType } from '@/command-menu-item/utils/doesCommandMenuItemMatchPageType';
import { doesCommandMenuItemMatchSelectionState } from '@/command-menu-item/utils/doesCommandMenuItemMatchSelectionState';
import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useMemo } from 'react';
@@ -17,6 +19,7 @@ type CommandMenuContextProviderContentProps = {
containerType: CommandMenuContextType['containerType'];
children: React.ReactNode;
commandMenuContextApi: CommandMenuContextApi;
isInPreviewMode: boolean;
};
export const CommandMenuContextProviderContent = ({
@@ -24,19 +27,27 @@ export const CommandMenuContextProviderContent = ({
containerType,
children,
commandMenuContextApi,
isInPreviewMode,
}: CommandMenuContextProviderContentProps) => {
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
const commandMenuItemsDraft = useAtomStateValue(commandMenuItemsDraftState);
const currentPageLayoutId = useAtomStateValue(currentPageLayoutIdState);
const filteredCommandMenuItems = useMemo(() => {
const currentObjectMetadataItemId =
commandMenuContextApi.objectMetadataItem.id;
const hasSelectedRecords =
commandMenuContextApi.numberOfSelectedRecords > 0;
const commandMenuItemsToDisplay = isInPreviewMode
? (commandMenuItemsDraft ?? commandMenuItems)
: commandMenuItems;
return commandMenuItems
return commandMenuItemsToDisplay
.filter(
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
)
.filter(doesCommandMenuItemMatchPageType(commandMenuContextApi.pageType))
.filter(doesCommandMenuItemMatchSelectionState(hasSelectedRecords))
.filter(doesCommandMenuItemMatchPageLayoutId(currentPageLayoutId))
.filter((item) =>
evaluateConditionalAvailabilityExpression(
@@ -47,7 +58,13 @@ export const CommandMenuContextProviderContent = ({
.sort(
(firstItem, secondItem) => firstItem.position - secondItem.position,
);
}, [commandMenuItems, commandMenuContextApi, currentPageLayoutId]);
}, [
commandMenuContextApi,
commandMenuItems,
commandMenuItemsDraft,
currentPageLayoutId,
isInPreviewMode,
]);
return (
<CommandMenuContext.Provider
@@ -56,6 +73,7 @@ export const CommandMenuContextProviderContent = ({
containerType,
commandMenuItems: filteredCommandMenuItems,
commandMenuContextApi,
isInPreviewMode,
}}
>
{children}
@@ -12,6 +12,7 @@ type CommandMenuContextProviderWithWorkflowEnrichmentProps = {
children: React.ReactNode;
commandMenuContextApi: CommandMenuContextApi;
selectedWorkflowRecordIds: string[];
isInPreviewMode: boolean;
};
export const CommandMenuContextProviderWithWorkflowEnrichment = ({
@@ -20,6 +21,7 @@ export const CommandMenuContextProviderWithWorkflowEnrichment = ({
children,
commandMenuContextApi,
selectedWorkflowRecordIds,
isInPreviewMode,
}: CommandMenuContextProviderWithWorkflowEnrichmentProps) => {
const workflowsWithCurrentVersions = useWorkflowsWithCurrentVersions(
selectedWorkflowRecordIds,
@@ -54,6 +56,7 @@ export const CommandMenuContextProviderWithWorkflowEnrichment = ({
displayType={displayType}
containerType={containerType}
commandMenuContextApi={enrichedCommandMenuContextApi}
isInPreviewMode={isInPreviewMode}
>
{children}
</CommandMenuContextProviderContent>
@@ -11,6 +11,7 @@ import { isSelectedItemIdComponentFamilyState } from '@/ui/layout/selectable-lis
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
import { COMMAND_MENU_DEFAULT_ICON } from '@/workflow/workflow-trigger/constants/CommandMenuDefaultIcon';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
@@ -18,6 +19,14 @@ import { Loader } from 'twenty-ui/feedback';
import { MenuItem } from 'twenty-ui/navigation';
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
const StyledPreviewWrapper = styled.div`
cursor: not-allowed;
& * {
pointer-events: none;
}
`;
type CommandMenuItemRendererProps = {
item: CommandMenuItemFieldsFragment;
};
@@ -27,7 +36,8 @@ type CommandMenuItemButtonRendererProps = CommandMenuItemRendererProps;
const CommandMenuItemButtonRenderer = ({
item,
}: CommandMenuItemButtonRendererProps) => {
const { commandMenuContextApi } = useContext(CommandMenuContext);
const { commandMenuContextApi, isInPreviewMode } =
useContext(CommandMenuContext);
const { getIcon } = useIcons();
const { iconKey, label, shortLabel } = interpolateCommandMenuItemFields(
@@ -43,14 +53,19 @@ const CommandMenuItemButtonRenderer = ({
label,
});
const command = { key: item.id, label, shortLabel, Icon };
if (isInPreviewMode) {
return (
<StyledPreviewWrapper>
<CommandMenuButton command={command} />
</StyledPreviewWrapper>
);
}
return (
<CommandMenuButton
command={{
key: item.id,
label,
shortLabel,
Icon,
}}
command={command}
onClick={disabled ? undefined : handleClick}
disabled={disabled}
/>
@@ -2,6 +2,7 @@ import { PINNED_COMMAND_MENU_ITEMS_GAP } from '@/command-menu-item/display/const
import { commandMenuPinnedInlineLayoutState } from '@/command-menu-item/display/states/commandMenuPinnedInlineLayoutState';
import { getVisibleCommandMenuItemCountForContainerWidth } from '@/command-menu-item/display/utils/getVisibleCommandMenuItemCountForContainerWidth';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { isNumber } from '@sniptt/guards';
import { useCallback, useMemo } from 'react';
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
@@ -25,18 +26,37 @@ export const usePinnedCommandMenuItemsInlineLayout = ({
[pinnedCommandMenuItems],
);
const hasKnownPinnedInlineLayout = useMemo(
() =>
commandMenuPinnedInlineLayout.containerWidth > 0 &&
pinnedCommandMenuItemKeysInDisplayOrder.every((commandMenuItemKey) =>
isNumber(
commandMenuPinnedInlineLayout.commandMenuItemWidthsByKey[
commandMenuItemKey
],
),
),
[commandMenuPinnedInlineLayout, pinnedCommandMenuItemKeysInDisplayOrder],
);
const visiblePinnedCommandMenuItemCount = useMemo(
() =>
getVisibleCommandMenuItemCountForContainerWidth({
commandMenuItemKeysInDisplayOrder:
pinnedCommandMenuItemKeysInDisplayOrder,
commandMenuItemWidthsByKey:
commandMenuPinnedInlineLayout.commandMenuItemWidthsByKey,
commandMenuItemsContainerWidth:
commandMenuPinnedInlineLayout.containerWidth,
commandMenuItemsGapWidth: PINNED_COMMAND_MENU_ITEMS_GAP,
}),
[commandMenuPinnedInlineLayout, pinnedCommandMenuItemKeysInDisplayOrder],
hasKnownPinnedInlineLayout
? getVisibleCommandMenuItemCountForContainerWidth({
commandMenuItemKeysInDisplayOrder:
pinnedCommandMenuItemKeysInDisplayOrder,
commandMenuItemWidthsByKey:
commandMenuPinnedInlineLayout.commandMenuItemWidthsByKey,
commandMenuItemsContainerWidth:
commandMenuPinnedInlineLayout.containerWidth,
commandMenuItemsGapWidth: PINNED_COMMAND_MENU_ITEMS_GAP,
})
: 0,
[
commandMenuPinnedInlineLayout,
hasKnownPinnedInlineLayout,
pinnedCommandMenuItemKeysInDisplayOrder,
],
);
const pinnedInlineCommandMenuItems = useMemo(
@@ -83,7 +83,7 @@ export const CommandMenuItemEditRecordSelectionDropdown = ({
const TriggerIcon = isNoneSelected ? IconSquareX : IconSquareCheck;
const triggerLabel = isNoneSelected
? t`No record selected`
: t`Records selected`;
: t`Record(s) selected`;
return (
<Dropdown
@@ -108,6 +108,7 @@ export const CommandMenuItemEditRecordSelectionDropdown = ({
</StyledClickableArea>
}
dropdownPlacement="bottom-start"
dropdownOffset={{ y: 4 }}
dropdownComponents={
<DropdownContent widthInPixels={GenericDropdownContentWidth.Medium}>
<StyledDropdownMenuContainer
@@ -122,7 +123,7 @@ export const CommandMenuItemEditRecordSelectionDropdown = ({
/>
<MenuItemSelect
LeftIcon={IconSquareCheck}
text={t`Records selected`}
text={t`Record(s) selected`}
selected={!isNoneSelected}
onClick={() => handleSelectMode('selection')}
/>
@@ -1,140 +0,0 @@
import { PinnedCommandMenuItemsInlineMeasurements } from '@/command-menu-item/display/components/PinnedCommandMenuItemsInlineMeasurements';
import { PINNED_COMMAND_MENU_ITEMS_GAP } from '@/command-menu-item/display/constants/PinnedCommandMenuItemsGap';
import { usePinnedCommandMenuItemsInlineLayout } from '@/command-menu-item/display/hooks/usePinnedCommandMenuItemsInlineLayout';
import { interpolateCommandMenuItemFields } from '@/command-menu-item/display/utils/interpolateCommandMenuItemFields';
import { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/commandMenuItemsDraftState';
import { useCommandMenuContextApi } from '@/command-menu-item/hooks/useCommandMenuContextApi';
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/utils/doesCommandMenuItemMatchObjectMetadataId';
import { CommandMenuButton } from '@/command-menu/components/CommandMenuButton';
import { mainContextStoreHasSelectedRecordsSelector } from '@/context-store/states/selectors/mainContextStoreHasSelectedRecordsSelector';
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 { useIcons } from 'twenty-ui/display';
import { ThemeContext } from 'twenty-ui/theme-constants';
import { CommandMenuItemAvailabilityType } from '~/generated-metadata/graphql';
const StyledCommandMenuItemContainer = 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 mainContextStoreHasSelectedRecords = useAtomStateValue(
mainContextStoreHasSelectedRecordsSelector,
);
const allowedAvailabilityTypes = useMemo(
() =>
new Set<CommandMenuItemAvailabilityType>([
CommandMenuItemAvailabilityType.GLOBAL,
CommandMenuItemAvailabilityType.GLOBAL_OBJECT_CONTEXT,
mainContextStoreHasSelectedRecords
? CommandMenuItemAvailabilityType.RECORD_SELECTION
: CommandMenuItemAvailabilityType.FALLBACK,
]),
[mainContextStoreHasSelectedRecords],
);
const pinnedCommandMenuItems = commandMenuItemsDraft
.filter(
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
)
.filter((item) => allowedAvailabilityTypes.has(item.availabilityType))
.filter((item) => item.isPinned);
const {
pinnedInlineCommandMenuItems,
pinnedOverflowCommandMenuItems,
onContainerDimensionChange,
onCommandMenuItemDimensionChange,
} = usePinnedCommandMenuItemsInlineLayout({
pinnedCommandMenuItems,
});
return (
<>
<PinnedCommandMenuItemsInlineMeasurements
pinnedCommandMenuItems={[
...pinnedInlineCommandMenuItems,
...pinnedOverflowCommandMenuItems,
]}
onPinnedCommandMenuItemDimensionChange={
onCommandMenuItemDimensionChange
}
/>
<StyledWrapper>
<NodeDimension onDimensionChange={onContainerDimensionChange}>
<StyledContainer>
<StyledItemsContainer>
{pinnedInlineCommandMenuItems.map((item) => {
const { iconKey, label, shortLabel } =
interpolateCommandMenuItemFields(item, commandMenuContextApi);
const Icon = getIcon(iconKey, COMMAND_MENU_DEFAULT_ICON);
return (
<StyledCommandMenuItemContainer
key={item.id}
layout
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'unset', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{
duration: theme.animation.duration.instant,
ease: 'easeInOut',
}}
>
<CommandMenuButton
command={{
key: item.id,
label,
shortLabel,
Icon,
}}
disabled
/>
</StyledCommandMenuItemContainer>
);
})}
</StyledItemsContainer>
</StyledContainer>
</NodeDimension>
</StyledWrapper>
</>
);
};
@@ -1,15 +1,13 @@
import { CommandMenuItemEditRecordSelectionDropdown } from '@/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown';
import { CommandMenuItemOptionsDropdown } from '@/command-menu-item/edit/components/CommandMenuItemOptionsDropdown';
import { useEditableCommandMenuItems } from '@/command-menu-item/edit/hooks/useEditableCommandMenuItems';
import { useReorderCommandMenuItemsInDraft } from '@/command-menu-item/edit/hooks/useReorderCommandMenuItemsInDraft';
import { useResetCommandMenuItemsDraft } from '@/command-menu-item/edit/hooks/useResetCommandMenuItemsDraft';
import { useUpdateCommandMenuItemInDraft } from '@/command-menu-item/edit/hooks/useUpdateCommandMenuItemInDraft';
import { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/commandMenuItemsDraftState';
import { useCommandMenuContextApi } from '@/command-menu-item/hooks/useCommandMenuContextApi';
import { useCurrentCommandMenuContextApi } from '@/command-menu-item/hooks/useCurrentCommandMenuContextApi';
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/utils/doesCommandMenuItemMatchObjectMetadataId';
import { groupCommandMenuItems } from '@/command-menu-item/utils/groupCommandMenuItems';
import { COMMAND_MENU_CLICK_OUTSIDE_ID } from '@/command-menu/constants/CommandMenuClickOutsideId';
import { mainContextStoreHasSelectedRecordsSelector } from '@/context-store/states/selectors/mainContextStoreHasSelectedRecordsSelector';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
@@ -36,10 +34,7 @@ import {
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 { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
const StyledContainer = styled.div`
@@ -66,7 +61,7 @@ const StyledContent = styled.div`
export const SidePanelCommandMenuItemEditPage = () => {
const { t } = useLingui();
const { getIcon } = useIcons();
const commandMenuContextApi = useCommandMenuContextApi();
const commandMenuContextApi = useCurrentCommandMenuContextApi();
const currentObjectMetadataItemId =
commandMenuContextApi.objectMetadataItem.id;
@@ -76,44 +71,20 @@ export const SidePanelCommandMenuItemEditPage = () => {
const isRecordPage =
commandMenuContextApi.pageType === ContextStorePageType.Record;
const isIndexPage =
commandMenuContextApi.pageType === ContextStorePageType.Index;
const mainContextStoreHasSelectedRecords = useAtomStateValue(
mainContextStoreHasSelectedRecordsSelector,
);
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,
...(isIndexPage || isRecordPage
? [CommandMenuItemAvailabilityType.GLOBAL_OBJECT_CONTEXT]
: []),
...(mainContextStoreHasSelectedRecords
? [CommandMenuItemAvailabilityType.RECORD_SELECTION]
: []),
]);
const filteredCommandMenuItems = commandMenuItemsDraft
.filter(
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
)
.filter((item) => allowedAvailabilityTypes.has(item.availabilityType))
.sort((firstItem, secondItem) => firstItem.position - secondItem.position);
const editableCommandMenuItems = useEditableCommandMenuItems();
const filteredCommandMenuItemIds = new Set(
filteredCommandMenuItems.map((item) => item.id),
editableCommandMenuItems.map((item) => item.id),
);
const getDisplayLabel = (item: CommandMenuItemFieldsFragment) =>
@@ -123,7 +94,7 @@ export const SidePanelCommandMenuItemEditPage = () => {
}) ?? item.label;
const { pinned: allPinnedItems, other: allOtherItems } =
groupCommandMenuItems(filteredCommandMenuItems);
groupCommandMenuItems(editableCommandMenuItems);
const normalizedSearch =
sidePanelSearch.length > 0
@@ -0,0 +1,38 @@
import { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/commandMenuItemsDraftState';
import { useCurrentCommandMenuContextApi } from '@/command-menu-item/hooks/useCurrentCommandMenuContextApi';
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/utils/doesCommandMenuItemMatchObjectMetadataId';
import { doesCommandMenuItemMatchPageLayoutId } from '@/command-menu-item/utils/doesCommandMenuItemMatchPageLayoutId';
import { doesCommandMenuItemMatchPageType } from '@/command-menu-item/utils/doesCommandMenuItemMatchPageType';
import { doesCommandMenuItemMatchSelectionState } from '@/command-menu-item/utils/doesCommandMenuItemMatchSelectionState';
import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useMemo } from 'react';
import { CommandMenuItemAvailabilityType } from '~/generated-metadata/graphql';
export const useEditableCommandMenuItems = () => {
const commandMenuContextApi = useCurrentCommandMenuContextApi();
const commandMenuItemsDraft = useAtomStateValue(commandMenuItemsDraftState);
const currentPageLayoutId = useAtomStateValue(currentPageLayoutIdState);
return useMemo(() => {
const currentObjectMetadataItemId =
commandMenuContextApi.objectMetadataItem.id;
const hasSelectedRecords =
commandMenuContextApi.numberOfSelectedRecords > 0;
return (commandMenuItemsDraft ?? [])
.filter(
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
)
.filter(doesCommandMenuItemMatchPageType(commandMenuContextApi.pageType))
.filter(doesCommandMenuItemMatchSelectionState(hasSelectedRecords))
.filter(
(item) =>
item.availabilityType !== CommandMenuItemAvailabilityType.FALLBACK,
)
.filter(doesCommandMenuItemMatchPageLayoutId(currentPageLayoutId))
.sort(
(firstItem, secondItem) => firstItem.position - secondItem.position,
);
}, [commandMenuItemsDraft, commandMenuContextApi, currentPageLayoutId]);
};
@@ -70,6 +70,7 @@ const getWrapper =
objectMetadataItem: {},
objectMetadataLabel: '',
},
isInPreviewMode: false,
}}
>
{children}
@@ -30,7 +30,7 @@ import {
} from 'twenty-shared/types';
import { isDefined, resolveObjectMetadataLabel } from 'twenty-shared/utils';
export const useCommandMenuContextApi = (): CommandMenuContextApi => {
export const useCurrentCommandMenuContextApi = (): CommandMenuContextApi => {
const store = useStore();
const contextStoreInstanceId = useAvailableComponentInstanceIdOrThrow(
@@ -0,0 +1,36 @@
import { doesCommandMenuItemMatchSelectionState } from '@/command-menu-item/utils/doesCommandMenuItemMatchSelectionState';
import {
CommandMenuItemAvailabilityType,
type CommandMenuItemFieldsFragment,
} from '~/generated-metadata/graphql';
const buildCommandMenuItem = (
availabilityType: CommandMenuItemAvailabilityType,
) =>
({
availabilityType,
}) as CommandMenuItemFieldsFragment;
describe('doesCommandMenuItemMatchSelectionState', () => {
it('should keep a non-record-selection item when no records are selected', () => {
const item = buildCommandMenuItem(CommandMenuItemAvailabilityType.GLOBAL);
expect(doesCommandMenuItemMatchSelectionState(false)(item)).toBe(true);
});
it('should hide a record-selection item when no records are selected', () => {
const item = buildCommandMenuItem(
CommandMenuItemAvailabilityType.RECORD_SELECTION,
);
expect(doesCommandMenuItemMatchSelectionState(false)(item)).toBe(false);
});
it('should keep a record-selection item when records are selected', () => {
const item = buildCommandMenuItem(
CommandMenuItemAvailabilityType.RECORD_SELECTION,
);
expect(doesCommandMenuItemMatchSelectionState(true)(item)).toBe(true);
});
});
@@ -0,0 +1,9 @@
import {
CommandMenuItemAvailabilityType,
type CommandMenuItemFieldsFragment,
} from '~/generated-metadata/graphql';
export const doesCommandMenuItemMatchSelectionState =
(hasSelectedRecords: boolean) => (item: CommandMenuItemFieldsFragment) =>
item.availabilityType !==
CommandMenuItemAvailabilityType.RECORD_SELECTION || hasSelectedRecords;
@@ -1,11 +1,10 @@
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { EMPTY_COMMAND_MENU_CONTEXT_API } from '@/command-menu-item/constants/EmptyCommandMenuContextApi';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { ApolloCoreClientContext } from '@/object-metadata/contexts/ApolloCoreClientContext';
import { UpdateMultipleRecordsContainer } from '@/object-record/record-update-multiple/components/UpdateMultipleRecordsContainer';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { useEffect } from 'react';
import { ApolloClient, InMemoryCache } from '@apollo/client';
import { MockLink } from '@apollo/client/testing';
import {
@@ -14,6 +13,7 @@ import {
type StoryObj,
} from '@storybook/react-vite';
import gql from 'graphql-tag';
import { useEffect } from 'react';
import { expect, userEvent, within } from 'storybook/test';
import { ContextStoreDecorator } from '~/testing/decorators/ContextStoreDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
@@ -80,6 +80,7 @@ const meta: Meta<typeof UpdateMultipleRecordsContainer> = {
containerType: 'index-page-dropdown',
displayType: 'dropdownItem',
commandMenuContextApi: EMPTY_COMMAND_MENU_CONTEXT_API,
isInPreviewMode: false,
}}
>
<Story />