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:
committed by
GitHub
parent
cc4659ce11
commit
454758471f
+194
@@ -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();
|
||||
});
|
||||
});
|
||||
+113
-11
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user