Refactor command menu items deprecated code (#19508)

- Removes the intermediate `CommandMenuItemConfig` /
`CommandConfigContext` / `CommandMenuItemDisplay` abstraction layers,
replacing them with a single `CommandMenuItemRenderer` that renders
directly from the command menu items from the backend
- Eliminates the server-items/ subdirectory by moving its contents
(hooks/, contexts/, states/, display/, edit/) up into the parent
command-menu-item/ module, removing an unnecessary nesting level.
This commit is contained in:
Raphaël Bosi
2026-04-10 11:28:27 +02:00
committed by GitHub
parent f13e7e01fe
commit 4b3a46d953
132 changed files with 1376 additions and 2723 deletions
@@ -1,8 +1,8 @@
import { PinnedCommandMenuItemButtons } from '@/command-menu-item/server-items/display/components/PinnedCommandMenuItemButtons';
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 { CommandMenuItemEditButton } from '@/command-menu-item/server-items/edit/components/CommandMenuItemEditButton';
import { PinnedCommandMenuItemButtonsEditMode } from '@/command-menu-item/server-items/edit/components/PinnedCommandMenuItemButtonsEditMode';
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';
@@ -1,11 +1,9 @@
import { CommandMenuItemComponent } from '@/command-menu-item/display/components/CommandMenuItemComponent';
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
import { COMMAND_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/command-menu-item/constants/CommandMenuDropdownClickOutsideId';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { CommandMenuItemRenderer } from '@/command-menu-item/display/components/CommandMenuItemRenderer';
import { recordIndexCommandMenuDropdownPositionComponentState } from '@/command-menu-item/states/recordIndexCommandMenuDropdownPositionComponentState';
import { getCommandMenuDropdownIdFromCommandMenuId } from '@/command-menu-item/utils/getCommandMenuDropdownIdFromCommandMenuId';
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
@@ -21,6 +19,7 @@ import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { IconLayoutSidebarRightExpand } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
import { CommandMenuItemAvailabilityType } from '~/generated-metadata/graphql';
const StyledDropdownMenuContainer = styled.div`
align-items: center;
@@ -36,10 +35,10 @@ export const RecordIndexCommandMenuDropdown = () => {
const { t } = useLingui();
const { commandMenuItems } = useContext(CommandMenuContext);
const recordIndexActions = commandMenuItems.filter(
(action) =>
action.type === CommandMenuItemType.Standard &&
action.scope === CommandMenuItemScope.RecordSelection,
const recordIndexCommandMenuItems = commandMenuItems.filter(
(item) =>
item.availabilityType ===
CommandMenuItemAvailabilityType.RECORD_SELECTION,
);
const commandMenuId = useAvailableComponentInstanceIdOrThrow(
@@ -57,7 +56,7 @@ export const RecordIndexCommandMenuDropdown = () => {
const { openSidePanelMenu } = useSidePanelMenu();
const selectedItemIdArray = [
...recordIndexActions.map((action) => action.key),
...recordIndexCommandMenuItems.map((item) => item.id),
'more-actions',
];
@@ -87,8 +86,8 @@ export const RecordIndexCommandMenuDropdown = () => {
selectableItemIdArray={selectedItemIdArray}
selectableListInstanceId={dropdownId}
>
{recordIndexActions.map((action) => (
<CommandMenuItemComponent action={action} key={action.key} />
{recordIndexCommandMenuItems.map((item) => (
<CommandMenuItemRenderer item={item} key={item.id} />
))}
<SelectableListItem
itemId="more-actions"
@@ -1,16 +1,16 @@
import { CommandMenuItemComponent } from '@/command-menu-item/display/components/CommandMenuItemComponent';
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { CommandMenuItemRenderer } from '@/command-menu-item/display/components/CommandMenuItemRenderer';
import { getSidePanelCommandMenuDropdownIdFromCommandMenuId } from '@/command-menu-item/utils/getSidePanelCommandMenuDropdownIdFromCommandMenuId';
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { OptionsDropdownMenu } from '@/ui/layout/dropdown/components/OptionsDropdownMenu';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { sidePanelWidgetFooterActionsState } from '@/ui/layout/side-panel/states/sidePanelWidgetFooterActionsState';
import { sidePanelWidgetFooterCommandMenuItemsState } from '@/ui/layout/side-panel/states/sidePanelWidgetFooterCommandMenuItemsState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useContext } from 'react';
import { useContext, useMemo } from 'react';
import { HorizontalSeparator } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
import { CommandMenuItemAvailabilityType } from '~/generated-metadata/graphql';
export const RecordPageSidePanelCommandMenuDropdown = () => {
const { commandMenuItems } = useContext(CommandMenuContext);
@@ -24,21 +24,30 @@ export const RecordPageSidePanelCommandMenuDropdown = () => {
const { closeDropdown } = useCloseDropdown();
const sidePanelWidgetFooterActions = useAtomStateValue(
sidePanelWidgetFooterActionsState,
const sidePanelWidgetFooterCommandMenuItems = useAtomStateValue(
sidePanelWidgetFooterCommandMenuItemsState,
);
const dropdownWidgetActions = sidePanelWidgetFooterActions.filter(
(action) => action.isPinned === false,
);
const dropdownWidgetCommandMenuItems =
sidePanelWidgetFooterCommandMenuItems.filter(
(commandMenuItem) => commandMenuItem.isPinned === false,
);
const recordSelectionActions = commandMenuItems.filter(
(action) => action.scope === CommandMenuItemScope.RecordSelection,
const recordSelectionCommandMenuItems = useMemo(
() =>
commandMenuItems.filter(
(item) =>
item.availabilityType ===
CommandMenuItemAvailabilityType.RECORD_SELECTION,
),
[commandMenuItems],
);
const selectableItemIdArray = [
...dropdownWidgetActions.map((action) => action.key),
...recordSelectionActions.map((action) => action.key),
...dropdownWidgetCommandMenuItems.map(
(commandMenuItem) => commandMenuItem.id,
),
...recordSelectionCommandMenuItems.map((item) => item.id),
];
return (
@@ -47,21 +56,23 @@ export const RecordPageSidePanelCommandMenuDropdown = () => {
selectableListId={commandMenuId}
selectableItemIdArray={selectableItemIdArray}
>
{dropdownWidgetActions.map((action) => (
{dropdownWidgetCommandMenuItems.map((commandMenuItem) => (
<MenuItem
key={action.key}
text={action.label}
LeftIcon={action.Icon}
key={commandMenuItem.id}
text={commandMenuItem.label}
LeftIcon={commandMenuItem.Icon}
onClick={() => {
closeDropdown(dropdownId);
action.onClick();
commandMenuItem.onClick();
}}
/>
))}
{dropdownWidgetActions.length > 0 &&
recordSelectionActions.length > 0 && <HorizontalSeparator noMargin />}
{recordSelectionActions.map((action) => (
<CommandMenuItemComponent action={action} key={action.key} />
{dropdownWidgetCommandMenuItems.length > 0 &&
recordSelectionCommandMenuItems.length > 0 && (
<HorizontalSeparator noMargin />
)}
{recordSelectionCommandMenuItems.map((item) => (
<CommandMenuItemRenderer item={item} key={item.id} />
))}
</OptionsDropdownMenu>
);
@@ -1,6 +1,6 @@
import { CommandMenuContextProvider } from '@/command-menu-item/contexts/CommandMenuContextProvider';
import { PinnedCommandMenuItemButtons } from '@/command-menu-item/server-items/display/components/PinnedCommandMenuItemButtons';
import { CommandMenuItemEditButton } from '@/command-menu-item/server-items/edit/components/CommandMenuItemEditButton';
import { PinnedCommandMenuItemButtons } from '@/command-menu-item/display/components/PinnedCommandMenuItemButtons';
import { CommandMenuItemEditButton } from '@/command-menu-item/edit/components/CommandMenuItemEditButton';
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';
@@ -1,22 +1,20 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { Provider as JotaiProvider } from 'jotai';
import * as test from 'storybook/test';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { expect, within } from 'storybook/test';
import { RecordIndexCommandMenuDropdown } from '@/command-menu-item/components/RecordIndexCommandMenuDropdown';
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 { recordIndexCommandMenuDropdownPositionComponentState } from '@/command-menu-item/states/recordIndexCommandMenuDropdownPositionComponentState';
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { EMPTY_COMMAND_MENU_CONTEXT_API } from '@/command-menu-item/constants/EmptyCommandMenuContextApi';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { RouterDecorator } from 'twenty-ui/testing';
import { ContextStoreDecorator } from '~/testing/decorators/ContextStoreDecorator';
const deleteMock = test.fn();
const addToFavoritesMock = test.fn();
const exportMock = test.fn();
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
const meta: Meta<typeof RecordIndexCommandMenuDropdown> = {
title: 'Modules/CommandMenu/RecordIndexCommandMenuDropdown',
@@ -43,14 +41,10 @@ const meta: Meta<typeof RecordIndexCommandMenuDropdown> = {
>
<CommandMenuContext.Provider
value={{
isInSidePanel: true,
displayType: 'dropdownItem',
containerType: 'index-page-dropdown',
commandMenuItems: createMockCommandMenuItems({
deleteMock,
addToFavoritesMock,
exportMock,
}),
commandMenuItems: createMockCommandMenuItems(),
commandMenuContextApi: EMPTY_COMMAND_MENU_CONTEXT_API,
}}
>
<Story />
@@ -60,6 +54,8 @@ const meta: Meta<typeof RecordIndexCommandMenuDropdown> = {
);
},
ContextStoreDecorator,
ObjectMetadataItemsDecorator,
SnackBarDecorator,
RouterDecorator,
],
};
@@ -82,21 +78,13 @@ export const WithInteractions: Story = {
const canvas = within(canvasElement.ownerDocument.body);
const deleteButton = await canvas.findByText('Delete');
await userEvent.click(deleteButton);
const addToFavoritesButton = await canvas.findByText('Add to favorites');
await userEvent.click(addToFavoritesButton);
const exportButton = await canvas.findByText('Export');
await userEvent.click(exportButton);
const moreActionsButton = await canvas.findByText('More actions');
await waitFor(() => {
expect(deleteMock).toHaveBeenCalled();
expect(addToFavoritesMock).toHaveBeenCalled();
expect(exportMock).toHaveBeenCalled();
expect(moreActionsButton).toBeInTheDocument();
});
expect(deleteButton).toBeInTheDocument();
expect(addToFavoritesButton).toBeInTheDocument();
expect(exportButton).toBeInTheDocument();
expect(moreActionsButton).toBeInTheDocument();
},
};
@@ -1,7 +1,6 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { Provider as JotaiProvider } from 'jotai';
import * as test from 'storybook/test';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { userEvent, within } from 'storybook/test';
import { RecordPageSidePanelCommandMenuDropdown } from '@/command-menu-item/components/RecordPageSidePanelCommandMenuDropdown';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
@@ -9,15 +8,13 @@ import { createMockCommandMenuItems } from '@/command-menu-item/mock/command-men
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 { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
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';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { JestContextStoreSetter } from '~/testing/jest/JestContextStoreSetter';
const deleteMock = test.fn();
const addToFavoritesMock = test.fn();
const exportMock = test.fn();
const meta: Meta<typeof RecordPageSidePanelCommandMenuDropdown> = {
title: 'Modules/CommandMenu/RecordPageSidePanelCommandMenuDropdown',
@@ -40,14 +37,13 @@ const meta: Meta<typeof RecordPageSidePanelCommandMenuDropdown> = {
>
<CommandMenuContext.Provider
value={{
isInSidePanel: true,
displayType: 'dropdownItem',
containerType: 'command-menu-show-page-dropdown',
commandMenuItems: createMockCommandMenuItems({
deleteMock,
addToFavoritesMock,
exportMock,
}),
commandMenuItems: createMockCommandMenuItems(),
commandMenuContextApi: {
...EMPTY_COMMAND_MENU_CONTEXT_API,
isInSidePanel: true,
},
}}
>
<Story />
@@ -102,11 +98,5 @@ export const WithButtonClicks: Story = {
const exportButton = await canvas.findByText('Export');
await userEvent.click(exportButton);
await waitFor(() => {
expect(deleteMock).toHaveBeenCalled();
expect(addToFavoritesMock).toHaveBeenCalled();
expect(exportMock).toHaveBeenCalled();
});
},
};
@@ -0,0 +1,30 @@
import {
CommandMenuContextApiPageType,
type CommandMenuContextApi,
} from 'twenty-shared/types';
export const EMPTY_COMMAND_MENU_CONTEXT_API: CommandMenuContextApi = {
pageType: CommandMenuContextApiPageType.INDEX_PAGE,
isInSidePanel: false,
isPageInEditMode: false,
favoriteRecordIds: [],
isSelectAll: false,
hasAnySoftDeleteFilterOnView: false,
numberOfSelectedRecords: 0,
objectPermissions: {
canReadObjectRecords: false,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
restrictedFields: {},
objectMetadataId: '',
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
selectedRecords: [],
featureFlags: {},
targetObjectReadPermissions: {},
targetObjectWritePermissions: {},
objectMetadataItem: {},
objectMetadataLabel: '',
};
@@ -1,6 +0,0 @@
import { type CommandMenuItemConfig } from '@/command-menu-item/types/CommandMenuItemConfig';
import { createContext } from 'react';
export const CommandConfigContext = createContext<CommandMenuItemConfig | null>(
null,
);
@@ -1,17 +1,19 @@
import { type CommandMenuItemConfig } from '@/command-menu-item/types/CommandMenuItemConfig';
import { EMPTY_COMMAND_MENU_CONTEXT_API } from '@/command-menu-item/constants/EmptyCommandMenuContextApi';
import { type CommandMenuItemContainerType } from '@/command-menu-item/types/CommandMenuItemContainerType';
import { createContext } from 'react';
import { type CommandMenuContextApi } from 'twenty-shared/types';
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
export type CommandMenuContextType = {
isInSidePanel: boolean;
displayType: 'button' | 'listItem' | 'dropdownItem';
containerType: CommandMenuItemContainerType;
commandMenuItems: CommandMenuItemConfig[];
commandMenuItems: CommandMenuItemFieldsFragment[];
commandMenuContextApi: CommandMenuContextApi;
};
export const CommandMenuContext = createContext<CommandMenuContextType>({
isInSidePanel: false,
containerType: 'command-menu-list',
displayType: 'button',
commandMenuItems: [],
commandMenuContextApi: EMPTY_COMMAND_MENU_CONTEXT_API,
});
@@ -1,21 +1,63 @@
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type CommandMenuContextType } from '@/command-menu-item/contexts/CommandMenuContext';
import { CommandMenuContextProviderServerItems } from '@/command-menu-item/server-items/common/contexts/CommandMenuContextProviderServerItems';
import { useCommandMenuContextApi } from '@/command-menu-item/hooks/useCommandMenuContextApi';
import { CommandMenuContextProviderContent } from './CommandMenuContextProviderContent';
import { CommandMenuContextProviderWithWorkflowEnrichment } from './CommandMenuContextProviderWithWorkflowEnrichment';
type CommandMenuContextProviderProps = {
isInSidePanel: boolean;
displayType: CommandMenuContextType['displayType'];
containerType: CommandMenuContextType['containerType'];
children: React.ReactNode;
};
export const CommandMenuContextProvider = ({
children,
isInSidePanel,
displayType,
containerType,
}: Omit<CommandMenuContextType, 'commandMenuItems'> & {
children: React.ReactNode;
}) => {
children,
}: CommandMenuContextProviderProps) => {
const commandMenuContextApiFromHook = useCommandMenuContextApi();
const commandMenuContextApi = isInSidePanel
? { ...commandMenuContextApiFromHook, isInSidePanel: true }
: commandMenuContextApiFromHook;
const currentObjectNameSingular =
commandMenuContextApi.objectMetadataItem.nameSingular;
const isWorkflow =
currentObjectNameSingular === CoreObjectNameSingular.Workflow;
const selectedWorkflowRecordIds = isWorkflow
? commandMenuContextApi.selectedRecords
.map((record) => record.id)
.filter(isDefined)
: [];
if (selectedWorkflowRecordIds.length > 0) {
return (
<CommandMenuContextProviderWithWorkflowEnrichment
displayType={displayType}
containerType={containerType}
commandMenuContextApi={commandMenuContextApi}
selectedWorkflowRecordIds={selectedWorkflowRecordIds}
>
{children}
</CommandMenuContextProviderWithWorkflowEnrichment>
);
}
return (
<CommandMenuContextProviderServerItems
isInSidePanel={isInSidePanel}
<CommandMenuContextProviderContent
displayType={displayType}
containerType={containerType}
commandMenuContextApi={commandMenuContextApi}
>
{children}
</CommandMenuContextProviderServerItems>
</CommandMenuContextProviderContent>
);
};
@@ -0,0 +1,58 @@
import {
CommandMenuContext,
type CommandMenuContextType,
} from '@/command-menu-item/contexts/CommandMenuContext';
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/utils/doesCommandMenuItemMatchObjectMetadataId';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useMemo } from 'react';
import { type CommandMenuContextApi } from 'twenty-shared/types';
import { evaluateConditionalAvailabilityExpression } from 'twenty-shared/utils';
type CommandMenuContextProviderContentProps = {
displayType: CommandMenuContextType['displayType'];
containerType: CommandMenuContextType['containerType'];
children: React.ReactNode;
commandMenuContextApi: CommandMenuContextApi;
};
export const CommandMenuContextProviderContent = ({
displayType,
containerType,
children,
commandMenuContextApi,
}: CommandMenuContextProviderContentProps) => {
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
const filteredCommandMenuItems = useMemo(() => {
const currentObjectMetadataItemId =
commandMenuContextApi.objectMetadataItem.id;
return commandMenuItems
.filter(
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
)
.filter((item) =>
evaluateConditionalAvailabilityExpression(
item.conditionalAvailabilityExpression,
commandMenuContextApi,
),
)
.sort(
(firstItem, secondItem) => firstItem.position - secondItem.position,
);
}, [commandMenuItems, commandMenuContextApi]);
return (
<CommandMenuContext.Provider
value={{
displayType,
containerType,
commandMenuItems: filteredCommandMenuItems,
commandMenuContextApi,
}}
>
{children}
</CommandMenuContext.Provider>
);
};
@@ -2,28 +2,25 @@ import { type CommandMenuContextApi } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type CommandMenuContextType } from '@/command-menu-item/contexts/CommandMenuContext';
import { useWorkflowsWithCurrentVersions } from '@/command-menu-item/server-items/common/hooks/useWorkflowsWithCurrentVersions';
import { useWorkflowsWithCurrentVersions } from '@/command-menu-item/hooks/useWorkflowsWithCurrentVersions';
import { CommandMenuContextProviderServerItemsContent } from './CommandMenuContextProviderServerItemsContent';
import { CommandMenuContextProviderContent } from './CommandMenuContextProviderContent';
type CommandMenuContextProviderServerItemsWithWorkflowEnrichmentProps = {
isInSidePanel: CommandMenuContextType['isInSidePanel'];
type CommandMenuContextProviderWithWorkflowEnrichmentProps = {
displayType: CommandMenuContextType['displayType'];
containerType: CommandMenuContextType['containerType'];
children: React.ReactNode;
commandMenuContextApi: CommandMenuContextApi;
selectedWorkflowRecordIds: string[];
};
export const CommandMenuContextProviderServerItemsWithWorkflowEnrichment = ({
isInSidePanel,
export const CommandMenuContextProviderWithWorkflowEnrichment = ({
displayType,
containerType,
children,
commandMenuContextApi,
selectedWorkflowRecordIds,
}: CommandMenuContextProviderServerItemsWithWorkflowEnrichmentProps & {
commandMenuContextApi: CommandMenuContextApi;
selectedWorkflowRecordIds: string[];
}) => {
}: CommandMenuContextProviderWithWorkflowEnrichmentProps) => {
const workflowsWithCurrentVersions = useWorkflowsWithCurrentVersions(
selectedWorkflowRecordIds,
);
@@ -53,13 +50,12 @@ export const CommandMenuContextProviderServerItemsWithWorkflowEnrichment = ({
};
return (
<CommandMenuContextProviderServerItemsContent
isInSidePanel={isInSidePanel}
<CommandMenuContextProviderContent
displayType={displayType}
containerType={containerType}
commandMenuContextApi={enrichedCommandMenuContextApi}
>
{children}
</CommandMenuContextProviderServerItemsContent>
</CommandMenuContextProviderContent>
);
};
@@ -1,32 +0,0 @@
import { CommandConfigContext } from '@/command-menu-item/contexts/CommandConfigContext';
import { CommandMenuItemDisplay } from '@/command-menu-item/display/components/CommandMenuItemDisplay';
import { useCloseCommandMenu } from '@/command-menu-item/hooks/useCloseCommandMenu';
import { useContext } from 'react';
export const Command = ({
onClick,
closeSidePanelOnShowPageOptionsExecution = false,
closeSidePanelOnCommandMenuListExecution = true,
}: {
onClick: () => void;
closeSidePanelOnShowPageOptionsExecution?: boolean;
closeSidePanelOnCommandMenuListExecution?: boolean;
}) => {
const commandMenuItemConfig = useContext(CommandConfigContext);
const { closeCommandMenu } = useCloseCommandMenu({
closeSidePanelOnShowPageOptionsExecution,
closeSidePanelOnCommandMenuListExecution,
});
if (!commandMenuItemConfig) {
return null;
}
const handleClick = () => {
closeCommandMenu();
onClick();
};
return <CommandMenuItemDisplay onClick={handleClick} />;
};
@@ -1,58 +0,0 @@
import { type CommandMenuItemDisplayProps } from '@/command-menu-item/display/components/CommandMenuItemDisplay';
import { getCommandMenuItemLabel } from '@/command-menu-item/utils/getCommandMenuItemLabel';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { SelectableListComponentInstanceContext } from '@/ui/layout/selectable-list/states/contexts/SelectableListComponentInstanceContext';
import { isSelectedItemIdComponentFamilyState } from '@/ui/layout/selectable-list/states/isSelectedItemIdComponentFamilyState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
import { useNavigate } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
import { MenuItem } from 'twenty-ui/navigation';
export const CommandDropdownItem = ({
action,
onClick,
to,
disabled = false,
}: {
action: CommandMenuItemDisplayProps;
onClick?: () => void;
to?: string;
disabled?: boolean;
}) => {
const navigate = useNavigate();
const handleClick = () => {
if (disabled) {
return;
}
onClick?.();
if (isDefined(to)) {
navigate(to);
}
};
const selectableListInstanceId = useAvailableComponentInstanceIdOrThrow(
SelectableListComponentInstanceContext,
);
const isSelectedItemId = useAtomComponentFamilyStateValue(
isSelectedItemIdComponentFamilyState,
action.key,
selectableListInstanceId,
);
return (
<SelectableListItem itemId={action.key} onEnter={handleClick}>
<MenuItem
focused={isSelectedItemId}
key={action.key}
LeftIcon={action.Icon}
onClick={handleClick}
text={getCommandMenuItemLabel(action.label)}
disabled={disabled}
/>
</SelectableListItem>
);
};
@@ -1,21 +0,0 @@
import { CommandMenuItemDisplay } from '@/command-menu-item/display/components/CommandMenuItemDisplay';
import { useCloseCommandMenu } from '@/command-menu-item/hooks/useCloseCommandMenu';
import { type PathParam } from 'react-router-dom';
import { type AppPath } from 'twenty-shared/types';
import { getAppPath } from 'twenty-shared/utils';
export const CommandLink = <T extends AppPath>({
to,
params,
queryParams,
}: {
to: T;
params?: { [key in PathParam<T>]: string | null };
queryParams?: Record<string, any>;
}) => {
const { closeCommandMenu } = useCloseCommandMenu();
const path = getAppPath(to, params, queryParams);
return <CommandMenuItemDisplay onClick={closeCommandMenu} to={path} />;
};
@@ -1,61 +0,0 @@
import { type CommandMenuItemDisplayProps } from '@/command-menu-item/display/components/CommandMenuItemDisplay';
import { getCommandMenuItemLabel } from '@/command-menu-item/utils/getCommandMenuItemLabel';
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { useNavigate } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
import { Loader } from 'twenty-ui/feedback';
import { CommandListItemLoader } from './CommandListItemLoader';
export const CommandListItem = ({
action,
onClick,
to,
disabled = false,
progress,
showDisabledLoader = false,
}: {
action: CommandMenuItemDisplayProps;
onClick?: () => void;
to?: string;
disabled?: boolean;
progress?: number;
showDisabledLoader?: boolean;
}) => {
const navigate = useNavigate();
const handleClick = () => {
if (disabled) {
return;
}
onClick?.();
if (isDefined(to)) {
navigate(to);
}
};
const loaderComponent =
disabled && showDisabledLoader ? (
isDefined(progress) ? (
<CommandListItemLoader progress={progress} />
) : (
<Loader />
)
) : undefined;
return (
<SelectableListItem itemId={action.key} onEnter={handleClick}>
<CommandMenuItem
id={action.key}
Icon={action.Icon}
label={getCommandMenuItemLabel(action.label)}
description={getCommandMenuItemLabel(action.description)}
to={to}
onClick={disabled ? undefined : onClick}
hotKeys={action.hotKeys}
disabled={disabled}
RightComponent={loaderComponent}
/>
</SelectableListItem>
);
};
@@ -1,23 +0,0 @@
import { CommandMenuButton } from '@/command-menu/components/CommandMenuButton';
import { type CommandMenuItemDisplayProps } from '@/command-menu-item/display/components/CommandMenuItemDisplay';
export const CommandMenuItemButton = ({
action,
onClick,
to,
disabled = false,
}: {
action: CommandMenuItemDisplayProps;
onClick?: (event?: React.MouseEvent<HTMLElement>) => void;
to?: string;
disabled?: boolean;
}) => {
return (
<CommandMenuButton
command={action}
to={to}
onClick={onClick}
disabled={disabled}
/>
);
};
@@ -1,14 +0,0 @@
import { CommandConfigContext } from '@/command-menu-item/contexts/CommandConfigContext';
import { type CommandMenuItemConfig } from '@/command-menu-item/types/CommandMenuItemConfig';
export const CommandMenuItemComponent = ({
action,
}: {
action: CommandMenuItemConfig;
}) => {
return (
<CommandConfigContext.Provider value={action}>
{action.component}
</CommandConfigContext.Provider>
);
};
@@ -1,89 +0,0 @@
import { CommandMenuItemButton } from '@/command-menu-item/display/components/CommandMenuItemButton';
import { CommandDropdownItem } from '@/command-menu-item/display/components/CommandDropdownItem';
import { CommandListItem } from '@/command-menu-item/display/components/CommandListItem';
import { CommandConfigContext } from '@/command-menu-item/contexts/CommandConfigContext';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { useIsCommandBlockedByGlobalLayoutCustomization } from '@/command-menu-item/hooks/useIsCommandBlockedByGlobalLayoutCustomization';
import { type MessageDescriptor } from '@lingui/core';
import { useContext } from 'react';
import { type Nullable } from 'twenty-shared/types';
import { assertUnreachable } from 'twenty-shared/utils';
import { type IconComponent } from 'twenty-ui/display';
import { type MenuItemAccent } from 'twenty-ui/navigation';
export type CommandMenuItemDisplayProps = {
key: string;
label: Nullable<MessageDescriptor | string>;
shortLabel?: Nullable<MessageDescriptor | string>;
description?: MessageDescriptor | string;
Icon: IconComponent;
isPrimaryCTA?: boolean;
accent?: MenuItemAccent;
hotKeys?: Nullable<string[]>;
};
export const CommandMenuItemDisplay = ({
onClick,
to,
disabled,
progress,
showDisabledLoader = false,
}: {
onClick?: (event?: React.MouseEvent<HTMLElement>) => void;
to?: string;
disabled?: boolean;
progress?: number;
showDisabledLoader?: boolean;
}) => {
const action = useContext(CommandConfigContext);
const { displayType } = useContext(CommandMenuContext);
const isBlockedByGlobalLayoutCustomization =
useIsCommandBlockedByGlobalLayoutCustomization(action);
if (!action) {
return null;
}
const isDisabled =
disabled === true || isBlockedByGlobalLayoutCustomization === true;
const onClickWhenEnabled = isDisabled ? undefined : onClick;
const toWhenEnabled = isDisabled ? undefined : to;
if (displayType === 'button') {
return (
<CommandMenuItemButton
action={action}
onClick={onClickWhenEnabled}
to={toWhenEnabled}
disabled={isDisabled}
/>
);
}
if (displayType === 'listItem') {
return (
<CommandListItem
action={action}
onClick={onClickWhenEnabled}
to={toWhenEnabled}
disabled={isDisabled}
progress={progress}
showDisabledLoader={showDisabledLoader}
/>
);
}
if (displayType === 'dropdownItem') {
return (
<CommandDropdownItem
action={action}
onClick={onClickWhenEnabled}
to={toWhenEnabled}
disabled={isDisabled}
/>
);
}
return assertUnreachable(displayType, 'Unsupported display type');
};
@@ -0,0 +1,154 @@
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { CommandListItemLoader } from '@/command-menu-item/display/components/CommandListItemLoader';
import { interpolateCommandMenuItemFields } from '@/command-menu-item/display/utils/interpolateCommandMenuItemFields';
import { useCommandMenuItemClick } from '@/command-menu-item/hooks/useCommandMenuItemClick';
import { getCommandMenuItemLabel } from '@/command-menu-item/utils/getCommandMenuItemLabel';
import { CommandMenuButton } from '@/command-menu/components/CommandMenuButton';
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { SelectableListComponentInstanceContext } from '@/ui/layout/selectable-list/states/contexts/SelectableListComponentInstanceContext';
import { isSelectedItemIdComponentFamilyState } from '@/ui/layout/selectable-list/states/isSelectedItemIdComponentFamilyState';
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 { useContext } from 'react';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { Loader } from 'twenty-ui/feedback';
import { MenuItem } from 'twenty-ui/navigation';
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
type CommandMenuItemRendererProps = {
item: CommandMenuItemFieldsFragment;
};
type CommandMenuItemButtonRendererProps = CommandMenuItemRendererProps;
const CommandMenuItemButtonRenderer = ({
item,
}: CommandMenuItemButtonRendererProps) => {
const { commandMenuContextApi } = useContext(CommandMenuContext);
const { getIcon } = useIcons();
const { iconKey, label, shortLabel } = interpolateCommandMenuItemFields(
item,
commandMenuContextApi,
);
const Icon = getIcon(iconKey, COMMAND_MENU_DEFAULT_ICON);
const { handleClick, disabled } = useCommandMenuItemClick({
item,
Icon,
label,
});
return (
<CommandMenuButton
command={{
key: item.id,
label,
shortLabel,
Icon,
}}
onClick={disabled ? undefined : handleClick}
disabled={disabled}
/>
);
};
const CommandMenuItemSelectableRenderer = ({
item,
displayType,
}: CommandMenuItemRendererProps & {
displayType: 'listItem' | 'dropdownItem';
}) => {
const { commandMenuContextApi } = useContext(CommandMenuContext);
const { getIcon } = useIcons();
const { iconKey, label } = interpolateCommandMenuItemFields(
item,
commandMenuContextApi,
);
const Icon = getIcon(iconKey, COMMAND_MENU_DEFAULT_ICON);
const { handleClick, disabled, progress, showDisabledLoader } =
useCommandMenuItemClick({ item, Icon, label });
const selectableListInstanceId = useAvailableComponentInstanceIdOrThrow(
SelectableListComponentInstanceContext,
);
const isSelectedItemId = useAtomComponentFamilyStateValue(
isSelectedItemIdComponentFamilyState,
item.id,
selectableListInstanceId,
);
const onItemClick = () => {
if (disabled) {
return;
}
handleClick();
};
if (displayType === 'listItem') {
const loaderComponent =
disabled && showDisabledLoader ? (
isDefined(progress) ? (
<CommandListItemLoader progress={progress} />
) : (
<Loader />
)
) : undefined;
return (
<SelectableListItem itemId={item.id} onEnter={onItemClick}>
<CommandMenuItem
id={item.id}
Icon={Icon}
label={getCommandMenuItemLabel(label)}
onClick={disabled ? undefined : handleClick}
hotKeys={item.hotKeys}
disabled={disabled}
RightComponent={loaderComponent}
/>
</SelectableListItem>
);
}
return (
<SelectableListItem itemId={item.id} onEnter={onItemClick}>
<MenuItem
focused={isSelectedItemId}
LeftIcon={Icon}
onClick={onItemClick}
text={getCommandMenuItemLabel(label)}
disabled={disabled}
/>
</SelectableListItem>
);
};
// oxlint-disable-next-line twenty/effect-components
export const CommandMenuItemRenderer = ({
item,
}: CommandMenuItemRendererProps) => {
const { displayType } = useContext(CommandMenuContext);
if (displayType === 'button') {
return <CommandMenuItemButtonRenderer item={item} />;
}
if (displayType === 'listItem' || displayType === 'dropdownItem') {
return (
<CommandMenuItemSelectableRenderer
item={item}
displayType={displayType}
/>
);
}
return assertUnreachable(displayType, 'Unsupported display type');
};
@@ -1,79 +0,0 @@
import { type ReactNode, useContext } from 'react';
import { t } from '@lingui/core/macro';
import { CommandMenuItemDisplay } from '@/command-menu-item/display/components/CommandMenuItemDisplay';
import { CommandConfigContext } from '@/command-menu-item/contexts/CommandConfigContext';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { useCloseCommandMenu } from '@/command-menu-item/hooks/useCloseCommandMenu';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { type ButtonAccent } from 'twenty-ui/input';
export type CommandModalProps = {
title: string;
subtitle: ReactNode;
onConfirmClick: () => void | Promise<void>;
confirmButtonText?: string;
confirmButtonAccent?: ButtonAccent;
isLoading?: boolean;
closeSidePanelOnShowPageOptionsExecution?: boolean;
closeSidePanelOnCommandMenuListExecution?: boolean;
};
export const CommandModal = ({
title,
subtitle,
onConfirmClick,
confirmButtonText = t`Confirm`,
confirmButtonAccent = 'danger',
isLoading = false,
closeSidePanelOnShowPageOptionsExecution,
closeSidePanelOnCommandMenuListExecution,
}: CommandModalProps) => {
const { openModal } = useModal();
const { closeCommandMenu } = useCloseCommandMenu({
closeSidePanelOnShowPageOptionsExecution,
closeSidePanelOnCommandMenuListExecution,
});
const handleConfirmClick = async () => {
await onConfirmClick();
closeCommandMenu();
};
const commandMenuItemConfig = useContext(CommandConfigContext);
const { containerType } = useContext(CommandMenuContext);
const modalId = `${commandMenuItemConfig?.key}-command-menu-item-modal-${containerType}`;
const isModalOpened = useAtomComponentStateValue(
isModalOpenedComponentState,
modalId,
);
if (!commandMenuItemConfig) {
return null;
}
const handleClick = () => openModal(modalId);
return (
<>
<CommandMenuItemDisplay onClick={handleClick} />
{isModalOpened && (
<ConfirmationModal
modalInstanceId={modalId}
title={title}
subtitle={subtitle}
onConfirmClick={handleConfirmClick}
confirmButtonText={confirmButtonText}
confirmButtonAccent={confirmButtonAccent}
loading={isLoading}
/>
)}
</>
);
};
@@ -1,69 +0,0 @@
import { CommandConfigContext } from '@/command-menu-item/contexts/CommandConfigContext';
import { Command } from '@/command-menu-item/display/components/Command';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { useOpenFrontComponentInSidePanel } from '@/side-panel/hooks/useOpenFrontComponentInSidePanel';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const FrontComponentCommandMenuItem = ({
frontComponentId,
}: {
frontComponentId: string;
}) => {
const { openFrontComponentInSidePanel } = useOpenFrontComponentInSidePanel();
const commandMenuItemConfig = useContext(CommandConfigContext);
const contextStoreTargetedRecordsRule = useAtomComponentStateValue(
contextStoreTargetedRecordsRuleComponentState,
);
const contextStoreCurrentObjectMetadataItemId = useAtomComponentStateValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const currentObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.id === contextStoreCurrentObjectMetadataItemId,
);
const selectedRecordIds =
contextStoreTargetedRecordsRule.mode === 'selection'
? contextStoreTargetedRecordsRule.selectedRecordIds
: [];
const recordId =
selectedRecordIds.length === 1 ? selectedRecordIds[0] : undefined;
const objectNameSingular = currentObjectMetadataItem?.nameSingular;
const displayLabel =
typeof commandMenuItemConfig?.label === 'string'
? commandMenuItemConfig.label
: '';
const Icon = commandMenuItemConfig?.Icon;
const handleClick = () => {
if (!isDefined(Icon)) {
return;
}
openFrontComponentInSidePanel({
frontComponentId,
pageTitle: displayLabel,
pageIcon: Icon,
recordContext:
isDefined(recordId) && isDefined(objectNameSingular)
? { recordId, objectNameSingular }
: undefined,
});
};
return <Command onClick={handleClick} />;
};
@@ -1,75 +0,0 @@
import { useContext } from 'react';
import { CommandConfigContext } from '@/command-menu-item/contexts/CommandConfigContext';
import { useMountCommand } from '@/command-menu-item/engine-command/hooks/useMountCommand';
import { isEngineCommandMountedFamilySelector } from '@/command-menu-item/engine-command/selectors/isEngineCommandMountedFamilySelector';
import { useCloseCommandMenu } from '@/command-menu-item/hooks/useCloseCommandMenu';
import { commandMenuItemProgressFamilyState } from '@/command-menu-item/states/commandMenuItemProgressFamilyState';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { isDefined } from 'twenty-shared/utils';
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
import { CommandMenuItemDisplay } from './CommandMenuItemDisplay';
export const HeadlessCommandMenuItem = ({
item,
}: {
item: CommandMenuItemFieldsFragment;
}) => {
const commandMenuItemConfig = useContext(CommandConfigContext);
const mountCommand = useMountCommand();
const contextStoreInstanceId = useAvailableComponentInstanceIdOrThrow(
ContextStoreComponentInstanceContext,
);
const isMounted = useAtomFamilySelectorValue(
isEngineCommandMountedFamilySelector,
item.id,
);
const { closeCommandMenu } = useCloseCommandMenu({
closeSidePanelOnShowPageOptionsExecution: false,
closeSidePanelOnCommandMenuListExecution: false,
});
const commandMenuItemProgress = useAtomFamilyStateValue(
commandMenuItemProgressFamilyState,
item.id,
);
if (!isDefined(commandMenuItemConfig)) {
return null;
}
const handleClick = async () => {
if (isMounted) {
return;
}
closeCommandMenu();
await mountCommand({
engineCommandId: item.id,
contextStoreInstanceId,
engineComponentKey: item.engineComponentKey,
frontComponentId: item.frontComponentId ?? undefined,
workflowVersionId: item.workflowVersionId ?? undefined,
availabilityType: item.availabilityType,
availabilityObjectMetadataId: item.availabilityObjectMetadataId,
payload: item.payload ?? undefined,
});
};
return (
<CommandMenuItemDisplay
onClick={handleClick}
disabled={isMounted}
progress={commandMenuItemProgress}
showDisabledLoader={isMounted}
/>
);
};
@@ -1,15 +1,15 @@
import { CommandMenuItemComponent } from '@/command-menu-item/display/components/CommandMenuItemComponent';
import { CommandMenuItemRenderer } from '@/command-menu-item/display/components/CommandMenuItemRenderer';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { PinnedCommandMenuItemsInlineMeasurements } from '@/command-menu-item/server-items/display/components/PinnedCommandMenuItemsInlineMeasurements';
import { PINNED_COMMAND_MENU_ITEMS_GAP } from '@/command-menu-item/server-items/display/constants/PinnedCommandMenuItemsGap';
import { usePinnedCommandMenuItemsInlineLayout } from '@/command-menu-item/server-items/display/hooks/usePinnedCommandMenuItemsInlineLayout';
import { 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 { NodeDimension } from '@/ui/utilities/dimensions/components/NodeDimension';
import { styled } from '@linaria/react';
import { motion } from 'framer-motion';
import { useContext, useMemo } from 'react';
import { ThemeContext } from 'twenty-ui/theme-constants';
const StyledActionContainer = styled(motion.div)`
const StyledCommandMenuItemContainer = styled(motion.div)`
align-items: center;
display: flex;
justify-content: center;
@@ -40,10 +40,7 @@ export const PinnedCommandMenuItemButtons = () => {
const { commandMenuItems } = useContext(CommandMenuContext);
const pinnedCommandMenuItems = useMemo(
() =>
commandMenuItems.filter(
(commandMenuItem) => commandMenuItem.isPinned === true,
),
() => commandMenuItems.filter((item) => item.isPinned === true),
[commandMenuItems],
);
@@ -71,25 +68,21 @@ export const PinnedCommandMenuItemButtons = () => {
<NodeDimension onDimensionChange={onContainerDimensionChange}>
<StyledContainer>
<StyledItemsContainer>
{pinnedInlineCommandMenuItems.map(
(pinnedInlineCommandMenuItem) => (
<StyledActionContainer
key={pinnedInlineCommandMenuItem.key}
layout
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'unset', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{
duration: theme.animation.duration.instant,
ease: 'easeInOut',
}}
>
<CommandMenuItemComponent
action={pinnedInlineCommandMenuItem}
/>
</StyledActionContainer>
),
)}
{pinnedInlineCommandMenuItems.map((item) => (
<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',
}}
>
<CommandMenuItemRenderer item={item} />
</StyledCommandMenuItemContainer>
))}
</StyledItemsContainer>
</StyledContainer>
</NodeDimension>
@@ -0,0 +1,68 @@
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { PINNED_COMMAND_MENU_ITEMS_GAP } from '@/command-menu-item/display/constants/PinnedCommandMenuItemsGap';
import { interpolateCommandMenuItemFields } from '@/command-menu-item/display/utils/interpolateCommandMenuItemFields';
import { CommandMenuButton } from '@/command-menu/components/CommandMenuButton';
import { NodeDimension } from '@/ui/utilities/dimensions/components/NodeDimension';
import { COMMAND_MENU_DEFAULT_ICON } from '@/workflow/workflow-trigger/constants/CommandMenuDefaultIcon';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import { useIcons } from 'twenty-ui/display';
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
type ElementDimensions = {
width: number;
height: number;
};
type PinnedCommandMenuItemsInlineMeasurementsProps = {
pinnedCommandMenuItems: CommandMenuItemFieldsFragment[];
onPinnedCommandMenuItemDimensionChange: (
commandMenuItemKey: string,
) => (dimensions: ElementDimensions) => void;
};
const StyledHiddenMeasurementsContainer = styled.div`
display: flex;
gap: ${PINNED_COMMAND_MENU_ITEMS_GAP}px;
pointer-events: none;
position: absolute;
top: -9999px;
visibility: hidden;
`;
export const PinnedCommandMenuItemsInlineMeasurements = ({
pinnedCommandMenuItems,
onPinnedCommandMenuItemDimensionChange,
}: PinnedCommandMenuItemsInlineMeasurementsProps) => {
const { getIcon } = useIcons();
const { commandMenuContextApi } = useContext(CommandMenuContext);
return (
<StyledHiddenMeasurementsContainer>
{pinnedCommandMenuItems.map((item) => {
const { iconKey, label, shortLabel } = interpolateCommandMenuItemFields(
item,
commandMenuContextApi,
);
const Icon = getIcon(iconKey, COMMAND_MENU_DEFAULT_ICON);
return (
<NodeDimension
key={item.id}
onDimensionChange={onPinnedCommandMenuItemDimensionChange(item.id)}
>
<CommandMenuButton
command={{
key: `${item.id}-inline-measurement`,
label,
shortLabel,
Icon,
}}
/>
</NodeDimension>
);
})}
</StyledHiddenMeasurementsContainer>
);
};
@@ -1,64 +1,64 @@
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { PINNED_COMMAND_MENU_ITEMS_GAP } from '@/command-menu-item/server-items/display/constants/PinnedCommandMenuItemsGap';
import { commandMenuPinnedInlineLayoutState } from '@/command-menu-item/server-items/display/states/commandMenuPinnedInlineLayoutState';
import { getVisibleCommandMenuItemCountForContainerWidth } from '@/command-menu-item/server-items/display/utils/getVisibleCommandMenuItemCountForContainerWidth';
import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
import { CommandMenuItemRenderer } from '@/command-menu-item/display/components/CommandMenuItemRenderer';
import { PINNED_COMMAND_MENU_ITEMS_GAP } from '@/command-menu-item/display/constants/PinnedCommandMenuItemsGap';
import { commandMenuPinnedInlineLayoutState } from '@/command-menu-item/display/states/commandMenuPinnedInlineLayoutState';
import { getVisibleCommandMenuItemCountForContainerWidth } from '@/command-menu-item/display/utils/getVisibleCommandMenuItemCountForContainerWidth';
import { groupCommandMenuItems } from '@/command-menu-item/utils/groupCommandMenuItems';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { SIDE_PANEL_PREVIOUS_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelPreviousComponentInstanceId';
import { SIDE_PANEL_RESET_CONTEXT_TO_SELECTION } from '@/side-panel/constants/SidePanelResetContextToSelection';
import { SidePanelResetContextToSelectionButton } from '@/side-panel/pages/root/components/SidePanelResetContextToSelectionButton';
import { useFilterActionsWithSidePanelSearch } from '@/side-panel/pages/root/hooks/useFilterActionsWithSidePanelSearch';
import { useFilterCommandMenuItemsWithSidePanelSearch } from '@/side-panel/pages/root/hooks/useFilterCommandMenuItemsWithSidePanelSearch';
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
import { type SidePanelCommandMenuItemGroupConfig } from '@/side-panel/types/SidePanelCommandMenuItemGroupConfig';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useLingui } from '@lingui/react/macro';
import { isNumber } from '@sniptt/guards';
import { useContext } from 'react';
import { useContext, useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { CommandMenuItemAvailabilityType } from '~/generated-metadata/graphql';
export const SidePanelCommandMenuItemDisplayPage = () => {
const { t } = useLingui();
const sidePanelSearch = useAtomStateValue(sidePanelSearchState);
const { commandMenuItems } = useContext(CommandMenuContext);
const { commandMenuItems, commandMenuContextApi } =
useContext(CommandMenuContext);
const commandMenuPinnedInlineLayout = useAtomStateValue(
commandMenuPinnedInlineLayoutState,
);
const { filterActionsWithSidePanelSearch } =
useFilterActionsWithSidePanelSearch({
const { filterCommandMenuItemsWithSidePanelSearch } =
useFilterCommandMenuItemsWithSidePanelSearch({
sidePanelSearch,
commandMenuContextApi,
});
const pinnedCommandMenuItems = commandMenuItems
.filter((commandMenuItem) => commandMenuItem.isPinned === true)
.sort(
(firstPinnedCommandMenuItem, secondPinnedCommandMenuItem) =>
firstPinnedCommandMenuItem.position -
secondPinnedCommandMenuItem.position,
);
const { pinned: pinnedCommandMenuItems, other: nonPinnedCommandMenuItems } =
useMemo(() => groupCommandMenuItems(commandMenuItems), [commandMenuItems]);
const unpinnedCommandMenuItems = commandMenuItems
.filter(
(commandMenuItem) =>
commandMenuItem.isPinned !== true &&
commandMenuItem.type !== CommandMenuItemType.Fallback,
)
.sort(
(firstUnpinnedCommandMenuItem, secondUnpinnedCommandMenuItem) =>
firstUnpinnedCommandMenuItem.position -
secondUnpinnedCommandMenuItem.position,
);
const unpinnedCommandMenuItems = useMemo(
() =>
nonPinnedCommandMenuItems.filter(
(item) =>
item.availabilityType !== CommandMenuItemAvailabilityType.FALLBACK,
),
[nonPinnedCommandMenuItems],
);
const fallbackCommandMenuItems = commandMenuItems.filter(
(commandMenuItem) => commandMenuItem.type === CommandMenuItemType.Fallback,
const fallbackCommandMenuItems = useMemo(
() =>
nonPinnedCommandMenuItems.filter(
(item) =>
item.availabilityType === CommandMenuItemAvailabilityType.FALLBACK,
),
[nonPinnedCommandMenuItems],
);
const pinnedCommandMenuItemKeysInDisplayOrder = pinnedCommandMenuItems.map(
(pinnedCommandMenuItem) => pinnedCommandMenuItem.key,
(item) => item.id,
);
const visiblePinnedCommandMenuItemCount =
@@ -74,11 +74,9 @@ export const SidePanelCommandMenuItemDisplayPage = () => {
const hasKnownPinnedInlineLayout =
commandMenuPinnedInlineLayout.containerWidth > 0 &&
pinnedCommandMenuItemKeysInDisplayOrder.every((commandMenuItemKey) =>
pinnedCommandMenuItemKeysInDisplayOrder.every((itemKey) =>
isNumber(
commandMenuPinnedInlineLayout.commandMenuItemWidthsByKey[
commandMenuItemKey
],
commandMenuPinnedInlineLayout.commandMenuItemWidthsByKey[itemKey],
),
);
@@ -86,32 +84,20 @@ export const SidePanelCommandMenuItemDisplayPage = () => {
? pinnedCommandMenuItems.slice(visiblePinnedCommandMenuItemCount)
: pinnedCommandMenuItems;
const matchingPinnedItems = filterActionsWithSidePanelSearch(
const matchingPinnedItems = filterCommandMenuItemsWithSidePanelSearch(
pinnedOverflowCommandMenuItems,
);
const matchingOtherItems = filterActionsWithSidePanelSearch(
const matchingOtherItems = filterCommandMenuItemsWithSidePanelSearch(
unpinnedCommandMenuItems,
);
const noResults = !matchingPinnedItems.length && !matchingOtherItems.length;
const commandGroups: SidePanelCommandMenuItemGroupConfig[] = [
{
heading: t`Pinned`,
items: matchingPinnedItems,
},
{
heading: t`Other`,
items: matchingOtherItems,
},
{
heading: t`Fallback`,
items: noResults ? fallbackCommandMenuItems : [],
},
];
const selectableItems = commandGroups.flatMap((group) => group.items ?? []);
const selectableItemIds = selectableItems.map((item) => item.key);
const selectableItemIds = [
...matchingPinnedItems,
...matchingOtherItems,
...(noResults ? fallbackCommandMenuItems : []),
].map((item) => item.id);
// oxlint-disable-next-line twenty/matching-state-variable
const previousContextStoreCurrentObjectMetadataItemId =
@@ -125,16 +111,33 @@ export const SidePanelCommandMenuItemDisplayPage = () => {
}
return (
<SidePanelList
commandGroups={commandGroups}
selectableItemIds={selectableItemIds}
noResults={noResults}
>
<SidePanelList selectableItemIds={selectableItemIds} noResults={noResults}>
{isDefined(previousContextStoreCurrentObjectMetadataItemId) && (
<SidePanelGroup heading={t`Context`}>
<SidePanelResetContextToSelectionButton />
</SidePanelGroup>
)}
{matchingPinnedItems.length > 0 && (
<SidePanelGroup heading={t`Pinned`}>
{matchingPinnedItems.map((item) => (
<CommandMenuItemRenderer item={item} key={item.id} />
))}
</SidePanelGroup>
)}
{matchingOtherItems.length > 0 && (
<SidePanelGroup heading={t`Other`}>
{matchingOtherItems.map((item) => (
<CommandMenuItemRenderer item={item} key={item.id} />
))}
</SidePanelGroup>
)}
{noResults && fallbackCommandMenuItems.length > 0 && (
<SidePanelGroup heading={t`Fallback`}>
{fallbackCommandMenuItems.map((item) => (
<CommandMenuItemRenderer item={item} key={item.id} />
))}
</SidePanelGroup>
)}
</SidePanelList>
);
};
@@ -1,65 +0,0 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, fn, userEvent, within } from 'storybook/test';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
import { CommandMenuItemButton } from '@/command-menu-item/display/components/CommandMenuItemButton';
import { createMockCommandMenuItems } from '@/command-menu-item/mock/command-menu-items.mock';
import { getCommandMenuItemLabel } from '@/command-menu-item/utils/getCommandMenuItemLabel';
import { EngineComponentKey } from '~/generated-metadata/graphql';
const meta: Meta<typeof CommandMenuItemButton> = {
title: 'Modules/CommandMenuItem/Display/CommandMenuItemButton',
component: CommandMenuItemButton,
decorators: [ComponentDecorator, RouterDecorator],
};
export default meta;
type Story = StoryObj<typeof CommandMenuItemButton>;
const deleteMock = fn();
const addToFavoritesMock = fn();
const mockActions = createMockCommandMenuItems({
deleteMock,
addToFavoritesMock,
});
const addToFavoritesCommandMenuItem = mockActions.find(
(action) => action.key === EngineComponentKey.ADD_TO_FAVORITES,
);
const goToPeopleCommandMenuItem = mockActions.find(
(action) => action.key === EngineComponentKey.GO_TO_PEOPLE,
);
export const Default: Story = {
args: {
action: addToFavoritesCommandMenuItem,
onClick: addToFavoritesMock,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByText(
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.shortLabel),
),
);
expect(addToFavoritesMock).toHaveBeenCalled();
},
};
export const WithLink: Story = {
args: {
action: goToPeopleCommandMenuItem,
to: '/objects/people',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const menuItem = await canvas.findByText(
getCommandMenuItemLabel(goToPeopleCommandMenuItem?.shortLabel),
);
expect(menuItem).toBeVisible();
expect(canvas.getByRole('link')).toHaveAttribute('href', '/objects/people');
},
};
@@ -1,67 +0,0 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import { ComponentDecorator } from 'twenty-ui/testing';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { CommandMenuItemComponent } from '@/command-menu-item/display/components/CommandMenuItemComponent';
import { createMockCommandMenuItems } from '@/command-menu-item/mock/command-menu-items.mock';
import { getCommandMenuItemLabel } from '@/command-menu-item/utils/getCommandMenuItemLabel';
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { EngineComponentKey } from '~/generated-metadata/graphql';
const mockActions = createMockCommandMenuItems({});
const addToFavoritesCommandMenuItem = mockActions.find(
(action) => action.key === EngineComponentKey.ADD_TO_FAVORITES,
);
if (!addToFavoritesCommandMenuItem) {
throw new Error('Add to favorites action not found');
}
const meta: Meta<typeof CommandMenuItemComponent> = {
title: 'Modules/CommandMenuItem/Display/CommandMenuItemComponent',
component: CommandMenuItemComponent,
decorators: [
ComponentDecorator,
(Story) => (
<CommandMenuComponentInstanceContext.Provider
value={{ instanceId: 'story' }}
>
<CommandMenuContext.Provider
value={{
isInSidePanel: false,
containerType: 'index-page-header',
displayType: 'button',
commandMenuItems: [addToFavoritesCommandMenuItem],
}}
>
<Story />
</CommandMenuContext.Provider>
</CommandMenuComponentInstanceContext.Provider>
),
],
args: {
action: addToFavoritesCommandMenuItem,
},
parameters: {
container: {
width: 'auto',
},
},
};
export default meta;
type Story = StoryObj<typeof CommandMenuItemComponent>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await canvas.findByText(
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.shortLabel),
),
).toBeVisible();
},
};
@@ -1,146 +0,0 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, fn, userEvent, within } from 'storybook/test';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
import { CommandConfigContext } from '@/command-menu-item/contexts/CommandConfigContext';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { CommandMenuItemDisplay } from '@/command-menu-item/display/components/CommandMenuItemDisplay';
import { createMockCommandMenuItems } from '@/command-menu-item/mock/command-menu-items.mock';
import { getCommandMenuItemLabel } from '@/command-menu-item/utils/getCommandMenuItemLabel';
import { SelectableListComponentInstanceContext } from '@/ui/layout/selectable-list/states/contexts/SelectableListComponentInstanceContext';
import { EngineComponentKey } from '~/generated-metadata/graphql';
type Story = StoryObj<typeof CommandMenuItemDisplay>;
const deleteMock = fn();
const addToFavoritesMock = fn();
const mockActions = createMockCommandMenuItems({
deleteMock,
addToFavoritesMock,
});
const addToFavoritesCommandMenuItem = mockActions.find(
(action) => action.key === EngineComponentKey.ADD_TO_FAVORITES,
);
if (!addToFavoritesCommandMenuItem) {
throw new Error('addToFavoritesCommandMenuItem not found');
}
const meta: Meta<typeof CommandMenuItemDisplay> = {
title: 'Modules/CommandMenuItem/Display/CommandMenuItemDisplay',
component: CommandMenuItemDisplay,
decorators: [
(Story) => (
<CommandConfigContext.Provider value={addToFavoritesCommandMenuItem}>
<Story />
</CommandConfigContext.Provider>
),
ComponentDecorator,
RouterDecorator,
],
};
export default meta;
export const AsButton: Story = {
args: {
onClick: addToFavoritesMock,
},
decorators: [
(Story) => (
<CommandMenuContext.Provider
value={{
isInSidePanel: false,
containerType: 'command-menu-list',
displayType: 'button',
commandMenuItems: [],
}}
>
<Story />
</CommandMenuContext.Provider>
),
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByText(
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.shortLabel),
),
);
expect(addToFavoritesMock).toHaveBeenCalled();
},
};
export const AsListItem: Story = {
args: {
onClick: addToFavoritesMock,
},
decorators: [
(Story) => (
<SelectableListComponentInstanceContext.Provider
value={{ instanceId: 'story' }}
>
<Story />
</SelectableListComponentInstanceContext.Provider>
),
(Story) => (
<CommandMenuContext.Provider
value={{
isInSidePanel: false,
containerType: 'command-menu-list',
displayType: 'listItem',
commandMenuItems: [],
}}
>
<Story />
</CommandMenuContext.Provider>
),
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByText(
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label),
),
);
expect(addToFavoritesMock).toHaveBeenCalled();
},
};
export const AsDropdownItem: Story = {
args: {
onClick: addToFavoritesMock,
},
decorators: [
(Story) => (
<SelectableListComponentInstanceContext.Provider
value={{ instanceId: 'story' }}
>
<Story />
</SelectableListComponentInstanceContext.Provider>
),
(Story) => (
<CommandMenuContext.Provider
value={{
isInSidePanel: false,
containerType: 'command-menu-list',
displayType: 'dropdownItem',
commandMenuItems: [],
}}
>
<Story />
</CommandMenuContext.Provider>
),
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByText(
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label),
),
);
expect(addToFavoritesMock).toHaveBeenCalled();
},
};
@@ -1,75 +0,0 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, fn, userEvent, within } from 'storybook/test';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
import { CommandDropdownItem } from '@/command-menu-item/display/components/CommandDropdownItem';
import { createMockCommandMenuItems } from '@/command-menu-item/mock/command-menu-items.mock';
import { getCommandMenuItemLabel } from '@/command-menu-item/utils/getCommandMenuItemLabel';
import { SelectableListComponentInstanceContext } from '@/ui/layout/selectable-list/states/contexts/SelectableListComponentInstanceContext';
import { EngineComponentKey } from '~/generated-metadata/graphql';
const meta: Meta<typeof CommandDropdownItem> = {
title: 'Modules/CommandMenuItem/Display/CommandDropdownItem',
component: CommandDropdownItem,
decorators: [
(Story) => (
<SelectableListComponentInstanceContext.Provider
value={{ instanceId: 'story' }}
>
<Story />
</SelectableListComponentInstanceContext.Provider>
),
ComponentDecorator,
RouterDecorator,
],
};
export default meta;
type Story = StoryObj<typeof CommandDropdownItem>;
const deleteMock = fn();
const addToFavoritesMock = fn();
const mockActions = createMockCommandMenuItems({
deleteMock,
addToFavoritesMock,
});
const addToFavoritesCommandMenuItem = mockActions.find(
(action) => action.key === EngineComponentKey.ADD_TO_FAVORITES,
);
const goToPeopleCommandMenuItem = mockActions.find(
(action) => action.key === EngineComponentKey.GO_TO_PEOPLE,
);
export const Default: Story = {
args: {
action: addToFavoritesCommandMenuItem,
onClick: addToFavoritesMock,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByText(
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label),
),
);
expect(addToFavoritesMock).toHaveBeenCalled();
},
};
export const WithLink: Story = {
args: {
action: goToPeopleCommandMenuItem,
to: '/objects/people',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const dropdownItem = await canvas.findByText(
getCommandMenuItemLabel(goToPeopleCommandMenuItem?.label),
);
expect(dropdownItem).toBeVisible();
},
};
@@ -1,75 +0,0 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, fn, userEvent, within } from 'storybook/test';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
import { CommandListItem } from '@/command-menu-item/display/components/CommandListItem';
import { createMockCommandMenuItems } from '@/command-menu-item/mock/command-menu-items.mock';
import { getCommandMenuItemLabel } from '@/command-menu-item/utils/getCommandMenuItemLabel';
import { SelectableListComponentInstanceContext } from '@/ui/layout/selectable-list/states/contexts/SelectableListComponentInstanceContext';
import { EngineComponentKey } from '~/generated-metadata/graphql';
type Story = StoryObj<typeof CommandListItem>;
const deleteMock = fn();
const addToFavoritesMock = fn();
const mockActions = createMockCommandMenuItems({
deleteMock,
addToFavoritesMock,
});
const addToFavoritesCommandMenuItem = mockActions.find(
(action) => action.key === EngineComponentKey.ADD_TO_FAVORITES,
);
const goToPeopleCommandMenuItem = mockActions.find(
(action) => action.key === EngineComponentKey.GO_TO_PEOPLE,
);
const meta: Meta<typeof CommandListItem> = {
title: 'Modules/CommandMenuItem/Display/CommandListItem',
component: CommandListItem,
decorators: [
(Story) => (
<SelectableListComponentInstanceContext.Provider
value={{ instanceId: 'story' }}
>
<Story />
</SelectableListComponentInstanceContext.Provider>
),
ComponentDecorator,
RouterDecorator,
],
};
export default meta;
export const Default: Story = {
args: {
action: addToFavoritesCommandMenuItem,
onClick: addToFavoritesMock,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByText(
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label),
),
);
expect(addToFavoritesMock).toHaveBeenCalled();
},
};
export const WithLink: Story = {
args: {
action: goToPeopleCommandMenuItem,
to: '/objects/people',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const listItem = await canvas.findByText(
getCommandMenuItemLabel(goToPeopleCommandMenuItem?.label),
);
expect(listItem).toBeVisible();
},
};
@@ -1,9 +1,9 @@
import { type CommandMenuItemConfig } from '@/command-menu-item/types/CommandMenuItemConfig';
import { PINNED_COMMAND_MENU_ITEMS_GAP } from '@/command-menu-item/server-items/display/constants/PinnedCommandMenuItemsGap';
import { commandMenuPinnedInlineLayoutState } from '@/command-menu-item/server-items/display/states/commandMenuPinnedInlineLayoutState';
import { getVisibleCommandMenuItemCountForContainerWidth } from '@/command-menu-item/server-items/display/utils/getVisibleCommandMenuItemCountForContainerWidth';
import { PINNED_COMMAND_MENU_ITEMS_GAP } from '@/command-menu-item/display/constants/PinnedCommandMenuItemsGap';
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 { useCallback, useMemo } from 'react';
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
type ElementDimensions = {
width: number;
@@ -11,7 +11,7 @@ type ElementDimensions = {
};
type UsePinnedCommandMenuItemsInlineLayoutParams = {
pinnedCommandMenuItems: CommandMenuItemConfig[];
pinnedCommandMenuItems: CommandMenuItemFieldsFragment[];
};
export const usePinnedCommandMenuItemsInlineLayout = ({
@@ -20,22 +20,9 @@ export const usePinnedCommandMenuItemsInlineLayout = ({
const [commandMenuPinnedInlineLayout, setCommandMenuPinnedInlineLayout] =
useAtomState(commandMenuPinnedInlineLayoutState);
const pinnedCommandMenuItemsSortedByPosition = useMemo(
() =>
[...pinnedCommandMenuItems].sort(
(firstPinnedCommandMenuItem, secondPinnedCommandMenuItem) =>
firstPinnedCommandMenuItem.position -
secondPinnedCommandMenuItem.position,
),
[pinnedCommandMenuItems],
);
const pinnedCommandMenuItemKeysInDisplayOrder = useMemo(
() =>
pinnedCommandMenuItemsSortedByPosition.map(
(pinnedCommandMenuItem) => pinnedCommandMenuItem.key,
),
[pinnedCommandMenuItemsSortedByPosition],
() => pinnedCommandMenuItems.map((item) => item.id),
[pinnedCommandMenuItems],
);
const visiblePinnedCommandMenuItemCount = useMemo(
@@ -53,20 +40,13 @@ export const usePinnedCommandMenuItemsInlineLayout = ({
);
const pinnedInlineCommandMenuItems = useMemo(
() =>
pinnedCommandMenuItemsSortedByPosition.slice(
0,
visiblePinnedCommandMenuItemCount,
),
[pinnedCommandMenuItemsSortedByPosition, visiblePinnedCommandMenuItemCount],
() => pinnedCommandMenuItems.slice(0, visiblePinnedCommandMenuItemCount),
[pinnedCommandMenuItems, visiblePinnedCommandMenuItemCount],
);
const pinnedOverflowCommandMenuItems = useMemo(
() =>
pinnedCommandMenuItemsSortedByPosition.slice(
visiblePinnedCommandMenuItemCount,
),
[pinnedCommandMenuItemsSortedByPosition, visiblePinnedCommandMenuItemCount],
() => pinnedCommandMenuItems.slice(visiblePinnedCommandMenuItemCount),
[pinnedCommandMenuItems, visiblePinnedCommandMenuItemCount],
);
const onContainerDimensionChange = useCallback(
@@ -1,4 +1,4 @@
import { type CommandMenuPinnedInlineLayout } from '@/command-menu-item/server-items/display/types/CommandMenuPinnedInlineLayout';
import { type CommandMenuPinnedInlineLayout } from '@/command-menu-item/display/types/CommandMenuPinnedInlineLayout';
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const commandMenuPinnedInlineLayoutState =
@@ -1,4 +1,4 @@
import { getVisibleCommandMenuItemCountForContainerWidth } from '@/command-menu-item/server-items/display/utils/getVisibleCommandMenuItemCountForContainerWidth';
import { getVisibleCommandMenuItemCountForContainerWidth } from '@/command-menu-item/display/utils/getVisibleCommandMenuItemCountForContainerWidth';
describe('getVisibleCommandMenuItemCountForContainerWidth', () => {
it('should return all items when container width is zero', () => {
@@ -0,0 +1,32 @@
import { type CommandMenuContextApi, type Nullable } from 'twenty-shared/types';
import { interpolateCommandMenuItemTemplate } from 'twenty-shared/utils';
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
type InterpolatedCommandMenuItemFields = {
iconKey: Nullable<string>;
label: string;
shortLabel: Nullable<string>;
};
export const interpolateCommandMenuItemFields = (
item: CommandMenuItemFieldsFragment,
commandMenuContextApi: CommandMenuContextApi,
): InterpolatedCommandMenuItemFields => {
const iconKey = interpolateCommandMenuItemTemplate({
label: item.icon,
context: commandMenuContextApi,
});
const label =
interpolateCommandMenuItemTemplate({
label: item.label,
context: commandMenuContextApi,
}) ?? item.label;
const shortLabel = interpolateCommandMenuItemTemplate({
label: item.shortLabel,
context: commandMenuContextApi,
});
return { iconKey, label, shortLabel };
};
@@ -1,5 +1,5 @@
import { COMMAND_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/command-menu-item/constants/CommandMenuDropdownClickOutsideId';
import { useSelectFirstRecordForEditMode } from '@/command-menu-item/server-items/edit/hooks/useSelectFirstRecordForEditMode';
import { useSelectFirstRecordForEditMode } from '@/command-menu-item/edit/hooks/useSelectFirstRecordForEditMode';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { mainContextStoreHasSelectedRecordsSelector } from '@/context-store/states/selectors/mainContextStoreHasSelectedRecordsSelector';
import { useResetRecordIndexSelection } from '@/object-record/record-index/hooks/useResetRecordIndexSelection';
@@ -1,4 +1,4 @@
import { useUpdateCommandMenuItemInDraft } from '@/command-menu-item/server-items/edit/hooks/useUpdateCommandMenuItemInDraft';
import { useUpdateCommandMenuItemInDraft } from '@/command-menu-item/edit/hooks/useUpdateCommandMenuItemInDraft';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
@@ -1,26 +1,23 @@
import { useCommandMenuContextApi } from '@/command-menu-item/server-items/common/hooks/useCommandMenuContextApi';
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/server-items/common/utils/doesCommandMenuItemMatchObjectMetadataId';
import { PinnedCommandMenuItemsInlineMeasurements } from '@/command-menu-item/server-items/display/components/PinnedCommandMenuItemsInlineMeasurements';
import { PINNED_COMMAND_MENU_ITEMS_GAP } from '@/command-menu-item/server-items/display/constants/PinnedCommandMenuItemsGap';
import { usePinnedCommandMenuItemsInlineLayout } from '@/command-menu-item/server-items/display/hooks/usePinnedCommandMenuItemsInlineLayout';
import { mainContextStoreHasSelectedRecordsSelector } from '@/context-store/states/selectors/mainContextStoreHasSelectedRecordsSelector';
import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState';
import { type CommandMenuItemConfig } from '@/command-menu-item/types/CommandMenuItemConfig';
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
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 { interpolateCommandMenuItemTemplate } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { ThemeContext } from 'twenty-ui/theme-constants';
import { CommandMenuItemAvailabilityType } from '~/generated-metadata/graphql';
const StyledActionContainer = styled(motion.div)`
const StyledCommandMenuItemContainer = styled(motion.div)`
align-items: center;
display: flex;
justify-content: center;
@@ -72,52 +69,12 @@ export const PinnedCommandMenuItemButtonsEditMode = () => {
[mainContextStoreHasSelectedRecords],
);
const interpolateLabel = (rawLabel: string | null | undefined) =>
interpolateCommandMenuItemTemplate({
label: rawLabel,
context: commandMenuContextApi,
});
const pinnedCommandMenuItems: CommandMenuItemConfig[] = useMemo(
() =>
commandMenuItemsDraft
.filter(
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
)
.filter((item) => allowedAvailabilityTypes.has(item.availabilityType))
.filter((item) => item.isPinned)
.map((item) => {
const Icon = getIcon(item.icon, COMMAND_MENU_DEFAULT_ICON);
const label = interpolateLabel(item.label) ?? item.label;
const shortLabel = interpolateLabel(item.shortLabel);
const key = `edit-preview-${item.id}`;
return {
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.Global,
key,
label,
shortLabel,
position: item.position,
Icon,
isPinned: true,
shouldBeRegistered: () => true,
component: (
<CommandMenuButton
command={{ key, label, shortLabel, Icon }}
disabled
/>
),
};
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
currentObjectMetadataItemId,
allowedAvailabilityTypes,
commandMenuContextApi,
getIcon,
],
);
const pinnedCommandMenuItems = commandMenuItemsDraft
.filter(
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
)
.filter((item) => allowedAvailabilityTypes.has(item.availabilityType))
.filter((item) => item.isPinned);
const {
pinnedInlineCommandMenuItems,
@@ -143,10 +100,15 @@ export const PinnedCommandMenuItemButtonsEditMode = () => {
<NodeDimension onDimensionChange={onContainerDimensionChange}>
<StyledContainer>
<StyledItemsContainer>
{pinnedInlineCommandMenuItems.map(
(pinnedInlineCommandMenuItem) => (
<StyledActionContainer
key={pinnedInlineCommandMenuItem.key}
{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 }}
@@ -156,10 +118,18 @@ export const PinnedCommandMenuItemButtonsEditMode = () => {
ease: 'easeInOut',
}}
>
{pinnedInlineCommandMenuItem.component}
</StyledActionContainer>
),
)}
<CommandMenuButton
command={{
key: item.id,
label,
shortLabel,
Icon,
}}
disabled
/>
</StyledCommandMenuItemContainer>
);
})}
</StyledItemsContainer>
</StyledContainer>
</NodeDimension>
@@ -1,12 +1,13 @@
import { useCommandMenuContextApi } from '@/command-menu-item/server-items/common/hooks/useCommandMenuContextApi';
import { commandMenuItemsSelector } from '@/command-menu-item/server-items/common/states/commandMenuItemsSelector';
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/server-items/common/utils/doesCommandMenuItemMatchObjectMetadataId';
import { CommandMenuItemEditRecordSelectionDropdown } from '@/command-menu-item/server-items/edit/components/CommandMenuItemEditRecordSelectionDropdown';
import { CommandMenuItemOptionsDropdown } from '@/command-menu-item/server-items/edit/components/CommandMenuItemOptionsDropdown';
import { useReorderCommandMenuItemsInDraft } from '@/command-menu-item/server-items/edit/hooks/useReorderCommandMenuItemsInDraft';
import { useResetCommandMenuItemsDraft } from '@/command-menu-item/server-items/edit/hooks/useResetCommandMenuItemsDraft';
import { useUpdateCommandMenuItemInDraft } from '@/command-menu-item/server-items/edit/hooks/useUpdateCommandMenuItemInDraft';
import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState';
import { useCommandMenuContextApi } from '@/command-menu-item/hooks/useCommandMenuContextApi';
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 { CommandMenuItemEditRecordSelectionDropdown } from '@/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown';
import { CommandMenuItemOptionsDropdown } from '@/command-menu-item/edit/components/CommandMenuItemOptionsDropdown';
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 { mainContextStoreHasSelectedRecordsSelector } from '@/context-store/states/selectors/mainContextStoreHasSelectedRecordsSelector';
import { COMMAND_MENU_CLICK_OUTSIDE_ID } from '@/command-menu/constants/CommandMenuClickOutsideId';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
@@ -41,17 +42,6 @@ import {
} from '~/generated-metadata/graphql';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
const partitionByPinned = (items: CommandMenuItemFieldsFragment[]) => {
const pinned = items
.filter((item) => item.isPinned)
.sort((a, b) => a.position - b.position);
const other = items
.filter((item) => !item.isPinned)
.sort((a, b) => a.position - b.position);
return { pinned, other };
};
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
@@ -112,7 +102,8 @@ export const SidePanelCommandMenuItemEditPage = () => {
.filter(
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
)
.filter((item) => allowedAvailabilityTypes.has(item.availabilityType));
.filter((item) => allowedAvailabilityTypes.has(item.availabilityType))
.sort((firstItem, secondItem) => firstItem.position - secondItem.position);
const filteredCommandMenuItemIds = new Set(
filteredCommandMenuItems.map((item) => item.id),
@@ -124,9 +115,8 @@ export const SidePanelCommandMenuItemEditPage = () => {
context: commandMenuContextApi,
}) ?? item.label;
const { pinned: allPinnedItems, other: allOtherItems } = partitionByPinned(
filteredCommandMenuItems,
);
const { pinned: allPinnedItems, other: allOtherItems } =
groupCommandMenuItems(filteredCommandMenuItems);
const normalizedSearch =
sidePanelSearch.length > 0
@@ -247,7 +237,7 @@ export const SidePanelCommandMenuItemEditPage = () => {
/>
</StyledViewbar>
<StyledContent>
<SidePanelList commandGroups={[]} selectableItemIds={selectableItemIds}>
<SidePanelList selectableItemIds={selectableItemIds}>
<SidePanelGroup heading={t`Pinned`}>
<DraggableList
onDragEnd={handlePinnedDragEnd}
@@ -2,9 +2,9 @@ import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState';
import { computeInsertPositionFromBounds } from '@/command-menu-item/server-items/edit/utils/computeInsertPositionFromBounds';
import { getPositionBoundsAtInsertionPoint } from '@/command-menu-item/server-items/edit/utils/getPositionBoundsAtInsertionPoint';
import { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/commandMenuItemsDraftState';
import { computeInsertPositionFromBounds } from '@/command-menu-item/edit/utils/computeInsertPositionFromBounds';
import { getPositionBoundsAtInsertionPoint } from '@/command-menu-item/edit/utils/getPositionBoundsAtInsertionPoint';
export const useReorderCommandMenuItemsInDraft = () => {
const store = useStore();
@@ -1,8 +1,8 @@
import { useCallback } from 'react';
import { useStore } from 'jotai';
import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState';
import { commandMenuItemsSelector } from '@/command-menu-item/server-items/common/states/commandMenuItemsSelector';
import { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/commandMenuItemsDraftState';
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
// Resets the draft to the current server state, discarding all user edits.
export const useResetCommandMenuItemsDraft = () => {
@@ -3,9 +3,9 @@ import { useStore } from 'jotai';
import { useMutation } from '@apollo/client/react';
import { isDefined } from 'twenty-shared/utils';
import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState';
import { UPDATE_COMMAND_MENU_ITEM } from '@/command-menu-item/server-items/common/graphql/mutations/updateCommandMenuItem';
import { commandMenuItemsSelector } from '@/command-menu-item/server-items/common/states/commandMenuItemsSelector';
import { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/commandMenuItemsDraftState';
import { UPDATE_COMMAND_MENU_ITEM } from '@/command-menu-item/graphql/mutations/updateCommandMenuItem';
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { type UpdateCommandMenuItemInput } from '~/generated-metadata/graphql';
@@ -1,5 +1,5 @@
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { getRecordIndexId } from '@/command-menu-item/server-items/edit/utils/getRecordIndexId';
import { getRecordIndexId } from '@/command-menu-item/edit/utils/getRecordIndexId';
import { isRecordBoardCardSelectedComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardSelectedComponentFamilyState';
import { useResetRecordIndexSelection } from '@/object-record/record-index/hooks/useResetRecordIndexSelection';
import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState';
@@ -2,8 +2,8 @@ import { useCallback } from 'react';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState';
import { type CommandMenuItemEditableFields } from '@/command-menu-item/server-items/edit/types/CommandMenuItemEditableFields';
import { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/commandMenuItemsDraftState';
import { type CommandMenuItemEditableFields } from '@/command-menu-item/edit/types/CommandMenuItemEditableFields';
export const useUpdateCommandMenuItemInDraft = () => {
const store = useStore();
@@ -1,4 +1,4 @@
import { computeInsertPositionFromBounds } from '@/command-menu-item/server-items/edit/utils/computeInsertPositionFromBounds';
import { computeInsertPositionFromBounds } from '@/command-menu-item/edit/utils/computeInsertPositionFromBounds';
describe('computeInsertPositionFromBounds', () => {
it('returns midpoint when both bounds are defined', () => {
@@ -1,4 +1,4 @@
import { getPositionBoundsAtInsertionPoint } from '@/command-menu-item/server-items/edit/utils/getPositionBoundsAtInsertionPoint';
import { getPositionBoundsAtInsertionPoint } from '@/command-menu-item/edit/utils/getPositionBoundsAtInsertionPoint';
const makeItems = (positions: number[]) =>
positions.map((position, index) => ({
@@ -3,6 +3,7 @@ import { useCloseCommandMenu } from '@/command-menu-item/hooks/useCloseCommandMe
import { type CommandMenuItemContainerType } from '@/command-menu-item/types/CommandMenuItemContainerType';
import { act, renderHook } from '@testing-library/react';
import { type ReactNode } from 'react';
import { CommandMenuContextApiPageType } from 'twenty-shared/types';
const TEST_COMMAND_MENU_ID = 'test-cmd-menu-1';
@@ -40,9 +41,33 @@ const getWrapper =
<CommandMenuContext.Provider
value={{
containerType,
isInSidePanel,
displayType: 'button',
commandMenuItems: [],
commandMenuContextApi: {
pageType: CommandMenuContextApiPageType.INDEX_PAGE,
isInSidePanel,
isPageInEditMode: false,
favoriteRecordIds: [],
isSelectAll: false,
hasAnySoftDeleteFilterOnView: false,
numberOfSelectedRecords: 0,
objectPermissions: {
canReadObjectRecords: false,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
restrictedFields: {},
objectMetadataId: '',
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
selectedRecords: [],
featureFlags: {},
targetObjectReadPermissions: {},
targetObjectWritePermissions: {},
objectMetadataItem: {},
objectMetadataLabel: '',
},
}}
>
{children}
@@ -1,87 +0,0 @@
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { useIsCommandBlockedByGlobalLayoutCustomization } from '@/command-menu-item/hooks/useIsCommandBlockedByGlobalLayoutCustomization';
import { type CommandMenuItemConfig } from '@/command-menu-item/types/CommandMenuItemConfig';
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
import { renderHook } from '@testing-library/react';
import { createStore, Provider as JotaiProvider } from 'jotai';
import { type ReactNode } from 'react';
import { CommandMenuItemViewType } from 'twenty-shared/types';
import { Icon123 } from 'twenty-ui/display';
const getWrapper =
(store = createStore()) =>
({ children }: { children: ReactNode }) => (
<JotaiProvider store={store}>{children}</JotaiProvider>
);
const buildCommandMenuItemConfig = (
isAllowedDuringGlobalLayoutCustomization?: boolean,
): CommandMenuItemConfig => ({
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.Global,
key: 'test-command',
label: 'Test Command',
position: 1,
Icon: Icon123,
availableOn: [CommandMenuItemViewType.GLOBAL],
shouldBeRegistered: () => true,
component: null,
isAllowedDuringGlobalLayoutCustomization,
});
describe('useIsCommandBlockedByGlobalLayoutCustomization', () => {
it('should not block commands when global layout customization is inactive', () => {
const store = createStore();
const wrapper = getWrapper(store);
const commandMenuItemConfig = buildCommandMenuItemConfig(false);
store.set(isLayoutCustomizationModeEnabledState.atom, false);
const { result } = renderHook(
() =>
useIsCommandBlockedByGlobalLayoutCustomization(commandMenuItemConfig),
{
wrapper,
},
);
expect(result.current).toBe(false);
});
it('should block commands by default when global layout customization is active', () => {
const store = createStore();
const wrapper = getWrapper(store);
const commandMenuItemConfig = buildCommandMenuItemConfig();
store.set(isLayoutCustomizationModeEnabledState.atom, true);
const { result } = renderHook(
() =>
useIsCommandBlockedByGlobalLayoutCustomization(commandMenuItemConfig),
{
wrapper,
},
);
expect(result.current).toBe(true);
});
it('should allow commands explicitly marked for global layout customization', () => {
const store = createStore();
const wrapper = getWrapper(store);
const commandMenuItemConfig = buildCommandMenuItemConfig(true);
store.set(isLayoutCustomizationModeEnabledState.atom, true);
const { result } = renderHook(
() =>
useIsCommandBlockedByGlobalLayoutCustomization(commandMenuItemConfig),
{
wrapper,
},
);
expect(result.current).toBe(false);
});
});
@@ -1,12 +1,11 @@
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { getCommandMenuDropdownIdFromCommandMenuId } from '@/command-menu-item/utils/getCommandMenuDropdownIdFromCommandMenuId';
import { getSidePanelCommandMenuDropdownIdFromCommandMenuId } from '@/command-menu-item/utils/getSidePanelCommandMenuDropdownIdFromCommandMenuId';
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const useCloseCommandMenu = ({
closeSidePanelOnShowPageOptionsExecution = false,
@@ -15,7 +14,9 @@ export const useCloseCommandMenu = ({
closeSidePanelOnShowPageOptionsExecution?: boolean;
closeSidePanelOnCommandMenuListExecution?: boolean;
} = {}) => {
const { containerType, isInSidePanel } = useContext(CommandMenuContext);
const { containerType, commandMenuContextApi } =
useContext(CommandMenuContext);
const isInSidePanel = commandMenuContextApi.isInSidePanel;
const { closeSidePanelMenu } = useSidePanelMenu();
@@ -31,10 +32,7 @@ export const useCloseCommandMenu = ({
const closeCommandMenu = () => {
if (containerType === 'command-menu-list') {
if (
isDefined(closeSidePanelOnCommandMenuListExecution) &&
!closeSidePanelOnCommandMenuListExecution
) {
if (!closeSidePanelOnCommandMenuListExecution) {
return;
}
closeSidePanelMenu();
@@ -49,7 +47,6 @@ export const useCloseCommandMenu = ({
if (
containerType === 'command-menu-show-page-dropdown' &&
isDefined(closeSidePanelOnShowPageOptionsExecution) &&
closeSidePanelOnShowPageOptionsExecution
) {
closeSidePanelMenu();
@@ -0,0 +1,119 @@
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { useMountCommand } from '@/command-menu-item/engine-command/hooks/useMountCommand';
import { isEngineCommandMountedFamilySelector } from '@/command-menu-item/engine-command/selectors/isEngineCommandMountedFamilySelector';
import { useCloseCommandMenu } from '@/command-menu-item/hooks/useCloseCommandMenu';
import { commandMenuItemProgressFamilyState } from '@/command-menu-item/states/commandMenuItemProgressFamilyState';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { useOpenFrontComponentInSidePanel } from '@/side-panel/hooks/useOpenFrontComponentInSidePanel';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { type IconComponent } from 'twenty-ui/display';
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
export const useCommandMenuItemClick = ({
item,
Icon,
label,
}: {
item: CommandMenuItemFieldsFragment;
Icon: IconComponent;
label: string;
}) => {
const { commandMenuContextApi } = useContext(CommandMenuContext);
const mountCommand = useMountCommand();
const { openFrontComponentInSidePanel } = useOpenFrontComponentInSidePanel();
const contextStoreInstanceId = useAvailableComponentInstanceIdOrThrow(
ContextStoreComponentInstanceContext,
);
const isMounted = useAtomFamilySelectorValue(
isEngineCommandMountedFamilySelector,
item.id,
);
const commandMenuItemProgress = useAtomFamilyStateValue(
commandMenuItemProgressFamilyState,
item.id,
);
const isHeadless =
isDefined(item.frontComponentId) &&
item.frontComponent?.isHeadless === true;
const isEngineCommand =
isDefined(item.engineComponentKey) && !isDefined(item.frontComponentId);
const isFrontComponent =
isDefined(item.frontComponentId) &&
item.frontComponent?.isHeadless !== true;
const shouldMountCommand = isHeadless || isEngineCommand;
const closeBehavior = shouldMountCommand
? ({
closeSidePanelOnShowPageOptionsExecution: false,
closeSidePanelOnCommandMenuListExecution: false,
} as const)
: ({} as const);
const { closeCommandMenu } = useCloseCommandMenu(closeBehavior);
const disabled = shouldMountCommand ? isMounted : false;
const handleClick = async () => {
if (shouldMountCommand) {
if (isMounted) {
return;
}
closeCommandMenu();
await mountCommand({
engineCommandId: item.id,
contextStoreInstanceId,
engineComponentKey: item.engineComponentKey,
frontComponentId: item.frontComponentId ?? undefined,
workflowVersionId: item.workflowVersionId ?? undefined,
availabilityType: item.availabilityType,
availabilityObjectMetadataId: item.availabilityObjectMetadataId,
payload: item.payload ?? undefined,
});
return;
}
if (isFrontComponent && isDefined(item.frontComponentId)) {
const { selectedRecords, objectMetadataItem } = commandMenuContextApi;
const recordId =
selectedRecords.length === 1 ? selectedRecords[0].id : undefined;
const objectNameSingular = objectMetadataItem.nameSingular as
| string
| undefined;
closeCommandMenu();
openFrontComponentInSidePanel({
frontComponentId: item.frontComponentId,
pageTitle: label,
pageIcon: Icon,
recordContext:
isDefined(recordId) && isDefined(objectNameSingular)
? { recordId, objectNameSingular }
: undefined,
});
}
};
return {
handleClick,
disabled,
progress: shouldMountCommand ? commandMenuItemProgress : undefined,
showDisabledLoader: shouldMountCommand ? isMounted : false,
};
};
@@ -1,7 +1,7 @@
import { isDefined } from 'twenty-shared/utils';
import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState';
import { commandMenuItemsSelector } from '@/command-menu-item/server-items/common/states/commandMenuItemsSelector';
import { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/commandMenuItemsDraftState';
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
@@ -1,17 +0,0 @@
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { type CommandMenuItemConfig } from '@/command-menu-item/types/CommandMenuItemConfig';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const useIsCommandBlockedByGlobalLayoutCustomization = (
commandMenuItemConfig: CommandMenuItemConfig | null,
) => {
const isLayoutCustomizationModeEnabled = useAtomStateValue(
isLayoutCustomizationModeEnabledState,
);
if (!isLayoutCustomizationModeEnabled) {
return false;
}
return !commandMenuItemConfig?.isAllowedDuringGlobalLayoutCustomization;
};
@@ -1,98 +1,81 @@
import { msg } from '@lingui/core/macro';
import { CommandMenuItemViewType, AppPath } from 'twenty-shared/types';
import {
IconFileExport,
IconHeart,
IconTrash,
IconUser,
} from 'twenty-ui/display';
CommandMenuItemAvailabilityType,
EngineComponentKey,
type CommandMenuItemFieldsFragment,
} from '~/generated-metadata/graphql';
import { Command } from '@/command-menu-item/display/components/Command';
import { CommandLink } from '@/command-menu-item/display/components/CommandLink';
import { type CommandMenuItemConfig } from '@/command-menu-item/types/CommandMenuItemConfig';
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { EngineComponentKey } from '~/generated-metadata/graphql';
export const createMockCommandMenuItems = ({
deleteMock = () => {},
addToFavoritesMock = () => {},
exportMock = () => {},
}: {
deleteMock?: () => void;
addToFavoritesMock?: () => void;
exportMock?: () => void;
}): CommandMenuItemConfig[] => [
{
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
key: EngineComponentKey.ADD_TO_FAVORITES,
label: msg`Add to favorites`,
shortLabel: msg`Add to favorites`,
position: 2,
isPinned: true,
Icon: IconHeart,
shouldBeRegistered: () => true,
availableOn: [
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
CommandMenuItemViewType.SHOW_PAGE,
],
component: <Command onClick={addToFavoritesMock} />,
},
{
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
key: EngineComponentKey.EXPORT_FROM_RECORD_INDEX,
label: msg`Export`,
shortLabel: msg`Export`,
position: 4,
Icon: IconFileExport,
accent: 'default',
isPinned: false,
shouldBeRegistered: () => true,
availableOn: [CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION],
component: <Command onClick={exportMock} />,
},
{
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
key: EngineComponentKey.DELETE_SINGLE_RECORD,
label: msg`Delete`,
shortLabel: msg`Delete`,
position: 7,
Icon: IconTrash,
accent: 'default',
isPinned: true,
shouldBeRegistered: () => true,
availableOn: [
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
CommandMenuItemViewType.SHOW_PAGE,
],
component: <Command onClick={deleteMock} />,
},
{
type: CommandMenuItemType.Navigation,
scope: CommandMenuItemScope.Global,
key: EngineComponentKey.GO_TO_PEOPLE,
label: msg`Go to People`,
shortLabel: msg`People`,
position: 19,
Icon: IconUser,
isPinned: false,
availableOn: [
CommandMenuItemViewType.INDEX_PAGE_NO_SELECTION,
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
CommandMenuItemViewType.INDEX_PAGE_BULK_SELECTION,
CommandMenuItemViewType.SHOW_PAGE,
],
shouldBeRegistered: () => true,
component: (
<CommandLink
to={AppPath.RecordIndexPage}
params={{ objectNamePlural: CoreObjectNamePlural.Person }}
/>
),
hotKeys: ['G', 'P'],
},
];
export const createMockCommandMenuItems =
(): CommandMenuItemFieldsFragment[] => [
{
__typename: 'CommandMenuItem',
id: 'mock-add-to-favorites',
workflowVersionId: null,
frontComponentId: null,
frontComponent: null,
engineComponentKey: EngineComponentKey.ADD_TO_FAVORITES,
label: 'Add to favorites',
icon: 'IconHeart',
shortLabel: 'Add to favorites',
position: 2,
isPinned: true,
hotKeys: null,
conditionalAvailabilityExpression: null,
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
availabilityObjectMetadataId: null,
payload: null,
},
{
__typename: 'CommandMenuItem',
id: 'mock-export',
workflowVersionId: null,
frontComponentId: null,
frontComponent: null,
engineComponentKey: EngineComponentKey.EXPORT_FROM_RECORD_INDEX,
label: 'Export',
icon: 'IconFileExport',
shortLabel: 'Export',
position: 4,
isPinned: false,
hotKeys: null,
conditionalAvailabilityExpression: null,
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
availabilityObjectMetadataId: null,
payload: null,
},
{
__typename: 'CommandMenuItem',
id: 'mock-delete',
workflowVersionId: null,
frontComponentId: null,
frontComponent: null,
engineComponentKey: EngineComponentKey.DELETE_SINGLE_RECORD,
label: 'Delete',
icon: 'IconTrash',
shortLabel: 'Delete',
position: 7,
isPinned: true,
hotKeys: null,
conditionalAvailabilityExpression: null,
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
availabilityObjectMetadataId: null,
payload: null,
},
{
__typename: 'CommandMenuItem',
id: 'mock-go-to-people',
workflowVersionId: null,
frontComponentId: null,
frontComponent: null,
engineComponentKey: EngineComponentKey.GO_TO_PEOPLE,
label: 'Go to People',
icon: 'IconUser',
shortLabel: 'People',
position: 19,
isPinned: false,
hotKeys: ['G', 'P'],
conditionalAvailabilityExpression: null,
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
availabilityObjectMetadataId: null,
payload: null,
},
];
@@ -1,68 +0,0 @@
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/server-items/common/hooks/useCommandMenuContextApi';
import { CommandMenuContextProviderServerItemsContent } from './CommandMenuContextProviderServerItemsContent';
import { CommandMenuContextProviderServerItemsWithWorkflowEnrichment } from './CommandMenuContextProviderServerItemsWithWorkflowEnrichment';
type CommandMenuContextProviderServerItemsProps = {
isInSidePanel: CommandMenuContextType['isInSidePanel'];
displayType: CommandMenuContextType['displayType'];
containerType: CommandMenuContextType['containerType'];
children: React.ReactNode;
};
export const CommandMenuContextProviderServerItems = ({
isInSidePanel,
displayType,
containerType,
children,
}: CommandMenuContextProviderServerItemsProps) => {
const commandMenuContextApiFromHook = useCommandMenuContextApi();
// SidePanelRecordPage shadows the outer ContextStore provider with a
// per-page instance ID, so useCommandMenuContextApi derives isInSidePanel
// as false. The explicit prop from the caller is the source of truth.
const commandMenuContextApi = isInSidePanel
? { ...commandMenuContextApiFromHook, isInSidePanel: true as const }
: commandMenuContextApiFromHook;
const currentObjectNameSingular =
commandMenuContextApi.objectMetadataItem.nameSingular;
const isWorkflow =
currentObjectNameSingular === CoreObjectNameSingular.Workflow;
const selectedWorkflowRecordIds = isWorkflow
? commandMenuContextApi.selectedRecords
.map((record) => record.id)
.filter(isDefined)
: [];
if (selectedWorkflowRecordIds.length > 0) {
return (
<CommandMenuContextProviderServerItemsWithWorkflowEnrichment
isInSidePanel={isInSidePanel}
displayType={displayType}
containerType={containerType}
commandMenuContextApi={commandMenuContextApi}
selectedWorkflowRecordIds={selectedWorkflowRecordIds}
>
{children}
</CommandMenuContextProviderServerItemsWithWorkflowEnrichment>
);
}
return (
<CommandMenuContextProviderServerItemsContent
isInSidePanel={isInSidePanel}
displayType={displayType}
containerType={containerType}
commandMenuContextApi={commandMenuContextApi}
>
{children}
</CommandMenuContextProviderServerItemsContent>
);
};
@@ -1,40 +0,0 @@
import {
CommandMenuContext,
type CommandMenuContextType,
} from '@/command-menu-item/contexts/CommandMenuContext';
import { useCommandMenuItemsFromBackend } from '@/command-menu-item/server-items/common/hooks/useCommandMenuItemsFromBackend';
import { type CommandMenuContextApi } from 'twenty-shared/types';
type CommandMenuContextProviderServerItemsContentProps = {
isInSidePanel: CommandMenuContextType['isInSidePanel'];
displayType: CommandMenuContextType['displayType'];
containerType: CommandMenuContextType['containerType'];
children: React.ReactNode;
};
export const CommandMenuContextProviderServerItemsContent = ({
isInSidePanel,
displayType,
containerType,
children,
commandMenuContextApi,
}: CommandMenuContextProviderServerItemsContentProps & {
commandMenuContextApi: CommandMenuContextApi;
}) => {
const commandMenuItems = useCommandMenuItemsFromBackend(
commandMenuContextApi,
);
return (
<CommandMenuContext.Provider
value={{
isInSidePanel,
displayType,
containerType,
commandMenuItems,
}}
>
{children}
</CommandMenuContext.Provider>
);
};
@@ -1,154 +0,0 @@
import { useCommandMenuItemsFromBackend } from '@/command-menu-item/server-items/common/hooks/useCommandMenuItemsFromBackend';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { renderHook } from '@testing-library/react';
import {
CommandMenuContextApiPageType,
type CommandMenuContextApi,
} from 'twenty-shared/types';
import { Icon123 } from 'twenty-ui/display';
import {
CommandMenuItemAvailabilityType,
type CommandMenuItemFieldsFragment,
EngineComponentKey,
} from '~/generated-metadata/graphql';
jest.mock('@/ui/utilities/state/jotai/hooks/useAtomStateValue');
jest.mock('twenty-ui/display', () => ({
...jest.requireActual('twenty-ui/display'),
useIcons: () => ({
getIcon: () => Icon123,
}),
}));
jest.mock('twenty-shared/utils', () => {
const actual = jest.requireActual('twenty-shared/utils');
return {
...actual,
interpolateCommandMenuItemTemplate: jest.fn(
({ label }: { label?: string | null }) => label ?? null,
),
evaluateConditionalAvailabilityExpression: jest.fn(
(expression?: string | null) => expression !== 'hide',
),
};
});
const mockedUseAtomStateValue = jest.mocked(useAtomStateValue);
const getCommandMenuContextApi = (
numberOfSelectedRecords: number,
): CommandMenuContextApi => ({
pageType: CommandMenuContextApiPageType.INDEX_PAGE,
isInSidePanel: false,
isPageInEditMode: false,
favoriteRecordIds: [],
isSelectAll: false,
hasAnySoftDeleteFilterOnView: false,
objectMetadataItem: {
id: 'company-id',
},
objectMetadataLabel: 'Company',
numberOfSelectedRecords,
objectPermissions: {
objectMetadataId: 'company-id',
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
selectedRecords: [],
featureFlags: {},
targetObjectReadPermissions: {},
targetObjectWritePermissions: {},
});
const buildCommandMenuItem = ({
id,
position,
availabilityType,
expression,
isPinned = true,
}: {
id: string;
position: number;
availabilityType: CommandMenuItemAvailabilityType;
expression?: string;
isPinned?: boolean;
}): CommandMenuItemFieldsFragment => ({
id,
label: id,
shortLabel: `${id}-short`,
icon: 'Icon123',
position,
isPinned,
hotKeys: null,
engineComponentKey: EngineComponentKey.CREATE_NEW_RECORD,
frontComponentId: null,
workflowVersionId: null,
availabilityType,
availabilityObjectMetadataId: 'company-id',
conditionalAvailabilityExpression: expression ?? null,
});
describe('useCommandMenuItemsFromBackend', () => {
it('filters out items hidden by conditional availability expression', () => {
mockedUseAtomStateValue.mockReturnValue([
buildCommandMenuItem({
id: 'global-visible',
position: 1,
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
}),
buildCommandMenuItem({
id: 'global-hidden',
position: 2,
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
expression: 'hide',
}),
buildCommandMenuItem({
id: 'record-visible',
position: 3,
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
}),
]);
const { result } = renderHook(() =>
useCommandMenuItemsFromBackend(getCommandMenuContextApi(0)),
);
expect(result.current.map((item) => item.id)).toEqual(['global-visible']);
});
it('maps global, record selection and fallback items to V2 output', () => {
mockedUseAtomStateValue.mockReturnValue([
buildCommandMenuItem({
id: 'global-item',
position: 2,
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
}),
buildCommandMenuItem({
id: 'record-item',
position: 1,
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
}),
buildCommandMenuItem({
id: 'fallback-item',
position: 3,
availabilityType: CommandMenuItemAvailabilityType.FALLBACK,
}),
]);
const { result } = renderHook(() =>
useCommandMenuItemsFromBackend(getCommandMenuContextApi(1)),
);
expect(result.current.map((item) => item.id)).toEqual([
'record-item',
'global-item',
'fallback-item',
]);
expect(result.current[2].isPinned).toBe(false);
});
});
@@ -1,242 +0,0 @@
import { FrontComponentCommandMenuItem } from '@/command-menu-item/display/components/FrontComponentCommandMenuItem';
import { HeadlessCommandMenuItem } from '@/command-menu-item/display/components/HeadlessCommandMenuItem';
import { commandMenuItemsSelector } from '@/command-menu-item/server-items/common/states/commandMenuItemsSelector';
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/server-items/common/utils/doesCommandMenuItemMatchObjectMetadataId';
import { type CommandMenuItemConfig } from '@/command-menu-item/types/CommandMenuItemConfig';
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
import { type CommandMenuContextApi } from 'twenty-shared/types';
import {
evaluateConditionalAvailabilityExpression,
interpolateCommandMenuItemTemplate,
isDefined,
} from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { COMMAND_MENU_DEFAULT_ICON } from '@/workflow/workflow-trigger/constants/CommandMenuDefaultIcon';
import {
CommandMenuItemAvailabilityType,
type CommandMenuItemFieldsFragment,
} from '~/generated-metadata/graphql';
type CommandMenuItemWithFrontComponent = CommandMenuItemFieldsFragment & {
frontComponentId: string;
conditionalAvailabilityExpression?: string | null;
};
type CommandMenuItemWithSource = CommandMenuItemFieldsFragment & {
conditionalAvailabilityExpression?: string | null;
};
type BuildCommandMenuItemFromFrontComponentParams = {
item: CommandMenuItemWithFrontComponent;
type?: CommandMenuItemType;
scope: CommandMenuItemScope;
isPinned: boolean;
getIcon: ReturnType<typeof useIcons>['getIcon'];
commandMenuContextApi: CommandMenuContextApi;
};
const buildCommandMenuItemFromFrontComponent = ({
item,
type = CommandMenuItemType.FrontComponent,
scope,
isPinned,
getIcon,
commandMenuContextApi,
}: BuildCommandMenuItemFromFrontComponentParams): CommandMenuItemConfig => {
const displayLabel = interpolateCommandMenuItemTemplate({
label: item.label,
context: commandMenuContextApi,
});
const displayShortLabel = interpolateCommandMenuItemTemplate({
label: item.shortLabel,
context: commandMenuContextApi,
});
const interpolatedIcon = interpolateCommandMenuItemTemplate({
label: item.icon,
context: commandMenuContextApi,
});
const Icon = getIcon(interpolatedIcon, COMMAND_MENU_DEFAULT_ICON);
const isHeadless = item.frontComponent?.isHeadless === true;
return {
type,
key: `command-menu-item-front-component-${item.id}`,
id: item.id,
scope,
label: displayLabel,
shortLabel: displayShortLabel,
position: item.position,
isPinned,
Icon,
hotKeys: item.hotKeys,
component: isHeadless ? (
<HeadlessCommandMenuItem item={item} />
) : (
<FrontComponentCommandMenuItem frontComponentId={item.frontComponentId} />
),
};
};
type BuildCommandMenuItemFromStandardKeyParams = {
item: CommandMenuItemWithSource;
type?: CommandMenuItemType;
scope: CommandMenuItemScope;
isPinned: boolean;
getIcon: ReturnType<typeof useIcons>['getIcon'];
commandMenuContextApi: CommandMenuContextApi;
};
const buildCommandItemFromEngineKey = ({
item,
type = CommandMenuItemType.Standard,
scope,
isPinned,
getIcon,
commandMenuContextApi,
}: BuildCommandMenuItemFromStandardKeyParams): CommandMenuItemConfig => {
const interpolatedIcon = interpolateCommandMenuItemTemplate({
label: item.icon,
context: commandMenuContextApi,
});
const Icon = getIcon(interpolatedIcon, COMMAND_MENU_DEFAULT_ICON);
return {
type,
key: `command-menu-item-engine-${item.id}`,
id: item.id,
scope,
label: interpolateCommandMenuItemTemplate({
label: item.label,
context: commandMenuContextApi,
}),
shortLabel: interpolateCommandMenuItemTemplate({
label: item.shortLabel,
context: commandMenuContextApi,
}),
position: item.position,
isPinned,
Icon,
hotKeys: item.hotKeys,
component: <HeadlessCommandMenuItem item={item} />,
};
};
export const useCommandMenuItemsFromBackend = (
commandMenuContextApi: CommandMenuContextApi,
): CommandMenuItemConfig[] => {
const { getIcon } = useIcons();
const currentObjectMetadataItemId =
commandMenuContextApi.objectMetadataItem.id;
const hasRecordSelection = commandMenuContextApi.numberOfSelectedRecords >= 1;
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
const itemsWithObjectMatches = commandMenuItems.filter(
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
);
const availableItems = itemsWithObjectMatches.filter((item) =>
evaluateConditionalAvailabilityExpression(
item.conditionalAvailabilityExpression,
commandMenuContextApi,
),
);
const buildCommandMenuItem = ({
item,
scope,
isPinned,
typeOverride,
}: {
item: CommandMenuItemFieldsFragment;
scope: CommandMenuItemScope;
isPinned: boolean;
typeOverride?: CommandMenuItemType;
}): CommandMenuItemConfig | null => {
if (isDefined(item.frontComponentId)) {
return buildCommandMenuItemFromFrontComponent({
item: item as CommandMenuItemWithFrontComponent,
type: typeOverride,
scope,
isPinned,
getIcon,
commandMenuContextApi,
});
}
if (isDefined(item.engineComponentKey)) {
return buildCommandItemFromEngineKey({
item,
type: typeOverride,
scope,
isPinned,
getIcon,
commandMenuContextApi,
});
}
return null;
};
const globalItems = availableItems.filter(
(item) => item.availabilityType === CommandMenuItemAvailabilityType.GLOBAL,
);
const recordScopedItems = availableItems.filter(
(item) =>
item.availabilityType ===
CommandMenuItemAvailabilityType.RECORD_SELECTION,
);
const fallbackItems = availableItems.filter(
(item) =>
item.availabilityType === CommandMenuItemAvailabilityType.FALLBACK,
);
const globalCommandMenuItems = globalItems
.map((item) =>
buildCommandMenuItem({
item,
scope: CommandMenuItemScope.Global,
isPinned: item.isPinned,
}),
)
.filter(isDefined);
const recordScopedCommandMenuItems = hasRecordSelection
? recordScopedItems
.map((item) =>
buildCommandMenuItem({
item,
scope: CommandMenuItemScope.RecordSelection,
isPinned: item.isPinned,
}),
)
.filter(isDefined)
: [];
const fallbackCommandMenuItems = fallbackItems
.map((item) =>
buildCommandMenuItem({
item,
scope: CommandMenuItemScope.Global,
isPinned: false,
typeOverride: CommandMenuItemType.Fallback,
}),
)
.filter(isDefined);
return [
...globalCommandMenuItems,
...recordScopedCommandMenuItems,
...fallbackCommandMenuItems,
].sort((a, b) => a.position - b.position);
};
@@ -1,54 +0,0 @@
import { CommandMenuButton } from '@/command-menu/components/CommandMenuButton';
import { type CommandMenuItemConfig } from '@/command-menu-item/types/CommandMenuItemConfig';
import { PINNED_COMMAND_MENU_ITEMS_GAP } from '@/command-menu-item/server-items/display/constants/PinnedCommandMenuItemsGap';
import { NodeDimension } from '@/ui/utilities/dimensions/components/NodeDimension';
import { styled } from '@linaria/react';
type ElementDimensions = {
width: number;
height: number;
};
type PinnedCommandMenuItemsInlineMeasurementsProps = {
pinnedCommandMenuItems: CommandMenuItemConfig[];
onPinnedCommandMenuItemDimensionChange: (
commandMenuItemKey: string,
) => (dimensions: ElementDimensions) => void;
};
const StyledHiddenMeasurementsContainer = styled.div`
display: flex;
gap: ${PINNED_COMMAND_MENU_ITEMS_GAP}px;
pointer-events: none;
position: absolute;
top: -9999px;
visibility: hidden;
`;
export const PinnedCommandMenuItemsInlineMeasurements = ({
pinnedCommandMenuItems,
onPinnedCommandMenuItemDimensionChange,
}: PinnedCommandMenuItemsInlineMeasurementsProps) => {
return (
<StyledHiddenMeasurementsContainer>
{pinnedCommandMenuItems.map((pinnedCommandMenuItem) => (
<NodeDimension
key={pinnedCommandMenuItem.key}
onDimensionChange={onPinnedCommandMenuItemDimensionChange(
pinnedCommandMenuItem.key,
)}
>
<CommandMenuButton
command={{
key: `${pinnedCommandMenuItem.key}-inline-measurement`,
label: pinnedCommandMenuItem.label,
shortLabel: pinnedCommandMenuItem.shortLabel,
Icon: pinnedCommandMenuItem.Icon,
isPrimaryCTA: pinnedCommandMenuItem.isPrimaryCTA,
}}
/>
</NodeDimension>
))}
</StyledHiddenMeasurementsContainer>
);
};
@@ -1,31 +0,0 @@
import { type CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
import { type CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
import { type MessageDescriptor } from '@lingui/core';
import {
type CommandMenuItemViewType,
type Nullable,
} from 'twenty-shared/types';
import { type IconComponent } from 'twenty-ui/display';
import { type MenuItemAccent } from 'twenty-ui/navigation';
import { type PermissionFlagType } from '~/generated-metadata/graphql';
export type CommandMenuItemConfig = {
type: CommandMenuItemType;
scope: CommandMenuItemScope;
key: string;
id?: string;
label: Nullable<MessageDescriptor | string>;
shortLabel?: Nullable<MessageDescriptor | string>;
description?: MessageDescriptor | string;
position: number;
Icon: IconComponent;
isPinned?: boolean;
isPrimaryCTA?: boolean;
accent?: MenuItemAccent;
availableOn?: CommandMenuItemViewType[];
shouldBeRegistered?: () => boolean;
component: React.ReactNode;
hotKeys?: Nullable<string[]>;
requiredPermissionFlag?: PermissionFlagType;
isAllowedDuringGlobalLayoutCustomization?: boolean;
};
@@ -1,6 +0,0 @@
export enum CommandMenuItemScope {
Global = 'Global',
RecordSelection = 'RecordSelection',
CreateRelatedRecord = 'CreateRelatedRecord',
Object = 'Object',
}
@@ -1,7 +0,0 @@
export enum CommandMenuItemType {
Standard = 'Standard',
WorkflowRun = 'WorkflowRun',
FrontComponent = 'FrontComponent',
Fallback = 'Fallback',
Navigation = 'Navigation',
}
@@ -1,7 +1,7 @@
import { getCommandMenuDropdownIdFromCommandMenuId } from '@/command-menu-item/utils/getCommandMenuDropdownIdFromCommandMenuId';
describe('getCommandMenuDropdownIdFromCommandMenuId', () => {
it('should return the correct action menu dropdown id', () => {
it('should return the correct command menu dropdown id', () => {
expect(getCommandMenuDropdownIdFromCommandMenuId('command-menu-id')).toBe(
'command-menu-dropdown-command-menu-id',
);
@@ -1,7 +1,7 @@
import { getCommandMenuIdFromRecordIndexId } from '@/command-menu-item/utils/getCommandMenuIdFromRecordIndexId';
describe('getCommandMenuIdFromRecordIndexId', () => {
it('should return the correct action menu id', () => {
it('should return the correct command menu id', () => {
expect(getCommandMenuIdFromRecordIndexId('record-index-id')).toBe(
'command-menu-record-index-record-index-id',
);
@@ -1,7 +1,7 @@
import { getSidePanelCommandMenuDropdownIdFromCommandMenuId } from '@/command-menu-item/utils/getSidePanelCommandMenuDropdownIdFromCommandMenuId';
describe('getSidePanelCommandMenuDropdownIdFromCommandMenuId', () => {
it('should return the side panel action menu dropdown id', () => {
it('should return the side panel command menu dropdown id', () => {
expect(
getSidePanelCommandMenuDropdownIdFromCommandMenuId('command-menu-id'),
).toBe('side-panel-command-menu-dropdown-command-menu-id');
@@ -0,0 +1,18 @@
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
export const groupCommandMenuItems = (
items: CommandMenuItemFieldsFragment[],
) => {
const pinned: CommandMenuItemFieldsFragment[] = [];
const other: CommandMenuItemFieldsFragment[] = [];
for (const item of items) {
if (item.isPinned) {
pinned.push(item);
} else {
other.push(item);
}
}
return { pinned, other };
};
@@ -1,26 +0,0 @@
import { type IconComponent } from 'twenty-ui/display';
export enum CommandType {
Navigate = 'Navigate',
Create = 'Create',
StandardAction = 'StandardAction',
WorkflowRun = 'WorkflowRun',
Fallback = 'Fallback',
}
export enum CommandScope {
Global = 'Global',
RecordSelection = 'RecordSelection',
Object = 'Object',
}
export type Command = {
id: string;
to?: string;
label: string;
description?: string;
type?: CommandType;
scope?: CommandScope;
Icon?: IconComponent;
hotKeys?: string[];
onCommandClick?: () => void;
};
@@ -4,8 +4,8 @@ import { useCallback } from 'react';
import { SidePanelPages } from 'twenty-shared/types';
import { IconPencil } from 'twenty-ui/display';
import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState';
import { commandMenuItemsSelector } from '@/command-menu-item/server-items/common/states/commandMenuItemsSelector';
import { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/commandMenuItemsDraftState';
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
import { activeCustomizationPageLayoutIdsState } from '@/layout-customization/states/activeCustomizationPageLayoutIdsState';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
@@ -1,4 +1,4 @@
import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState';
import { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/commandMenuItemsDraftState';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { activeCustomizationPageLayoutIdsState } from '@/layout-customization/states/activeCustomizationPageLayoutIdsState';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
@@ -1,4 +1,4 @@
import { useCommandMenuItemsDraftState } from '@/command-menu-item/server-items/common/hooks/useCommandMenuItemsDraftState';
import { useCommandMenuItemsDraftState } from '@/command-menu-item/hooks/useCommandMenuItemsDraftState';
import { activeCustomizationPageLayoutIdsState } from '@/layout-customization/states/activeCustomizationPageLayoutIdsState';
import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout';
import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemsDraftState';
@@ -1,5 +1,5 @@
import { useSaveCommandMenuItemsDraft } from '@/command-menu-item/server-items/edit/hooks/useSaveCommandMenuItemsDraft';
import { useCommandMenuItemsDraftState } from '@/command-menu-item/server-items/common/hooks/useCommandMenuItemsDraftState';
import { useSaveCommandMenuItemsDraft } from '@/command-menu-item/edit/hooks/useSaveCommandMenuItemsDraft';
import { useCommandMenuItemsDraftState } from '@/command-menu-item/hooks/useCommandMenuItemsDraftState';
import { useExitLayoutCustomizationMode } from '@/layout-customization/hooks/useExitLayoutCustomizationMode';
import { activeCustomizationPageLayoutIdsState } from '@/layout-customization/states/activeCustomizationPageLayoutIdsState';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
@@ -42,7 +42,6 @@ export const SidePanelEditFolderPickerSubPage = () => {
onSearchChange={setSearchValue}
>
<SidePanelList
commandGroups={[]}
selectableItemIds={selectableItemIds}
noResults={isEmpty}
noResultsText={noResultsText}
@@ -76,7 +76,7 @@ export const SidePanelEditLinkItemView = ({
};
return (
<SidePanelList commandGroups={[]} selectableItemIds={selectableItemIds}>
<SidePanelList selectableItemIds={selectableItemIds}>
<SidePanelGroup heading={t`Customize`}>
<TextInput
fullWidth
@@ -72,7 +72,7 @@ export const SidePanelEditObjectViewBase = ({
: effectiveColor;
return (
<SidePanelList commandGroups={[]} selectableItemIds={selectableItemIds}>
<SidePanelList selectableItemIds={selectableItemIds}>
{showColorOption &&
isDefined(selectedItem) &&
objectMetadataItem?.isSystem !== true && (
@@ -162,7 +162,6 @@ export const SidePanelNavigationMenuItemEditPage = () => {
case NavigationMenuItemType.FOLDER:
return (
<SidePanelList
commandGroups={[]}
selectableItemIds={[
ADD_ITEM_TO_FOLDER_ACTION_ID,
...getOrganizeActionsSelectableItemIds(false),
@@ -203,7 +202,6 @@ export const SidePanelNavigationMenuItemEditPage = () => {
default:
return (
<SidePanelList
commandGroups={[]}
selectableItemIds={getOrganizeActionsSelectableItemIds(
canMoveToOtherFolder,
)}
@@ -60,7 +60,7 @@ export const SidePanelNewSidebarItemMainMenu = ({
return (
<SidePanelAddToNavigationDroppable>
{({ innerRef, droppableProps, placeholder }) => (
<SidePanelList commandGroups={[]} selectableItemIds={selectableItemIds}>
<SidePanelList selectableItemIds={selectableItemIds}>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<div ref={innerRef} {...droppableProps}>
<SidePanelGroup heading={t`Data`}>
@@ -101,7 +101,6 @@ export const SidePanelNewSidebarItemRecordSubPage = () => {
<SidePanelAddToNavigationDroppable>
{({ innerRef, droppableProps, placeholder }) => (
<SidePanelList
commandGroups={[]}
selectableItemIds={selectableItemIds}
loading={recordSearchLoading}
noResults={isEmpty}
@@ -48,7 +48,6 @@ export const SidePanelNewSidebarItemViewObjectPickerSubView = ({
onSearchChange={onSearchChange}
>
<SidePanelList
commandGroups={[]}
selectableItemIds={selectableItemIds}
noResults={isEmpty}
noResultsText={noResultsText}
@@ -98,7 +98,6 @@ export const SidePanelNewSidebarItemViewPickerSubView = ({
<SidePanelAddToNavigationDroppable>
{({ innerRef, droppableProps, placeholder }) => (
<SidePanelList
commandGroups={[]}
selectableItemIds={selectableItemIds}
noResults={isEmpty}
noResultsText={noResultsText}
@@ -47,7 +47,6 @@ export const SidePanelNewSidebarItemViewSystemSubView = ({
onSearchChange={onSearchChange}
>
<SidePanelList
commandGroups={[]}
selectableItemIds={selectableItemIds}
noResults={isEmpty}
noResultsText={noResultsText}
@@ -90,7 +90,6 @@ export const SidePanelObjectPickerSubView = ({
<SidePanelAddToNavigationDroppable>
{({ innerRef, droppableProps, placeholder }) => (
<SidePanelList
commandGroups={[]}
selectableItemIds={selectableItemIds}
noResults={isEmpty}
noResultsText={noResultsText}
@@ -105,7 +104,6 @@ export const SidePanelObjectPickerSubView = ({
</SidePanelAddToNavigationDroppable>
) : (
<SidePanelList
commandGroups={[]}
selectableItemIds={selectableItemIds}
noResults={isEmpty}
noResultsText={noResultsText}
@@ -74,7 +74,6 @@ export const SidePanelSystemObjectPickerSubView = ({
<SidePanelAddToNavigationDroppable>
{({ innerRef, droppableProps, placeholder }) => (
<SidePanelList
commandGroups={[]}
selectableItemIds={selectableItemIds}
noResults={isEmpty}
noResultsText={noResultsText}
@@ -89,7 +88,6 @@ export const SidePanelSystemObjectPickerSubView = ({
</SidePanelAddToNavigationDroppable>
) : (
<SidePanelList
commandGroups={[]}
selectableItemIds={selectableItemIds}
noResults={isEmpty}
noResultsText={noResultsText}
@@ -1,5 +1,5 @@
import { RecordIndexCommandMenu } from '@/command-menu-item/components/RecordIndexCommandMenu';
import { CommandMenuItemMoreActionsButton } from '@/command-menu-item/server-items/display/components/CommandMenuItemMoreActionsButton';
import { SidePanelToggleButton } from '@/side-panel/components/SidePanelToggleButton';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
@@ -77,9 +77,7 @@ export const RecordIndexPageHeader = () => {
{isDefined(contextStoreCurrentViewId) && (
<>
<RecordIndexCommandMenu />
{!isLayoutCustomizationModeEnabled && (
<CommandMenuItemMoreActionsButton />
)}
{!isLayoutCustomizationModeEnabled && <SidePanelToggleButton />}
</>
)}
</PageHeader>
@@ -1,7 +1,6 @@
import { RecordPageSidePanelCommandMenu } from '@/command-menu-item/components/RecordPageSidePanelCommandMenu';
import { RecordShowSidePanelOpenRecordButton } from '@/command-menu-item/components/RecordShowSidePanelOpenRecordButton';
import { InformationBannerDeletedRecord } from '@/information-banner/components/deleted-record/InformationBannerDeletedRecord';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { RecordShowContainerContextStoreTargetedRecordsEffect } from '@/object-record/record-show/components/RecordShowContainerContextStoreTargetedRecordsEffect';
import { RecordShowEffect } from '@/object-record/record-show/components/RecordShowEffect';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
@@ -10,10 +9,11 @@ import { usePageLayoutIdForRecord } from '@/page-layout/hooks/usePageLayoutIdFor
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
import { type TargetRecordIdentifier } from '@/ui/layout/contexts/TargetRecordIdentifier';
import { SidePanelFooter } from '@/ui/layout/side-panel/components/SidePanelFooter';
import { sidePanelWidgetFooterActionsState } from '@/ui/layout/side-panel/states/sidePanelWidgetFooterActionsState';
import { styled } from '@linaria/react';
import { sidePanelWidgetFooterCommandMenuItemsState } from '@/ui/layout/side-panel/states/sidePanelWidgetFooterCommandMenuItemsState';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -58,15 +58,17 @@ export const PageLayoutRecordPageRenderer = ({
targetObjectNameSingular: targetRecordIdentifier.targetObjectNameSingular,
});
const sidePanelWidgetFooterActions = useAtomStateValue(
sidePanelWidgetFooterActionsState,
const sidePanelWidgetFooterCommandMenuItems = useAtomStateValue(
sidePanelWidgetFooterCommandMenuItemsState,
);
const pinnedWidgetActions = sidePanelWidgetFooterActions.filter(
(action) => action.isPinned !== false,
);
const pinnedWidgetCommandMenuItems =
sidePanelWidgetFooterCommandMenuItems.filter(
(commandMenuItem) => commandMenuItem.isPinned !== false,
);
const hasPinnedWidgetActions = pinnedWidgetActions.length > 0;
const hasPinnedWidgetCommandMenuItems =
pinnedWidgetCommandMenuItems.length > 0;
return (
<>
@@ -115,18 +117,20 @@ export const PageLayoutRecordPageRenderer = ({
<SidePanelFooter
actions={[
<RecordPageSidePanelCommandMenu key="options" />,
...(hasPinnedWidgetActions
? pinnedWidgetActions.map((action) => (
...(hasPinnedWidgetCommandMenuItems
? pinnedWidgetCommandMenuItems.map((commandMenuItem) => (
<Button
key={action.key}
key={commandMenuItem.id}
size="small"
variant={action.isPrimaryCTA ? 'primary' : 'secondary'}
accent={action.isPrimaryCTA ? 'blue' : 'default'}
title={action.label}
Icon={action.Icon}
hotkeys={action.hotkeys}
onClick={action.onClick}
disabled={action.disabled}
variant={
commandMenuItem.isPrimaryCTA ? 'primary' : 'secondary'
}
accent={commandMenuItem.isPrimaryCTA ? 'blue' : 'default'}
title={commandMenuItem.label}
Icon={commandMenuItem.Icon}
hotkeys={commandMenuItem.hotkeys}
onClick={commandMenuItem.onClick}
disabled={commandMenuItem.disabled}
/>
))
: [
@@ -1,4 +1,5 @@
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { EMPTY_COMMAND_MENU_CONTEXT_API } from '@/command-menu-item/constants/EmptyCommandMenuContextApi';
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';
@@ -78,7 +79,7 @@ const meta: Meta<typeof UpdateMultipleRecordsContainer> = {
commandMenuItems: [],
containerType: 'index-page-dropdown',
displayType: 'dropdownItem',
isInSidePanel: true,
commandMenuContextApi: EMPTY_COMMAND_MENU_CONTEXT_API,
}}
>
<Story />
@@ -5,8 +5,8 @@ import { EmailComposerFields } from '@/activities/emails/components/EmailCompose
import { useEmailComposerState } from '@/activities/emails/hooks/useEmailComposerState';
import { type ReplyContextReady } from '@/activities/emails/hooks/useReplyContext';
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
import { sidePanelWidgetFooterActionsState } from '@/ui/layout/side-panel/states/sidePanelWidgetFooterActionsState';
import { type SidePanelFooterAction } from '@/ui/layout/side-panel/types/SidePanelFooterAction';
import { sidePanelWidgetFooterCommandMenuItemsState } from '@/ui/layout/side-panel/states/sidePanelWidgetFooterCommandMenuItemsState';
import { type SidePanelFooterCommandMenuItem } from '@/ui/layout/side-panel/types/SidePanelFooterCommandMenuItem';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { t } from '@lingui/core/macro';
@@ -55,15 +55,15 @@ export const EmailThreadComposer = ({
onSent: handleReplySent,
});
const setSidePanelWidgetFooterActions = useSetAtomState(
sidePanelWidgetFooterActionsState,
const setSidePanelWidgetFooterCommandMenuItems = useSetAtomState(
sidePanelWidgetFooterCommandMenuItemsState,
);
const footerActions = useMemo((): SidePanelFooterAction[] => {
const footerActions = useMemo((): SidePanelFooterCommandMenuItem[] => {
if (!isComposerOpen) {
return [
{
key: 'reply',
id: 'reply',
label: t`Reply`,
Icon: IconArrowBackUp,
isPrimaryCTA: true,
@@ -74,14 +74,14 @@ export const EmailThreadComposer = ({
return [
{
key: 'cancel-reply',
id: 'cancel-reply',
label: t`Cancel reply`,
Icon: IconX,
isPinned: false,
onClick: () => setIsComposerOpen(false),
},
{
key: 'send',
id: 'send',
label: t`Send`,
Icon: IconSend,
isPrimaryCTA: true,
@@ -102,10 +102,10 @@ export const EmailThreadComposer = ({
return;
}
setSidePanelWidgetFooterActions(footerActions);
setSidePanelWidgetFooterCommandMenuItems(footerActions);
return () => setSidePanelWidgetFooterActions([]);
}, [isInSidePanel, footerActions, setSidePanelWidgetFooterActions]);
return () => setSidePanelWidgetFooterCommandMenuItems([]);
}, [isInSidePanel, footerActions, setSidePanelWidgetFooterCommandMenuItems]);
const handleSendHotkey = useCallback(() => {
if (isComposerOpen && composerState.canSend) {
@@ -1,12 +1,9 @@
import { CommandMenuItemComponent } from '@/command-menu-item/display/components/CommandMenuItemComponent';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelDefaultSelectionEffect } from '@/side-panel/components/SidePanelDefaultSelectionEffect';
import { SIDE_PANEL_SELECTABLE_LIST_ID } from '@/side-panel/constants/SidePanelSelectableListId';
import { SIDE_PANEL_TOP_BAR_HEIGHT } from '@/side-panel/constants/SidePanelTopBarHeight';
import { SIDE_PANEL_LIST_PADDING } from '@/side-panel/constants/SidePanelListPadding';
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
import { hasUserSelectedSidePanelListItemState } from '@/side-panel/states/hasUserSelectedSidePanelListItemState';
import { type SidePanelCommandMenuItemGroupConfig } from '@/side-panel/types/SidePanelCommandMenuItemGroupConfig';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
@@ -15,9 +12,8 @@ import { t } from '@lingui/core/macro';
import { MOBILE_VIEWPORT, themeCssVariables } from 'twenty-ui/theme-constants';
export type SidePanelListProps = {
commandGroups: SidePanelCommandMenuItemGroupConfig[];
selectableItemIds: string[];
children?: React.ReactNode;
children: React.ReactNode;
loading?: boolean;
noResults?: boolean;
noResultsText?: string;
@@ -54,7 +50,6 @@ const StyledEmpty = styled.div`
`;
export const SidePanelList = ({
commandGroups,
selectableItemIds,
children,
loading = false,
@@ -79,15 +74,6 @@ export const SidePanelList = ({
}}
>
{children}
{commandGroups.map(({ heading, items }) =>
items?.length ? (
<SidePanelGroup heading={heading} key={heading}>
{items.map((item) => (
<CommandMenuItemComponent action={item} key={item.key} />
))}
</SidePanelGroup>
) : null,
)}
{noResults && !loading && (
<StyledEmpty>{noResultsText ?? t`No results found`}</StyledEmpty>
)}

Some files were not shown because too many files have changed in this diff Show More