diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/hooks/useSearchRecordsRecordAgnosticAction.ts b/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/hooks/useSearchRecordsRecordAgnosticAction.ts index 224a26d0f..51a3bdf5c 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/hooks/useSearchRecordsRecordAgnosticAction.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/hooks/useSearchRecordsRecordAgnosticAction.ts @@ -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, diff --git a/packages/twenty-front/src/modules/command-menu/hooks/__tests__/useCommandMenu.test.tsx b/packages/twenty-front/src/modules/command-menu/hooks/__tests__/useCommandMenu.test.tsx index a5c88a6cb..ae593f49d 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/__tests__/useCommandMenu.test.tsx +++ b/packages/twenty-front/src/modules/command-menu/hooks/__tests__/useCommandMenu.test.tsx @@ -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 }) => ( @@ -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, + }); + }); }); diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts index b4ce57fd3..a25545aa3 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts +++ b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts @@ -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, diff --git a/packages/twenty-front/src/modules/command-menu/states/commandMenuNavigationStackState.ts b/packages/twenty-front/src/modules/command-menu/states/commandMenuNavigationStackState.ts new file mode 100644 index 000000000..fa7650f42 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/states/commandMenuNavigationStackState.ts @@ -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: [], +}); diff --git a/packages/twenty-front/src/modules/ui/layout/right-drawer/hooks/useRightDrawer.ts b/packages/twenty-front/src/modules/ui/layout/right-drawer/hooks/useRightDrawer.ts index ebcc3efe2..b13ee0954 100644 --- a/packages/twenty-front/src/modules/ui/layout/right-drawer/hooks/useRightDrawer.ts +++ b/packages/twenty-front/src/modules/ui/layout/right-drawer/hooks/useRightDrawer.ts @@ -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(