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 e1e4c298c..a4418a67c 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 2a1dae154..bc7b9727d 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 6e4109d60..201e94e6a 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 6c09a5580..f1ea6ccf6 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 fd5e57453..1ca2ef269 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 574a55096..3c1ca5662 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 c92fb987d..5d76ae1f2 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 3c9c043e1..bcdbbb4e5 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 ae68bdc4e..9155fed53 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 d38b3b577..292730ea0 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 0d568a473..bd3286d7a 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 6ceebcdc1..f8eef8fea 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 13b2f4989..b0d70d287 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 60dbb10be..ec646f543 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 fde125c70..28ae0760c 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 1f3ad72f1..889b6185f 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 ea45fbaae..0f17035ef 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 27534386f..2428cdc62 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 32741a62f..84756f7e2 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 6cbb1edaf..0b1ef505d 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 25a7a0d87..cb7a15b9a 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 cab5a5b09..963111b5f 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 8309dc458..a77615ebb 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 893958fdc..9c2ea43fc 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 cf8040f51..b1c9e5ca5 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 b4f85c41d..9787bccc6 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 000000000..9b0ac2086 --- /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 000000000..21e4fddcb --- /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 84ce4187e..251bc233c 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 3e50d250a..46b9afd86 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 aadf5c583..1ef713560 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 4a9a42986..e2af0665b 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 9262451f0..d9b1e298c 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 53eb5d6d2..c6c544f6d 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 393bbe56a..2835fcad6 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 e35b2e435..9cefe8f11 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 8ea2bae6d..7e1c98ab1 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 cbb9e649d..18f8df02a 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 6990b9429..7b40c7672 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 e75b04485..3d046d75c 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 fd1e18c39..5f9511e1b 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 fe81cdb4e..da2a9c9a1 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 000000000..f79ac064c --- /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 000000000..20dd54ae2 --- /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 000000000..481fe4836 --- /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 000000000..af4171373 --- /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 000000000..49173eadd --- /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 000000000..c966e92be --- /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 000000000..c22529b6a --- /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 000000000..270a8db4e --- /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 000000000..606256ee0 --- /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 000000000..f49871a24 --- /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 000000000..9581e78c6 --- /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 000000000..9dda63e1e --- /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 000000000..7562a2d4e --- /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 000000000..262547f39 --- /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 000000000..0ac5ab524 --- /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 000000000..670f7038e --- /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 000000000..4d97c51a5 --- /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 000000000..23c9fbb5b --- /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 19d587921..10565ce04 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 d8e62312c..46ca5b869 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 000000000..562de2617 --- /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 000000000..1f4023799 --- /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 d98e9ab12..6268fa260 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 bc8d7026c..f9c98ddd5 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 816e7a0a2..9d186d0a2 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 adcf1cb28..f1392bd0e 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 000000000..05f619113 --- /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; +};