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 e1e4c298c4a..a4418a67ca3 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 2a1dae15427..bc7b9727d8c 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 6e4109d60cf..201e94e6ab8 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 6c09a558078..f1ea6ccf64d 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 fd5e574532d..1ca2ef269c0 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 574a55096d6..3c1ca566246 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 c92fb987db9..5d76ae1f296 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 3c9c043e1d3..bcdbbb4e530 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 ae68bdc4ef8..9155fed53b7 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 d38b3b57748..292730ea091 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 0d568a47353..bd3286d7a8b 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 6ceebcdc10f..f8eef8feaac 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 13b2f498997..b0d70d287fa 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 60dbb10beb0..ec646f543cd 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 fde125c70a2..28ae0760c96 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 1f3ad72f1e0..889b6185f30 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 ea45fbaaeca..0f17035ef44 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 27534386f9b..2428cdc62fc 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 32741a62f1f..84756f7e286 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 6cbb1edaf1f..0b1ef505d27 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 25a7a0d8746..cb7a15b9a0b 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 cab5a5b0972..963111b5f1a 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 8309dc458f6..a77615ebb24 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 893958fdc79..9c2ea43fc9a 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 cf8040f5101..b1c9e5ca574 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 b4f85c41d1b..9787bccc64e 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 00000000000..9b0ac208669 --- /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 00000000000..21e4fddcb23 --- /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 84ce4187eee..251bc233c5f 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 3e50d250a58..46b9afd863c 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 aadf5c583e5..1ef713560af 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 4a9a42986ee..e2af0665bbe 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 9262451f0ab..d9b1e298cd4 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 53eb5d6d278..c6c544f6d8b 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 393bbe56a19..2835fcad6f2 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 e35b2e43509..9cefe8f1147 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 8ea2bae6d7e..7e1c98ab191 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 cbb9e649dcf..18f8df02ad4 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 6990b9429a0..7b40c767243 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 e75b044854a..3d046d75c12 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 fd1e18c3902..5f9511e1bce 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 fe81cdb4ecc..da2a9c9a103 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 00000000000..f79ac064cd1 --- /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 00000000000..20dd54ae2b8 --- /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 00000000000..481fe4836c5 --- /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 00000000000..af4171373db --- /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 00000000000..49173eaddeb --- /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 00000000000..c966e92be96 --- /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 00000000000..c22529b6aab --- /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 00000000000..270a8db4eed --- /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 00000000000..606256ee0dc --- /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 00000000000..f49871a247c --- /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 00000000000..9581e78c6d0 --- /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 00000000000..9dda63e1ef3 --- /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 00000000000..7562a2d4e86 --- /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 00000000000..262547f397d --- /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 00000000000..0ac5ab524a7 --- /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 00000000000..670f7038ee1 --- /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 00000000000..4d97c51a501 --- /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 00000000000..23c9fbb5b15 --- /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 19d5879211a..10565ce04e4 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 d8e62312cfe..46ca5b86959 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 00000000000..562de261780 --- /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 00000000000..1f4023799e8 --- /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 d98e9ab1248..6268fa26000 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 bc8d7026c12..f9c98ddd57b 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 816e7a0a26e..9d186d0a210 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 adcf1cb284f..f1392bd0e01 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 00000000000..05f6191138d --- /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; +};