[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:
+9
-15
@@ -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"
|
||||
|
||||
+6
@@ -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>
|
||||
|
||||
+1
@@ -125,6 +125,7 @@ export const StandalonePageCommandMenu = () => {
|
||||
containerType: 'standalone-page-header',
|
||||
commandMenuItems: filteredCommandMenuItems,
|
||||
commandMenuContextApi,
|
||||
isInPreviewMode: false,
|
||||
}}
|
||||
>
|
||||
{!isMobile && <PinnedCommandMenuItemButtons />}
|
||||
|
||||
+1
@@ -45,6 +45,7 @@ const meta: Meta<typeof RecordIndexCommandMenuDropdown> = {
|
||||
containerType: 'index-page-dropdown',
|
||||
commandMenuItems: createMockCommandMenuItems(),
|
||||
commandMenuContextApi: EMPTY_COMMAND_MENU_CONTEXT_API,
|
||||
isInPreviewMode: false,
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
|
||||
+2
-1
@@ -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,
|
||||
});
|
||||
|
||||
+6
-2
@@ -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>
|
||||
|
||||
+20
-2
@@ -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}
|
||||
|
||||
+3
@@ -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>
|
||||
|
||||
+22
-7
@@ -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}
|
||||
/>
|
||||
|
||||
+30
-10
@@ -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(
|
||||
|
||||
+3
-2
@@ -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')}
|
||||
/>
|
||||
|
||||
-140
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+7
-36
@@ -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
|
||||
|
||||
+38
@@ -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]);
|
||||
};
|
||||
+1
@@ -70,6 +70,7 @@ const getWrapper =
|
||||
objectMetadataItem: {},
|
||||
objectMetadataLabel: '',
|
||||
},
|
||||
isInPreviewMode: false,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
+1
-1
@@ -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(
|
||||
+36
@@ -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);
|
||||
});
|
||||
});
|
||||
+9
@@ -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;
|
||||
+3
-2
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user