feat: fix Command Menu Side Panel Layout (#15883)
[Figma Design](https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=81380-344641&t=FpjWNOK2gZuDQQfr-0) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds a side panel layout for the Command Menu, routes modals into a local container, updates the top bar and context chips, and standardizes small button sizes. > > - **Command Menu**: > - **Side Panel Layout**: Introduces `CommandMenuSidePanelLayout` with animated width, hosts `CommandMenuRouter`, and provides a modal container via `ModalContainerContext`. > - **Top Bar**: Redesign (`CommandMenuTopBar`) with back icon, optional AI sparkles action, compact height (`COMMAND_MENU_SEARCH_BAR_HEIGHT=40`), and updated placeholder. > - **Context Chips**: Adds `CommandMenuLastContextChip` and `CommandMenuRecordInfo`; extends `CommandMenuContextChip` with `page` prop; updates `CommandMenuContextChipGroups` to render last chip as record info when applicable. > - **Container Simplification**: `CommandMenuContainer` simplified to just provide contexts and `AgentChatProvider`. > - **Modal System**: > - Adds `ModalContainerContext` and updates `Modal` to portal into provided container; `Modal.Backdrop` supports `isInContainer`. > - Updates usages (e.g., `UserOrMetadataLoader`, `ActionModal`) to align with new modal behavior. > - **Page Integration**: > - Replaces `PageBody` with `CommandMenuSidePanelLayout` in `RecordShowPage` and `RecordIndexContainerGater`. > - Removes global `CommandMenuRouter` from `DefaultLayout` (keeps keyboard shortcuts). > - **UI/Styling**: > - Standardizes several buttons to `size="small"` (e.g., command actions, open record, options, reply, workflow footer). > - Adjusts `ShowPageSubContainer` styling when rendered inside command menu. > - Storybook tests updated for new placeholder text. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 81fcaa145618a2fa1c3e11e2dc88fe832c51374c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com> Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com> Co-authored-by: Devessier <baptiste@devessier.fr> Co-authored-by: Aman Raj <92664006+araj00@users.noreply.github.com> Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
This commit is contained in:
+43
@@ -8,6 +8,8 @@ import { commandMenuNavigationStackState } from '@/command-menu/states/commandMe
|
||||
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
|
||||
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
|
||||
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
|
||||
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
|
||||
import { IconDotsVertical } from 'twenty-ui/display';
|
||||
|
||||
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<RecoilRoot>
|
||||
@@ -84,4 +86,45 @@ describe('useCommandMenu', () => {
|
||||
|
||||
expect(result.current.isCommandMenuOpened).toBe(false);
|
||||
});
|
||||
|
||||
it('should navigate command menu and reset navigation stack when resetNavigationStack is true', () => {
|
||||
const { result } = renderHooks();
|
||||
|
||||
act(() => {
|
||||
result.current.commandMenu.navigateCommandMenu({
|
||||
page: CommandMenuPages.Root,
|
||||
pageTitle: 'First Page',
|
||||
pageIcon: IconDotsVertical,
|
||||
resetNavigationStack: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.commandMenuPage).toBe(CommandMenuPages.Root);
|
||||
expect(result.current.commandMenuPageInfo.title).toBe('First Page');
|
||||
expect(result.current.commandMenuNavigationStack).toHaveLength(1);
|
||||
|
||||
act(() => {
|
||||
result.current.commandMenu.navigateCommandMenu({
|
||||
page: CommandMenuPages.SearchRecords,
|
||||
pageTitle: 'Second Page',
|
||||
pageIcon: IconDotsVertical,
|
||||
resetNavigationStack: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.commandMenuNavigationStack).toHaveLength(2);
|
||||
|
||||
act(() => {
|
||||
result.current.commandMenu.navigateCommandMenu({
|
||||
page: CommandMenuPages.Root,
|
||||
pageTitle: 'Reset Page',
|
||||
pageIcon: IconDotsVertical,
|
||||
resetNavigationStack: true,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.commandMenuPage).toBe(CommandMenuPages.Root);
|
||||
expect(result.current.commandMenuPageInfo.title).toBe('Reset Page');
|
||||
expect(result.current.commandMenuNavigationStack).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import { type ActionConfig } from '@/action-menu/actions/types/ActionConfig';
|
||||
import { ActionScope } from '@/action-menu/actions/types/ActionScope';
|
||||
import { ActionType } from '@/action-menu/actions/types/ActionType';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { IconPlus } from 'twenty-ui/display';
|
||||
import { useFilterActionsWithCommandMenuSearch } from '../useFilterActionsWithCommandMenuSearch';
|
||||
|
||||
const MockComponent = <div>Mock Component</div>;
|
||||
|
||||
describe('useFilterActionsWithCommandMenuSearch', () => {
|
||||
const mockActions: ActionConfig[] = [
|
||||
{
|
||||
key: 'action-1',
|
||||
label: 'Create Record',
|
||||
type: ActionType.Standard,
|
||||
scope: ActionScope.Global,
|
||||
position: 1,
|
||||
Icon: IconPlus,
|
||||
shouldBeRegistered: () => true,
|
||||
component: MockComponent,
|
||||
hotKeys: ['c', 'r'],
|
||||
},
|
||||
{
|
||||
key: 'action-2',
|
||||
label: 'Delete Record',
|
||||
type: ActionType.Standard,
|
||||
scope: ActionScope.RecordSelection,
|
||||
position: 2,
|
||||
Icon: IconPlus,
|
||||
shouldBeRegistered: () => true,
|
||||
component: MockComponent,
|
||||
hotKeys: ['d', 'e', 'l'],
|
||||
},
|
||||
{
|
||||
key: 'action-3',
|
||||
label: 'Update Record',
|
||||
type: ActionType.Standard,
|
||||
scope: ActionScope.Object,
|
||||
position: 3,
|
||||
Icon: IconPlus,
|
||||
shouldBeRegistered: () => true,
|
||||
component: MockComponent,
|
||||
},
|
||||
];
|
||||
|
||||
it('should return all actions when search is empty', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFilterActionsWithCommandMenuSearch({ commandMenuSearch: '' }),
|
||||
);
|
||||
|
||||
const filtered =
|
||||
result.current.filterActionsWithCommandMenuSearch(mockActions);
|
||||
|
||||
expect(filtered).toEqual(mockActions);
|
||||
});
|
||||
|
||||
it('should filter actions by label', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFilterActionsWithCommandMenuSearch({ commandMenuSearch: 'Create' }),
|
||||
);
|
||||
|
||||
const filtered =
|
||||
result.current.filterActionsWithCommandMenuSearch(mockActions);
|
||||
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].key).toBe('action-1');
|
||||
});
|
||||
|
||||
it('should filter actions by hotkeys', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFilterActionsWithCommandMenuSearch({ commandMenuSearch: 'del' }),
|
||||
);
|
||||
|
||||
const filtered =
|
||||
result.current.filterActionsWithCommandMenuSearch(mockActions);
|
||||
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].key).toBe('action-2');
|
||||
});
|
||||
|
||||
it('should return empty array when no actions match', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFilterActionsWithCommandMenuSearch({ commandMenuSearch: 'xyz' }),
|
||||
);
|
||||
|
||||
const filtered =
|
||||
result.current.filterActionsWithCommandMenuSearch(mockActions);
|
||||
|
||||
expect(filtered).toEqual([]);
|
||||
});
|
||||
|
||||
it('should match actions by either label or hotkeys', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFilterActionsWithCommandMenuSearch({ commandMenuSearch: 'Record' }),
|
||||
);
|
||||
|
||||
const filtered =
|
||||
result.current.filterActionsWithCommandMenuSearch(mockActions);
|
||||
|
||||
expect(filtered).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -69,7 +69,7 @@ export const useCommandMenuHotKeys = () => {
|
||||
keys: ['@'],
|
||||
callback: () => {
|
||||
if (isAiEnabled) {
|
||||
openAskAIPage();
|
||||
openAskAIPage({ resetNavigationStack: true });
|
||||
}
|
||||
},
|
||||
containsModifier: false,
|
||||
|
||||
+16
-1
@@ -1,18 +1,33 @@
|
||||
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
|
||||
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
|
||||
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { IconSparkles } from 'twenty-ui/display';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
export const useOpenAskAIPageInCommandMenu = () => {
|
||||
const { navigateCommandMenu } = useCommandMenu();
|
||||
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
|
||||
|
||||
const openAskAIPage = ({
|
||||
pageTitle,
|
||||
resetNavigationStack,
|
||||
}: {
|
||||
pageTitle?: string | null;
|
||||
resetNavigationStack?: boolean;
|
||||
} = {}) => {
|
||||
const shouldReset =
|
||||
resetNavigationStack !== undefined
|
||||
? resetNavigationStack
|
||||
: isCommandMenuOpened;
|
||||
|
||||
const openAskAIPage = (pageTitle?: string | null) => {
|
||||
navigateCommandMenu({
|
||||
page: CommandMenuPages.AskAI,
|
||||
pageTitle: pageTitle ?? t`Ask AI`,
|
||||
pageIcon: IconSparkles,
|
||||
pageId: v4(),
|
||||
resetNavigationStack: shouldReset,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+4
@@ -1,11 +1,14 @@
|
||||
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
|
||||
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
|
||||
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { v4 } from 'uuid';
|
||||
import { IconSearch } from 'twenty-ui/display';
|
||||
|
||||
export const useOpenRecordsSearchPageInCommandMenu = () => {
|
||||
const { navigateCommandMenu } = useCommandMenu();
|
||||
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
|
||||
|
||||
const openRecordsSearchPage = () => {
|
||||
navigateCommandMenu({
|
||||
@@ -13,6 +16,7 @@ export const useOpenRecordsSearchPageInCommandMenu = () => {
|
||||
pageTitle: t`Search`,
|
||||
pageIcon: IconSearch,
|
||||
pageId: v4(),
|
||||
resetNavigationStack: isCommandMenuOpened,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
|
||||
import { commandMenuWorkflowIdComponentState } from '@/command-menu/pages/workflow/states/commandMenuWorkflowIdComponentState';
|
||||
import { commandMenuWorkflowRunIdComponentState } from '@/command-menu/pages/workflow/states/commandMenuWorkflowRunIdComponentState';
|
||||
import { commandMenuWorkflowStepIdComponentState } from '@/command-menu/pages/workflow/states/commandMenuWorkflowStepIdComponentState';
|
||||
import { commandMenuWorkflowVersionIdComponentState } from '@/command-menu/pages/workflow/states/commandMenuWorkflowVersionIdComponentState';
|
||||
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
|
||||
import { type WorkflowRunStepStatus } from '@/workflow/types/Workflow';
|
||||
import { useSetInitialWorkflowRunRightDrawerTab } from '@/workflow/workflow-diagram/hooks/useSetInitialWorkflowRunRightDrawerTab';
|
||||
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
IconBolt,
|
||||
type IconComponent,
|
||||
@@ -67,7 +70,12 @@ export const useWorkflowCommandMenu = () => {
|
||||
|
||||
const openWorkflowEditStepInCommandMenu = useRecoilCallback(
|
||||
({ set }) => {
|
||||
return (workflowId: string, title: string, icon: IconComponent) => {
|
||||
return (
|
||||
workflowId: string,
|
||||
title: string,
|
||||
icon: IconComponent,
|
||||
stepId?: string,
|
||||
) => {
|
||||
const pageId = v4();
|
||||
|
||||
set(
|
||||
@@ -77,6 +85,22 @@ export const useWorkflowCommandMenu = () => {
|
||||
workflowId,
|
||||
);
|
||||
|
||||
if (isDefined(stepId)) {
|
||||
set(
|
||||
commandMenuWorkflowStepIdComponentState.atomFamily({
|
||||
instanceId: pageId,
|
||||
}),
|
||||
stepId,
|
||||
);
|
||||
|
||||
set(
|
||||
workflowSelectedNodeComponentState.atomFamily({
|
||||
instanceId: workflowId,
|
||||
}),
|
||||
stepId,
|
||||
);
|
||||
}
|
||||
|
||||
navigateCommandMenu({
|
||||
page: CommandMenuPages.WorkflowStepEdit,
|
||||
pageTitle: title,
|
||||
@@ -118,11 +142,13 @@ export const useWorkflowCommandMenu = () => {
|
||||
workflowVersionId,
|
||||
title,
|
||||
icon,
|
||||
stepId,
|
||||
}: {
|
||||
workflowId: string;
|
||||
workflowVersionId: string;
|
||||
title: string;
|
||||
icon: IconComponent;
|
||||
stepId?: string;
|
||||
}) => {
|
||||
const pageId = v4();
|
||||
|
||||
@@ -139,6 +165,22 @@ export const useWorkflowCommandMenu = () => {
|
||||
workflowVersionId,
|
||||
);
|
||||
|
||||
if (isDefined(stepId)) {
|
||||
set(
|
||||
commandMenuWorkflowStepIdComponentState.atomFamily({
|
||||
instanceId: pageId,
|
||||
}),
|
||||
stepId,
|
||||
);
|
||||
|
||||
set(
|
||||
workflowSelectedNodeComponentState.atomFamily({
|
||||
instanceId: workflowVersionId,
|
||||
}),
|
||||
stepId,
|
||||
);
|
||||
}
|
||||
|
||||
navigateCommandMenu({
|
||||
page: CommandMenuPages.WorkflowStepView,
|
||||
pageTitle: title,
|
||||
@@ -181,6 +223,19 @@ export const useWorkflowCommandMenu = () => {
|
||||
}),
|
||||
workflowRunId,
|
||||
);
|
||||
set(
|
||||
commandMenuWorkflowStepIdComponentState.atomFamily({
|
||||
instanceId: pageId,
|
||||
}),
|
||||
workflowSelectedNode,
|
||||
);
|
||||
|
||||
set(
|
||||
workflowSelectedNodeComponentState.atomFamily({
|
||||
instanceId: workflowRunId,
|
||||
}),
|
||||
workflowSelectedNode,
|
||||
);
|
||||
|
||||
navigateCommandMenu({
|
||||
page: CommandMenuPages.WorkflowRunStepView,
|
||||
|
||||
Reference in New Issue
Block a user