From 4649736d4963aaa21ac61443026b532a7d2d2afd Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:04:28 +0530 Subject: [PATCH] [Command Menu] Fix record-selection command filtering in edit mode (#20034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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" --- .../components/RecordIndexCommandMenu.tsx | 24 ++- .../components/RecordShowCommandMenu.tsx | 6 + .../components/StandalonePageCommandMenu.tsx | 1 + ...RecordIndexCommandMenuDropdown.stories.tsx | 1 + ...owSidePanelCommandMenuDropdown.stories.tsx | 3 +- .../contexts/CommandMenuContext.ts | 2 + .../contexts/CommandMenuContextProvider.tsx | 8 +- .../CommandMenuContextProviderContent.tsx | 22 ++- ...uContextProviderWithWorkflowEnrichment.tsx | 3 + .../components/CommandMenuItemRenderer.tsx | 29 +++- .../usePinnedCommandMenuItemsInlineLayout.ts | 40 +++-- ...andMenuItemEditRecordSelectionDropdown.tsx | 5 +- .../PinnedCommandMenuItemButtonsEditMode.tsx | 140 ------------------ .../SidePanelCommandMenuItemEditPage.tsx | 43 +----- .../edit/hooks/useEditableCommandMenuItems.ts | 38 +++++ .../__tests__/useCloseCommandMenu.test.tsx | 1 + ....ts => useCurrentCommandMenuContextApi.ts} | 2 +- ...CommandMenuItemMatchSelectionState.test.ts | 36 +++++ .../doesCommandMenuItemMatchSelectionState.ts | 9 ++ ...UpdateMultipleRecordsContainer.stories.tsx | 5 +- 20 files changed, 200 insertions(+), 218 deletions(-) delete mode 100644 packages/twenty-front/src/modules/command-menu-item/edit/components/PinnedCommandMenuItemButtonsEditMode.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/edit/hooks/useEditableCommandMenuItems.ts rename packages/twenty-front/src/modules/command-menu-item/hooks/{useCommandMenuContextApi.ts => useCurrentCommandMenuContextApi.ts} (99%) create mode 100644 packages/twenty-front/src/modules/command-menu-item/utils/__tests__/doesCommandMenuItemMatchSelectionState.test.ts create mode 100644 packages/twenty-front/src/modules/command-menu-item/utils/doesCommandMenuItemMatchSelectionState.ts diff --git a/packages/twenty-front/src/modules/command-menu-item/components/RecordIndexCommandMenu.tsx b/packages/twenty-front/src/modules/command-menu-item/components/RecordIndexCommandMenu.tsx index 7b407eaee6..9c6349a52d 100644 --- a/packages/twenty-front/src/modules/command-menu-item/components/RecordIndexCommandMenu.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/components/RecordIndexCommandMenu.tsx @@ -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 ? ( - - ) : ( - - {!isMobile && } - - )} + + {!isMobile && } + { @@ -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 && } diff --git a/packages/twenty-front/src/modules/command-menu-item/components/StandalonePageCommandMenu.tsx b/packages/twenty-front/src/modules/command-menu-item/components/StandalonePageCommandMenu.tsx index 40d1acb708..c818f0aded 100644 --- a/packages/twenty-front/src/modules/command-menu-item/components/StandalonePageCommandMenu.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/components/StandalonePageCommandMenu.tsx @@ -125,6 +125,7 @@ export const StandalonePageCommandMenu = () => { containerType: 'standalone-page-header', commandMenuItems: filteredCommandMenuItems, commandMenuContextApi, + isInPreviewMode: false, }} > {!isMobile && } diff --git a/packages/twenty-front/src/modules/command-menu-item/components/__stories__/RecordIndexCommandMenuDropdown.stories.tsx b/packages/twenty-front/src/modules/command-menu-item/components/__stories__/RecordIndexCommandMenuDropdown.stories.tsx index 5c8e6683a0..8788ef4bd8 100644 --- a/packages/twenty-front/src/modules/command-menu-item/components/__stories__/RecordIndexCommandMenuDropdown.stories.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/components/__stories__/RecordIndexCommandMenuDropdown.stories.tsx @@ -45,6 +45,7 @@ const meta: Meta = { containerType: 'index-page-dropdown', commandMenuItems: createMockCommandMenuItems(), commandMenuContextApi: EMPTY_COMMAND_MENU_CONTEXT_API, + isInPreviewMode: false, }} > diff --git a/packages/twenty-front/src/modules/command-menu-item/components/__stories__/RecordShowSidePanelCommandMenuDropdown.stories.tsx b/packages/twenty-front/src/modules/command-menu-item/components/__stories__/RecordShowSidePanelCommandMenuDropdown.stories.tsx index 2d91768e8f..68b128bd90 100644 --- a/packages/twenty-front/src/modules/command-menu-item/components/__stories__/RecordShowSidePanelCommandMenuDropdown.stories.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/components/__stories__/RecordShowSidePanelCommandMenuDropdown.stories.tsx @@ -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 = { ...EMPTY_COMMAND_MENU_CONTEXT_API, isInSidePanel: true, }, + isInPreviewMode: false, }} > diff --git a/packages/twenty-front/src/modules/command-menu-item/contexts/CommandMenuContext.ts b/packages/twenty-front/src/modules/command-menu-item/contexts/CommandMenuContext.ts index 2af4c363af..e5fad7c515 100644 --- a/packages/twenty-front/src/modules/command-menu-item/contexts/CommandMenuContext.ts +++ b/packages/twenty-front/src/modules/command-menu-item/contexts/CommandMenuContext.ts @@ -9,6 +9,7 @@ export type CommandMenuContextType = { containerType: CommandMenuItemContainerType; commandMenuItems: CommandMenuItemFieldsFragment[]; commandMenuContextApi: CommandMenuContextApi; + isInPreviewMode: boolean; }; export const CommandMenuContext = createContext({ @@ -16,4 +17,5 @@ export const CommandMenuContext = createContext({ displayType: 'button', commandMenuItems: [], commandMenuContextApi: EMPTY_COMMAND_MENU_CONTEXT_API, + isInPreviewMode: false, }); diff --git a/packages/twenty-front/src/modules/command-menu-item/contexts/CommandMenuContextProvider.tsx b/packages/twenty-front/src/modules/command-menu-item/contexts/CommandMenuContextProvider.tsx index 355e38e19d..84b16e60bc 100644 --- a/packages/twenty-front/src/modules/command-menu-item/contexts/CommandMenuContextProvider.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/contexts/CommandMenuContextProvider.tsx @@ -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} @@ -56,6 +59,7 @@ export const CommandMenuContextProvider = ({ displayType={displayType} containerType={containerType} commandMenuContextApi={commandMenuContextApi} + isInPreviewMode={isInPreviewMode} > {children} diff --git a/packages/twenty-front/src/modules/command-menu-item/contexts/CommandMenuContextProviderContent.tsx b/packages/twenty-front/src/modules/command-menu-item/contexts/CommandMenuContextProviderContent.tsx index 2c9ea657a0..2c5e265191 100644 --- a/packages/twenty-front/src/modules/command-menu-item/contexts/CommandMenuContextProviderContent.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/contexts/CommandMenuContextProviderContent.tsx @@ -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 ( {children} diff --git a/packages/twenty-front/src/modules/command-menu-item/contexts/CommandMenuContextProviderWithWorkflowEnrichment.tsx b/packages/twenty-front/src/modules/command-menu-item/contexts/CommandMenuContextProviderWithWorkflowEnrichment.tsx index 3242645aff..96d84c001b 100644 --- a/packages/twenty-front/src/modules/command-menu-item/contexts/CommandMenuContextProviderWithWorkflowEnrichment.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/contexts/CommandMenuContextProviderWithWorkflowEnrichment.tsx @@ -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} diff --git a/packages/twenty-front/src/modules/command-menu-item/display/components/CommandMenuItemRenderer.tsx b/packages/twenty-front/src/modules/command-menu-item/display/components/CommandMenuItemRenderer.tsx index 7753cceee6..db16d7c6db 100644 --- a/packages/twenty-front/src/modules/command-menu-item/display/components/CommandMenuItemRenderer.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/display/components/CommandMenuItemRenderer.tsx @@ -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 ( + + + + ); + } + return ( diff --git a/packages/twenty-front/src/modules/command-menu-item/display/hooks/usePinnedCommandMenuItemsInlineLayout.ts b/packages/twenty-front/src/modules/command-menu-item/display/hooks/usePinnedCommandMenuItemsInlineLayout.ts index c8fe027876..d9ea6e45a2 100644 --- a/packages/twenty-front/src/modules/command-menu-item/display/hooks/usePinnedCommandMenuItemsInlineLayout.ts +++ b/packages/twenty-front/src/modules/command-menu-item/display/hooks/usePinnedCommandMenuItemsInlineLayout.ts @@ -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( diff --git a/packages/twenty-front/src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx b/packages/twenty-front/src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx index 9e4b62f3ac..9e7e1cb965 100644 --- a/packages/twenty-front/src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx @@ -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 ( } dropdownPlacement="bottom-start" + dropdownOffset={{ y: 4 }} dropdownComponents={ handleSelectMode('selection')} /> diff --git a/packages/twenty-front/src/modules/command-menu-item/edit/components/PinnedCommandMenuItemButtonsEditMode.tsx b/packages/twenty-front/src/modules/command-menu-item/edit/components/PinnedCommandMenuItemButtonsEditMode.tsx deleted file mode 100644 index ecbdeba6a6..0000000000 --- a/packages/twenty-front/src/modules/command-menu-item/edit/components/PinnedCommandMenuItemButtonsEditMode.tsx +++ /dev/null @@ -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.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 ( - <> - - - - - - {pinnedInlineCommandMenuItems.map((item) => { - const { iconKey, label, shortLabel } = - interpolateCommandMenuItemFields(item, commandMenuContextApi); - - const Icon = getIcon(iconKey, COMMAND_MENU_DEFAULT_ICON); - - return ( - - - - ); - })} - - - - - - ); -}; diff --git a/packages/twenty-front/src/modules/command-menu-item/edit/components/SidePanelCommandMenuItemEditPage.tsx b/packages/twenty-front/src/modules/command-menu-item/edit/components/SidePanelCommandMenuItemEditPage.tsx index 8ab4af27e4..0260555308 100644 --- a/packages/twenty-front/src/modules/command-menu-item/edit/components/SidePanelCommandMenuItemEditPage.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/edit/components/SidePanelCommandMenuItemEditPage.tsx @@ -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.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 diff --git a/packages/twenty-front/src/modules/command-menu-item/edit/hooks/useEditableCommandMenuItems.ts b/packages/twenty-front/src/modules/command-menu-item/edit/hooks/useEditableCommandMenuItems.ts new file mode 100644 index 0000000000..c9b5e701eb --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/edit/hooks/useEditableCommandMenuItems.ts @@ -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]); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/hooks/__tests__/useCloseCommandMenu.test.tsx b/packages/twenty-front/src/modules/command-menu-item/hooks/__tests__/useCloseCommandMenu.test.tsx index f79235c02c..502468875a 100644 --- a/packages/twenty-front/src/modules/command-menu-item/hooks/__tests__/useCloseCommandMenu.test.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/hooks/__tests__/useCloseCommandMenu.test.tsx @@ -70,6 +70,7 @@ const getWrapper = objectMetadataItem: {}, objectMetadataLabel: '', }, + isInPreviewMode: false, }} > {children} diff --git a/packages/twenty-front/src/modules/command-menu-item/hooks/useCommandMenuContextApi.ts b/packages/twenty-front/src/modules/command-menu-item/hooks/useCurrentCommandMenuContextApi.ts similarity index 99% rename from packages/twenty-front/src/modules/command-menu-item/hooks/useCommandMenuContextApi.ts rename to packages/twenty-front/src/modules/command-menu-item/hooks/useCurrentCommandMenuContextApi.ts index 4e49638fad..430dce88d0 100644 --- a/packages/twenty-front/src/modules/command-menu-item/hooks/useCommandMenuContextApi.ts +++ b/packages/twenty-front/src/modules/command-menu-item/hooks/useCurrentCommandMenuContextApi.ts @@ -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( diff --git a/packages/twenty-front/src/modules/command-menu-item/utils/__tests__/doesCommandMenuItemMatchSelectionState.test.ts b/packages/twenty-front/src/modules/command-menu-item/utils/__tests__/doesCommandMenuItemMatchSelectionState.test.ts new file mode 100644 index 0000000000..1f07bdf725 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/utils/__tests__/doesCommandMenuItemMatchSelectionState.test.ts @@ -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); + }); +}); diff --git a/packages/twenty-front/src/modules/command-menu-item/utils/doesCommandMenuItemMatchSelectionState.ts b/packages/twenty-front/src/modules/command-menu-item/utils/doesCommandMenuItemMatchSelectionState.ts new file mode 100644 index 0000000000..388a3ac089 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/utils/doesCommandMenuItemMatchSelectionState.ts @@ -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; diff --git a/packages/twenty-front/src/modules/object-record/record-update-multiple/components/__stories__/UpdateMultipleRecordsContainer.stories.tsx b/packages/twenty-front/src/modules/object-record/record-update-multiple/components/__stories__/UpdateMultipleRecordsContainer.stories.tsx index 2e300a6d98..fa7e7d66aa 100644 --- a/packages/twenty-front/src/modules/object-record/record-update-multiple/components/__stories__/UpdateMultipleRecordsContainer.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-update-multiple/components/__stories__/UpdateMultipleRecordsContainer.stories.tsx @@ -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 = { containerType: 'index-page-dropdown', displayType: 'dropdownItem', commandMenuContextApi: EMPTY_COMMAND_MENU_CONTEXT_API, + isInPreviewMode: false, }} >