Fix side panel command menu header controls (#21747)

## Summary

Tested the 3 behaviors of the issue locally. Animation is not perfect on
the closing but I think this is a great v1

- Move the side-panel close action to the right side of the top bar
while keeping back navigation on the left.
- Keep the nav side-panel button as the command-menu entry point for
direct side-panel pages and hide it while command-menu pages/history are
active.
- Reset command-menu search/filter state when opening the root command
menu from the nav button.

Fixes twentyhq/core-team-issues#2504

## Videos

### Before


https://github.com/user-attachments/assets/08c1b6b3-5fbd-4154-a85d-5072a3b7690e

### After


https://github.com/user-attachments/assets/11682dea-f21c-47b5-91a8-869f30b09d96


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21747?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Thomas des Francs
2026-06-18 15:30:05 +02:00
committed by GitHub
parent cc4659ce11
commit 454758471f
8 changed files with 373 additions and 168 deletions
@@ -0,0 +1,194 @@
import { i18n } from '@lingui/core';
import { I18nProvider } from '@lingui/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { createStore, Provider as JotaiProvider } from 'jotai';
import { MemoryRouter } from 'react-router-dom';
import { SidePanelToggleButton } from '@/side-panel/components/SidePanelToggleButton';
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
import { sidePanelNavigationStackState } from '@/side-panel/states/sidePanelNavigationStackState';
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
import { sidePanelSearchObjectFilterState } from '@/side-panel/states/sidePanelSearchObjectFilterState';
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
import { PAGE_HEADER_SIDE_PANEL_BUTTON_CLICK_OUTSIDE_ID } from '@/ui/layout/page-header/constants/PageHeaderSidePanelButtonClickOutsideId';
import { SidePanelPages } from 'twenty-shared/types';
import { IconDotsVertical } from 'twenty-ui/icon';
jest.mock('twenty-ui/utilities', () => ({
useIsMobile: () => false,
}));
jest.mock('twenty-ui/surfaces', () => ({
...jest.requireActual('twenty-ui/surfaces'),
AppTooltip: () => null,
}));
const renderSidePanelToggleButton = ({
isSidePanelOpened = false,
sidePanelPage = SidePanelPages.CommandMenuDisplay,
sidePanelNavigationStack = [],
sidePanelSearch = '',
sidePanelSearchObjectFilter = null,
}: {
isSidePanelOpened?: boolean;
sidePanelPage?: SidePanelPages;
sidePanelNavigationStack?: Array<{
page: SidePanelPages;
pageTitle: string;
pageIcon: typeof IconDotsVertical;
pageId: string;
}>;
sidePanelSearch?: string;
sidePanelSearchObjectFilter?: string | null;
} = {}) => {
const store = createStore();
store.set(isSidePanelOpenedState.atom, isSidePanelOpened);
store.set(sidePanelPageState.atom, sidePanelPage);
store.set(sidePanelNavigationStackState.atom, sidePanelNavigationStack);
store.set(sidePanelSearchState.atom, sidePanelSearch);
store.set(sidePanelSearchObjectFilterState.atom, sidePanelSearchObjectFilter);
render(
<I18nProvider i18n={i18n}>
<JotaiProvider store={store}>
<MemoryRouter
future={{
v7_relativeSplatPath: true,
v7_startTransition: true,
}}
>
<SidePanelToggleButton />
</MemoryRouter>
</JotaiProvider>
</I18nProvider>,
);
return { store };
};
describe('SidePanelToggleButton', () => {
it('opens the command menu when the side panel is closed', () => {
const { store } = renderSidePanelToggleButton();
fireEvent.click(screen.getByTestId('page-header-side-panel-button'));
expect(store.get(isSidePanelOpenedState.atom)).toBe(true);
expect(store.get(sidePanelPageState.atom)).toBe(
SidePanelPages.CommandMenuDisplay,
);
expect(store.get(sidePanelNavigationStackState.atom)).toHaveLength(1);
});
it('hides the navbar command menu button while the command menu is open', () => {
renderSidePanelToggleButton({
isSidePanelOpened: true,
sidePanelPage: SidePanelPages.CommandMenuDisplay,
sidePanelNavigationStack: [
{
page: SidePanelPages.CommandMenuDisplay,
pageTitle: 'Command Menu',
pageIcon: IconDotsVertical,
pageId: 'command-menu',
},
],
});
expect(
screen.queryByTestId('page-header-side-panel-button'),
).not.toBeInTheDocument();
});
it('hides the navbar command menu button when the side panel has command-menu history', () => {
renderSidePanelToggleButton({
isSidePanelOpened: true,
sidePanelPage: SidePanelPages.ViewRecord,
sidePanelNavigationStack: [
{
page: SidePanelPages.CommandMenuDisplay,
pageTitle: 'Command Menu',
pageIcon: IconDotsVertical,
pageId: 'command-menu',
},
{
page: SidePanelPages.ViewRecord,
pageTitle: 'Company',
pageIcon: IconDotsVertical,
pageId: 'view-record',
},
],
});
expect(
screen.queryByTestId('page-header-side-panel-button'),
).not.toBeInTheDocument();
});
it('keeps the navbar command menu button on unrelated side-panel drill-down pages', () => {
renderSidePanelToggleButton({
isSidePanelOpened: true,
sidePanelPage: SidePanelPages.WorkflowStepCreate,
sidePanelNavigationStack: [
{
page: SidePanelPages.WorkflowStepEdit,
pageTitle: 'Edit step',
pageIcon: IconDotsVertical,
pageId: 'workflow-step-edit',
},
{
page: SidePanelPages.WorkflowStepCreate,
pageTitle: 'Create step',
pageIcon: IconDotsVertical,
pageId: 'workflow-step-create',
},
],
});
expect(screen.getByTestId('page-header-side-panel-button')).toBeVisible();
});
it('marks the command menu button as a click-outside exclusion', () => {
renderSidePanelToggleButton();
expect(
screen
.getByTestId('page-header-side-panel-button')
.closest('[data-click-outside-id]'),
).toHaveAttribute(
'data-click-outside-id',
PAGE_HEADER_SIDE_PANEL_BUTTON_CLICK_OUTSIDE_ID,
);
});
it('replaces a directly opened side-panel page with the root command menu', () => {
const { store } = renderSidePanelToggleButton({
isSidePanelOpened: true,
sidePanelPage: SidePanelPages.ViewRecord,
sidePanelSearch: 'acme',
sidePanelSearchObjectFilter: 'company',
sidePanelNavigationStack: [
{
page: SidePanelPages.ViewRecord,
pageTitle: 'Company',
pageIcon: IconDotsVertical,
pageId: 'view-record',
},
],
});
fireEvent.click(screen.getByTestId('page-header-side-panel-button'));
expect(store.get(isSidePanelOpenedState.atom)).toBe(true);
expect(store.get(sidePanelPageState.atom)).toBe(
SidePanelPages.CommandMenuDisplay,
);
expect(store.get(sidePanelNavigationStackState.atom)).toMatchObject([
{
page: SidePanelPages.CommandMenuDisplay,
pageTitle: 'Command Menu',
},
]);
expect(store.get(sidePanelSearchState.atom)).toBe('');
expect(store.get(sidePanelSearchObjectFilterState.atom)).toBeNull();
});
});
@@ -36,8 +36,10 @@ jest.mock('@/side-panel/hooks/useSidePanelMenu', () => ({
}),
}));
let mockIsMobile = false;
jest.mock('twenty-ui/utilities', () => ({
useIsMobile: () => true,
useIsMobile: () => mockIsMobile,
}));
const recordIndexFocusItem = {
@@ -52,27 +54,36 @@ const recordIndexFocusItem = {
},
};
const createSidePanelTopBarStore = () => {
const store = createStore();
store.set(isSidePanelOpenedState.atom, true);
store.set(sidePanelPageState.atom, SidePanelPages.CommandMenuDisplay);
store.set(sidePanelNavigationStackState.atom, [
const createSidePanelTopBarStore = ({
sidePanelPage = SidePanelPages.CommandMenuDisplay,
sidePanelNavigationStack = [
{
page: SidePanelPages.CommandMenuDisplay,
pageTitle: 'Command Menu',
pageIcon: IconDotsVertical,
pageId: 'command-menu',
},
]);
],
}: {
sidePanelPage?: SidePanelPages;
sidePanelNavigationStack?: Array<{
page: SidePanelPages;
pageTitle: string;
pageIcon: typeof IconDotsVertical;
pageId: string;
}>;
} = {}) => {
const store = createStore();
store.set(isSidePanelOpenedState.atom, true);
store.set(sidePanelPageState.atom, sidePanelPage);
store.set(sidePanelNavigationStackState.atom, sidePanelNavigationStack);
store.set(focusStackState.atom, [recordIndexFocusItem]);
return store;
};
const renderSidePanelCommandMenu = () => {
const store = createSidePanelTopBarStore();
const renderSidePanelCommandMenu = (store = createSidePanelTopBarStore()) => {
render(
<I18nProvider i18n={i18n}>
<JotaiProvider store={store}>
@@ -93,6 +104,10 @@ const renderSidePanelCommandMenu = () => {
};
describe('SidePanelTopBar', () => {
beforeEach(() => {
mockIsMobile = false;
});
it('keeps the command menu search input focused while arrowing through items', async () => {
const { store } = renderSidePanelCommandMenu();
@@ -134,4 +149,91 @@ describe('SidePanelTopBar', () => {
},
});
});
it('shows only the close button on the root command menu', () => {
renderSidePanelCommandMenu();
expect(
screen.getByRole('button', { name: 'Close side panel' }),
).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Back' }),
).not.toBeInTheDocument();
});
it('does not show the close button on mobile', () => {
mockIsMobile = true;
renderSidePanelCommandMenu();
expect(
screen.queryByRole('button', { name: 'Close side panel' }),
).not.toBeInTheDocument();
});
it('renders the close button after the command menu content', () => {
renderSidePanelCommandMenu();
const input = screen.getByTestId(SIDE_PANEL_FOCUS_ID);
const closeButton = screen.getByRole('button', {
name: 'Close side panel',
});
expect(
Boolean(
input.compareDocumentPosition(closeButton) &
Node.DOCUMENT_POSITION_FOLLOWING,
),
).toBe(true);
});
it('shows both back and close buttons for command menu subpages', () => {
renderSidePanelCommandMenu(
createSidePanelTopBarStore({
sidePanelPage: SidePanelPages.SearchRecords,
sidePanelNavigationStack: [
{
page: SidePanelPages.CommandMenuDisplay,
pageTitle: 'Command Menu',
pageIcon: IconDotsVertical,
pageId: 'command-menu',
},
{
page: SidePanelPages.SearchRecords,
pageTitle: 'Search',
pageIcon: IconDotsVertical,
pageId: 'search-records',
},
],
}),
);
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Close side panel' }),
).toBeInTheDocument();
});
it('shows only the close button when a page was opened directly', () => {
renderSidePanelCommandMenu(
createSidePanelTopBarStore({
sidePanelPage: SidePanelPages.ViewRecord,
sidePanelNavigationStack: [
{
page: SidePanelPages.ViewRecord,
pageTitle: 'Company',
pageIcon: IconDotsVertical,
pageId: 'view-record',
},
],
}),
);
expect(
screen.getByRole('button', { name: 'Close side panel' }),
).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Back' }),
).not.toBeInTheDocument();
});
});