From c982bcdb52a1c177300e77f7c38b9533b0f80ecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Wed, 21 May 2025 15:52:40 +0200 Subject: [PATCH] Introduce focus stack to handle hotkeys (#12166) # Introduce focus stack to handle hotkeys This PR introduces a focus stack to track the order in which the elements are focused: - Each focused element has a unique focus id - When an element is focused, it is pushed on top of the stack - When an element loses focus, we remove it from the stack This focus stack is then used to determine which hotkeys are available. The previous implementation lead to many regressions because of race conditions, of wrong order of open and close operations and by overwriting previous states. This implementation should be way more robust than the previous one. The new api can be incrementally implemented since it preserves backwards compatibility by writing to the old hotkey scopes states. For now, it has been implemented on the modal components. To test this PR, verify that the shortcuts still work correctly, especially for the modal components. --- .../src/hooks/useHotkeyScopeOnMount.ts | 4 +- .../components/ActivityRichTextEditor.tsx | 6 +- .../useOpenActivityTargetCellEditMode.ts | 6 +- .../hooks/__tests__/useCommandMenu.test.tsx | 18 +-- .../command-menu/hooks/useCommandMenu.ts | 6 +- .../hooks/useCommandMenuHotKeys.ts | 14 ++- .../hooks/useNavigateCommandMenu.ts | 13 +- .../components/KeyboardShortcutMenu.tsx | 5 +- .../KeyboardShortcutMenuOpenContent.tsx | 5 +- .../useKeyboardShortcutMenu.test.tsx | 18 +-- .../hooks/useKeyboardShortcutMenu.ts | 6 +- .../components/RecordBoardHotkeyEffect.tsx | 16 ++- .../components/RecordBoardColumnHeader.tsx | 8 +- .../FormFieldInputInnerContainer.tsx | 6 +- .../components/FormMultiSelectFieldInput.tsx | 4 +- .../hooks/useOpenFieldInputEditMode.ts | 13 +- .../useOpenRelationFromManyFieldInput.tsx | 6 +- .../hooks/useOpenRelationToOneFieldInput.tsx | 6 +- .../record-inline-cell/hooks/useInlineCell.ts | 6 +- .../hooks/useMapKeyboardToFocus.ts | 8 +- .../RecordTitleCellTextFieldDisplay.tsx | 11 +- .../RecordTitleFullNameFieldDisplay.tsx | 11 +- .../hooks/useRecordTitleCell.tsx | 20 +-- .../dialog-manager/components/Dialog.tsx | 33 ++--- .../components/DialogManager.tsx | 1 + .../components/DialogManagerEffect.tsx | 6 +- .../constants/DialogListenerId.ts | 1 + .../DialogManagerHotkeyScopeMemoizeKey.ts | 1 + .../dialog-manager/hooks/useDialogManager.ts | 6 +- .../ui/input/components/AutosizeTextInput.tsx | 8 +- .../ui/input/components/IconPicker.tsx | 6 +- .../modules/ui/input/components/TextArea.tsx | 6 +- .../modules/ui/input/components/TextInput.tsx | 4 +- .../ui/input/components/TitleInput.tsx | 4 +- .../ui/layout/dropdown/hooks/useDropdown.ts | 8 +- .../ui/layout/dropdown/hooks/useDropdownV2.ts | 16 +-- .../hooks/useOpenDropdownFromOutside.ts | 4 +- .../ModalHotkeysAndClickOutsideEffect.tsx | 33 +++-- .../__stories__/ConfirmationModal.stories.tsx | 16 +++ .../components/__stories__/Modal.stories.tsx | 16 +++ .../modal/hooks/__tests__/useModal.spec.ts | 58 --------- .../ui/layout/modal/hooks/useModal.tsx | 78 +++++++----- .../usePushFocusItemToFocusStack.test.tsx | 90 +++++++++++++ .../useRemoveFocusIdFromFocusStack.test.tsx | 101 +++++++++++++++ .../__tests__/useResetFocusStack.test.tsx | 71 +++++++++++ .../useResetFocusStackToFocusItem.test.tsx | 102 +++++++++++++++ .../hooks/usePushFocusItemToFocusStack.ts | 71 +++++++++++ .../hooks/useRemoveFocusIdFromFocusStack.ts | 22 ++++ .../focus/hooks/useResetFocusStack.ts | 18 +++ .../hooks/useResetFocusStackToFocusItem.ts | 28 +++++ .../focus/states/currentFocusIdSelector.ts | 11 ++ .../currentGlobalHotkeysConfigSelector.ts | 21 ++++ .../utilities/focus/states/focusStackState.ts | 7 ++ .../focus/types/FocusComponentInstance.ts | 6 + .../focus/types/FocusComponentType.ts | 3 + .../utilities/focus/types/FocusStackItem.ts | 8 ++ .../hotkey/constants/DebugHotkeyScope.ts | 1 + .../constants/DefaultGlobalHotkeysConfig.ts | 6 + .../hotkey/hooks/useGlobalHotkeys.ts | 74 +++++++++++ .../hotkey/hooks/useGlobalHotkeysCallback.ts | 119 ++++++++++++++++++ ...Hotkeys.ts => useGlobalHotkeysSequence.ts} | 12 +- .../utilities/hotkey/hooks/useGoToHotkeys.ts | 5 +- .../hooks/useHotkeysOnFocusedElement.ts | 73 +++++++++++ .../useHotkeysOnFocusedElementCallback.ts | 89 +++++++++++++ .../hotkey/hooks/usePreviousHotkeyScope.ts | 24 ++-- .../hotkey/hooks/useScopedHotkeyCallback.ts | 3 +- .../hotkey/hooks/useScopedHotkeys.ts | 2 +- .../hotkey/hooks/useSetHotkeyScope.ts | 8 +- .../hotkey/types/GlobalHotkeysConfig.ts | 4 + 69 files changed, 1233 insertions(+), 267 deletions(-) create mode 100644 packages/twenty-front/src/modules/ui/feedback/dialog-manager/constants/DialogListenerId.ts create mode 100644 packages/twenty-front/src/modules/ui/feedback/dialog-manager/constants/DialogManagerHotkeyScopeMemoizeKey.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/focus/hooks/__tests__/usePushFocusItemToFocusStack.test.tsx create mode 100644 packages/twenty-front/src/modules/ui/utilities/focus/hooks/__tests__/useRemoveFocusIdFromFocusStack.test.tsx create mode 100644 packages/twenty-front/src/modules/ui/utilities/focus/hooks/__tests__/useResetFocusStack.test.tsx create mode 100644 packages/twenty-front/src/modules/ui/utilities/focus/hooks/__tests__/useResetFocusStackToFocusItem.test.tsx create mode 100644 packages/twenty-front/src/modules/ui/utilities/focus/hooks/usePushFocusItemToFocusStack.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/focus/hooks/useRemoveFocusIdFromFocusStack.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/focus/hooks/useResetFocusStack.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/focus/hooks/useResetFocusStackToFocusItem.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/focus/states/currentFocusIdSelector.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/focus/states/currentGlobalHotkeysConfigSelector.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/focus/states/focusStackState.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/focus/types/FocusComponentInstance.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/focus/types/FocusComponentType.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/focus/types/FocusStackItem.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/hotkey/constants/DebugHotkeyScope.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/hotkey/constants/DefaultGlobalHotkeysConfig.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useGlobalHotkeys.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useGlobalHotkeysCallback.ts rename packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/{useSequenceScopedHotkeys.ts => useGlobalHotkeysSequence.ts} (84%) create mode 100644 packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElementCallback.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/hotkey/types/GlobalHotkeysConfig.ts diff --git a/packages/twenty-front/src/hooks/useHotkeyScopeOnMount.ts b/packages/twenty-front/src/hooks/useHotkeyScopeOnMount.ts index e1e4c298c4..a4418a67ca 100644 --- a/packages/twenty-front/src/hooks/useHotkeyScopeOnMount.ts +++ b/packages/twenty-front/src/hooks/useHotkeyScopeOnMount.ts @@ -12,7 +12,9 @@ export const useHotkeyScopeOnMount = (hotkeyScope: string) => { } = usePreviousHotkeyScope(); useEffect(() => { - setHotkeyScopeAndMemorizePreviousScope(hotkeyScope); + setHotkeyScopeAndMemorizePreviousScope({ + scope: hotkeyScope, + }); return () => { goBackToPreviousHotkeyScope(); }; diff --git a/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx b/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx index 2a1dae1542..bc7b9727d8 100644 --- a/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx +++ b/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx @@ -353,9 +353,9 @@ export const ActivityRichTextEditor = ({ ); const handleBlockEditorFocus = () => { - setHotkeyScopeAndMemorizePreviousScope( - ActivityEditorHotkeyScope.ActivityBody, - ); + setHotkeyScopeAndMemorizePreviousScope({ + scope: ActivityEditorHotkeyScope.ActivityBody, + }); }; const handlerBlockEditorBlur = () => { diff --git a/packages/twenty-front/src/modules/activities/inline-cell/hooks/useOpenActivityTargetCellEditMode.ts b/packages/twenty-front/src/modules/activities/inline-cell/hooks/useOpenActivityTargetCellEditMode.ts index 6e4109d60c..201e94e6ab 100644 --- a/packages/twenty-front/src/modules/activities/inline-cell/hooks/useOpenActivityTargetCellEditMode.ts +++ b/packages/twenty-front/src/modules/activities/inline-cell/hooks/useOpenActivityTargetCellEditMode.ts @@ -84,9 +84,9 @@ export const useOpenActivityTargetCellEditMode = () => { ), }); - setHotkeyScopeAndMemorizePreviousScope( - MultipleRecordPickerHotkeyScope.MultipleRecordPicker, - ); + setHotkeyScopeAndMemorizePreviousScope({ + scope: MultipleRecordPickerHotkeyScope.MultipleRecordPicker, + }); }, [multipleRecordPickerPerformSearch, setHotkeyScopeAndMemorizePreviousScope], ); 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 6c09a55807..f1ea6ccf64 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 @@ -71,12 +71,13 @@ describe('useCommandMenu', () => { }); expect(result.current.isCommandMenuOpened).toBe(true); - expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith( - CommandMenuHotkeyScope.CommandMenuFocused, - { + expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith({ + scope: CommandMenuHotkeyScope.CommandMenuFocused, + memoizeKey: 'command-menu', + customScopes: { commandMenuOpen: true, }, - ); + }); act(() => { result.current.commandMenu.closeCommandMenu(); @@ -95,12 +96,13 @@ describe('useCommandMenu', () => { }); expect(result.current.isCommandMenuOpened).toBe(true); - expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith( - CommandMenuHotkeyScope.CommandMenuFocused, - { + expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith({ + scope: CommandMenuHotkeyScope.CommandMenuFocused, + memoizeKey: 'command-menu', + customScopes: { commandMenuOpen: true, }, - ); + }); act(() => { result.current.commandMenu.toggleCommandMenu(); 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 fd5e574532..1ca2ef269c 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts +++ b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts @@ -16,9 +16,7 @@ import { isCommandMenuOpenedState } from '../states/isCommandMenuOpenedState'; export const useCommandMenu = () => { const { navigateCommandMenu } = useNavigateCommandMenu(); const { closeAnyOpenDropdown } = useCloseAnyOpenDropdown(); - const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope( - COMMAND_MENU_COMPONENT_INSTANCE_ID, - ); + const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope(); const closeCommandMenu = useRecoilCallback( ({ set, snapshot }) => @@ -32,7 +30,7 @@ export const useCommandMenu = () => { set(isCommandMenuClosingState, true); set(isDragSelectionStartEnabledState, true); closeAnyOpenDropdown(); - goBackToPreviousHotkeyScope(); + goBackToPreviousHotkeyScope(COMMAND_MENU_COMPONENT_INSTANCE_ID); } }, [closeAnyOpenDropdown, goBackToPreviousHotkeyScope], diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuHotKeys.ts b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuHotKeys.ts index 574a55096d..3c1ca56624 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuHotKeys.ts +++ b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuHotKeys.ts @@ -9,7 +9,7 @@ import { CommandMenuHotkeyScope } from '@/command-menu/types/CommandMenuHotkeySc import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; import { useKeyboardShortcutMenu } from '@/keyboard-shortcut-menu/hooks/useKeyboardShortcutMenu'; -import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; +import { useGlobalHotkeys } from '@/ui/utilities/hotkey/hooks/useGlobalHotkeys'; import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { isNonEmptyString } from '@sniptt/guards'; @@ -36,21 +36,23 @@ export const useCommandMenuHotKeys = () => { COMMAND_MENU_COMPONENT_INSTANCE_ID, ); - useScopedHotkeys( + useGlobalHotkeys( 'ctrl+k,meta+k', () => { closeKeyboardShortcutMenu(); toggleCommandMenu(); }, + true, AppHotkeyScope.CommandMenu, [closeKeyboardShortcutMenu, toggleCommandMenu], ); - useScopedHotkeys( + useGlobalHotkeys( ['/'], () => { openRecordsSearchPage(); }, + false, AppHotkeyScope.KeyboardShortcutMenu, [openRecordsSearchPage], { @@ -58,16 +60,17 @@ export const useCommandMenuHotKeys = () => { }, ); - useScopedHotkeys( + useGlobalHotkeys( [Key.Escape], () => { goBackFromCommandMenu(); }, + true, CommandMenuHotkeyScope.CommandMenuFocused, [goBackFromCommandMenu], ); - useScopedHotkeys( + useGlobalHotkeys( [Key.Backspace, Key.Delete], () => { if (isNonEmptyString(commandMenuSearch)) { @@ -88,6 +91,7 @@ export const useCommandMenuHotKeys = () => { goBackFromCommandMenu(); } }, + true, CommandMenuHotkeyScope.CommandMenuFocused, [ commandMenuPage, diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useNavigateCommandMenu.ts b/packages/twenty-front/src/modules/command-menu/hooks/useNavigateCommandMenu.ts index c92fb987db..5d76ae1f29 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/useNavigateCommandMenu.ts +++ b/packages/twenty-front/src/modules/command-menu/hooks/useNavigateCommandMenu.ts @@ -27,9 +27,7 @@ export type CommandMenuNavigationStackItem = { }; export const useNavigateCommandMenu = () => { - const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope( - COMMAND_MENU_COMPONENT_INSTANCE_ID, - ); + const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope(); const { copyContextStoreStates } = useCopyContextStoreStates(); @@ -55,12 +53,13 @@ export const useNavigateCommandMenu = () => { return; } - setHotkeyScopeAndMemorizePreviousScope( - CommandMenuHotkeyScope.CommandMenuFocused, - { + setHotkeyScopeAndMemorizePreviousScope({ + scope: CommandMenuHotkeyScope.CommandMenuFocused, + customScopes: { commandMenuOpen: true, }, - ); + memoizeKey: COMMAND_MENU_COMPONENT_INSTANCE_ID, + }); copyContextStoreStates({ instanceIdToCopyFrom: MAIN_CONTEXT_STORE_INSTANCE_ID, diff --git a/packages/twenty-front/src/modules/keyboard-shortcut-menu/components/KeyboardShortcutMenu.tsx b/packages/twenty-front/src/modules/keyboard-shortcut-menu/components/KeyboardShortcutMenu.tsx index 3c9c043e1d..bcdbbb4e53 100644 --- a/packages/twenty-front/src/modules/keyboard-shortcut-menu/components/KeyboardShortcutMenu.tsx +++ b/packages/twenty-front/src/modules/keyboard-shortcut-menu/components/KeyboardShortcutMenu.tsx @@ -1,13 +1,13 @@ import { useRecoilValue } from 'recoil'; import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; -import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope'; import { useKeyboardShortcutMenu } from '../hooks/useKeyboardShortcutMenu'; import { isKeyboardShortcutMenuOpenedState } from '../states/isKeyboardShortcutMenuOpenedState'; import { KeyboardShortcutMenuOpenContent } from '@/keyboard-shortcut-menu/components/KeyboardShortcutMenuOpenContent'; +import { useGlobalHotkeys } from '@/ui/utilities/hotkey/hooks/useGlobalHotkeys'; export const KeyboardShortcutMenu = () => { const { toggleKeyboardShortcutMenu } = useKeyboardShortcutMenu(); @@ -16,12 +16,13 @@ export const KeyboardShortcutMenu = () => { ); const { closeCommandMenu } = useCommandMenu(); - useScopedHotkeys( + useGlobalHotkeys( 'shift+?,meta+?', () => { closeCommandMenu(); toggleKeyboardShortcutMenu(); }, + true, AppHotkeyScope.KeyboardShortcutMenu, [toggleKeyboardShortcutMenu], ); diff --git a/packages/twenty-front/src/modules/keyboard-shortcut-menu/components/KeyboardShortcutMenuOpenContent.tsx b/packages/twenty-front/src/modules/keyboard-shortcut-menu/components/KeyboardShortcutMenuOpenContent.tsx index ae68bdc4ef..9155fed53b 100644 --- a/packages/twenty-front/src/modules/keyboard-shortcut-menu/components/KeyboardShortcutMenuOpenContent.tsx +++ b/packages/twenty-front/src/modules/keyboard-shortcut-menu/components/KeyboardShortcutMenuOpenContent.tsx @@ -2,11 +2,11 @@ import { Key } from 'ts-key-enum'; import { KEYBOARD_SHORTCUTS_GENERAL } from '@/keyboard-shortcut-menu/constants/KeyboardShortcutsGeneral'; import { KEYBOARD_SHORTCUTS_TABLE } from '@/keyboard-shortcut-menu/constants/KeyboardShortcutsTable'; -import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope'; import { useKeyboardShortcutMenu } from '../hooks/useKeyboardShortcutMenu'; +import { useGlobalHotkeys } from '@/ui/utilities/hotkey/hooks/useGlobalHotkeys'; import { KeyboardMenuDialog } from './KeyboardShortcutMenuDialog'; import { KeyboardMenuGroup } from './KeyboardShortcutMenuGroup'; import { KeyboardMenuItem } from './KeyboardShortcutMenuItem'; @@ -15,11 +15,12 @@ export const KeyboardShortcutMenuOpenContent = () => { const { toggleKeyboardShortcutMenu, closeKeyboardShortcutMenu } = useKeyboardShortcutMenu(); - useScopedHotkeys( + useGlobalHotkeys( [Key.Escape], () => { closeKeyboardShortcutMenu(); }, + false, AppHotkeyScope.KeyboardShortcutMenuOpen, [closeKeyboardShortcutMenu], ); diff --git a/packages/twenty-front/src/modules/keyboard-shortcut-menu/hooks/__tests__/useKeyboardShortcutMenu.test.tsx b/packages/twenty-front/src/modules/keyboard-shortcut-menu/hooks/__tests__/useKeyboardShortcutMenu.test.tsx index d38b3b5774..292730ea09 100644 --- a/packages/twenty-front/src/modules/keyboard-shortcut-menu/hooks/__tests__/useKeyboardShortcutMenu.test.tsx +++ b/packages/twenty-front/src/modules/keyboard-shortcut-menu/hooks/__tests__/useKeyboardShortcutMenu.test.tsx @@ -48,18 +48,18 @@ describe('useKeyboardShortcutMenu', () => { result.current.toggleKeyboardShortcutMenu(); }); - expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith( - AppHotkeyScope.KeyboardShortcutMenu, - ); + expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith({ + scope: AppHotkeyScope.KeyboardShortcutMenu, + }); expect(result.current.isKeyboardShortcutMenuOpened).toBe(true); act(() => { result.current.toggleKeyboardShortcutMenu(); }); - expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith( - AppHotkeyScope.KeyboardShortcutMenu, - ); + expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith({ + scope: AppHotkeyScope.KeyboardShortcutMenu, + }); expect(result.current.isKeyboardShortcutMenuOpened).toBe(false); }); @@ -69,9 +69,9 @@ describe('useKeyboardShortcutMenu', () => { result.current.openKeyboardShortcutMenu(); }); - expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith( - AppHotkeyScope.KeyboardShortcutMenu, - ); + expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith({ + scope: AppHotkeyScope.KeyboardShortcutMenu, + }); expect(result.current.isKeyboardShortcutMenuOpened).toBe(true); act(() => { diff --git a/packages/twenty-front/src/modules/keyboard-shortcut-menu/hooks/useKeyboardShortcutMenu.ts b/packages/twenty-front/src/modules/keyboard-shortcut-menu/hooks/useKeyboardShortcutMenu.ts index 0d568a4735..bd3286d7a8 100644 --- a/packages/twenty-front/src/modules/keyboard-shortcut-menu/hooks/useKeyboardShortcutMenu.ts +++ b/packages/twenty-front/src/modules/keyboard-shortcut-menu/hooks/useKeyboardShortcutMenu.ts @@ -15,9 +15,9 @@ export const useKeyboardShortcutMenu = () => { ({ set }) => () => { set(isKeyboardShortcutMenuOpenedState, true); - setHotkeyScopeAndMemorizePreviousScope( - AppHotkeyScope.KeyboardShortcutMenu, - ); + setHotkeyScopeAndMemorizePreviousScope({ + scope: AppHotkeyScope.KeyboardShortcutMenu, + }); }, [setHotkeyScopeAndMemorizePreviousScope], ); diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHotkeyEffect.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHotkeyEffect.tsx index 6ceebcdc10..f8eef8feaa 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHotkeyEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHotkeyEffect.tsx @@ -52,7 +52,9 @@ export const RecordBoardHotkeyEffect = () => { useScopedHotkeys( Key.ArrowLeft, () => { - setHotkeyScopeAndMemorizePreviousScope(BoardHotkeyScope.BoardFocus); + setHotkeyScopeAndMemorizePreviousScope({ + scope: BoardHotkeyScope.BoardFocus, + }); move('left'); }, RecordIndexHotkeyScope.RecordIndex, @@ -61,7 +63,9 @@ export const RecordBoardHotkeyEffect = () => { useScopedHotkeys( Key.ArrowRight, () => { - setHotkeyScopeAndMemorizePreviousScope(BoardHotkeyScope.BoardFocus); + setHotkeyScopeAndMemorizePreviousScope({ + scope: BoardHotkeyScope.BoardFocus, + }); move('right'); }, RecordIndexHotkeyScope.RecordIndex, @@ -70,7 +74,9 @@ export const RecordBoardHotkeyEffect = () => { useScopedHotkeys( Key.ArrowUp, () => { - setHotkeyScopeAndMemorizePreviousScope(BoardHotkeyScope.BoardFocus); + setHotkeyScopeAndMemorizePreviousScope({ + scope: BoardHotkeyScope.BoardFocus, + }); move('up'); }, RecordIndexHotkeyScope.RecordIndex, @@ -79,7 +85,9 @@ export const RecordBoardHotkeyEffect = () => { useScopedHotkeys( Key.ArrowDown, () => { - setHotkeyScopeAndMemorizePreviousScope(BoardHotkeyScope.BoardFocus); + setHotkeyScopeAndMemorizePreviousScope({ + scope: BoardHotkeyScope.BoardFocus, + }); move('down'); }, RecordIndexHotkeyScope.RecordIndex, diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx index 13b2f49899..b0d70d287f 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx @@ -79,12 +79,12 @@ export const RecordBoardColumnHeader = () => { const handleBoardColumnMenuOpen = () => { setIsBoardColumnMenuOpen(true); - setHotkeyScopeAndMemorizePreviousScope( - RecordBoardColumnHotkeyScope.BoardColumn, - { + setHotkeyScopeAndMemorizePreviousScope({ + scope: RecordBoardColumnHotkeyScope.BoardColumn, + customScopes: { goto: false, }, - ); + }); }; const handleBoardColumnMenuClose = () => { diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormFieldInputInnerContainer.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormFieldInputInnerContainer.tsx index 60dbb10beb..ec646f543c 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormFieldInputInnerContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormFieldInputInnerContainer.tsx @@ -60,9 +60,9 @@ export const FormFieldInputInnerContainer = forwardRef( onFocus?.(e); if (!preventSetHotkeyScope) { - setHotkeyScopeAndMemorizePreviousScope( - FormFieldInputHotKeyScope.FormFieldInput, - ); + setHotkeyScopeAndMemorizePreviousScope({ + scope: FormFieldInputHotKeyScope.FormFieldInput, + }); } }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormMultiSelectFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormMultiSelectFieldInput.tsx index fde125c70a..28ae0760c9 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormMultiSelectFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormMultiSelectFieldInput.tsx @@ -119,7 +119,9 @@ export const FormMultiSelectFieldInput = ({ editingMode: 'edit', }); - setHotkeyScopeAndMemorizePreviousScope(hotkeyScope); + setHotkeyScopeAndMemorizePreviousScope({ + scope: hotkeyScope, + }); }; const onOptionSelected = (value: FieldMultiSelectValue) => { diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/useOpenFieldInputEditMode.ts b/packages/twenty-front/src/modules/object-record/record-field/hooks/useOpenFieldInputEditMode.ts index 1f3ad72f1e..889b6185f3 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/hooks/useOpenFieldInputEditMode.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/useOpenFieldInputEditMode.ts @@ -31,9 +31,7 @@ export const useOpenFieldInputEditMode = () => { const { openActivityTargetCellEditMode } = useOpenActivityTargetCellEditMode(); - const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope( - INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY, - ); + const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope(); const openFieldInput = useRecoilCallback( ({ snapshot }) => @@ -105,10 +103,11 @@ export const useOpenFieldInputEditMode = () => { } } - setHotkeyScopeAndMemorizePreviousScope( - DEFAULT_CELL_SCOPE.scope, - DEFAULT_CELL_SCOPE.customScopes, - ); + setHotkeyScopeAndMemorizePreviousScope({ + scope: DEFAULT_CELL_SCOPE.scope, + customScopes: DEFAULT_CELL_SCOPE.customScopes, + memoizeKey: INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY, + }); }, [ openActivityTargetCellEditMode, diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useOpenRelationFromManyFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useOpenRelationFromManyFieldInput.tsx index ea45fbaaec..0f17035ef4 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useOpenRelationFromManyFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useOpenRelationFromManyFieldInput.tsx @@ -88,9 +88,9 @@ export const useOpenRelationFromManyFieldInput = () => { forcePickableMorphItems: pickableMorphItems, }); - setHotkeyScopeAndMemorizePreviousScope( - MultipleRecordPickerHotkeyScope.MultipleRecordPicker, - ); + setHotkeyScopeAndMemorizePreviousScope({ + scope: MultipleRecordPickerHotkeyScope.MultipleRecordPicker, + }); }, [performSearch, setHotkeyScopeAndMemorizePreviousScope], ); diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useOpenRelationToOneFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useOpenRelationToOneFieldInput.tsx index 27534386f9..2428cdc62f 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useOpenRelationToOneFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useOpenRelationToOneFieldInput.tsx @@ -34,9 +34,9 @@ export const useOpenRelationToOneFieldInput = () => { ); } - setHotkeyScopeAndMemorizePreviousScope( - SingleRecordPickerHotkeyScope.SingleRecordPicker, - ); + setHotkeyScopeAndMemorizePreviousScope({ + scope: SingleRecordPickerHotkeyScope.SingleRecordPicker, + }); }, [setHotkeyScopeAndMemorizePreviousScope], ); diff --git a/packages/twenty-front/src/modules/object-record/record-inline-cell/hooks/useInlineCell.ts b/packages/twenty-front/src/modules/object-record/record-inline-cell/hooks/useInlineCell.ts index 32741a62f1..84756f7e28 100644 --- a/packages/twenty-front/src/modules/object-record/record-inline-cell/hooks/useInlineCell.ts +++ b/packages/twenty-front/src/modules/object-record/record-inline-cell/hooks/useInlineCell.ts @@ -35,9 +35,7 @@ export const useInlineCell = ( const { goBackToPreviousDropdownFocusId } = useGoBackToPreviousDropdownFocusId(); - const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope( - INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY, - ); + const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope(); const initFieldInputDraftValue = useInitDraftValueV2(); @@ -45,7 +43,7 @@ export const useInlineCell = ( onCloseEditMode?.(); setIsInlineCellInEditMode(false); - goBackToPreviousHotkeyScope(); + goBackToPreviousHotkeyScope(INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY); goBackToPreviousDropdownFocusId(); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useMapKeyboardToFocus.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useMapKeyboardToFocus.ts index 6cbb1edaf1..0b1ef505d2 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/useMapKeyboardToFocus.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useMapKeyboardToFocus.ts @@ -31,7 +31,9 @@ export const useMapKeyboardToFocus = (recordTableId?: string) => { useScopedHotkeys( [Key.ArrowUp], () => { - setHotkeyScopeAndMemorizePreviousScope(TableHotkeyScope.TableFocus); + setHotkeyScopeAndMemorizePreviousScope({ + scope: TableHotkeyScope.TableFocus, + }); move('up'); }, RecordIndexHotkeyScope.RecordIndex, @@ -41,7 +43,9 @@ export const useMapKeyboardToFocus = (recordTableId?: string) => { useScopedHotkeys( [Key.ArrowDown], () => { - setHotkeyScopeAndMemorizePreviousScope(TableHotkeyScope.TableFocus); + setHotkeyScopeAndMemorizePreviousScope({ + scope: TableHotkeyScope.TableFocus, + }); move('down'); }, RecordIndexHotkeyScope.RecordIndex, diff --git a/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleCellTextFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleCellTextFieldDisplay.tsx index 25a7a0d874..cb7a15b9a0 100644 --- a/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleCellTextFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleCellTextFieldDisplay.tsx @@ -42,16 +42,15 @@ export const RecordTitleCellSingleTextDisplayMode = () => { const { openInlineCell } = useInlineCell(); - const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope( - INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY, - ); + const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope(); return ( { - setHotkeyScopeAndMemorizePreviousScope( - TitleInputHotkeyScope.TitleInput, - ); + setHotkeyScopeAndMemorizePreviousScope({ + scope: TitleInputHotkeyScope.TitleInput, + memoizeKey: INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY, + }); openInlineCell(); }} > diff --git a/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleFullNameFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleFullNameFieldDisplay.tsx index cab5a5b097..963111b5f1 100644 --- a/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleFullNameFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleFullNameFieldDisplay.tsx @@ -45,15 +45,14 @@ export const RecordTitleFullNameFieldDisplay = () => { .join(' ') .trim(); - const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope( - INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY, - ); + const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope(); return ( { - setHotkeyScopeAndMemorizePreviousScope( - TitleInputHotkeyScope.TitleInput, - ); + setHotkeyScopeAndMemorizePreviousScope({ + scope: TitleInputHotkeyScope.TitleInput, + memoizeKey: INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY, + }); openInlineCell(); }} > diff --git a/packages/twenty-front/src/modules/object-record/record-title-cell/hooks/useRecordTitleCell.tsx b/packages/twenty-front/src/modules/object-record/record-title-cell/hooks/useRecordTitleCell.tsx index 8309dc458f..a77615ebb2 100644 --- a/packages/twenty-front/src/modules/object-record/record-title-cell/hooks/useRecordTitleCell.tsx +++ b/packages/twenty-front/src/modules/object-record/record-title-cell/hooks/useRecordTitleCell.tsx @@ -18,7 +18,7 @@ export const useRecordTitleCell = () => { const { setHotkeyScopeAndMemorizePreviousScope, goBackToPreviousHotkeyScope, - } = usePreviousHotkeyScope(INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY); + } = usePreviousHotkeyScope(); const closeRecordTitleCell = useRecoilCallback( ({ set }) => @@ -38,7 +38,7 @@ export const useRecordTitleCell = () => { false, ); - goBackToPreviousHotkeyScope(); + goBackToPreviousHotkeyScope(INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY); goBackToPreviousDropdownFocusId(); }, @@ -61,14 +61,16 @@ export const useRecordTitleCell = () => { customEditHotkeyScopeForField?: HotkeyScope; }) => { if (isDefined(customEditHotkeyScopeForField)) { - setHotkeyScopeAndMemorizePreviousScope( - customEditHotkeyScopeForField.scope, - customEditHotkeyScopeForField.customScopes, - ); + setHotkeyScopeAndMemorizePreviousScope({ + scope: customEditHotkeyScopeForField.scope, + customScopes: customEditHotkeyScopeForField.customScopes, + memoizeKey: INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY, + }); } else { - setHotkeyScopeAndMemorizePreviousScope( - TitleInputHotkeyScope.TitleInput, - ); + setHotkeyScopeAndMemorizePreviousScope({ + scope: TitleInputHotkeyScope.TitleInput, + memoizeKey: INLINE_CELL_HOTKEY_SCOPE_MEMOIZE_KEY, + }); } const recordTitleCellId = getRecordTitleCellId( diff --git a/packages/twenty-front/src/modules/ui/feedback/dialog-manager/components/Dialog.tsx b/packages/twenty-front/src/modules/ui/feedback/dialog-manager/components/Dialog.tsx index 893958fdc7..9c2ea43fc9 100644 --- a/packages/twenty-front/src/modules/ui/feedback/dialog-manager/components/Dialog.tsx +++ b/packages/twenty-front/src/modules/ui/feedback/dialog-manager/components/Dialog.tsx @@ -1,11 +1,13 @@ import styled from '@emotion/styled'; import { motion } from 'framer-motion'; -import { useCallback } from 'react'; import { Key } from 'ts-key-enum'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; +import { DIALOG_LISTENER_ID } from '@/ui/feedback/dialog-manager/constants/DialogListenerId'; import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices'; +import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; +import { useRef } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { Button } from 'twenty-ui/input'; import { DialogHotkeyScope } from '../types/DialogHotkeyScope'; @@ -69,7 +71,6 @@ export type DialogProps = React.ComponentPropsWithoutRef & { title?: string; message?: string; buttons?: DialogButtonOptions[]; - allowDismiss?: boolean; children?: React.ReactNode; className?: string; onClose?: () => void; @@ -79,16 +80,11 @@ export const Dialog = ({ title, message, buttons = [], - allowDismiss = true, children, className, onClose, id, }: DialogProps) => { - const closeSnackbar = useCallback(() => { - onClose && onClose(); - }, [onClose]); - const dialogVariants = { open: { opacity: 1 }, closed: { opacity: 0 }, @@ -108,7 +104,7 @@ export const Dialog = ({ if (isDefined(confirmButton)) { confirmButton?.onClick?.(event); - closeSnackbar(); + onClose?.(); } }, DialogHotkeyScope.Dialog, @@ -119,30 +115,35 @@ export const Dialog = ({ Key.Escape, (event: KeyboardEvent) => { event.preventDefault(); - closeSnackbar(); + onClose?.(); }, DialogHotkeyScope.Dialog, [], ); + const dialogRef = useRef(null); + + useListenClickOutside({ + refs: [dialogRef], + callback: () => { + onClose?.(); + }, + listenerId: DIALOG_LISTENER_ID, + }); + return ( { - if (allowDismiss) { - e.stopPropagation(); - closeSnackbar(); - } - }} className={className} > {title && {title}} {message && {message}} @@ -150,8 +151,8 @@ export const Dialog = ({ {buttons.map(({ accent, onClick, role, title: key, variant }) => ( { + onClose?.(); onClick?.(event); - closeSnackbar(); }} fullWidth={true} variant={variant ?? 'secondary'} diff --git a/packages/twenty-front/src/modules/ui/feedback/dialog-manager/components/DialogManager.tsx b/packages/twenty-front/src/modules/ui/feedback/dialog-manager/components/DialogManager.tsx index cf8040f510..b1c9e5ca57 100644 --- a/packages/twenty-front/src/modules/ui/feedback/dialog-manager/components/DialogManager.tsx +++ b/packages/twenty-front/src/modules/ui/feedback/dialog-manager/components/DialogManager.tsx @@ -15,6 +15,7 @@ export const DialogManager = ({ children }: React.PropsWithChildren) => { {dialogInternal.queue.map(({ buttons, children, id, message, title }) => ( closeDialog(id)} /> diff --git a/packages/twenty-front/src/modules/ui/feedback/dialog-manager/components/DialogManagerEffect.tsx b/packages/twenty-front/src/modules/ui/feedback/dialog-manager/components/DialogManagerEffect.tsx index b4f85c41d1..9787bccc64 100644 --- a/packages/twenty-front/src/modules/ui/feedback/dialog-manager/components/DialogManagerEffect.tsx +++ b/packages/twenty-front/src/modules/ui/feedback/dialog-manager/components/DialogManagerEffect.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; +import { DIALOG_MANAGER_HOTKEY_SCOPE_MEMOIZE_KEY } from '@/ui/feedback/dialog-manager/constants/DialogManagerHotkeyScopeMemoizeKey'; import { useDialogManagerScopedStates } from '../hooks/internal/useDialogManagerScopedStates'; import { DialogHotkeyScope } from '../types/DialogHotkeyScope'; @@ -15,7 +16,10 @@ export const DialogManagerEffect = () => { return; } - setHotkeyScopeAndMemorizePreviousScope(DialogHotkeyScope.Dialog); + setHotkeyScopeAndMemorizePreviousScope({ + scope: DialogHotkeyScope.Dialog, + memoizeKey: DIALOG_MANAGER_HOTKEY_SCOPE_MEMOIZE_KEY, + }); }, [dialogInternal.queue, setHotkeyScopeAndMemorizePreviousScope]); return <>; diff --git a/packages/twenty-front/src/modules/ui/feedback/dialog-manager/constants/DialogListenerId.ts b/packages/twenty-front/src/modules/ui/feedback/dialog-manager/constants/DialogListenerId.ts new file mode 100644 index 0000000000..9b0ac20866 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/feedback/dialog-manager/constants/DialogListenerId.ts @@ -0,0 +1 @@ +export const DIALOG_LISTENER_ID = 'DIALOG_LISTENER_ID'; diff --git a/packages/twenty-front/src/modules/ui/feedback/dialog-manager/constants/DialogManagerHotkeyScopeMemoizeKey.ts b/packages/twenty-front/src/modules/ui/feedback/dialog-manager/constants/DialogManagerHotkeyScopeMemoizeKey.ts new file mode 100644 index 0000000000..21e4fddcb2 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/feedback/dialog-manager/constants/DialogManagerHotkeyScopeMemoizeKey.ts @@ -0,0 +1 @@ +export const DIALOG_MANAGER_HOTKEY_SCOPE_MEMOIZE_KEY = 'dialog-manager'; diff --git a/packages/twenty-front/src/modules/ui/feedback/dialog-manager/hooks/useDialogManager.ts b/packages/twenty-front/src/modules/ui/feedback/dialog-manager/hooks/useDialogManager.ts index 84ce4187ee..251bc233c5 100644 --- a/packages/twenty-front/src/modules/ui/feedback/dialog-manager/hooks/useDialogManager.ts +++ b/packages/twenty-front/src/modules/ui/feedback/dialog-manager/hooks/useDialogManager.ts @@ -4,6 +4,7 @@ import { v4 } from 'uuid'; import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; +import { DIALOG_MANAGER_HOTKEY_SCOPE_MEMOIZE_KEY } from '@/ui/feedback/dialog-manager/constants/DialogManagerHotkeyScopeMemoizeKey'; import { DialogManagerScopeInternalContext } from '../scopes/scope-internal-context/DialogManagerScopeInternalContext'; import { dialogInternalScopedState } from '../states/dialogInternalScopedState'; import { DialogOptions } from '../types/DialogOptions'; @@ -25,9 +26,10 @@ export const useDialogManager = (props?: useDialogManagerProps) => { (id: string) => { set(dialogInternalScopedState({ scopeId: scopeId }), (prevState) => ({ ...prevState, - queue: prevState.queue.filter((snackBar) => snackBar.id !== id), + queue: prevState.queue.filter((dialog) => dialog.id !== id), })); - goBackToPreviousHotkeyScope(); + + goBackToPreviousHotkeyScope(DIALOG_MANAGER_HOTKEY_SCOPE_MEMOIZE_KEY); }, [goBackToPreviousHotkeyScope, scopeId], ); diff --git a/packages/twenty-front/src/modules/ui/input/components/AutosizeTextInput.tsx b/packages/twenty-front/src/modules/ui/input/components/AutosizeTextInput.tsx index 3e50d250a5..46b9afd863 100644 --- a/packages/twenty-front/src/modules/ui/input/components/AutosizeTextInput.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/AutosizeTextInput.tsx @@ -7,9 +7,9 @@ import { Key } from 'ts-key-enum'; import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; -import { InputHotkeyScope } from '../types/InputHotkeyScope'; -import { Button, RoundedIconButton } from 'twenty-ui/input'; import { IconArrowRight } from 'twenty-ui/display'; +import { Button, RoundedIconButton } from 'twenty-ui/input'; +import { InputHotkeyScope } from '../types/InputHotkeyScope'; const MAX_ROWS = 5; @@ -197,7 +197,9 @@ export const AutosizeTextInput = ({ const handleFocus = () => { onFocus?.(); setIsFocused(true); - setHotkeyScopeAndMemorizePreviousScope(InputHotkeyScope.TextInput); + setHotkeyScopeAndMemorizePreviousScope({ + scope: InputHotkeyScope.TextInput, + }); }; const handleBlur = () => { diff --git a/packages/twenty-front/src/modules/ui/input/components/IconPicker.tsx b/packages/twenty-front/src/modules/ui/input/components/IconPicker.tsx index aadf5c583e..1ef713560a 100644 --- a/packages/twenty-front/src/modules/ui/input/components/IconPicker.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/IconPicker.tsx @@ -197,9 +197,9 @@ export const IconPicker = ({
{ - setHotkeyScopeAndMemorizePreviousScope( - IconPickerHotkeyScope.IconPicker, - ); + setHotkeyScopeAndMemorizePreviousScope({ + scope: IconPickerHotkeyScope.IconPicker, + }); }} onMouseLeave={goBackToPreviousHotkeyScope} > diff --git a/packages/twenty-front/src/modules/ui/input/components/TextArea.tsx b/packages/twenty-front/src/modules/ui/input/components/TextArea.tsx index 4a9a42986e..e2af0665bb 100644 --- a/packages/twenty-front/src/modules/ui/input/components/TextArea.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/TextArea.tsx @@ -3,9 +3,9 @@ import { FocusEventHandler, useId } from 'react'; import TextareaAutosize from 'react-textarea-autosize'; import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; +import { RGBA } from 'twenty-ui/theme'; import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly'; import { InputHotkeyScope } from '../types/InputHotkeyScope'; -import { RGBA } from 'twenty-ui/theme'; const MAX_ROWS = 5; @@ -89,7 +89,9 @@ export const TextArea = ({ } = usePreviousHotkeyScope(); const handleFocus: FocusEventHandler = () => { - setHotkeyScopeAndMemorizePreviousScope(InputHotkeyScope.TextInput); + setHotkeyScopeAndMemorizePreviousScope({ + scope: InputHotkeyScope.TextInput, + }); }; const handleBlur: FocusEventHandler = () => { diff --git a/packages/twenty-front/src/modules/ui/input/components/TextInput.tsx b/packages/twenty-front/src/modules/ui/input/components/TextInput.tsx index 9262451f0a..d9b1e298cd 100644 --- a/packages/twenty-front/src/modules/ui/input/components/TextInput.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/TextInput.tsx @@ -55,7 +55,9 @@ export const TextInput = ({ setIsFocused(true); if (!disableHotkeys) { - setHotkeyScopeAndMemorizePreviousScope(InputHotkeyScope.TextInput); + setHotkeyScopeAndMemorizePreviousScope({ + scope: InputHotkeyScope.TextInput, + }); } }; diff --git a/packages/twenty-front/src/modules/ui/input/components/TitleInput.tsx b/packages/twenty-front/src/modules/ui/input/components/TitleInput.tsx index 53eb5d6d27..c6c544f6d8 100644 --- a/packages/twenty-front/src/modules/ui/input/components/TitleInput.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/TitleInput.tsx @@ -170,7 +170,9 @@ export const TitleInput = ({ onClick={() => { if (!disabled) { setIsOpened(true); - setHotkeyScopeAndMemorizePreviousScope(hotkeyScope); + setHotkeyScopeAndMemorizePreviousScope({ + scope: hotkeyScope, + }); } }} > diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useDropdown.ts b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useDropdown.ts index 393bbe56a1..2835fcad6f 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useDropdown.ts +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useDropdown.ts @@ -65,10 +65,10 @@ export const useDropdown = (dropdownId?: string) => { dropdownHotkeyScopeFromProps ?? dropdownHotkeyScope; if (isDefined(dropdownHotkeyScopeForOpening)) { - setHotkeyScopeAndMemorizePreviousScope( - dropdownHotkeyScopeForOpening.scope, - dropdownHotkeyScopeForOpening.customScopes, - ); + setHotkeyScopeAndMemorizePreviousScope({ + scope: dropdownHotkeyScopeForOpening.scope, + customScopes: dropdownHotkeyScopeForOpening.customScopes, + }); } } }, diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useDropdownV2.ts b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useDropdownV2.ts index e35b2e4350..9cefe8f114 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useDropdownV2.ts +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useDropdownV2.ts @@ -56,15 +56,15 @@ export const useDropdownV2 = () => { ); if (isDefined(customHotkeyScope)) { - setHotkeyScopeAndMemorizePreviousScope( - customHotkeyScope.scope, - customHotkeyScope.customScopes, - ); + setHotkeyScopeAndMemorizePreviousScope({ + scope: customHotkeyScope.scope, + customScopes: customHotkeyScope.customScopes, + }); } else if (isDefined(dropdownHotkeyScope)) { - setHotkeyScopeAndMemorizePreviousScope( - dropdownHotkeyScope.scope, - dropdownHotkeyScope.customScopes, - ); + setHotkeyScopeAndMemorizePreviousScope({ + scope: dropdownHotkeyScope.scope, + customScopes: dropdownHotkeyScope.customScopes, + }); } }, [setHotkeyScopeAndMemorizePreviousScope], diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useOpenDropdownFromOutside.ts b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useOpenDropdownFromOutside.ts index 8ea2bae6d7..7e1c98ab19 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useOpenDropdownFromOutside.ts +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useOpenDropdownFromOutside.ts @@ -19,7 +19,9 @@ export const useOpenDropdownFromOutside = () => { ); setActiveDropdownFocusIdAndMemorizePrevious(dropdownId); - setHotkeyScopeAndMemorizePreviousScope(dropdownId); + setHotkeyScopeAndMemorizePreviousScope({ + scope: dropdownId, + }); set(dropdownOpenState, true); }; diff --git a/packages/twenty-front/src/modules/ui/layout/modal/components/ModalHotkeysAndClickOutsideEffect.tsx b/packages/twenty-front/src/modules/ui/layout/modal/components/ModalHotkeysAndClickOutsideEffect.tsx index cbb9e649dc..18f8df02ad 100644 --- a/packages/twenty-front/src/modules/ui/layout/modal/components/ModalHotkeysAndClickOutsideEffect.tsx +++ b/packages/twenty-front/src/modules/ui/layout/modal/components/ModalHotkeysAndClickOutsideEffect.tsx @@ -1,6 +1,6 @@ import { ModalHotkeyScope } from '@/ui/layout/modal/components/types/ModalHotkeyScope'; import { MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_CLASS_NAME } from '@/ui/layout/modal/constants/ModalClickOutsideListenerExcludedClassName'; -import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; +import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { Key } from 'ts-key-enum'; @@ -19,27 +19,36 @@ export const ModalHotkeysAndClickOutsideEffect = ({ onClose, modalId, }: ModalHotkeysAndClickOutsideEffectProps) => { - useScopedHotkeys( - [Key.Enter], - () => { + useHotkeysOnFocusedElement({ + keys: [Key.Enter], + callback: () => { onEnter?.(); }, - ModalHotkeyScope.ModalFocus, - ); + focusId: modalId, + // TODO: Remove this once we've migrated hotkey scopes to the new api + scope: ModalHotkeyScope.ModalFocus, + dependencies: [onEnter], + }); - useScopedHotkeys( - [Key.Escape], - () => { + useHotkeysOnFocusedElement({ + keys: [Key.Escape], + callback: () => { if (isClosable && onClose !== undefined) { onClose(); } }, - ModalHotkeyScope.ModalFocus, - ); + focusId: modalId, + // TODO: Remove this once we've migrated hotkey scopes to the new api + scope: ModalHotkeyScope.ModalFocus, + dependencies: [isClosable, onClose], + }); useListenClickOutside({ refs: [modalRef], - excludeClassNames: [MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_CLASS_NAME], + excludeClassNames: [ + MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_CLASS_NAME, + 'dialog-manager-dialog', + ], listenerId: `MODAL_CLICK_OUTSIDE_LISTENER_ID_${modalId}`, callback: () => { if (isClosable && onClose !== undefined) { diff --git a/packages/twenty-front/src/modules/ui/layout/modal/components/__stories__/ConfirmationModal.stories.tsx b/packages/twenty-front/src/modules/ui/layout/modal/components/__stories__/ConfirmationModal.stories.tsx index 6990b9429a..7b40c76724 100644 --- a/packages/twenty-front/src/modules/ui/layout/modal/components/__stories__/ConfirmationModal.stories.tsx +++ b/packages/twenty-front/src/modules/ui/layout/modal/components/__stories__/ConfirmationModal.stories.tsx @@ -2,6 +2,8 @@ import { Meta, StoryObj } from '@storybook/react'; import { expect, fn, userEvent, waitFor, within } from '@storybook/test'; import { ModalHotkeyScope } from '@/ui/layout/modal/components/types/ModalHotkeyScope'; +import { focusStackState } from '@/ui/utilities/focus/states/focusStackState'; +import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState'; import { internalHotkeysEnabledScopesState } from '@/ui/utilities/hotkey/states/internal/internalHotkeysEnabledScopesState'; import { ComponentDecorator } from 'twenty-ui/testing'; @@ -29,6 +31,20 @@ const initializeState = ({ set }: { set: (atom: any, value: any) => void }) => { }); set(internalHotkeysEnabledScopesState, [ModalHotkeyScope.ModalFocus]); + + set(focusStackState, [ + { + focusId: 'confirmation-modal', + componentInstance: { + componentType: FocusComponentType.MODAL, + componentInstanceId: 'confirmation-modal', + }, + globalHotkeysConfig: { + enableGlobalHotkeysWithModifiers: true, + enableGlobalHotkeysConflictingWithKeyboard: true, + }, + }, + ]); }; const meta: Meta = { diff --git a/packages/twenty-front/src/modules/ui/layout/modal/components/__stories__/Modal.stories.tsx b/packages/twenty-front/src/modules/ui/layout/modal/components/__stories__/Modal.stories.tsx index e75b044854..3d046d75c1 100644 --- a/packages/twenty-front/src/modules/ui/layout/modal/components/__stories__/Modal.stories.tsx +++ b/packages/twenty-front/src/modules/ui/layout/modal/components/__stories__/Modal.stories.tsx @@ -2,6 +2,8 @@ import { Meta, StoryObj } from '@storybook/react'; import { expect, fn, userEvent, waitFor, within } from '@storybook/test'; import { ModalHotkeyScope } from '@/ui/layout/modal/components/types/ModalHotkeyScope'; +import { focusStackState } from '@/ui/utilities/focus/states/focusStackState'; +import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState'; import { internalHotkeysEnabledScopesState } from '@/ui/utilities/hotkey/states/internal/internalHotkeysEnabledScopesState'; import { ComponentDecorator } from 'twenty-ui/testing'; @@ -29,6 +31,20 @@ const initializeState = ({ set }: { set: (atom: any, value: any) => void }) => { }); set(internalHotkeysEnabledScopesState, [ModalHotkeyScope.ModalFocus]); + + set(focusStackState, [ + { + focusId: 'modal-id', + componentInstance: { + componentType: FocusComponentType.MODAL, + componentInstanceId: 'modal-id', + }, + globalHotkeysConfig: { + enableGlobalHotkeysWithModifiers: true, + enableGlobalHotkeysConflictingWithKeyboard: true, + }, + }, + ]); }; const meta: Meta = { diff --git a/packages/twenty-front/src/modules/ui/layout/modal/hooks/__tests__/useModal.spec.ts b/packages/twenty-front/src/modules/ui/layout/modal/hooks/__tests__/useModal.spec.ts index fd1e18c390..5f9511e1bc 100644 --- a/packages/twenty-front/src/modules/ui/layout/modal/hooks/__tests__/useModal.spec.ts +++ b/packages/twenty-front/src/modules/ui/layout/modal/hooks/__tests__/useModal.spec.ts @@ -4,7 +4,6 @@ import { RecoilRoot, useRecoilValue } from 'recoil'; import { useModal } from '@/ui/layout/modal/hooks/useModal'; import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState'; import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; -import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; import { act } from 'react'; jest.mock('@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'); @@ -13,13 +12,6 @@ const mockSetHotkeyScopeAndMemorizePreviousScope = jest.fn(); const mockGoBackToPreviousHotkeyScope = jest.fn(); const modalId = 'test-modal-id'; -const customHotkeyScope: HotkeyScope = { - scope: 'test-scope', - customScopes: { - goto: true, - commandMenu: true, - }, -}; describe('useModal', () => { beforeEach(() => { @@ -52,31 +44,6 @@ describe('useModal', () => { expect(result.current.isModalOpened).toBe(true); }); - it('should open a modal with custom hotkey scope', () => { - const { result } = renderHook( - () => { - const modal = useModal(); - const isModalOpened = useRecoilValue( - isModalOpenedComponentState.atomFamily({ instanceId: modalId }), - ); - return { modal, isModalOpened }; - }, - { - wrapper: RecoilRoot, - }, - ); - - act(() => { - result.current.modal.openModal(modalId, customHotkeyScope); - }); - - expect(result.current.isModalOpened).toBe(true); - expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith( - customHotkeyScope.scope, - customHotkeyScope.customScopes, - ); - }); - it('should close a modal', () => { const { result } = renderHook( () => { @@ -153,29 +120,4 @@ describe('useModal', () => { expect(result.current.isModalOpened).toBe(false); expect(mockGoBackToPreviousHotkeyScope).toHaveBeenCalled(); }); - - it('should toggle a modal with custom hotkey scope', () => { - const { result } = renderHook( - () => { - const modal = useModal(); - const isModalOpened = useRecoilValue( - isModalOpenedComponentState.atomFamily({ instanceId: modalId }), - ); - return { modal, isModalOpened }; - }, - { - wrapper: RecoilRoot, - }, - ); - - act(() => { - result.current.modal.toggleModal(modalId, customHotkeyScope); - }); - - expect(result.current.isModalOpened).toBe(true); - expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith( - customHotkeyScope.scope, - customHotkeyScope.customScopes, - ); - }); }); diff --git a/packages/twenty-front/src/modules/ui/layout/modal/hooks/useModal.tsx b/packages/twenty-front/src/modules/ui/layout/modal/hooks/useModal.tsx index fe81cdb4ec..da2a9c9a10 100644 --- a/packages/twenty-front/src/modules/ui/layout/modal/hooks/useModal.tsx +++ b/packages/twenty-front/src/modules/ui/layout/modal/hooks/useModal.tsx @@ -1,16 +1,13 @@ -import { useRecoilCallback } from 'recoil'; - import { ModalHotkeyScope } from '@/ui/layout/modal/components/types/ModalHotkeyScope'; import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState'; -import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; -import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; -import { isDefined } from 'twenty-shared/utils'; +import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack'; +import { useRemoveFocusIdFromFocusStack } from '@/ui/utilities/focus/hooks/useRemoveFocusIdFromFocusStack'; +import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; +import { useRecoilCallback } from 'recoil'; export const useModal = () => { - const { - setHotkeyScopeAndMemorizePreviousScope, - goBackToPreviousHotkeyScope, - } = usePreviousHotkeyScope('modal'); + const pushFocusItem = usePushFocusItemToFocusStack(); + const removeFocusId = useRemoveFocusIdFromFocusStack(); const closeModal = useRecoilCallback( ({ set, snapshot }) => @@ -21,20 +18,26 @@ export const useModal = () => { ) .getValue(); - if (isModalOpen) { - goBackToPreviousHotkeyScope(); - set( - isModalOpenedComponentState.atomFamily({ instanceId: modalId }), - false, - ); + if (!isModalOpen) { + return; } + + removeFocusId({ + focusId: modalId, + memoizeKey: modalId, + }); + + set( + isModalOpenedComponentState.atomFamily({ instanceId: modalId }), + false, + ); }, - [goBackToPreviousHotkeyScope], + [removeFocusId], ); const openModal = useRecoilCallback( ({ set, snapshot }) => - (modalId: string, customHotkeyScope?: HotkeyScope) => { + (modalId: string) => { const isModalOpened = snapshot .getLoadable( isModalOpenedComponentState.atomFamily({ instanceId: modalId }), @@ -50,26 +53,35 @@ export const useModal = () => { true, ); - if (isDefined(customHotkeyScope)) { - setHotkeyScopeAndMemorizePreviousScope( - customHotkeyScope.scope, - customHotkeyScope.customScopes, - ); - } else { - setHotkeyScopeAndMemorizePreviousScope(ModalHotkeyScope.ModalFocus, { - goto: false, - commandMenu: false, - commandMenuOpen: false, - keyboardShortcutMenu: false, - }); - } + pushFocusItem({ + focusId: modalId, + component: { + type: FocusComponentType.MODAL, + instanceId: modalId, + }, + globalHotkeysConfig: { + enableGlobalHotkeysWithModifiers: false, + enableGlobalHotkeysConflictingWithKeyboard: false, + }, + // TODO: Remove this once we've migrated hotkey scopes to the new api + hotkeyScope: { + scope: ModalHotkeyScope.ModalFocus, + customScopes: { + goto: false, + commandMenu: false, + commandMenuOpen: false, + keyboardShortcutMenu: false, + }, + }, + memoizeKey: modalId, + }); }, - [setHotkeyScopeAndMemorizePreviousScope], + [pushFocusItem], ); const toggleModal = useRecoilCallback( ({ snapshot }) => - (modalId: string, customHotkeyScope?: HotkeyScope) => { + (modalId: string) => { const isModalOpen = snapshot .getLoadable( isModalOpenedComponentState.atomFamily({ instanceId: modalId }), @@ -79,7 +91,7 @@ export const useModal = () => { if (isModalOpen) { closeModal(modalId); } else { - openModal(modalId, customHotkeyScope); + openModal(modalId); } }, [closeModal, openModal], diff --git a/packages/twenty-front/src/modules/ui/utilities/focus/hooks/__tests__/usePushFocusItemToFocusStack.test.tsx b/packages/twenty-front/src/modules/ui/utilities/focus/hooks/__tests__/usePushFocusItemToFocusStack.test.tsx new file mode 100644 index 0000000000..f79ac064cd --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/focus/hooks/__tests__/usePushFocusItemToFocusStack.test.tsx @@ -0,0 +1,90 @@ +import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack'; +import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector'; +import { focusStackState } from '@/ui/utilities/focus/states/focusStackState'; +import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { RecoilRoot, useRecoilValue } from 'recoil'; + +const renderHooks = () => { + const { result } = renderHook( + () => { + const pushFocusItem = usePushFocusItemToFocusStack(); + const focusStack = useRecoilValue(focusStackState); + const currentFocusId = useRecoilValue(currentFocusIdSelector); + + return { + pushFocusItem, + focusStack, + currentFocusId, + }; + }, + { + wrapper: RecoilRoot, + }, + ); + + return { result }; +}; + +describe('usePushFocusItemToFocusStack', () => { + it('should push focus item to the stack', async () => { + const { result } = renderHooks(); + + expect(result.current.focusStack).toEqual([]); + + const focusItem = { + focusId: 'test-focus-id', + componentInstance: { + componentType: FocusComponentType.MODAL, + componentInstanceId: 'test-instance-id', + }, + globalHotkeysConfig: { + enableGlobalHotkeysWithModifiers: true, + enableGlobalHotkeysConflictingWithKeyboard: true, + }, + }; + + await act(async () => { + result.current.pushFocusItem({ + focusId: focusItem.focusId, + component: { + type: focusItem.componentInstance.componentType, + instanceId: focusItem.componentInstance.componentInstanceId, + }, + hotkeyScope: { scope: 'test-scope' }, + memoizeKey: 'global', + }); + }); + + expect(result.current.focusStack).toEqual([focusItem]); + expect(result.current.currentFocusId).toEqual(focusItem.focusId); + + const anotherFocusItem = { + focusId: 'another-focus-id', + componentInstance: { + componentType: FocusComponentType.MODAL, + componentInstanceId: 'another-instance-id', + }, + globalHotkeysConfig: { + enableGlobalHotkeysWithModifiers: true, + enableGlobalHotkeysConflictingWithKeyboard: true, + }, + }; + + await act(async () => { + result.current.pushFocusItem({ + focusId: anotherFocusItem.focusId, + component: { + type: anotherFocusItem.componentInstance.componentType, + instanceId: anotherFocusItem.componentInstance.componentInstanceId, + }, + hotkeyScope: { scope: 'test-scope' }, + memoizeKey: 'global', + }); + }); + + expect(result.current.focusStack).toEqual([focusItem, anotherFocusItem]); + expect(result.current.currentFocusId).toEqual(anotherFocusItem.focusId); + }); +}); diff --git a/packages/twenty-front/src/modules/ui/utilities/focus/hooks/__tests__/useRemoveFocusIdFromFocusStack.test.tsx b/packages/twenty-front/src/modules/ui/utilities/focus/hooks/__tests__/useRemoveFocusIdFromFocusStack.test.tsx new file mode 100644 index 0000000000..20dd54ae2b --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/focus/hooks/__tests__/useRemoveFocusIdFromFocusStack.test.tsx @@ -0,0 +1,101 @@ +import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack'; +import { useRemoveFocusIdFromFocusStack } from '@/ui/utilities/focus/hooks/useRemoveFocusIdFromFocusStack'; +import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector'; +import { focusStackState } from '@/ui/utilities/focus/states/focusStackState'; +import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { RecoilRoot, useRecoilValue } from 'recoil'; + +const renderHooks = () => { + const { result } = renderHook( + () => { + const pushFocusItem = usePushFocusItemToFocusStack(); + const removeFocusId = useRemoveFocusIdFromFocusStack(); + const focusStack = useRecoilValue(focusStackState); + const currentFocusId = useRecoilValue(currentFocusIdSelector); + + return { + pushFocusItem, + removeFocusId, + focusStack, + currentFocusId, + }; + }, + { + wrapper: RecoilRoot, + }, + ); + + return { result }; +}; + +describe('useRemoveFocusIdFromFocusStack', () => { + it('should remove focus id from the stack', async () => { + const { result } = renderHooks(); + + const firstFocusItem = { + focusId: 'first-focus-id', + componentInstance: { + componentType: FocusComponentType.MODAL, + componentInstanceId: 'first-instance-id', + }, + globalHotkeysConfig: { + enableGlobalHotkeysWithModifiers: true, + enableGlobalHotkeysConflictingWithKeyboard: true, + }, + }; + + const secondFocusItem = { + focusId: 'second-focus-id', + componentInstance: { + componentType: FocusComponentType.MODAL, + componentInstanceId: 'second-instance-id', + }, + globalHotkeysConfig: { + enableGlobalHotkeysWithModifiers: true, + enableGlobalHotkeysConflictingWithKeyboard: true, + }, + }; + + await act(async () => { + result.current.pushFocusItem({ + focusId: firstFocusItem.focusId, + component: { + type: firstFocusItem.componentInstance.componentType, + instanceId: firstFocusItem.componentInstance.componentInstanceId, + }, + hotkeyScope: { scope: 'test-scope' }, + memoizeKey: 'global', + }); + }); + + await act(async () => { + result.current.pushFocusItem({ + focusId: secondFocusItem.focusId, + component: { + type: secondFocusItem.componentInstance.componentType, + instanceId: secondFocusItem.componentInstance.componentInstanceId, + }, + hotkeyScope: { scope: 'test-scope' }, + memoizeKey: 'global', + }); + }); + + expect(result.current.focusStack).toEqual([ + firstFocusItem, + secondFocusItem, + ]); + expect(result.current.currentFocusId).toEqual(secondFocusItem.focusId); + + await act(async () => { + result.current.removeFocusId({ + focusId: firstFocusItem.focusId, + memoizeKey: 'global', + }); + }); + + expect(result.current.focusStack).toEqual([secondFocusItem]); + expect(result.current.currentFocusId).toEqual(secondFocusItem.focusId); + }); +}); diff --git a/packages/twenty-front/src/modules/ui/utilities/focus/hooks/__tests__/useResetFocusStack.test.tsx b/packages/twenty-front/src/modules/ui/utilities/focus/hooks/__tests__/useResetFocusStack.test.tsx new file mode 100644 index 0000000000..481fe4836c --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/focus/hooks/__tests__/useResetFocusStack.test.tsx @@ -0,0 +1,71 @@ +import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack'; +import { useResetFocusStack } from '@/ui/utilities/focus/hooks/useResetFocusStack'; +import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector'; +import { focusStackState } from '@/ui/utilities/focus/states/focusStackState'; +import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { RecoilRoot, useRecoilValue } from 'recoil'; + +const renderHooks = () => { + const { result } = renderHook( + () => { + const pushFocusItem = usePushFocusItemToFocusStack(); + const resetFocusStack = useResetFocusStack(); + const focusStack = useRecoilValue(focusStackState); + const currentFocusId = useRecoilValue(currentFocusIdSelector); + + return { + pushFocusItem, + resetFocusStack, + focusStack, + currentFocusId, + }; + }, + { + wrapper: RecoilRoot, + }, + ); + + return { result }; +}; + +describe('useResetFocusStack', () => { + it('should reset the focus stack', async () => { + const { result } = renderHooks(); + + const focusItem = { + focusId: 'test-focus-id', + componentInstance: { + componentType: FocusComponentType.MODAL, + componentInstanceId: 'test-instance-id', + }, + globalHotkeysConfig: { + enableGlobalHotkeysWithModifiers: true, + enableGlobalHotkeysConflictingWithKeyboard: true, + }, + }; + + await act(async () => { + result.current.pushFocusItem({ + focusId: focusItem.focusId, + component: { + type: focusItem.componentInstance.componentType, + instanceId: focusItem.componentInstance.componentInstanceId, + }, + hotkeyScope: { scope: 'test-scope' }, + memoizeKey: 'global', + }); + }); + + expect(result.current.focusStack).toEqual([focusItem]); + expect(result.current.currentFocusId).toEqual(focusItem.focusId); + + await act(async () => { + result.current.resetFocusStack(); + }); + + expect(result.current.focusStack).toEqual([]); + expect(result.current.currentFocusId).toEqual(undefined); + }); +}); diff --git a/packages/twenty-front/src/modules/ui/utilities/focus/hooks/__tests__/useResetFocusStackToFocusItem.test.tsx b/packages/twenty-front/src/modules/ui/utilities/focus/hooks/__tests__/useResetFocusStackToFocusItem.test.tsx new file mode 100644 index 0000000000..af4171373d --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/focus/hooks/__tests__/useResetFocusStackToFocusItem.test.tsx @@ -0,0 +1,102 @@ +import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack'; +import { useResetFocusStackToFocusItem } from '@/ui/utilities/focus/hooks/useResetFocusStackToFocusItem'; +import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector'; +import { focusStackState } from '@/ui/utilities/focus/states/focusStackState'; +import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { RecoilRoot, useRecoilValue } from 'recoil'; + +const renderHooks = () => { + const { result } = renderHook( + () => { + const pushFocusItem = usePushFocusItemToFocusStack(); + const resetFocusStackToFocusItem = useResetFocusStackToFocusItem(); + const focusStack = useRecoilValue(focusStackState); + const currentFocusId = useRecoilValue(currentFocusIdSelector); + + return { + pushFocusItem, + resetFocusStackToFocusItem, + focusStack, + currentFocusId, + }; + }, + { + wrapper: RecoilRoot, + }, + ); + + return { result }; +}; + +describe('useResetFocusStackToFocusItem', () => { + it('should reset the focus stack to a specific focus item', async () => { + const { result } = renderHooks(); + + const firstFocusItem = { + focusId: 'first-focus-id', + componentInstance: { + componentType: FocusComponentType.MODAL, + componentInstanceId: 'first-instance-id', + }, + globalHotkeysConfig: { + enableGlobalHotkeysWithModifiers: true, + enableGlobalHotkeysConflictingWithKeyboard: true, + }, + }; + + const secondFocusItem = { + focusId: 'second-focus-id', + componentInstance: { + componentType: FocusComponentType.MODAL, + componentInstanceId: 'second-instance-id', + }, + globalHotkeysConfig: { + enableGlobalHotkeysWithModifiers: true, + enableGlobalHotkeysConflictingWithKeyboard: true, + }, + }; + + await act(async () => { + result.current.pushFocusItem({ + focusId: firstFocusItem.focusId, + component: { + type: firstFocusItem.componentInstance.componentType, + instanceId: firstFocusItem.componentInstance.componentInstanceId, + }, + hotkeyScope: { scope: 'test-scope' }, + memoizeKey: 'global', + }); + }); + + await act(async () => { + result.current.pushFocusItem({ + focusId: secondFocusItem.focusId, + component: { + type: secondFocusItem.componentInstance.componentType, + instanceId: secondFocusItem.componentInstance.componentInstanceId, + }, + hotkeyScope: { scope: 'test-scope' }, + memoizeKey: 'global', + }); + }); + + expect(result.current.focusStack).toEqual([ + firstFocusItem, + secondFocusItem, + ]); + expect(result.current.currentFocusId).toEqual(secondFocusItem.focusId); + + await act(async () => { + result.current.resetFocusStackToFocusItem({ + focusStackItem: firstFocusItem, + hotkeyScope: { scope: 'test-scope' }, + memoizeKey: 'global', + }); + }); + + expect(result.current.focusStack).toEqual([firstFocusItem]); + expect(result.current.currentFocusId).toEqual(firstFocusItem.focusId); + }); +}); diff --git a/packages/twenty-front/src/modules/ui/utilities/focus/hooks/usePushFocusItemToFocusStack.ts b/packages/twenty-front/src/modules/ui/utilities/focus/hooks/usePushFocusItemToFocusStack.ts new file mode 100644 index 0000000000..49173eadde --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/focus/hooks/usePushFocusItemToFocusStack.ts @@ -0,0 +1,71 @@ +import { focusStackState } from '@/ui/utilities/focus/states/focusStackState'; +import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; +import { FocusStackItem } from '@/ui/utilities/focus/types/FocusStackItem'; +import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; +import { GlobalHotkeysConfig } from '@/ui/utilities/hotkey/types/GlobalHotkeysConfig'; +import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; +import { useRecoilCallback } from 'recoil'; + +export const usePushFocusItemToFocusStack = () => { + const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope(); + + const addOrMoveItemToTheTopOfTheStack = useRecoilCallback( + ({ set }) => + (focusStackItem: FocusStackItem) => { + set(focusStackState, (currentFocusStack) => [ + ...currentFocusStack.filter( + (currentFocusStackItem) => + currentFocusStackItem.focusId !== focusStackItem.focusId, + ), + focusStackItem, + ]); + }, + [], + ); + + return useRecoilCallback( + () => + ({ + focusId, + component, + hotkeyScope, + memoizeKey = 'global', + globalHotkeysConfig, + }: { + focusId: string; + component: { + type: FocusComponentType; + instanceId: string; + }; + globalHotkeysConfig?: Partial; + // TODO: Remove this once we've migrated hotkey scopes to the new api + hotkeyScope: HotkeyScope; + memoizeKey: string; + }) => { + const focusStackItem: FocusStackItem = { + focusId, + componentInstance: { + componentType: component.type, + componentInstanceId: component.instanceId, + }, + globalHotkeysConfig: { + enableGlobalHotkeysWithModifiers: + globalHotkeysConfig?.enableGlobalHotkeysWithModifiers ?? true, + enableGlobalHotkeysConflictingWithKeyboard: + globalHotkeysConfig?.enableGlobalHotkeysConflictingWithKeyboard ?? + true, + }, + }; + + addOrMoveItemToTheTopOfTheStack(focusStackItem); + + // TODO: Remove this once we've migrated hotkey scopes to the new api + setHotkeyScopeAndMemorizePreviousScope({ + scope: hotkeyScope.scope, + customScopes: hotkeyScope.customScopes, + memoizeKey, + }); + }, + [setHotkeyScopeAndMemorizePreviousScope, addOrMoveItemToTheTopOfTheStack], + ); +}; diff --git a/packages/twenty-front/src/modules/ui/utilities/focus/hooks/useRemoveFocusIdFromFocusStack.ts b/packages/twenty-front/src/modules/ui/utilities/focus/hooks/useRemoveFocusIdFromFocusStack.ts new file mode 100644 index 0000000000..c966e92be9 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/focus/hooks/useRemoveFocusIdFromFocusStack.ts @@ -0,0 +1,22 @@ +import { focusStackState } from '@/ui/utilities/focus/states/focusStackState'; +import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; +import { useRecoilCallback } from 'recoil'; + +export const useRemoveFocusIdFromFocusStack = () => { + const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope(); + + return useRecoilCallback( + ({ set }) => + ({ focusId, memoizeKey }: { focusId: string; memoizeKey: string }) => { + set(focusStackState, (previousFocusStack) => + previousFocusStack.filter( + (focusStackItem) => focusStackItem.focusId !== focusId, + ), + ); + + // TODO: Remove this once we've migrated hotkey scopes to the new api + goBackToPreviousHotkeyScope(memoizeKey); + }, + [goBackToPreviousHotkeyScope], + ); +}; diff --git a/packages/twenty-front/src/modules/ui/utilities/focus/hooks/useResetFocusStack.ts b/packages/twenty-front/src/modules/ui/utilities/focus/hooks/useResetFocusStack.ts new file mode 100644 index 0000000000..c22529b6aa --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/focus/hooks/useResetFocusStack.ts @@ -0,0 +1,18 @@ +import { focusStackState } from '@/ui/utilities/focus/states/focusStackState'; +import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState'; +import { previousHotkeyScopeFamilyState } from '@/ui/utilities/hotkey/states/internal/previousHotkeyScopeFamilyState'; +import { useRecoilCallback } from 'recoil'; + +export const useResetFocusStack = () => { + return useRecoilCallback( + ({ reset }) => + (memoizeKey = 'global') => { + reset(focusStackState); + + // TODO: Remove this once we've migrated hotkey scopes to the new api + reset(previousHotkeyScopeFamilyState(memoizeKey as string)); + reset(currentHotkeyScopeState); + }, + [], + ); +}; diff --git a/packages/twenty-front/src/modules/ui/utilities/focus/hooks/useResetFocusStackToFocusItem.ts b/packages/twenty-front/src/modules/ui/utilities/focus/hooks/useResetFocusStackToFocusItem.ts new file mode 100644 index 0000000000..270a8db4ee --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/focus/hooks/useResetFocusStackToFocusItem.ts @@ -0,0 +1,28 @@ +import { focusStackState } from '@/ui/utilities/focus/states/focusStackState'; +import { FocusStackItem } from '@/ui/utilities/focus/types/FocusStackItem'; +import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState'; +import { previousHotkeyScopeFamilyState } from '@/ui/utilities/hotkey/states/internal/previousHotkeyScopeFamilyState'; +import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; +import { useRecoilCallback } from 'recoil'; + +export const useResetFocusStackToFocusItem = () => { + return useRecoilCallback( + ({ set }) => + ({ + focusStackItem, + hotkeyScope, + memoizeKey, + }: { + focusStackItem: FocusStackItem; + hotkeyScope: HotkeyScope; + memoizeKey: string; + }) => { + set(focusStackState, [focusStackItem]); + + // TODO: Remove this once we've migrated hotkey scopes to the new api + set(previousHotkeyScopeFamilyState(memoizeKey), null); + set(currentHotkeyScopeState, hotkeyScope); + }, + [], + ); +}; diff --git a/packages/twenty-front/src/modules/ui/utilities/focus/states/currentFocusIdSelector.ts b/packages/twenty-front/src/modules/ui/utilities/focus/states/currentFocusIdSelector.ts new file mode 100644 index 0000000000..606256ee0d --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/focus/states/currentFocusIdSelector.ts @@ -0,0 +1,11 @@ +import { selector } from 'recoil'; +import { focusStackState } from './focusStackState'; + +export const currentFocusIdSelector = selector({ + key: 'currentFocusIdSelector', + get: ({ get }) => { + const focusStack = get(focusStackState); + + return focusStack.at(-1)?.focusId; + }, +}); diff --git a/packages/twenty-front/src/modules/ui/utilities/focus/states/currentGlobalHotkeysConfigSelector.ts b/packages/twenty-front/src/modules/ui/utilities/focus/states/currentGlobalHotkeysConfigSelector.ts new file mode 100644 index 0000000000..f49871a247 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/focus/states/currentGlobalHotkeysConfigSelector.ts @@ -0,0 +1,21 @@ +import { focusStackState } from '@/ui/utilities/focus/states/focusStackState'; +import { DEFAULT_GLOBAL_HOTKEYS_CONFIG } from '@/ui/utilities/hotkey/constants/DefaultGlobalHotkeysConfig'; +import { GlobalHotkeysConfig } from '@/ui/utilities/hotkey/types/GlobalHotkeysConfig'; +import { selector } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + +export const currentGlobalHotkeysConfigSelector = selector( + { + key: 'currentGlobalHotkeysConfigSelector', + get: ({ get }) => { + const focusStack = get(focusStackState); + const lastFocusStackItem = focusStack.at(-1); + + if (!isDefined(lastFocusStackItem)) { + return DEFAULT_GLOBAL_HOTKEYS_CONFIG; + } + + return lastFocusStackItem.globalHotkeysConfig; + }, + }, +); diff --git a/packages/twenty-front/src/modules/ui/utilities/focus/states/focusStackState.ts b/packages/twenty-front/src/modules/ui/utilities/focus/states/focusStackState.ts new file mode 100644 index 0000000000..9581e78c6d --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/focus/states/focusStackState.ts @@ -0,0 +1,7 @@ +import { FocusStackItem } from '@/ui/utilities/focus/types/FocusStackItem'; +import { createState } from 'twenty-ui/utilities'; + +export const focusStackState = createState({ + key: 'focusStackState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/ui/utilities/focus/types/FocusComponentInstance.ts b/packages/twenty-front/src/modules/ui/utilities/focus/types/FocusComponentInstance.ts new file mode 100644 index 0000000000..9dda63e1ef --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/focus/types/FocusComponentInstance.ts @@ -0,0 +1,6 @@ +import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; + +export type FocusComponentInstance = { + componentType: FocusComponentType; + componentInstanceId: string; +}; diff --git a/packages/twenty-front/src/modules/ui/utilities/focus/types/FocusComponentType.ts b/packages/twenty-front/src/modules/ui/utilities/focus/types/FocusComponentType.ts new file mode 100644 index 0000000000..7562a2d4e8 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/focus/types/FocusComponentType.ts @@ -0,0 +1,3 @@ +export enum FocusComponentType { + MODAL = 'modal', +} diff --git a/packages/twenty-front/src/modules/ui/utilities/focus/types/FocusStackItem.ts b/packages/twenty-front/src/modules/ui/utilities/focus/types/FocusStackItem.ts new file mode 100644 index 0000000000..262547f397 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/focus/types/FocusStackItem.ts @@ -0,0 +1,8 @@ +import { FocusComponentInstance } from '@/ui/utilities/focus/types/FocusComponentInstance'; +import { GlobalHotkeysConfig } from '@/ui/utilities/hotkey/types/GlobalHotkeysConfig'; + +export type FocusStackItem = { + focusId: string; + componentInstance: FocusComponentInstance; + globalHotkeysConfig: GlobalHotkeysConfig; +}; diff --git a/packages/twenty-front/src/modules/ui/utilities/hotkey/constants/DebugHotkeyScope.ts b/packages/twenty-front/src/modules/ui/utilities/hotkey/constants/DebugHotkeyScope.ts new file mode 100644 index 0000000000..0ac5ab524a --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/hotkey/constants/DebugHotkeyScope.ts @@ -0,0 +1 @@ +export const DEBUG_HOTKEY_SCOPE = false; diff --git a/packages/twenty-front/src/modules/ui/utilities/hotkey/constants/DefaultGlobalHotkeysConfig.ts b/packages/twenty-front/src/modules/ui/utilities/hotkey/constants/DefaultGlobalHotkeysConfig.ts new file mode 100644 index 0000000000..670f7038ee --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/hotkey/constants/DefaultGlobalHotkeysConfig.ts @@ -0,0 +1,6 @@ +import { GlobalHotkeysConfig } from '@/ui/utilities/hotkey/types/GlobalHotkeysConfig'; + +export const DEFAULT_GLOBAL_HOTKEYS_CONFIG: GlobalHotkeysConfig = { + enableGlobalHotkeysWithModifiers: true, + enableGlobalHotkeysConflictingWithKeyboard: true, +}; diff --git a/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useGlobalHotkeys.ts b/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useGlobalHotkeys.ts new file mode 100644 index 0000000000..4d97c51a50 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useGlobalHotkeys.ts @@ -0,0 +1,74 @@ +import { useGlobalHotkeysCallback } from '@/ui/utilities/hotkey/hooks/useGlobalHotkeysCallback'; +import { pendingHotkeyState } from '@/ui/utilities/hotkey/states/internal/pendingHotkeysState'; +import { useHotkeys } from 'react-hotkeys-hook'; +import { HotkeyCallback, Keys, Options } from 'react-hotkeys-hook/dist/types'; +import { useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + +type UseHotkeysOptionsWithoutBuggyOptions = Omit; + +export const useGlobalHotkeys = ( + keys: Keys, + callback: HotkeyCallback, + containsModifier: boolean, + // TODO: Remove this once we've migrated hotkey scopes to the new api + scope: string, + dependencies?: unknown[], + options?: UseHotkeysOptionsWithoutBuggyOptions, +) => { + const callGlobalHotkeysCallback = useGlobalHotkeysCallback(dependencies); + + const enableOnContentEditable = isDefined(options?.enableOnContentEditable) + ? options.enableOnContentEditable + : true; + + const enableOnFormTags = isDefined(options?.enableOnFormTags) + ? options.enableOnFormTags + : true; + + const preventDefault = isDefined(options?.preventDefault) + ? options.preventDefault === true + : true; + + const ignoreModifiers = isDefined(options?.ignoreModifiers) + ? options.ignoreModifiers === true + : false; + + const handleCallback = useRecoilCallback( + ({ snapshot, set }) => + async (keyboardEvent: KeyboardEvent, hotkeysEvent: any) => { + const pendingHotkey = snapshot + .getLoadable(pendingHotkeyState) + .getValue(); + + if (!pendingHotkey) { + callback(keyboardEvent, hotkeysEvent); + } + + set(pendingHotkeyState, null); + }, + [callback], + ); + + return useHotkeys( + keys, + (keyboardEvent, hotkeysEvent) => { + callGlobalHotkeysCallback({ + keyboardEvent, + hotkeysEvent, + callback: () => { + handleCallback(keyboardEvent, hotkeysEvent); + }, + scope, + preventDefault, + containsModifier, + }); + }, + { + enableOnContentEditable, + enableOnFormTags, + ignoreModifiers, + }, + dependencies, + ); +}; diff --git a/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useGlobalHotkeysCallback.ts b/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useGlobalHotkeysCallback.ts new file mode 100644 index 0000000000..23c9fbb5b1 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useGlobalHotkeysCallback.ts @@ -0,0 +1,119 @@ +import { currentGlobalHotkeysConfigSelector } from '@/ui/utilities/focus/states/currentGlobalHotkeysConfigSelector'; +import { internalHotkeysEnabledScopesState } from '@/ui/utilities/hotkey/states/internal/internalHotkeysEnabledScopesState'; +import { + Hotkey, + OptionsOrDependencyArray, +} from 'react-hotkeys-hook/dist/types'; +import { useRecoilCallback } from 'recoil'; +import { logDebug } from '~/utils/logDebug'; +import { DEBUG_HOTKEY_SCOPE } from '../constants/DebugHotkeyScope'; + +export const useGlobalHotkeysCallback = ( + dependencies?: OptionsOrDependencyArray, +) => { + const dependencyArray = Array.isArray(dependencies) ? dependencies : []; + + return useRecoilCallback( + ({ snapshot }) => + ({ + callback, + containsModifier, + hotkeysEvent, + keyboardEvent, + preventDefault, + scope, + }: { + keyboardEvent: KeyboardEvent; + hotkeysEvent: Hotkey; + containsModifier: boolean; + callback: (keyboardEvent: KeyboardEvent, hotkeysEvent: Hotkey) => void; + preventDefault?: boolean; + scope: string; + }) => { + // TODO: Remove this once we've migrated hotkey scopes to the new api + const currentHotkeyScopes = snapshot + .getLoadable(internalHotkeysEnabledScopesState) + .getValue(); + + const currentGlobalHotkeysConfig = snapshot + .getLoadable(currentGlobalHotkeysConfigSelector) + .getValue(); + + if ( + containsModifier && + !currentGlobalHotkeysConfig.enableGlobalHotkeysWithModifiers + ) { + if (DEBUG_HOTKEY_SCOPE) { + logDebug( + `DEBUG: %cI can't call hotkey (${ + hotkeysEvent.keys + }) because global hotkeys with modifiers are disabled`, + 'color: gray; ', + ); + } + + return; + } + + if ( + !containsModifier && + !currentGlobalHotkeysConfig.enableGlobalHotkeysConflictingWithKeyboard + ) { + if (DEBUG_HOTKEY_SCOPE) { + logDebug( + `DEBUG: %cI can't call hotkey (${ + hotkeysEvent.keys + }) because global hotkeys conflicting with keyboard are disabled`, + 'color: gray; ', + ); + } + return; + } + + // TODO: Remove this once we've migrated hotkey scopes to the new api + if (!currentHotkeyScopes.includes(scope)) { + if (DEBUG_HOTKEY_SCOPE) { + logDebug( + `DEBUG: %cI can't call hotkey (${ + hotkeysEvent.keys + }) because I'm in scope [${scope}] and the active scopes are : [${currentHotkeyScopes.join( + ', ', + )}]`, + 'color: gray; ', + ); + } + + return; + } + + // TODO: Remove this once we've migrated hotkey scopes to the new api + if (DEBUG_HOTKEY_SCOPE) { + logDebug( + `DEBUG: %cI can call hotkey (${ + hotkeysEvent.keys + }) because I'm in scope [${scope}] and the active scopes are : [${currentHotkeyScopes.join( + ', ', + )}]`, + 'color: green;', + ); + } + + if (preventDefault === true) { + if (DEBUG_HOTKEY_SCOPE) { + logDebug( + `DEBUG: %cI prevent default for hotkey (${hotkeysEvent.keys})`, + 'color: gray;', + ); + } + + keyboardEvent.stopPropagation(); + keyboardEvent.preventDefault(); + keyboardEvent.stopImmediatePropagation(); + } + + return callback(keyboardEvent, hotkeysEvent); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + dependencyArray, + ); +}; diff --git a/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useSequenceScopedHotkeys.ts b/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useGlobalHotkeysSequence.ts similarity index 84% rename from packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useSequenceScopedHotkeys.ts rename to packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useGlobalHotkeysSequence.ts index 19d5879211..10565ce04e 100644 --- a/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useSequenceScopedHotkeys.ts +++ b/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useGlobalHotkeysSequence.ts @@ -4,10 +4,10 @@ import { useRecoilState } from 'recoil'; import { pendingHotkeyState } from '../states/internal/pendingHotkeysState'; -import { useScopedHotkeyCallback } from './useScopedHotkeyCallback'; +import { useGlobalHotkeysCallback } from '@/ui/utilities/hotkey/hooks/useGlobalHotkeysCallback'; import { isDefined } from 'twenty-shared/utils'; -export const useSequenceHotkeys = ( +export const useGlobalHotkeysSequence = ( firstKey: Keys, secondKey: Keys, sequenceCallback: () => void, @@ -21,14 +21,15 @@ export const useSequenceHotkeys = ( ) => { const [pendingHotkey, setPendingHotkey] = useRecoilState(pendingHotkeyState); - const callScopedHotkeyCallback = useScopedHotkeyCallback(); + const callGlobalHotkeysCallback = useGlobalHotkeysCallback(); useHotkeys( firstKey, (keyboardEvent, hotkeysEvent) => { - callScopedHotkeyCallback({ + callGlobalHotkeysCallback({ keyboardEvent, hotkeysEvent, + containsModifier: false, callback: () => { setPendingHotkey(firstKey); }, @@ -46,9 +47,10 @@ export const useSequenceHotkeys = ( useHotkeys( secondKey, (keyboardEvent, hotkeysEvent) => { - callScopedHotkeyCallback({ + callGlobalHotkeysCallback({ keyboardEvent, hotkeysEvent, + containsModifier: false, callback: () => { if (pendingHotkey !== firstKey) { return; diff --git a/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useGoToHotkeys.ts b/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useGoToHotkeys.ts index d8e62312cf..46ca5b8695 100644 --- a/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useGoToHotkeys.ts +++ b/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useGoToHotkeys.ts @@ -1,10 +1,9 @@ import { Keys } from 'react-hotkeys-hook/dist/types'; import { useNavigate } from 'react-router-dom'; +import { useGlobalHotkeysSequence } from '@/ui/utilities/hotkey/hooks/useGlobalHotkeysSequence'; import { AppHotkeyScope } from '../types/AppHotkeyScope'; -import { useSequenceHotkeys } from './useSequenceScopedHotkeys'; - type GoToHotkeysProps = { key: Keys; location: string; @@ -18,7 +17,7 @@ export const useGoToHotkeys = ({ }: GoToHotkeysProps) => { const navigate = useNavigate(); - useSequenceHotkeys( + useGlobalHotkeysSequence( 'g', key, () => { diff --git a/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement.ts b/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement.ts new file mode 100644 index 0000000000..562de26178 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement.ts @@ -0,0 +1,73 @@ +import { useHotkeysOnFocusedElementCallback } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElementCallback'; +import { pendingHotkeyState } from '@/ui/utilities/hotkey/states/internal/pendingHotkeysState'; +import { useHotkeys } from 'react-hotkeys-hook'; +import { HotkeyCallback, Keys, Options } from 'react-hotkeys-hook/dist/types'; +import { useRecoilState } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + +type UseHotkeysOptionsWithoutBuggyOptions = Omit; + +export const useHotkeysOnFocusedElement = ({ + keys, + callback, + focusId, + // TODO: Remove this once we've migrated hotkey scopes to the new api + scope, + dependencies, + options, +}: { + keys: Keys; + callback: HotkeyCallback; + focusId: string; + // TODO: Remove this once we've migrated hotkey scopes to the new api + scope: string; + dependencies?: unknown[]; + options?: UseHotkeysOptionsWithoutBuggyOptions; +}) => { + const [pendingHotkey, setPendingHotkey] = useRecoilState(pendingHotkeyState); + + const callScopedHotkeyCallback = + useHotkeysOnFocusedElementCallback(dependencies); + + const enableOnContentEditable = isDefined(options?.enableOnContentEditable) + ? options.enableOnContentEditable + : true; + + const enableOnFormTags = isDefined(options?.enableOnFormTags) + ? options.enableOnFormTags + : true; + + const preventDefault = isDefined(options?.preventDefault) + ? options.preventDefault === true + : true; + + const ignoreModifiers = isDefined(options?.ignoreModifiers) + ? options.ignoreModifiers === true + : false; + + return useHotkeys( + keys, + (keyboardEvent, hotkeysEvent) => { + callScopedHotkeyCallback({ + keyboardEvent, + hotkeysEvent, + callback: () => { + if (!pendingHotkey) { + callback(keyboardEvent, hotkeysEvent); + return; + } + setPendingHotkey(null); + }, + focusId, + scope, + preventDefault, + }); + }, + { + enableOnContentEditable, + enableOnFormTags, + ignoreModifiers, + }, + dependencies, + ); +}; diff --git a/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElementCallback.ts b/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElementCallback.ts new file mode 100644 index 0000000000..1f4023799e --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElementCallback.ts @@ -0,0 +1,89 @@ +import { internalHotkeysEnabledScopesState } from '@/ui/utilities/hotkey/states/internal/internalHotkeysEnabledScopesState'; +import { + Hotkey, + OptionsOrDependencyArray, +} from 'react-hotkeys-hook/dist/types'; +import { useRecoilCallback } from 'recoil'; +import { logDebug } from '~/utils/logDebug'; +import { currentFocusIdSelector } from '../../focus/states/currentFocusIdSelector'; +import { DEBUG_HOTKEY_SCOPE } from '../constants/DebugHotkeyScope'; + +export const useHotkeysOnFocusedElementCallback = ( + dependencies?: OptionsOrDependencyArray, +) => { + const dependencyArray = Array.isArray(dependencies) ? dependencies : []; + + return useRecoilCallback( + ({ snapshot }) => + ({ + callback, + hotkeysEvent, + keyboardEvent, + focusId, + scope, + preventDefault, + }: { + keyboardEvent: KeyboardEvent; + hotkeysEvent: Hotkey; + callback: (keyboardEvent: KeyboardEvent, hotkeysEvent: Hotkey) => void; + focusId: string; + scope: string; + preventDefault?: boolean; + }) => { + const currentFocusId = snapshot + .getLoadable(currentFocusIdSelector) + .getValue(); + + // TODO: Remove this once we've migrated hotkey scopes to the new api + const currentHotkeyScopes = snapshot + .getLoadable(internalHotkeysEnabledScopesState) + .getValue(); + + if ( + currentFocusId !== focusId || + !currentHotkeyScopes.includes(scope) + ) { + if (DEBUG_HOTKEY_SCOPE) { + logDebug( + `DEBUG: %cI can't call hotkey (${ + hotkeysEvent.keys + }) because I'm in scope [${scope}] and the active scopes are : [${currentHotkeyScopes.join( + ', ', + )}] and the current focus identifier is [${focusId}]`, + 'color: gray; ', + ); + } + + return; + } + + if (DEBUG_HOTKEY_SCOPE) { + logDebug( + `DEBUG: %cI can call hotkey (${ + hotkeysEvent.keys + }) because I'm in scope [${scope}] and the active scopes are : [${currentHotkeyScopes.join( + ', ', + )}], and the current focus identifier is [${focusId}]`, + 'color: green;', + ); + } + + if (preventDefault === true) { + if (DEBUG_HOTKEY_SCOPE) { + logDebug( + `DEBUG: %cI prevent default for hotkey (${hotkeysEvent.keys})`, + 'color: gray;', + ); + } + + keyboardEvent.stopPropagation(); + keyboardEvent.preventDefault(); + keyboardEvent.stopImmediatePropagation(); + } + + return callback(keyboardEvent, hotkeysEvent); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + dependencyArray, + ); +}; diff --git a/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/usePreviousHotkeyScope.ts b/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/usePreviousHotkeyScope.ts index d98e9ab124..6268fa2600 100644 --- a/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/usePreviousHotkeyScope.ts +++ b/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/usePreviousHotkeyScope.ts @@ -1,7 +1,7 @@ import { useRecoilCallback } from 'recoil'; -import { DEBUG_HOTKEY_SCOPE } from '@/ui/utilities/hotkey/hooks/useScopedHotkeyCallback'; import { logDebug } from '~/utils/logDebug'; +import { DEBUG_HOTKEY_SCOPE } from '../constants/DebugHotkeyScope'; import { currentHotkeyScopeState } from '../states/internal/currentHotkeyScopeState'; import { previousHotkeyScopeFamilyState } from '../states/internal/previousHotkeyScopeFamilyState'; @@ -9,14 +9,14 @@ import { CustomHotkeyScopes } from '../types/CustomHotkeyScope'; import { useSetHotkeyScope } from './useSetHotkeyScope'; -export const usePreviousHotkeyScope = (memoizeKey = 'global') => { +export const usePreviousHotkeyScope = () => { const setHotkeyScope = useSetHotkeyScope(); const goBackToPreviousHotkeyScope = useRecoilCallback( ({ snapshot, set }) => - () => { + (memoizeKey = 'global') => { const previousHotkeyScope = snapshot - .getLoadable(previousHotkeyScopeFamilyState(memoizeKey)) + .getLoadable(previousHotkeyScopeFamilyState(memoizeKey as string)) .getValue(); if (!previousHotkeyScope) { @@ -39,14 +39,22 @@ export const usePreviousHotkeyScope = (memoizeKey = 'global') => { previousHotkeyScope.customScopes, ); - set(previousHotkeyScopeFamilyState(memoizeKey), null); + set(previousHotkeyScopeFamilyState(memoizeKey as string), null); }, - [setHotkeyScope, memoizeKey], + [setHotkeyScope], ); const setHotkeyScopeAndMemorizePreviousScope = useRecoilCallback( ({ snapshot, set }) => - (scope: string, customScopes?: CustomHotkeyScopes) => { + ({ + scope, + customScopes, + memoizeKey = 'global', + }: { + scope: string; + customScopes?: CustomHotkeyScopes; + memoizeKey?: string; + }) => { const currentHotkeyScope = snapshot .getLoadable(currentHotkeyScopeState) .getValue(); @@ -63,7 +71,7 @@ export const usePreviousHotkeyScope = (memoizeKey = 'global') => { set(previousHotkeyScopeFamilyState(memoizeKey), currentHotkeyScope); }, - [setHotkeyScope, memoizeKey], + [setHotkeyScope], ); return { diff --git a/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useScopedHotkeyCallback.ts b/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useScopedHotkeyCallback.ts index bc8d7026c1..f9c98ddd57 100644 --- a/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useScopedHotkeyCallback.ts +++ b/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useScopedHotkeyCallback.ts @@ -6,10 +6,9 @@ import { useRecoilCallback } from 'recoil'; import { logDebug } from '~/utils/logDebug'; +import { DEBUG_HOTKEY_SCOPE } from '../constants/DebugHotkeyScope'; import { internalHotkeysEnabledScopesState } from '../states/internal/internalHotkeysEnabledScopesState'; -export const DEBUG_HOTKEY_SCOPE = false; - export const useScopedHotkeyCallback = ( dependencies?: OptionsOrDependencyArray, ) => { diff --git a/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useScopedHotkeys.ts b/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useScopedHotkeys.ts index 816e7a0a26..9d186d0a21 100644 --- a/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useScopedHotkeys.ts +++ b/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useScopedHotkeys.ts @@ -2,9 +2,9 @@ import { useHotkeys } from 'react-hotkeys-hook'; import { HotkeyCallback, Keys, Options } from 'react-hotkeys-hook/dist/types'; import { useRecoilState } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; import { pendingHotkeyState } from '../states/internal/pendingHotkeysState'; import { useScopedHotkeyCallback } from './useScopedHotkeyCallback'; -import { isDefined } from 'twenty-shared/utils'; type UseHotkeysOptionsWithoutBuggyOptions = Omit; diff --git a/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useSetHotkeyScope.ts b/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useSetHotkeyScope.ts index adcf1cb284..f1392bd0e0 100644 --- a/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useSetHotkeyScope.ts +++ b/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useSetHotkeyScope.ts @@ -1,6 +1,6 @@ import { useRecoilCallback } from 'recoil'; -import { DEBUG_HOTKEY_SCOPE } from '@/ui/utilities/hotkey/hooks/useScopedHotkeyCallback'; +import { DEBUG_HOTKEY_SCOPE } from '../constants/DebugHotkeyScope'; import { isDefined } from 'twenty-shared/utils'; import { logDebug } from '~/utils/logDebug'; @@ -11,7 +11,7 @@ import { AppHotkeyScope } from '../types/AppHotkeyScope'; import { CustomHotkeyScopes } from '../types/CustomHotkeyScope'; import { HotkeyScope } from '../types/HotkeyScope'; -const isCustomScopesEqual = ( +const areCustomScopesEqual = ( customScopesA: CustomHotkeyScopes | undefined, customScopesB: CustomHotkeyScopes | undefined, ) => { @@ -34,7 +34,7 @@ export const useSetHotkeyScope = () => if (currentHotkeyScope.scope === hotkeyScopeToSet) { if (!isDefined(customScopes)) { if ( - isCustomScopesEqual( + areCustomScopesEqual( currentHotkeyScope?.customScopes, DEFAULT_HOTKEYS_SCOPE_CUSTOM_SCOPES, ) @@ -43,7 +43,7 @@ export const useSetHotkeyScope = () => } } else { if ( - isCustomScopesEqual( + areCustomScopesEqual( currentHotkeyScope?.customScopes, customScopes, ) diff --git a/packages/twenty-front/src/modules/ui/utilities/hotkey/types/GlobalHotkeysConfig.ts b/packages/twenty-front/src/modules/ui/utilities/hotkey/types/GlobalHotkeysConfig.ts new file mode 100644 index 0000000000..05f6191138 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/hotkey/types/GlobalHotkeysConfig.ts @@ -0,0 +1,4 @@ +export type GlobalHotkeysConfig = { + enableGlobalHotkeysWithModifiers: boolean; + enableGlobalHotkeysConflictingWithKeyboard: boolean; +};