361 create a navigation stack for the command menu (#9995)

Closes https://github.com/twentyhq/core-team-issues/issues/361

- Created navigation stack state
- Created navigation functions inside the `useCommandMenu` hook
- Added tests
This commit is contained in:
Raphaël Bosi
2025-02-04 15:47:43 +01:00
committed by GitHub
parent b2e4d0d04d
commit dfc1bb7c29
5 changed files with 279 additions and 38 deletions

View File

@ -1,25 +1,17 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageTitle';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { useRecoilCallback } from 'recoil';
import { IconSearch } from 'twenty-ui';
export const useSearchRecordsRecordAgnosticAction = () => {
const { openCommandMenu } = useCommandMenu();
const { navigateCommandMenu } = useCommandMenu();
const onClick = useRecoilCallback(
({ set }) =>
() => {
set(commandMenuPageState, CommandMenuPages.SearchRecords);
set(commandMenuPageInfoState, {
title: 'Search',
Icon: IconSearch,
});
openCommandMenu();
},
[openCommandMenu],
);
const onClick = () => {
navigateCommandMenu({
page: CommandMenuPages.SearchRecords,
pageTitle: 'Search',
pageIcon: IconSearch,
});
};
return {
onClick,

View File

@ -4,7 +4,12 @@ import { MemoryRouter } from 'react-router-dom';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageTitle';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { IconSearch } from 'twenty-ui';
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<RecoilRoot>
@ -22,10 +27,18 @@ const renderHooks = () => {
() => {
const commandMenu = useCommandMenu();
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
const commandMenuNavigationStack = useRecoilValue(
commandMenuNavigationStackState,
);
const commandMenuPage = useRecoilValue(commandMenuPageState);
const commandMenuPageInfo = useRecoilValue(commandMenuPageInfoState);
return {
commandMenu,
isCommandMenuOpened,
commandMenuNavigationStack,
commandMenuPage,
commandMenuPageInfo,
};
},
{
@ -69,4 +82,142 @@ describe('useCommandMenu', () => {
expect(result.current.isCommandMenuOpened).toBe(false);
});
it('should navigate to a page', () => {
const { result } = renderHooks();
expect(result.current.commandMenuNavigationStack).toEqual([]);
expect(result.current.commandMenuPage).toBe(CommandMenuPages.Root);
expect(result.current.commandMenuPageInfo).toEqual({
title: undefined,
Icon: undefined,
});
act(() => {
result.current.commandMenu.navigateCommandMenu({
page: CommandMenuPages.SearchRecords,
pageTitle: 'Search',
pageIcon: IconSearch,
});
});
expect(result.current.commandMenuNavigationStack).toEqual([
{
page: CommandMenuPages.SearchRecords,
pageTitle: 'Search',
pageIcon: IconSearch,
},
]);
expect(result.current.commandMenuPage).toBe(CommandMenuPages.SearchRecords);
expect(result.current.commandMenuPageInfo).toEqual({
title: 'Search',
Icon: IconSearch,
});
act(() => {
result.current.commandMenu.navigateCommandMenu({
page: CommandMenuPages.ViewRecord,
pageTitle: 'View Record',
});
});
expect(result.current.commandMenuNavigationStack).toEqual([
{
page: CommandMenuPages.SearchRecords,
pageTitle: 'Search',
pageIcon: IconSearch,
},
{
page: CommandMenuPages.ViewRecord,
pageTitle: 'View Record',
},
]);
expect(result.current.commandMenuPage).toBe(CommandMenuPages.ViewRecord);
expect(result.current.commandMenuPageInfo).toEqual({
title: 'View Record',
Icon: undefined,
});
});
it('should go back from a page', () => {
const { result } = renderHooks();
act(() => {
result.current.commandMenu.navigateCommandMenu({
page: CommandMenuPages.SearchRecords,
pageTitle: 'Search',
pageIcon: IconSearch,
});
});
act(() => {
result.current.commandMenu.navigateCommandMenu({
page: CommandMenuPages.ViewRecord,
pageTitle: 'View Record',
});
});
expect(result.current.commandMenuNavigationStack).toEqual([
{
page: CommandMenuPages.SearchRecords,
pageTitle: 'Search',
pageIcon: IconSearch,
},
{
page: CommandMenuPages.ViewRecord,
pageTitle: 'View Record',
},
]);
act(() => {
result.current.commandMenu.goBackFromCommandMenu();
});
expect(result.current.commandMenuNavigationStack).toEqual([
{
page: CommandMenuPages.SearchRecords,
pageTitle: 'Search',
pageIcon: IconSearch,
},
]);
expect(result.current.commandMenuPage).toBe(CommandMenuPages.SearchRecords);
expect(result.current.commandMenuPageInfo).toEqual({
title: 'Search',
Icon: IconSearch,
});
act(() => {
result.current.commandMenu.goBackFromCommandMenu();
});
expect(result.current.commandMenuNavigationStack).toEqual([]);
expect(result.current.commandMenuPage).toBe(CommandMenuPages.Root);
expect(result.current.commandMenuPageInfo).toEqual({
title: undefined,
Icon: undefined,
});
expect(result.current.isCommandMenuOpened).toBe(false);
});
it('should navigate to a page in history', () => {
const { result } = renderHooks();
act(() => {
result.current.commandMenu.navigateCommandMenu({
page: CommandMenuPages.SearchRecords,
pageTitle: 'Search',
pageIcon: IconSearch,
});
});
act(() => {
result.current.commandMenu.navigateCommandMenuHistory(0);
});
expect(result.current.commandMenuPage).toBe(CommandMenuPages.SearchRecords);
expect(result.current.commandMenuPageInfo).toEqual({
title: 'Search',
Icon: IconSearch,
});
});
});

View File

@ -7,6 +7,10 @@ import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { useCopyContextStoreStates } from '@/command-menu/hooks/useCopyContextStoreAndActionMenuStates';
import { useResetContextStoreStates } from '@/command-menu/hooks/useResetContextStoreStates';
import {
CommandMenuNavigationStackItem,
commandMenuNavigationStackState,
} from '@/command-menu/states/commandMenuNavigationStackState';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageTitle';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
@ -19,6 +23,7 @@ import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType
import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
import { viewableRecordNameSingularState } from '@/object-record/record-right-drawer/states/viewableRecordNameSingularState';
import { emitRightDrawerCloseEvent } from '@/ui/layout/right-drawer/utils/emitRightDrawerCloseEvent';
import { isDefined } from 'twenty-shared';
import { IconSearch } from 'twenty-ui';
import { isCommandMenuOpenedState } from '../states/isCommandMenuOpenedState';
@ -73,6 +78,7 @@ export const useCommandMenu = () => {
});
set(isCommandMenuOpenedState, false);
set(commandMenuSearchState, '');
set(commandMenuNavigationStackState, []);
resetSelectedItem();
goBackToPreviousHotkeyScope();
@ -100,32 +106,108 @@ export const useCommandMenu = () => {
[closeCommandMenu, openCommandMenu],
);
const openRecordInCommandMenu = useRecoilCallback(
({ set }) => {
return (recordId: string, objectNameSingular: string) => {
const navigateCommandMenu = useRecoilCallback(
({ snapshot, set }) => {
return ({
page,
pageTitle,
pageIcon,
}: CommandMenuNavigationStackItem) => {
set(commandMenuPageState, page);
set(commandMenuPageInfoState, {
title: pageTitle,
Icon: pageIcon,
});
const currentNavigationStack = snapshot
.getLoadable(commandMenuNavigationStackState)
.getValue();
set(commandMenuNavigationStackState, [
...currentNavigationStack,
{ page, pageTitle, pageIcon },
]);
openCommandMenu();
set(commandMenuPageState, CommandMenuPages.ViewRecord);
set(viewableRecordNameSingularState, objectNameSingular);
set(viewableRecordIdState, recordId);
};
},
[openCommandMenu],
);
const openRecordsSearchPage = useRecoilCallback(
({ set }) => {
const goBackFromCommandMenu = useRecoilCallback(
({ snapshot, set }) => {
return () => {
set(commandMenuPageState, CommandMenuPages.SearchRecords);
const currentNavigationStack = snapshot
.getLoadable(commandMenuNavigationStackState)
.getValue();
const newNavigationStack = currentNavigationStack.slice(0, -1);
const lastNavigationStackItem = newNavigationStack.at(-1);
if (!isDefined(lastNavigationStackItem)) {
closeCommandMenu();
return;
}
set(commandMenuPageState, lastNavigationStackItem.page);
set(commandMenuPageInfoState, {
title: 'Search',
Icon: IconSearch,
title: lastNavigationStackItem.pageTitle,
Icon: lastNavigationStackItem.pageIcon,
});
openCommandMenu();
set(commandMenuNavigationStackState, newNavigationStack);
};
},
[openCommandMenu],
[closeCommandMenu],
);
const navigateCommandMenuHistory = useRecoilCallback(({ snapshot, set }) => {
return (pageIndex: number) => {
const currentNavigationStack = snapshot
.getLoadable(commandMenuNavigationStackState)
.getValue();
const newNavigationStack = currentNavigationStack.slice(0, pageIndex + 1);
set(commandMenuNavigationStackState, newNavigationStack);
const newNavigationStackItem = newNavigationStack.at(-1);
if (!isDefined(newNavigationStackItem)) {
throw new Error(
`No command menu navigation stack item found for index ${pageIndex}`,
);
}
set(commandMenuPageState, newNavigationStackItem?.page);
set(commandMenuPageInfoState, {
title: newNavigationStackItem?.pageTitle,
Icon: newNavigationStackItem?.pageIcon,
});
};
}, []);
const openRecordInCommandMenu = useRecoilCallback(
({ set }) => {
return (recordId: string, objectNameSingular: string) => {
set(viewableRecordNameSingularState, objectNameSingular);
set(viewableRecordIdState, recordId);
navigateCommandMenu({
page: CommandMenuPages.ViewRecord,
});
};
},
[navigateCommandMenu],
);
const openRecordsSearchPage = () => {
navigateCommandMenu({
page: CommandMenuPages.SearchRecords,
pageTitle: 'Search',
pageIcon: IconSearch,
});
};
const setGlobalCommandMenuContext = useRecoilCallback(
({ set }) => {
return () => {
@ -177,6 +259,9 @@ export const useCommandMenu = () => {
return {
openCommandMenu,
closeCommandMenu,
navigateCommandMenu,
navigateCommandMenuHistory,
goBackFromCommandMenu,
openRecordsSearchPage,
openRecordInCommandMenu,
toggleCommandMenu,

View File

@ -0,0 +1,15 @@
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { IconComponent, createState } from 'twenty-ui';
export type CommandMenuNavigationStackItem = {
page: CommandMenuPages;
pageTitle?: string;
pageIcon?: IconComponent;
};
export const commandMenuNavigationStackState = createState<
CommandMenuNavigationStackItem[]
>({
key: 'command-menu/commandMenuNavigationStackState',
defaultValue: [],
});

View File

@ -4,8 +4,6 @@ import { isRightDrawerMinimizedState } from '@/ui/layout/right-drawer/states/isR
import { rightDrawerCloseEventState } from '@/ui/layout/right-drawer/states/rightDrawerCloseEventsState';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageTitle';
import { emitRightDrawerCloseEvent } from '@/ui/layout/right-drawer/utils/emitRightDrawerCloseEvent';
import { mapRightDrawerPageToCommandMenuPage } from '@/ui/layout/right-drawer/utils/mapRightDrawerPageToCommandMenuPage';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
@ -25,7 +23,7 @@ export const useRightDrawer = () => {
FeatureFlagKey.IsCommandMenuV2Enabled,
);
const { openCommandMenu } = useCommandMenu();
const { navigateCommandMenu } = useCommandMenu();
const openRightDrawer = useRecoilCallback(
({ set }) =>
@ -40,12 +38,12 @@ export const useRightDrawer = () => {
const commandMenuPage =
mapRightDrawerPageToCommandMenuPage(rightDrawerPage);
set(commandMenuPageState, commandMenuPage);
set(commandMenuPageInfoState, {
title: commandMenuPageInfo?.title,
Icon: commandMenuPageInfo?.Icon,
navigateCommandMenu({
page: commandMenuPage,
pageTitle: commandMenuPageInfo?.title,
pageIcon: commandMenuPageInfo?.Icon,
});
openCommandMenu();
return;
}
@ -53,7 +51,7 @@ export const useRightDrawer = () => {
set(isRightDrawerOpenState, true);
set(isRightDrawerMinimizedState, false);
},
[isCommandMenuV2Enabled, openCommandMenu],
[isCommandMenuV2Enabled, navigateCommandMenu],
);
const closeRightDrawer = useRecoilCallback(