Replace hotkey scopes by focus stack (Part 5 - Form field Inputs, Pages, Dialog ...) (#13106)

# Replace hotkey scopes by focus stack (Part 5 - Form field Inputs,
Pages, Dialog ...)

This PR is the 5th part of a refactoring aiming to deprecate the hotkey
scopes api in favor of the new focus stack api which is more robust.
Part 1: https://github.com/twentyhq/twenty/pull/12673
Part 2: https://github.com/twentyhq/twenty/pull/12798
Part 3: https://github.com/twentyhq/twenty/pull/12910
Part 4: https://github.com/twentyhq/twenty/pull/12933

In this part, all the last components using hotkey scopes were
refactored.
In the 6th and final part of this refactoring we will be able to
completely remove the hotkey scopes from the codebase.
This commit is contained in:
Raphaël Bosi
2025-07-08 20:18:32 +02:00
committed by GitHub
parent 66b633e08e
commit 9eaa8ad517
50 changed files with 590 additions and 678 deletions

View File

@ -1,26 +0,0 @@
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useEffect } from 'react';
/**
* @deprecated This hook uses useEffect
* Use event handlers and imperative code to manage hotkey scope changes.
*/
export const useHotkeyScopeOnMount = (hotkeyScope: string) => {
const {
goBackToPreviousHotkeyScope,
setHotkeyScopeAndMemorizePreviousScope,
} = usePreviousHotkeyScope();
useEffect(() => {
setHotkeyScopeAndMemorizePreviousScope({
scope: hotkeyScope,
});
return () => {
goBackToPreviousHotkeyScope();
};
}, [
hotkeyScope,
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
]);
};

View File

@ -1,5 +1,5 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useRecoilCallback, useRecoilState, useRecoilValue } from 'recoil'; import { useRecoilCallback, useRecoilState } from 'recoil';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile'; import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile';
@ -11,7 +11,6 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
import { modifyRecordFromCache } from '@/object-record/cache/utils/modifyRecordFromCache'; import { modifyRecordFromCache } from '@/object-record/cache/utils/modifyRecordFromCache';
import { isFieldValueReadOnly } from '@/object-record/record-field/utils/isFieldValueReadOnly'; import { isFieldValueReadOnly } from '@/object-record/record-field/utils/isFieldValueReadOnly';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { isNonTextWritingKey } from '@/ui/utilities/hotkey/utils/isNonTextWritingKey'; import { isNonTextWritingKey } from '@/ui/utilities/hotkey/utils/isNonTextWritingKey';
import { Key } from 'ts-key-enum'; import { Key } from 'ts-key-enum';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
@ -24,9 +23,6 @@ import { Task } from '@/activities/types/Task';
import { filterAttachmentsToRestore } from '@/activities/utils/filterAttachmentsToRestore'; import { filterAttachmentsToRestore } from '@/activities/utils/filterAttachmentsToRestore';
import { getActivityAttachmentIdsToDelete } from '@/activities/utils/getActivityAttachmentIdsToDelete'; import { getActivityAttachmentIdsToDelete } from '@/activities/utils/getActivityAttachmentIdsToDelete';
import { getActivityAttachmentPathsToRestore } from '@/activities/utils/getActivityAttachmentPathsToRestore'; import { getActivityAttachmentPathsToRestore } from '@/activities/utils/getActivityAttachmentPathsToRestore';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { CommandMenuHotkeyScope } from '@/command-menu/types/CommandMenuHotkeyScope';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords'; import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords';
import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords'; import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords';
@ -39,6 +35,7 @@ import { BlockEditor } from '@/ui/input/editor/components/BlockEditor';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack'; import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById'; import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import type { PartialBlock } from '@blocknote/core'; import type { PartialBlock } from '@blocknote/core';
import '@blocknote/core/fonts/inter.css'; import '@blocknote/core/fonts/inter.css';
import '@blocknote/mantine/style.css'; import '@blocknote/mantine/style.css';
@ -304,60 +301,56 @@ export const ActivityRichTextEditor = ({
uploadFile: handleEditorBuiltInUploadFile, uploadFile: handleEditorBuiltInUploadFile,
}); });
const commandMenuPage = useRecoilValue(commandMenuPageState); useHotkeysOnFocusedElement({
keys: Key.Escape,
useScopedHotkeys( callback: () => {
Key.Escape,
() => {
editor.domElement?.blur(); editor.domElement?.blur();
}, },
ActivityEditorHotkeyScope.ActivityBody, focusId: activityId,
); scope: ActivityEditorHotkeyScope.ActivityBody,
dependencies: [editor],
});
useScopedHotkeys( const handleAllKeys = (keyboardEvent: KeyboardEvent) => {
'*', if (keyboardEvent.key === Key.Escape) {
(keyboardEvent) => { return;
// TODO: remove once stacked hotkeys / focusKeys are in place }
if (commandMenuPage !== CommandMenuPages.EditRichText) {
return;
}
if (keyboardEvent.key === Key.Escape) { const isWritingText =
return; !isNonTextWritingKey(keyboardEvent.key) &&
} !keyboardEvent.ctrlKey &&
!keyboardEvent.metaKey;
const isWritingText = if (!isWritingText) {
!isNonTextWritingKey(keyboardEvent.key) && return;
!keyboardEvent.ctrlKey && }
!keyboardEvent.metaKey;
if (!isWritingText) { keyboardEvent.preventDefault();
return; keyboardEvent.stopPropagation();
} keyboardEvent.stopImmediatePropagation();
keyboardEvent.preventDefault(); const newBlockId = v4();
keyboardEvent.stopPropagation(); const newBlock = {
keyboardEvent.stopImmediatePropagation(); id: newBlockId,
type: 'paragraph' as const,
content: keyboardEvent.key,
};
const newBlockId = v4(); const lastBlock = editor.document[editor.document.length - 1];
const newBlock = { editor.insertBlocks([newBlock], lastBlock);
id: newBlockId,
type: 'paragraph' as const,
content: keyboardEvent.key,
};
const lastBlock = editor.document[editor.document.length - 1]; editor.setTextCursorPosition(newBlockId, 'end');
editor.insertBlocks([newBlock], lastBlock); editor.focus();
};
useHotkeysOnFocusedElement({
keys: '*',
callback: handleAllKeys,
focusId: activityId,
scope: ActivityEditorHotkeyScope.ActivityBody,
dependencies: [handleAllKeys],
});
editor.setTextCursorPosition(newBlockId, 'end');
editor.focus();
},
CommandMenuHotkeyScope.CommandMenuFocused,
[],
{
preventDefault: false,
},
);
const { labelIdentifierFieldMetadataItem } = useRecordShowContainerData({ const { labelIdentifierFieldMetadataItem } = useRecordShowContainerData({
objectNameSingular: activityObjectNameSingular, objectNameSingular: activityObjectNameSingular,
objectRecordId: activityId, objectRecordId: activityId,

View File

@ -31,8 +31,10 @@ import { useFocusedRecordTableRow } from '@/object-record/record-table/hooks/use
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId'; import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { AppBasePath } from '@/types/AppBasePath'; import { AppBasePath } from '@/types/AppBasePath';
import { AppPath } from '@/types/AppPath'; import { AppPath } from '@/types/AppPath';
import { PageFocusId } from '@/types/PageFocusId';
import { PageHotkeyScope } from '@/types/PageHotkeyScope'; import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; import { useResetFocusStackToFocusItem } from '@/ui/utilities/focus/hooks/useResetFocusStackToFocusItem';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { AnalyticsType } from '~/generated/graphql'; import { AnalyticsType } from '~/generated/graphql';
@ -48,8 +50,6 @@ export const PageChangeEffect = () => {
const [previousLocation, setPreviousLocation] = useState(''); const [previousLocation, setPreviousLocation] = useState('');
const setHotkeyScope = useSetHotkeyScope();
const location = useLocation(); const location = useLocation();
const pageChangeEffectNavigateLocation = const pageChangeEffectNavigateLocation =
@ -92,6 +92,8 @@ export const PageChangeEffect = () => {
const { closeCommandMenu } = useCommandMenu(); const { closeCommandMenu } = useCommandMenu();
const { resetFocusStackToFocusItem } = useResetFocusStackToFocusItem();
const { resetFocusStackToRecordIndex } = useResetFocusStackToRecordIndex(); const { resetFocusStackToRecordIndex } = useResetFocusStackToRecordIndex();
useEffect(() => { useEffect(() => {
@ -140,55 +142,200 @@ export const PageChangeEffect = () => {
break; break;
} }
case isMatchingLocation(location, AppPath.RecordShowPage): { case isMatchingLocation(location, AppPath.RecordShowPage): {
setHotkeyScope(PageHotkeyScope.RecordShowPage, { resetFocusStackToFocusItem({
goto: true, focusStackItem: {
keyboardShortcutMenu: true, focusId: PageFocusId.RecordShowPage,
searchRecords: true, componentInstance: {
componentType: FocusComponentType.PAGE,
componentInstanceId: PageFocusId.RecordShowPage,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: true,
},
memoizeKey: 'global',
},
hotkeyScope: {
scope: PageHotkeyScope.RecordShowPage,
customScopes: {
goto: true,
keyboardShortcutMenu: true,
searchRecords: true,
},
},
}); });
break; break;
} }
case isMatchingLocation(location, AppPath.SignInUp): { case isMatchingLocation(location, AppPath.SignInUp): {
setHotkeyScope(PageHotkeyScope.SignInUp); resetFocusStackToFocusItem({
focusStackItem: {
focusId: PageFocusId.SignInUp,
componentInstance: {
componentType: FocusComponentType.PAGE,
componentInstanceId: PageFocusId.SignInUp,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: false,
enableGlobalHotkeysConflictingWithKeyboard: false,
},
memoizeKey: 'global',
},
hotkeyScope: {
scope: PageHotkeyScope.SignInUp,
},
});
break; break;
} }
case isMatchingLocation(location, AppPath.Invite): { case isMatchingLocation(location, AppPath.Invite): {
setHotkeyScope(PageHotkeyScope.SignInUp); resetFocusStackToFocusItem({
focusStackItem: {
focusId: PageFocusId.InviteTeam,
componentInstance: {
componentType: FocusComponentType.PAGE,
componentInstanceId: PageFocusId.InviteTeam,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: false,
enableGlobalHotkeysConflictingWithKeyboard: false,
},
memoizeKey: 'global',
},
hotkeyScope: {
scope: PageHotkeyScope.InviteTeam,
},
});
break; break;
} }
case isMatchingLocation(location, AppPath.CreateProfile): { case isMatchingLocation(location, AppPath.CreateProfile): {
setHotkeyScope(PageHotkeyScope.CreateProfile); resetFocusStackToFocusItem({
focusStackItem: {
focusId: PageFocusId.CreateProfile,
componentInstance: {
componentType: FocusComponentType.PAGE,
componentInstanceId: PageFocusId.CreateProfile,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: false,
enableGlobalHotkeysConflictingWithKeyboard: false,
},
memoizeKey: 'global',
},
hotkeyScope: {
scope: PageHotkeyScope.CreateProfile,
},
});
break; break;
} }
case isMatchingLocation(location, AppPath.CreateWorkspace): { case isMatchingLocation(location, AppPath.CreateWorkspace): {
setHotkeyScope(PageHotkeyScope.CreateWorkspace); resetFocusStackToFocusItem({
focusStackItem: {
focusId: PageFocusId.CreateWorkspace,
componentInstance: {
componentType: FocusComponentType.PAGE,
componentInstanceId: PageFocusId.CreateWorkspace,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: false,
enableGlobalHotkeysConflictingWithKeyboard: false,
},
memoizeKey: 'global',
},
hotkeyScope: {
scope: PageHotkeyScope.CreateWorkspace,
},
});
break; break;
} }
case isMatchingLocation(location, AppPath.SyncEmails): { case isMatchingLocation(location, AppPath.SyncEmails): {
setHotkeyScope(PageHotkeyScope.SyncEmail); resetFocusStackToFocusItem({
focusStackItem: {
focusId: PageFocusId.SyncEmail,
componentInstance: {
componentType: FocusComponentType.PAGE,
componentInstanceId: PageFocusId.SyncEmail,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: false,
enableGlobalHotkeysConflictingWithKeyboard: false,
},
memoizeKey: 'global',
},
hotkeyScope: {
scope: PageHotkeyScope.SyncEmail,
},
});
break; break;
} }
case isMatchingLocation(location, AppPath.InviteTeam): { case isMatchingLocation(location, AppPath.InviteTeam): {
setHotkeyScope(PageHotkeyScope.InviteTeam); resetFocusStackToFocusItem({
focusStackItem: {
focusId: PageFocusId.InviteTeam,
componentInstance: {
componentType: FocusComponentType.PAGE,
componentInstanceId: PageFocusId.InviteTeam,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: false,
enableGlobalHotkeysConflictingWithKeyboard: false,
},
memoizeKey: 'global',
},
hotkeyScope: {
scope: PageHotkeyScope.InviteTeam,
},
});
break; break;
} }
case isMatchingLocation(location, AppPath.PlanRequired): { case isMatchingLocation(location, AppPath.PlanRequired): {
setHotkeyScope(PageHotkeyScope.PlanRequired); resetFocusStackToFocusItem({
focusStackItem: {
focusId: PageFocusId.PlanRequired,
componentInstance: {
componentType: FocusComponentType.PAGE,
componentInstanceId: PageFocusId.PlanRequired,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: false,
enableGlobalHotkeysConflictingWithKeyboard: false,
},
memoizeKey: 'global',
},
hotkeyScope: {
scope: PageHotkeyScope.PlanRequired,
},
});
break; break;
} }
case location.pathname.startsWith(AppBasePath.Settings): { case location.pathname.startsWith(AppBasePath.Settings): {
setHotkeyScope(PageHotkeyScope.Settings, { resetFocusStackToFocusItem({
goto: false, focusStackItem: {
keyboardShortcutMenu: false, focusId: PageFocusId.Settings,
commandMenu: false, componentInstance: {
commandMenuOpen: false, componentType: FocusComponentType.PAGE,
searchRecords: false, componentInstanceId: PageFocusId.Settings,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: false,
enableGlobalHotkeysConflictingWithKeyboard: false,
},
memoizeKey: 'global',
},
hotkeyScope: {
scope: PageHotkeyScope.Settings,
customScopes: {
goto: false,
keyboardShortcutMenu: false,
commandMenu: false,
commandMenuOpen: false,
searchRecords: false,
},
},
}); });
break; break;
} }
} }
}, [ }, [
location, location,
setHotkeyScope,
previousLocation, previousLocation,
contextStoreCurrentViewType, contextStoreCurrentViewType,
resetTableSelections, resetTableSelections,
@ -198,6 +345,7 @@ export const PageChangeEffect = () => {
deactivateBoardCard, deactivateBoardCard,
unfocusBoardCard, unfocusBoardCard,
resetFocusStackToRecordIndex, resetFocusStackToRecordIndex,
resetFocusStackToFocusItem,
]); ]);
useEffect(() => { useEffect(() => {

View File

@ -4,9 +4,12 @@ import { KEYBOARD_SHORTCUTS_GENERAL } from '@/keyboard-shortcut-menu/constants/K
import { KEYBOARD_SHORTCUTS_TABLE } from '@/keyboard-shortcut-menu/constants/KeyboardShortcutsTable'; import { KEYBOARD_SHORTCUTS_TABLE } from '@/keyboard-shortcut-menu/constants/KeyboardShortcutsTable';
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope'; import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { useKeyboardShortcutMenu } from '../hooks/useKeyboardShortcutMenu'; import {
KEYBOARD_SHORTCUT_MENU_INSTANCE_ID,
useKeyboardShortcutMenu,
} from '../hooks/useKeyboardShortcutMenu';
import { useGlobalHotkeys } from '@/ui/utilities/hotkey/hooks/useGlobalHotkeys'; import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { KeyboardMenuDialog } from './KeyboardShortcutMenuDialog'; import { KeyboardMenuDialog } from './KeyboardShortcutMenuDialog';
import { KeyboardMenuGroup } from './KeyboardShortcutMenuGroup'; import { KeyboardMenuGroup } from './KeyboardShortcutMenuGroup';
import { KeyboardMenuItem } from './KeyboardShortcutMenuItem'; import { KeyboardMenuItem } from './KeyboardShortcutMenuItem';
@ -15,15 +18,15 @@ export const KeyboardShortcutMenuOpenContent = () => {
const { toggleKeyboardShortcutMenu, closeKeyboardShortcutMenu } = const { toggleKeyboardShortcutMenu, closeKeyboardShortcutMenu } =
useKeyboardShortcutMenu(); useKeyboardShortcutMenu();
useGlobalHotkeys( useHotkeysOnFocusedElement({
[Key.Escape], keys: [Key.Escape],
() => { callback: () => {
closeKeyboardShortcutMenu(); closeKeyboardShortcutMenu();
}, },
false, focusId: KEYBOARD_SHORTCUT_MENU_INSTANCE_ID,
AppHotkeyScope.KeyboardShortcutMenuOpen, scope: AppHotkeyScope.KeyboardShortcutMenuOpen,
[closeKeyboardShortcutMenu], dependencies: [closeKeyboardShortcutMenu],
); });
return ( return (
<> <>

View File

@ -8,18 +8,24 @@ import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { useKeyboardShortcutMenu } from '../useKeyboardShortcutMenu'; import { useKeyboardShortcutMenu } from '../useKeyboardShortcutMenu';
const mockSetHotkeyScopeAndMemorizePreviousScope = jest.fn(); const mockPushFocusItemToFocusStack = jest.fn();
const mockRemoveFocusItemFromFocusStackById = jest.fn();
const mockGoBackToPreviousHotkeyScope = jest.fn(); jest.mock('@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack', () => ({
usePushFocusItemToFocusStack: () => ({
jest.mock('@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope', () => ({ pushFocusItemToFocusStack: mockPushFocusItemToFocusStack,
usePreviousHotkeyScope: () => ({
setHotkeyScopeAndMemorizePreviousScope:
mockSetHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope: mockGoBackToPreviousHotkeyScope,
}), }),
})); }));
jest.mock(
'@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById',
() => ({
useRemoveFocusItemFromFocusStackById: () => ({
removeFocusItemFromFocusStackById: mockRemoveFocusItemFromFocusStackById,
}),
}),
);
const renderHookConfig = () => { const renderHookConfig = () => {
const { result } = renderHook( const { result } = renderHook(
() => { () => {
@ -39,6 +45,10 @@ const renderHookConfig = () => {
}; };
describe('useKeyboardShortcutMenu', () => { describe('useKeyboardShortcutMenu', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should toggle keyboard shortcut menu correctly', async () => { it('should toggle keyboard shortcut menu correctly', async () => {
const { result } = renderHookConfig(); const { result } = renderHookConfig();
expect(result.current.toggleKeyboardShortcutMenu).toBeDefined(); expect(result.current.toggleKeyboardShortcutMenu).toBeDefined();
@ -48,8 +58,19 @@ describe('useKeyboardShortcutMenu', () => {
result.current.toggleKeyboardShortcutMenu(); result.current.toggleKeyboardShortcutMenu();
}); });
expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith({ expect(mockPushFocusItemToFocusStack).toHaveBeenCalledWith({
scope: AppHotkeyScope.KeyboardShortcutMenu, focusId: 'keyboard-shortcut-menu',
component: {
type: 'keyboard-shortcut-menu',
instanceId: 'keyboard-shortcut-menu',
},
globalHotkeysConfig: {
enableGlobalHotkeysConflictingWithKeyboard: false,
enableGlobalHotkeysWithModifiers: false,
},
hotkeyScope: {
scope: AppHotkeyScope.KeyboardShortcutMenuOpen,
},
}); });
expect(result.current.isKeyboardShortcutMenuOpened).toBe(true); expect(result.current.isKeyboardShortcutMenuOpened).toBe(true);
@ -57,8 +78,8 @@ describe('useKeyboardShortcutMenu', () => {
result.current.toggleKeyboardShortcutMenu(); result.current.toggleKeyboardShortcutMenu();
}); });
expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith({ expect(mockRemoveFocusItemFromFocusStackById).toHaveBeenCalledWith({
scope: AppHotkeyScope.KeyboardShortcutMenu, focusId: 'keyboard-shortcut-menu',
}); });
expect(result.current.isKeyboardShortcutMenuOpened).toBe(false); expect(result.current.isKeyboardShortcutMenuOpened).toBe(false);
}); });
@ -69,8 +90,19 @@ describe('useKeyboardShortcutMenu', () => {
result.current.openKeyboardShortcutMenu(); result.current.openKeyboardShortcutMenu();
}); });
expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith({ expect(mockPushFocusItemToFocusStack).toHaveBeenCalledWith({
scope: AppHotkeyScope.KeyboardShortcutMenu, focusId: 'keyboard-shortcut-menu',
component: {
type: 'keyboard-shortcut-menu',
instanceId: 'keyboard-shortcut-menu',
},
globalHotkeysConfig: {
enableGlobalHotkeysConflictingWithKeyboard: false,
enableGlobalHotkeysWithModifiers: false,
},
hotkeyScope: {
scope: AppHotkeyScope.KeyboardShortcutMenuOpen,
},
}); });
expect(result.current.isKeyboardShortcutMenuOpened).toBe(true); expect(result.current.isKeyboardShortcutMenuOpened).toBe(true);
@ -78,7 +110,9 @@ describe('useKeyboardShortcutMenu', () => {
result.current.closeKeyboardShortcutMenu(); result.current.closeKeyboardShortcutMenu();
}); });
expect(mockGoBackToPreviousHotkeyScope).toHaveBeenCalled(); expect(mockRemoveFocusItemFromFocusStackById).toHaveBeenCalledWith({
focusId: 'keyboard-shortcut-menu',
});
expect(result.current.isKeyboardShortcutMenuOpened).toBe(false); expect(result.current.isKeyboardShortcutMenuOpened).toBe(false);
}); });
}); });

View File

@ -1,25 +1,39 @@
import { useRecoilCallback } from 'recoil'; import { useRecoilCallback } from 'recoil';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope'; import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { isKeyboardShortcutMenuOpenedState } from '../states/isKeyboardShortcutMenuOpenedState'; import { isKeyboardShortcutMenuOpenedState } from '../states/isKeyboardShortcutMenuOpenedState';
export const KEYBOARD_SHORTCUT_MENU_INSTANCE_ID = 'keyboard-shortcut-menu';
export const useKeyboardShortcutMenu = () => { export const useKeyboardShortcutMenu = () => {
const { const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
setHotkeyScopeAndMemorizePreviousScope, const { removeFocusItemFromFocusStackById } =
goBackToPreviousHotkeyScope, useRemoveFocusItemFromFocusStackById();
} = usePreviousHotkeyScope();
const openKeyboardShortcutMenu = useRecoilCallback( const openKeyboardShortcutMenu = useRecoilCallback(
({ set }) => ({ set }) =>
() => { () => {
set(isKeyboardShortcutMenuOpenedState, true); set(isKeyboardShortcutMenuOpenedState, true);
setHotkeyScopeAndMemorizePreviousScope({ pushFocusItemToFocusStack({
scope: AppHotkeyScope.KeyboardShortcutMenu, focusId: KEYBOARD_SHORTCUT_MENU_INSTANCE_ID,
component: {
type: FocusComponentType.KEYBOARD_SHORTCUT_MENU,
instanceId: KEYBOARD_SHORTCUT_MENU_INSTANCE_ID,
},
globalHotkeysConfig: {
enableGlobalHotkeysConflictingWithKeyboard: false,
enableGlobalHotkeysWithModifiers: false,
},
hotkeyScope: {
scope: AppHotkeyScope.KeyboardShortcutMenuOpen,
},
}); });
}, },
[setHotkeyScopeAndMemorizePreviousScope], [pushFocusItemToFocusStack],
); );
const closeKeyboardShortcutMenu = useRecoilCallback( const closeKeyboardShortcutMenu = useRecoilCallback(
@ -31,10 +45,12 @@ export const useKeyboardShortcutMenu = () => {
if (isKeyboardShortcutMenuOpened) { if (isKeyboardShortcutMenuOpened) {
set(isKeyboardShortcutMenuOpenedState, false); set(isKeyboardShortcutMenuOpenedState, false);
goBackToPreviousHotkeyScope(); removeFocusItemFromFocusStackById({
focusId: KEYBOARD_SHORTCUT_MENU_INSTANCE_ID,
});
} }
}, },
[goBackToPreviousHotkeyScope], [removeFocusItemFromFocusStackById],
); );
const toggleKeyboardShortcutMenu = useRecoilCallback( const toggleKeyboardShortcutMenu = useRecoilCallback(

View File

@ -4,8 +4,8 @@ import { Key } from 'ts-key-enum';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { useRecordBoardCardNavigation } from '@/object-record/record-board/hooks/useRecordBoardCardNavigation'; import { useRecordBoardCardNavigation } from '@/object-record/record-board/hooks/useRecordBoardCardNavigation';
import { useRecordBoardSelectAllHotkeys } from '@/object-record/record-board/hooks/useRecordBoardSelectAllHotkeys'; import { useRecordBoardSelectAllHotkeys } from '@/object-record/record-board/hooks/useRecordBoardSelectAllHotkeys';
import { RECORD_INDEX_FOCUS_ID } from '@/object-record/record-index/constants/RecordIndexFocusId';
import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/RecordIndexHotkeyScope'; import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/RecordIndexHotkeyScope';
import { PageFocusId } from '@/types/PageFocusId';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement'; import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
export const RecordBoardHotkeyEffect = () => { export const RecordBoardHotkeyEffect = () => {
@ -18,7 +18,7 @@ export const RecordBoardHotkeyEffect = () => {
callback: () => { callback: () => {
move('down'); move('down');
}, },
focusId: RECORD_INDEX_FOCUS_ID, focusId: PageFocusId.RecordIndex,
scope: RecordIndexHotkeyScope.RecordIndex, scope: RecordIndexHotkeyScope.RecordIndex,
dependencies: [move], dependencies: [move],
}); });

View File

@ -1,65 +1,28 @@
import styled from '@emotion/styled';
import { useCallback, useRef } from 'react';
import { useRecordGroupActions } from '@/object-record/record-group/hooks/useRecordGroupActions'; import { useRecordGroupActions } from '@/object-record/record-group/hooks/useRecordGroupActions';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { ViewType } from '@/views/types/ViewType'; import { ViewType } from '@/views/types/ViewType';
import { MenuItem } from 'twenty-ui/navigation'; import { MenuItem } from 'twenty-ui/navigation';
const StyledMenuContainer = styled.div` export const RecordBoardColumnDropdownMenu = () => {
position: absolute;
top: ${({ theme }) => theme.spacing(10)};
width: 200px;
z-index: 1;
`;
type RecordBoardColumnDropdownMenuProps = {
onClose: () => void;
onDelete?: (id: string) => void;
stageId: string;
};
export const RecordBoardColumnDropdownMenu = ({
onClose,
}: RecordBoardColumnDropdownMenuProps) => {
const boardColumnMenuRef = useRef<HTMLDivElement>(null);
const recordGroupActions = useRecordGroupActions({ const recordGroupActions = useRecordGroupActions({
viewType: ViewType.Kanban, viewType: ViewType.Kanban,
}); });
const closeMenu = useCallback(() => {
onClose();
}, [onClose]);
useListenClickOutside({
refs: [boardColumnMenuRef],
callback: closeMenu,
listenerId: 'record-board-column-dropdown-menu',
});
return ( return (
<StyledMenuContainer ref={boardColumnMenuRef}> <DropdownContent selectDisabled>
<OverlayContainer> <DropdownMenuItemsContainer>
<DropdownContent selectDisabled> {recordGroupActions.map((action) => (
<DropdownMenuItemsContainer> <MenuItem
{recordGroupActions.map((action) => ( key={action.id}
<MenuItem onClick={() => {
key={action.id} action.callback();
onClick={() => { }}
action.callback(); LeftIcon={action.icon}
closeMenu(); text={action.label}
}} />
LeftIcon={action.icon} ))}
text={action.label} </DropdownMenuItemsContainer>
/> </DropdownContent>
))}
</DropdownMenuItemsContainer>
</DropdownContent>
</OverlayContainer>
</StyledMenuContainer>
); );
}; };

View File

@ -7,10 +7,10 @@ import { RecordBoardColumnDropdownMenu } from '@/object-record/record-board/reco
import { RecordBoardColumnHeaderAggregateDropdown } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdown'; import { RecordBoardColumnHeaderAggregateDropdown } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdown';
import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext'; import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext';
import { useAggregateRecordsForRecordBoardColumn } from '@/object-record/record-board/record-board-column/hooks/useAggregateRecordsForRecordBoardColumn'; import { useAggregateRecordsForRecordBoardColumn } from '@/object-record/record-board/record-board-column/hooks/useAggregateRecordsForRecordBoardColumn';
import { RecordBoardColumnHotkeyScope } from '@/object-record/record-board/types/BoardColumnHotkeyScope';
import { RecordGroupDefinitionType } from '@/object-record/record-group/types/RecordGroupDefinition'; import { RecordGroupDefinitionType } from '@/object-record/record-group/types/RecordGroupDefinition';
import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord'; import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useToggleDropdown } from '@/ui/layout/dropdown/hooks/useToggleDropdown';
import { Tag } from 'twenty-ui/components'; import { Tag } from 'twenty-ui/components';
import { IconDotsVertical, IconPlus } from 'twenty-ui/display'; import { IconDotsVertical, IconPlus } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input'; import { LightIconButton } from 'twenty-ui/input';
@ -66,32 +66,11 @@ const StyledTag = styled(Tag)`
export const RecordBoardColumnHeader = () => { export const RecordBoardColumnHeader = () => {
const { columnDefinition } = useContext(RecordBoardColumnContext); const { columnDefinition } = useContext(RecordBoardColumnContext);
const [isBoardColumnMenuOpen, setIsBoardColumnMenuOpen] = useState(false);
const [isHeaderHovered, setIsHeaderHovered] = useState(false); const [isHeaderHovered, setIsHeaderHovered] = useState(false);
const { objectMetadataItem, selectFieldMetadataItem } = const { objectMetadataItem, selectFieldMetadataItem } =
useContext(RecordBoardContext); useContext(RecordBoardContext);
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
const handleBoardColumnMenuOpen = () => {
setIsBoardColumnMenuOpen(true);
setHotkeyScopeAndMemorizePreviousScope({
scope: RecordBoardColumnHotkeyScope.BoardColumn,
customScopes: {
goto: false,
},
});
};
const handleBoardColumnMenuClose = () => {
goBackToPreviousHotkeyScope();
setIsBoardColumnMenuOpen(false);
};
const { aggregateValue, aggregateLabel } = const { aggregateValue, aggregateLabel } =
useAggregateRecordsForRecordBoardColumn(); useAggregateRecordsForRecordBoardColumn();
@ -105,6 +84,10 @@ export const RecordBoardColumnHeader = () => {
objectMetadataItem: objectMetadataItem, objectMetadataItem: objectMetadataItem,
}); });
const { toggleDropdown } = useToggleDropdown();
const dropdownId = `record-board-column-dropdown-${columnDefinition.id}`;
return ( return (
<StyledColumn> <StyledColumn>
<StyledHeader <StyledHeader
@ -113,25 +96,36 @@ export const RecordBoardColumnHeader = () => {
> >
<StyledHeaderContainer> <StyledHeaderContainer>
<StyledLeftContainer> <StyledLeftContainer>
<StyledTag <Dropdown
onClick={handleBoardColumnMenuOpen} dropdownId={dropdownId}
variant={ dropdownPlacement="bottom-start"
columnDefinition.type === RecordGroupDefinitionType.Value dropdownOffset={{
? 'solid' x: 0,
: 'outline' y: 10,
} }}
color={ clickableComponent={
columnDefinition.type === RecordGroupDefinitionType.Value <StyledTag
? columnDefinition.color variant={
: 'transparent' columnDefinition.type === RecordGroupDefinitionType.Value
} ? 'solid'
text={columnDefinition.title} : 'outline'
weight={ }
columnDefinition.type === RecordGroupDefinitionType.Value color={
? 'regular' columnDefinition.type === RecordGroupDefinitionType.Value
: 'medium' ? columnDefinition.color
: 'transparent'
}
text={columnDefinition.title}
weight={
columnDefinition.type === RecordGroupDefinitionType.Value
? 'regular'
: 'medium'
}
/>
} }
dropdownComponents={<RecordBoardColumnDropdownMenu />}
/> />
<RecordBoardColumnHeaderAggregateDropdown <RecordBoardColumnHeaderAggregateDropdown
aggregateValue={aggregateValue} aggregateValue={aggregateValue}
dropdownId={`record-board-column-aggregate-dropdown-${columnDefinition.id}`} dropdownId={`record-board-column-aggregate-dropdown-${columnDefinition.id}`}
@ -145,7 +139,11 @@ export const RecordBoardColumnHeader = () => {
<LightIconButton <LightIconButton
accent="tertiary" accent="tertiary"
Icon={IconDotsVertical} Icon={IconDotsVertical}
onClick={handleBoardColumnMenuOpen} onClick={() => {
toggleDropdown({
dropdownComponentInstanceIdFromProps: dropdownId,
});
}}
/> />
{hasObjectUpdatePermissions && ( {hasObjectUpdatePermissions && (
<LightIconButton <LightIconButton
@ -164,12 +162,6 @@ export const RecordBoardColumnHeader = () => {
</StyledRightContainer> </StyledRightContainer>
</StyledHeaderContainer> </StyledHeaderContainer>
</StyledHeader> </StyledHeader>
{isBoardColumnMenuOpen && (
<RecordBoardColumnDropdownMenu
onClose={handleBoardColumnMenuClose}
stageId={columnDefinition.id}
/>
)}
</StyledColumn> </StyledColumn>
); );
}; };

View File

@ -1,36 +1,24 @@
import { Key } from 'ts-key-enum';
import { useDropdownContextStateManagement } from '@/dropdown-context-state-management/hooks/useDropdownContextStateManagement'; import { useDropdownContextStateManagement } from '@/dropdown-context-state-management/hooks/useDropdownContextStateManagement';
import { import {
RecordBoardColumnHeaderAggregateDropdownContext, RecordBoardColumnHeaderAggregateDropdownContext,
RecordBoardColumnHeaderAggregateDropdownContextValue, RecordBoardColumnHeaderAggregateDropdownContextValue,
} from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext'; } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdownContext';
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { MenuItem } from 'twenty-ui/navigation'; import { MenuItem } from 'twenty-ui/navigation';
export const RecordBoardColumnHeaderAggregateDropdownMenuContent = () => { export const RecordBoardColumnHeaderAggregateDropdownMenuContent = () => {
const { t } = useLingui(); const { t } = useLingui();
const { onContentChange, closeDropdown } = const { onContentChange } =
useDropdownContextStateManagement<RecordBoardColumnHeaderAggregateDropdownContextValue>( useDropdownContextStateManagement<RecordBoardColumnHeaderAggregateDropdownContextValue>(
{ {
context: RecordBoardColumnHeaderAggregateDropdownContext, context: RecordBoardColumnHeaderAggregateDropdownContext,
}, },
); );
useScopedHotkeys(
[Key.Escape],
() => {
closeDropdown();
},
TableOptionsHotkeyScope.Dropdown,
);
return ( return (
<DropdownContent> <DropdownContent>
<DropdownMenuItemsContainer> <DropdownMenuItemsContainer>

View File

@ -10,18 +10,15 @@ import { getAggregateOperationLabel } from '@/object-record/record-board/record-
import { recordIndexKanbanAggregateOperationState } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState'; import { recordIndexKanbanAggregateOperationState } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations'; import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
import { ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations'; import { ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
import { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation'; import { AvailableFieldsForAggregateOperation } from '@/object-record/types/AvailableFieldsForAggregateOperation';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader'; import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent'; import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useUpdateViewAggregate } from '@/views/hooks/useUpdateViewAggregate'; import { useUpdateViewAggregate } from '@/views/hooks/useUpdateViewAggregate';
import isEmpty from 'lodash.isempty'; import isEmpty from 'lodash.isempty';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { IconCheck, IconChevronLeft } from 'twenty-ui/display'; import { IconCheck, IconChevronLeft } from 'twenty-ui/display';
export const RecordBoardColumnHeaderAggregateDropdownOptionsContent = ({ export const RecordBoardColumnHeaderAggregateDropdownOptionsContent = ({
@ -38,14 +35,6 @@ export const RecordBoardColumnHeaderAggregateDropdownOptionsContent = ({
}, },
); );
useScopedHotkeys(
[Key.Escape],
() => {
closeDropdown();
},
TableOptionsHotkeyScope.Dropdown,
);
const setAggregateOperation = useSetRecoilComponentStateV2( const setAggregateOperation = useSetRecoilComponentStateV2(
aggregateOperationComponentState, aggregateOperationComponentState,
); );

View File

@ -85,6 +85,7 @@ export const FormBooleanFieldInput = ({
<FormFieldInputRowContainer> <FormFieldInputRowContainer>
<FormFieldInputInnerContainer <FormFieldInputInnerContainer
formFieldInputInstanceId={instanceId}
hasRightElement={isDefined(VariablePicker) && !readonly} hasRightElement={isDefined(VariablePicker) && !readonly}
> >
{draftValue.type === 'static' ? ( {draftValue.type === 'static' ? (

View File

@ -296,6 +296,7 @@ export const FormDateTimeFieldInput = ({
<FormFieldInputRowContainer> <FormFieldInputRowContainer>
<StyledInputContainer <StyledInputContainer
formFieldInputInstanceId={instanceId}
ref={datePickerWrapperRef} ref={datePickerWrapperRef}
hasRightElement={isDefined(VariablePicker) && !readonly} hasRightElement={isDefined(VariablePicker) && !readonly}
> >

View File

@ -1,5 +1,7 @@
import { FormFieldInputHotKeyScope } from '@/object-record/record-field/form-types/constants/FormFieldInputHotKeyScope'; import { FormFieldInputHotKeyScope } from '@/object-record/record-field/form-types/constants/FormFieldInputHotKeyScope';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { css } from '@emotion/react'; import { css } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { forwardRef, HTMLAttributes, Ref } from 'react'; import { forwardRef, HTMLAttributes, Ref } from 'react';
@ -9,9 +11,12 @@ type FormFieldInputInnerContainerProps = {
multiline?: boolean; multiline?: boolean;
readonly?: boolean; readonly?: boolean;
preventSetHotkeyScope?: boolean; preventSetHotkeyScope?: boolean;
formFieldInputInstanceId: string;
}; };
const StyledFormFieldInputInnerContainer = styled.div<FormFieldInputInnerContainerProps>` const StyledFormFieldInputInnerContainer = styled.div<
Omit<FormFieldInputInnerContainerProps, 'formFieldInputInstanceId'>
>`
background-color: ${({ theme }) => theme.background.transparent.lighter}; background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium}; border: 1px solid ${({ theme }) => theme.border.color.medium};
border-top-left-radius: ${({ theme }) => theme.border.radius.sm}; border-top-left-radius: ${({ theme }) => theme.border.radius.sm};
@ -48,20 +53,25 @@ export const FormFieldInputInnerContainer = forwardRef(
readonly, readonly,
preventSetHotkeyScope = false, preventSetHotkeyScope = false,
onClick, onClick,
formFieldInputInstanceId,
}: HTMLAttributes<HTMLDivElement> & FormFieldInputInnerContainerProps, }: HTMLAttributes<HTMLDivElement> & FormFieldInputInnerContainerProps,
ref: Ref<HTMLDivElement>, ref: Ref<HTMLDivElement>,
) => { ) => {
const { const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
goBackToPreviousHotkeyScope, const { removeFocusItemFromFocusStackById } =
setHotkeyScopeAndMemorizePreviousScope, useRemoveFocusItemFromFocusStackById();
} = usePreviousHotkeyScope();
const handleFocus = (e: React.FocusEvent<HTMLDivElement>) => { const handleFocus = (e: React.FocusEvent<HTMLDivElement>) => {
onFocus?.(e); onFocus?.(e);
if (!preventSetHotkeyScope) { if (!preventSetHotkeyScope) {
setHotkeyScopeAndMemorizePreviousScope({ pushFocusItemToFocusStack({
scope: FormFieldInputHotKeyScope.FormFieldInput, focusId: formFieldInputInstanceId,
component: {
type: FocusComponentType.FORM_FIELD_INPUT,
instanceId: formFieldInputInstanceId,
},
hotkeyScope: { scope: FormFieldInputHotKeyScope.FormFieldInput },
}); });
} }
}; };
@ -70,7 +80,9 @@ export const FormFieldInputInnerContainer = forwardRef(
onBlur?.(e); onBlur?.(e);
if (!preventSetHotkeyScope) { if (!preventSetHotkeyScope) {
goBackToPreviousHotkeyScope(); removeFocusItemFromFocusStackById({
focusId: formFieldInputInstanceId,
});
} }
}; };

View File

@ -14,7 +14,9 @@ import { MultiSelectInput } from '@/ui/field/input/components/MultiSelectInput';
import { InputLabel } from '@/ui/input/components/InputLabel'; import { InputLabel } from '@/ui/input/components/InputLabel';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer'; import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString'; import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import { isArray } from '@sniptt/guards'; import { isArray } from '@sniptt/guards';
@ -88,10 +90,9 @@ export const FormMultiSelectFieldInput = ({
const hotkeyScope = const hotkeyScope =
FormMultiSelectFieldInputHotKeyScope.FormMultiSelectFieldInput; FormMultiSelectFieldInputHotKeyScope.FormMultiSelectFieldInput;
const { const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
setHotkeyScopeAndMemorizePreviousScope, const { removeFocusItemFromFocusStackById } =
goBackToPreviousHotkeyScope, useRemoveFocusItemFromFocusStackById();
} = usePreviousHotkeyScope();
const [draftValue, setDraftValue] = useState< const [draftValue, setDraftValue] = useState<
| { | {
@ -128,8 +129,13 @@ export const FormMultiSelectFieldInput = ({
editingMode: 'edit', editingMode: 'edit',
}); });
setHotkeyScopeAndMemorizePreviousScope({ pushFocusItemToFocusStack({
scope: hotkeyScope, focusId: instanceId,
component: {
type: FocusComponentType.FORM_FIELD_INPUT,
instanceId,
},
hotkeyScope: { scope: hotkeyScope },
}); });
}; };
@ -157,7 +163,7 @@ export const FormMultiSelectFieldInput = ({
editingMode: 'view', editingMode: 'view',
}); });
goBackToPreviousHotkeyScope(); removeFocusItemFromFocusStackById({ focusId: instanceId });
}; };
const handleVariableTagInsert = (variableName: string) => { const handleVariableTagInsert = (variableName: string) => {
@ -201,6 +207,7 @@ export const FormMultiSelectFieldInput = ({
<FormFieldInputRowContainer> <FormFieldInputRowContainer>
<FormFieldInputInnerContainer <FormFieldInputInnerContainer
formFieldInputInstanceId={instanceId}
hasRightElement={isDefined(VariablePicker) && !readonly} hasRightElement={isDefined(VariablePicker) && !readonly}
> >
{draftValue.type === 'static' ? ( {draftValue.type === 'static' ? (

View File

@ -121,6 +121,7 @@ export const FormNumberFieldInput = ({
<FormFieldInputRowContainer> <FormFieldInputRowContainer>
<FormFieldInputInnerContainer <FormFieldInputInnerContainer
formFieldInputInstanceId={instanceId}
hasRightElement={isDefined(VariablePicker) && !readonly} hasRightElement={isDefined(VariablePicker) && !readonly}
onBlur={onBlur} onBlur={onBlur}
> >

View File

@ -71,6 +71,7 @@ export const FormRawJsonFieldInput = ({
<FormFieldInputRowContainer multiline> <FormFieldInputRowContainer multiline>
<FormFieldInputInnerContainer <FormFieldInputInnerContainer
formFieldInputInstanceId={instanceId}
hasRightElement={isDefined(VariablePicker) && !readonly} hasRightElement={isDefined(VariablePicker) && !readonly}
multiline multiline
onBlur={onBlur} onBlur={onBlur}

View File

@ -7,8 +7,8 @@ import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/
import { InputLabel } from '@/ui/input/components/InputLabel'; import { InputLabel } from '@/ui/input/components/InputLabel';
import { Select } from '@/ui/input/components/Select'; import { Select } from '@/ui/input/components/Select';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString'; import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import { useId, useState } from 'react'; import { useId, useState } from 'react';
@ -40,7 +40,8 @@ export const FormSelectFieldInput = ({
const hotkeyScope = InlineCellHotkeyScope.InlineCell; const hotkeyScope = InlineCellHotkeyScope.InlineCell;
const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope(); const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
const [draftValue, setDraftValue] = useState< const [draftValue, setDraftValue] = useState<
| { | {
@ -72,7 +73,7 @@ export const FormSelectFieldInput = ({
editingMode: 'view', editingMode: 'view',
}); });
goBackToPreviousHotkeyScope(); removeFocusItemFromFocusStackById({ focusId: instanceId });
onChange(option); onChange(option);
}; };
@ -87,7 +88,7 @@ export const FormSelectFieldInput = ({
editingMode: 'view', editingMode: 'view',
}); });
goBackToPreviousHotkeyScope(); removeFocusItemFromFocusStackById({ focusId: instanceId });
}; };
const selectedOption = options.find( const selectedOption = options.find(
@ -119,14 +120,13 @@ export const FormSelectFieldInput = ({
onChange(variableName); onChange(variableName);
}; };
useScopedHotkeys( useHotkeysOnFocusedElement({
Key.Escape, keys: Key.Escape,
() => { callback: onCancel,
onCancel(); focusId: instanceId,
}, scope: hotkeyScope,
hotkeyScope, dependencies: [onCancel],
[onCancel], });
);
return ( return (
<FormFieldInputContainer> <FormFieldInputContainer>
@ -149,6 +149,7 @@ export const FormSelectFieldInput = ({
/> />
) : ( ) : (
<FormFieldInputInnerContainer <FormFieldInputInnerContainer
formFieldInputInstanceId={instanceId}
hasRightElement={isDefined(VariablePicker) && !readonly} hasRightElement={isDefined(VariablePicker) && !readonly}
> >
<VariableChipStandalone <VariableChipStandalone

View File

@ -150,7 +150,11 @@ export const FormSingleRecordPicker = ({
{label ? <InputLabel>{label}</InputLabel> : null} {label ? <InputLabel>{label}</InputLabel> : null}
<FormFieldInputRowContainer> <FormFieldInputRowContainer>
{disabled ? ( {disabled ? (
<StyledFormSelectContainer hasRightElement={false} readonly> <StyledFormSelectContainer
formFieldInputInstanceId={componentId}
hasRightElement={false}
readonly
>
<FormSingleRecordFieldChip <FormSingleRecordFieldChip
draftValue={draftValue} draftValue={draftValue}
selectedRecord={selectedRecord} selectedRecord={selectedRecord}
@ -169,6 +173,7 @@ export const FormSingleRecordPicker = ({
dropdownOffset={{ y: parseInt(theme.spacing(1), 10) }} dropdownOffset={{ y: parseInt(theme.spacing(1), 10) }}
clickableComponent={ clickableComponent={
<StyledFormSelectContainer <StyledFormSelectContainer
formFieldInputInstanceId={componentId}
hasRightElement={isDefined(VariablePicker) && !disabled} hasRightElement={isDefined(VariablePicker) && !disabled}
preventSetHotkeyScope={true} preventSetHotkeyScope={true}
> >

View File

@ -71,6 +71,7 @@ export const FormTextFieldInput = ({
<FormFieldInputRowContainer multiline={multiline}> <FormFieldInputRowContainer multiline={multiline}>
<FormFieldInputInnerContainer <FormFieldInputInnerContainer
formFieldInputInstanceId={instanceId}
hasRightElement={isDefined(VariablePicker) && !readonly} hasRightElement={isDefined(VariablePicker) && !readonly}
multiline={multiline} multiline={multiline}
onBlur={onBlur} onBlur={onBlur}

View File

@ -95,6 +95,7 @@ export const FormUuidFieldInput = ({
<FormFieldInputRowContainer> <FormFieldInputRowContainer>
<FormFieldInputInnerContainer <FormFieldInputInnerContainer
formFieldInputInstanceId={instanceId}
hasRightElement={isDefined(VariablePicker) && !readonly} hasRightElement={isDefined(VariablePicker) && !readonly}
> >
{draftValue.type === 'static' ? ( {draftValue.type === 'static' ? (

View File

@ -1 +0,0 @@
export const RECORD_INDEX_FOCUS_ID = 'record-index';

View File

@ -1,5 +1,5 @@
import { RECORD_INDEX_FOCUS_ID } from '@/object-record/record-index/constants/RecordIndexFocusId';
import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/RecordIndexHotkeyScope'; import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/RecordIndexHotkeyScope';
import { PageFocusId } from '@/types/PageFocusId';
import { useResetFocusStackToFocusItem } from '@/ui/utilities/focus/hooks/useResetFocusStackToFocusItem'; import { useResetFocusStackToFocusItem } from '@/ui/utilities/focus/hooks/useResetFocusStackToFocusItem';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
@ -9,10 +9,10 @@ export const useResetFocusStackToRecordIndex = () => {
const resetFocusStackToRecordIndex = () => { const resetFocusStackToRecordIndex = () => {
resetFocusStackToFocusItem({ resetFocusStackToFocusItem({
focusStackItem: { focusStackItem: {
focusId: RECORD_INDEX_FOCUS_ID, focusId: PageFocusId.RecordIndex,
componentInstance: { componentInstance: {
componentType: FocusComponentType.PAGE, componentType: FocusComponentType.PAGE,
componentInstanceId: RECORD_INDEX_FOCUS_ID, componentInstanceId: PageFocusId.RecordIndex,
}, },
globalHotkeysConfig: { globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: true, enableGlobalHotkeysWithModifiers: true,

View File

@ -14,7 +14,6 @@ import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/Recor
import { RecordTableComponentInstance } from '@/object-record/record-table/components/RecordTableComponentInstance'; import { RecordTableComponentInstance } from '@/object-record/record-table/components/RecordTableComponentInstance';
import { RecordTableContextProvider } from '@/object-record/record-table/components/RecordTableContextProvider'; import { RecordTableContextProvider } from '@/object-record/record-table/components/RecordTableContextProvider';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement'; import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { RecordUpdateContext } from '../contexts/EntityUpdateMutationHookContext'; import { RecordUpdateContext } from '../contexts/EntityUpdateMutationHookContext';
import { useRecordTable } from '../hooks/useRecordTable'; import { useRecordTable } from '../hooks/useRecordTable';
@ -48,16 +47,6 @@ export const RecordTableWithWrappers = ({
selectAllRows(); selectAllRows();
}; };
useScopedHotkeys(
'ctrl+a,meta+a',
handleSelectAllRows,
RecordIndexHotkeyScope.RecordIndex,
[],
{
enableOnFormTags: false,
},
);
useHotkeysOnFocusedElement({ useHotkeysOnFocusedElement({
keys: ['ctrl+a,meta+a'], keys: ['ctrl+a,meta+a'],
callback: handleSelectAllRows, callback: handleSelectAllRows,

View File

@ -1,10 +1,10 @@
import { Key } from 'ts-key-enum'; import { Key } from 'ts-key-enum';
import { RECORD_INDEX_FOCUS_ID } from '@/object-record/record-index/constants/RecordIndexFocusId';
import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/RecordIndexHotkeyScope'; import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/RecordIndexHotkeyScope';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { isAtLeastOneTableRowSelectedSelector } from '@/object-record/record-table/record-table-row/states/isAtLeastOneTableRowSelectedSelector'; import { isAtLeastOneTableRowSelectedSelector } from '@/object-record/record-table/record-table-row/states/isAtLeastOneTableRowSelectedSelector';
import { PageFocusId } from '@/types/PageFocusId';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement'; import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
@ -28,7 +28,7 @@ export const RecordTableBodyEscapeHotkeyEffect = () => {
useHotkeysOnFocusedElement({ useHotkeysOnFocusedElement({
keys: [Key.Escape], keys: [Key.Escape],
callback: handleEscape, callback: handleEscape,
focusId: RECORD_INDEX_FOCUS_ID, focusId: PageFocusId.RecordIndex,
scope: RecordIndexHotkeyScope.RecordIndex, scope: RecordIndexHotkeyScope.RecordIndex,
dependencies: [handleEscape], dependencies: [handleEscape],
options: { options: {

View File

@ -1,10 +1,10 @@
import { RECORD_INDEX_FOCUS_ID } from '@/object-record/record-index/constants/RecordIndexFocusId';
import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/RecordIndexHotkeyScope'; import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/RecordIndexHotkeyScope';
import { useRecordTableRowFocusHotkeys } from '@/object-record/record-table/hooks/useRecordTableRowFocusHotkeys'; import { useRecordTableRowFocusHotkeys } from '@/object-record/record-table/hooks/useRecordTableRowFocusHotkeys';
import { PageFocusId } from '@/types/PageFocusId';
export const RecordTableBodyFocusKeyboardEffect = () => { export const RecordTableBodyFocusKeyboardEffect = () => {
useRecordTableRowFocusHotkeys({ useRecordTableRowFocusHotkeys({
focusId: RECORD_INDEX_FOCUS_ID, focusId: PageFocusId.RecordIndex,
hotkeyScope: RecordIndexHotkeyScope.RecordIndex, hotkeyScope: RecordIndexHotkeyScope.RecordIndex,
}); });

View File

@ -1,15 +1,11 @@
import { RecordTableColumnAggregateFooterAggregateOperationMenuItems } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterAggregateOperationMenuItems'; import { RecordTableColumnAggregateFooterAggregateOperationMenuItems } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterAggregateOperationMenuItems';
import { RecordTableColumnAggregateFooterDropdownContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContext'; import { RecordTableColumnAggregateFooterDropdownContext } from '@/object-record/record-table/record-table-footer/components/RecordTableColumnAggregateFooterDropdownContext';
import { ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations'; import { ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader'; import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent'; import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useContext } from 'react'; import { useContext } from 'react';
import { Key } from 'ts-key-enum';
import { IconChevronLeft } from 'twenty-ui/display'; import { IconChevronLeft } from 'twenty-ui/display';
export const RecordTableColumnAggregateFooterDropdownSubmenuContent = ({ export const RecordTableColumnAggregateFooterDropdownSubmenuContent = ({
@ -19,19 +15,10 @@ export const RecordTableColumnAggregateFooterDropdownSubmenuContent = ({
aggregateOperations: ExtendedAggregateOperations[]; aggregateOperations: ExtendedAggregateOperations[];
title: string; title: string;
}) => { }) => {
const { dropdownId, resetContent } = useContext( const { resetContent } = useContext(
RecordTableColumnAggregateFooterDropdownContext, RecordTableColumnAggregateFooterDropdownContext,
); );
const { closeDropdown } = useCloseDropdown();
useScopedHotkeys(
[Key.Escape],
() => {
resetContent();
closeDropdown(dropdownId);
},
TableOptionsHotkeyScope.Dropdown,
);
return ( return (
<DropdownContent> <DropdownContent>
<DropdownMenuHeader <DropdownMenuHeader

View File

@ -4,14 +4,11 @@ import { RecordTableColumnAggregateFooterDropdownContext } from '@/object-record
import { NON_STANDARD_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/nonStandardAggregateOperationsOptions'; import { NON_STANDARD_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/nonStandardAggregateOperationsOptions';
import { useViewFieldAggregateOperation } from '@/object-record/record-table/record-table-footer/hooks/useViewFieldAggregateOperation'; import { useViewFieldAggregateOperation } from '@/object-record/record-table/record-table-footer/hooks/useViewFieldAggregateOperation';
import { getAvailableAggregateOperationsForFieldMetadataType } from '@/object-record/record-table/record-table-footer/utils/getAvailableAggregateOperationsForFieldMetadataType'; import { getAvailableAggregateOperationsForFieldMetadataType } from '@/object-record/record-table/record-table-footer/utils/getAvailableAggregateOperationsForFieldMetadataType';
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { useContext, useMemo } from 'react'; import { useContext, useMemo } from 'react';
import { Key } from 'ts-key-enum';
import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils'; import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils';
import { IconCheck } from 'twenty-ui/display'; import { IconCheck } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation'; import { MenuItem } from 'twenty-ui/navigation';
@ -28,14 +25,6 @@ export const RecordTableColumnAggregateFooterMenuContent = () => {
const { closeDropdown } = useCloseDropdown(); const { closeDropdown } = useCloseDropdown();
const { objectMetadataItem } = useRecordTableContextOrThrow(); const { objectMetadataItem } = useRecordTableContextOrThrow();
useScopedHotkeys(
[Key.Escape],
() => {
closeDropdown(dropdownId);
},
TableOptionsHotkeyScope.Dropdown,
);
const availableAggregateOperation = useMemo( const availableAggregateOperation = useMemo(
() => () =>
getAvailableAggregateOperationsForFieldMetadataType({ getAvailableAggregateOperationsForFieldMetadataType({

View File

@ -3,14 +3,10 @@ import {
SettingsServerlessFunctionCodeEditor, SettingsServerlessFunctionCodeEditor,
} from '@/settings/serverless-functions/components/SettingsServerlessFunctionCodeEditor'; } from '@/settings/serverless-functions/components/SettingsServerlessFunctionCodeEditor';
import { SETTINGS_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID } from '@/settings/serverless-functions/constants/SettingsServerlessFunctionTabListComponentId'; import { SETTINGS_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID } from '@/settings/serverless-functions/constants/SettingsServerlessFunctionTabListComponentId';
import { SettingsServerlessFunctionHotkeyScope } from '@/settings/serverless-functions/types/SettingsServerlessFunctionHotKeyScope';
import { SettingsPath } from '@/types/SettingsPath';
import { TabList } from '@/ui/layout/tab-list/components/TabList'; import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState'; import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { Key } from 'ts-key-enum';
import { import {
H2Title, H2Title,
IconGitCommit, IconGitCommit,
@ -19,8 +15,6 @@ import {
} from 'twenty-ui/display'; } from 'twenty-ui/display';
import { Button, CoreEditorHeader } from 'twenty-ui/input'; import { Button, CoreEditorHeader } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout'; import { Section } from 'twenty-ui/layout';
import { useHotkeyScopeOnMount } from '~/hooks/useHotkeyScopeOnMount';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
const StyledTabList = styled(TabList)` const StyledTabList = styled(TabList)`
border-bottom: none; border-bottom: none;
@ -91,19 +85,6 @@ export const SettingsServerlessFunctionCodeEditorTab = ({
/> />
); );
const navigate = useNavigateSettings();
useHotkeyScopeOnMount(
SettingsServerlessFunctionHotkeyScope.ServerlessFunctionEditorTab,
);
useScopedHotkeys(
[Key.Escape],
() => {
navigate(SettingsPath.ServerlessFunctions);
},
SettingsServerlessFunctionHotkeyScope.ServerlessFunctionEditorTab,
);
return ( return (
<Section> <Section>
<H2Title <H2Title

View File

@ -2,16 +2,12 @@ import { SettingsServerlessFunctionNewForm } from '@/settings/serverless-functio
import { SettingsServerlessFunctionTabEnvironmentVariablesSection } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTabEnvironmentVariablesSection'; import { SettingsServerlessFunctionTabEnvironmentVariablesSection } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTabEnvironmentVariablesSection';
import { useDeleteOneServerlessFunction } from '@/settings/serverless-functions/hooks/useDeleteOneServerlessFunction'; import { useDeleteOneServerlessFunction } from '@/settings/serverless-functions/hooks/useDeleteOneServerlessFunction';
import { ServerlessFunctionFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState'; import { ServerlessFunctionFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { SettingsServerlessFunctionHotkeyScope } from '@/settings/serverless-functions/types/SettingsServerlessFunctionHotKeyScope';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal'; import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { Key } from 'ts-key-enum';
import { H2Title } from 'twenty-ui/display'; import { H2Title } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input'; import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout'; import { Section } from 'twenty-ui/layout';
import { useHotkeyScopeOnMount } from '~/hooks/useHotkeyScopeOnMount';
import { useNavigateSettings } from '~/hooks/useNavigateSettings'; import { useNavigateSettings } from '~/hooks/useNavigateSettings';
const DELETE_FUNCTION_MODAL_ID = 'delete-function-modal'; const DELETE_FUNCTION_MODAL_ID = 'delete-function-modal';
@ -36,25 +32,6 @@ export const SettingsServerlessFunctionSettingsTab = ({
navigate(SettingsPath.ServerlessFunctions); navigate(SettingsPath.ServerlessFunctions);
}; };
useHotkeyScopeOnMount(
SettingsServerlessFunctionHotkeyScope.ServerlessFunctionSettingsTab,
);
useScopedHotkeys(
[Key.Delete],
() => {
openModal(DELETE_FUNCTION_MODAL_ID);
},
SettingsServerlessFunctionHotkeyScope.ServerlessFunctionSettingsTab,
);
useScopedHotkeys(
[Key.Escape],
() => {
navigate(SettingsPath.ServerlessFunctions);
},
SettingsServerlessFunctionHotkeyScope.ServerlessFunctionSettingsTab,
);
return ( return (
<> <>
<SettingsServerlessFunctionNewForm <SettingsServerlessFunctionNewForm

View File

@ -1,15 +1,9 @@
import { ServerlessFunctionExecutionResult } from '@/serverless-functions/components/ServerlessFunctionExecutionResult'; import { ServerlessFunctionExecutionResult } from '@/serverless-functions/components/ServerlessFunctionExecutionResult';
import { SettingsServerlessFunctionHotkeyScope } from '@/settings/serverless-functions/types/SettingsServerlessFunctionHotKeyScope';
import { SettingsPath } from '@/types/SettingsPath';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { serverlessFunctionTestDataFamilyState } from '@/workflow/states/serverlessFunctionTestDataFamilyState'; import { serverlessFunctionTestDataFamilyState } from '@/workflow/states/serverlessFunctionTestDataFamilyState';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { useHotkeyScopeOnMount } from '~/hooks/useHotkeyScopeOnMount';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { Button, CodeEditor, CoreEditorHeader } from 'twenty-ui/input';
import { H2Title, IconPlayerPlay } from 'twenty-ui/display'; import { H2Title, IconPlayerPlay } from 'twenty-ui/display';
import { Button, CodeEditor, CoreEditorHeader } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout'; import { Section } from 'twenty-ui/layout';
const StyledInputsContainer = styled.div` const StyledInputsContainer = styled.div`
@ -40,19 +34,6 @@ export const SettingsServerlessFunctionTestTab = ({
})); }));
}; };
const navigate = useNavigateSettings();
useHotkeyScopeOnMount(
SettingsServerlessFunctionHotkeyScope.ServerlessFunctionTestTab,
);
useScopedHotkeys(
[Key.Escape],
() => {
navigate(SettingsPath.ServerlessFunctions);
},
SettingsServerlessFunctionHotkeyScope.ServerlessFunctionTestTab,
);
return ( return (
<Section> <Section>
<H2Title <H2Title

View File

@ -0,0 +1,11 @@
export enum PageFocusId {
Settings = 'settings',
CreateWorkspace = 'create-workspace',
SignInUp = 'sign-in-up',
CreateProfile = 'create-profile',
InviteTeam = 'invite-team',
SyncEmail = 'sync-email',
PlanRequired = 'plan-required',
RecordShowPage = 'record-show-page',
RecordIndex = 'record-index',
}

View File

@ -2,11 +2,11 @@ import styled from '@emotion/styled';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Key } from 'ts-key-enum'; import { Key } from 'ts-key-enum';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { DIALOG_CLICK_OUTSIDE_ID } from '@/ui/feedback/dialog-manager/constants/DialogClickOutsideId'; import { DIALOG_CLICK_OUTSIDE_ID } from '@/ui/feedback/dialog-manager/constants/DialogClickOutsideId';
import { DIALOG_FOCUS_ID } from '@/ui/feedback/dialog-manager/constants/DialogFocusId';
import { DIALOG_LISTENER_ID } from '@/ui/feedback/dialog-manager/constants/DialogListenerId'; import { DIALOG_LISTENER_ID } from '@/ui/feedback/dialog-manager/constants/DialogListenerId';
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices'; import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useRef } from 'react'; import { useRef } from 'react';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
@ -96,31 +96,37 @@ export const Dialog = ({
closed: { y: '50vh' }, closed: { y: '50vh' },
}; };
useScopedHotkeys( const handleEnter = (event: KeyboardEvent) => {
Key.Enter, const confirmButton = buttons.find((button) => button.role === 'confirm');
(event: KeyboardEvent) => {
const confirmButton = buttons.find((button) => button.role === 'confirm');
event.preventDefault(); event.preventDefault();
if (isDefined(confirmButton)) { if (isDefined(confirmButton)) {
confirmButton?.onClick?.(event); confirmButton?.onClick?.(event);
onClose?.();
}
},
DialogHotkeyScope.Dialog,
[],
);
useScopedHotkeys(
Key.Escape,
(event: KeyboardEvent) => {
event.preventDefault();
onClose?.(); onClose?.();
}, }
DialogHotkeyScope.Dialog, };
[],
); const handleEscape = (event: KeyboardEvent) => {
event.preventDefault();
onClose?.();
};
useHotkeysOnFocusedElement({
keys: [Key.Enter],
callback: handleEnter,
focusId: DIALOG_FOCUS_ID,
scope: DialogHotkeyScope.Dialog,
dependencies: [buttons],
});
useHotkeysOnFocusedElement({
keys: [Key.Escape],
callback: handleEscape,
focusId: DIALOG_FOCUS_ID,
scope: DialogHotkeyScope.Dialog,
dependencies: [handleEscape],
});
const dialogRef = useRef<HTMLDivElement>(null); const dialogRef = useRef<HTMLDivElement>(null);

View File

@ -1,26 +1,34 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; import { DIALOG_FOCUS_ID } from '@/ui/feedback/dialog-manager/constants/DialogFocusId';
import { DIALOG_MANAGER_HOTKEY_SCOPE_MEMOIZE_KEY } from '@/ui/feedback/dialog-manager/constants/DialogManagerHotkeyScopeMemoizeKey'; import { DIALOG_MANAGER_HOTKEY_SCOPE_MEMOIZE_KEY } from '@/ui/feedback/dialog-manager/constants/DialogManagerHotkeyScopeMemoizeKey';
import { DialogHotkeyScope } from '@/ui/feedback/dialog-manager/types/DialogHotkeyScope';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useDialogManagerScopedStates } from '../hooks/internal/useDialogManagerScopedStates'; import { useDialogManagerScopedStates } from '../hooks/internal/useDialogManagerScopedStates';
import { DialogHotkeyScope } from '../types/DialogHotkeyScope';
export const DialogManagerEffect = () => { export const DialogManagerEffect = () => {
const { dialogInternal } = useDialogManagerScopedStates(); const { dialogInternal } = useDialogManagerScopedStates();
const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope(); const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
useEffect(() => { useEffect(() => {
if (dialogInternal.queue.length === 0) { if (dialogInternal.queue.length === 0) {
return; return;
} }
setHotkeyScopeAndMemorizePreviousScope({ pushFocusItemToFocusStack({
scope: DialogHotkeyScope.Dialog, focusId: DIALOG_FOCUS_ID,
component: {
type: FocusComponentType.DIALOG,
instanceId: DIALOG_FOCUS_ID,
},
hotkeyScope: {
scope: DialogHotkeyScope.Dialog,
},
memoizeKey: DIALOG_MANAGER_HOTKEY_SCOPE_MEMOIZE_KEY, memoizeKey: DIALOG_MANAGER_HOTKEY_SCOPE_MEMOIZE_KEY,
}); });
}, [dialogInternal.queue, setHotkeyScopeAndMemorizePreviousScope]); }, [dialogInternal.queue, pushFocusItemToFocusStack]);
return <></>; return <></>;
}; };

View File

@ -0,0 +1 @@
export const DIALOG_FOCUS_ID = 'dialog';

View File

@ -1,10 +1,10 @@
import { useRecoilCallback } from 'recoil'; import { useRecoilCallback } from 'recoil';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; 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 { DIALOG_FOCUS_ID } from '@/ui/feedback/dialog-manager/constants/DialogFocusId';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { DialogManagerScopeInternalContext } from '../scopes/scope-internal-context/DialogManagerScopeInternalContext'; import { DialogManagerScopeInternalContext } from '../scopes/scope-internal-context/DialogManagerScopeInternalContext';
import { dialogInternalScopedState } from '../states/dialogInternalScopedState'; import { dialogInternalScopedState } from '../states/dialogInternalScopedState';
import { DialogOptions } from '../types/DialogOptions'; import { DialogOptions } from '../types/DialogOptions';
@ -19,7 +19,8 @@ export const useDialogManager = (props?: useDialogManagerProps) => {
props?.dialogManagerScopeId, props?.dialogManagerScopeId,
); );
const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope(); const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
const closeDialog = useRecoilCallback( const closeDialog = useRecoilCallback(
({ set }) => ({ set }) =>
@ -29,9 +30,9 @@ export const useDialogManager = (props?: useDialogManagerProps) => {
queue: prevState.queue.filter((dialog) => dialog.id !== id), queue: prevState.queue.filter((dialog) => dialog.id !== id),
})); }));
goBackToPreviousHotkeyScope(DIALOG_MANAGER_HOTKEY_SCOPE_MEMOIZE_KEY); removeFocusItemFromFocusStackById({ focusId: DIALOG_FOCUS_ID });
}, },
[goBackToPreviousHotkeyScope, scopeId], [removeFocusItemFromFocusStackById, scopeId],
); );
const setDialogQueue = useRecoilCallback( const setDialogQueue = useRecoilCallback(

View File

@ -1,11 +1,13 @@
import { TextInputV2 } from '@/ui/input/components/TextInputV2'; import { TextInputV2 } from '@/ui/input/components/TextInputV2';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { FocusEvent, useRef } from 'react'; import { FocusEvent, useRef } from 'react';
import { Key } from 'ts-key-enum'; import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { IconComponent, TablerIconsProps } from 'twenty-ui/display'; import { IconComponent, TablerIconsProps } from 'twenty-ui/display';
import { useHotkeyScopeOnMount } from '~/hooks/useHotkeyScopeOnMount';
type NavigationDrawerInputProps = { type NavigationDrawerInputProps = {
className?: string; className?: string;
@ -19,6 +21,8 @@ type NavigationDrawerInputProps = {
hotkeyScope: string; hotkeyScope: string;
}; };
const NAVIGATION_DRAWER_INPUT_FOCUS_ID = 'navigation-drawer-input';
export const NavigationDrawerInput = ({ export const NavigationDrawerInput = ({
className, className,
placeholder, placeholder,
@ -32,37 +36,64 @@ export const NavigationDrawerInput = ({
}: NavigationDrawerInputProps) => { }: NavigationDrawerInputProps) => {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
useHotkeyScopeOnMount(hotkeyScope); useHotkeysOnFocusedElement({
keys: Key.Escape,
useScopedHotkeys( callback: () => {
[Key.Escape],
() => {
onCancel(value); onCancel(value);
removeFocusItemFromFocusStackById({
focusId: NAVIGATION_DRAWER_INPUT_FOCUS_ID,
});
}, },
hotkeyScope, focusId: NAVIGATION_DRAWER_INPUT_FOCUS_ID,
); scope: hotkeyScope,
});
useScopedHotkeys( useHotkeysOnFocusedElement({
[Key.Enter], keys: Key.Enter,
() => { callback: () => {
onSubmit(value); onSubmit(value);
removeFocusItemFromFocusStackById({
focusId: NAVIGATION_DRAWER_INPUT_FOCUS_ID,
});
}, },
hotkeyScope, focusId: NAVIGATION_DRAWER_INPUT_FOCUS_ID,
); scope: hotkeyScope,
});
useListenClickOutside({ useListenClickOutside({
refs: [inputRef], refs: [inputRef],
callback: (event) => { callback: (event) => {
event.stopImmediatePropagation(); event.stopImmediatePropagation();
onClickOutside(event, value); onClickOutside(event, value);
removeFocusItemFromFocusStackById({
focusId: NAVIGATION_DRAWER_INPUT_FOCUS_ID,
});
}, },
listenerId: 'navigation-drawer-input', listenerId: 'navigation-drawer-input',
}); });
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
const handleFocus = (event: FocusEvent<HTMLInputElement>) => { const handleFocus = (event: FocusEvent<HTMLInputElement>) => {
if (isDefined(value)) { if (isDefined(value)) {
event.target.select(); event.target.select();
} }
pushFocusItemToFocusStack({
focusId: NAVIGATION_DRAWER_INPUT_FOCUS_ID,
component: {
type: FocusComponentType.TEXT_INPUT,
instanceId: NAVIGATION_DRAWER_INPUT_FOCUS_ID,
},
hotkeyScope: { scope: hotkeyScope },
});
};
const handleBlur = () => {
removeFocusItemFromFocusStackById({
focusId: NAVIGATION_DRAWER_INPUT_FOCUS_ID,
});
}; };
return ( return (
@ -74,6 +105,7 @@ export const NavigationDrawerInput = ({
onChange={onChange} onChange={onChange}
placeholder={placeholder} placeholder={placeholder}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur}
sizeVariant="md" sizeVariant="md"
fullWidth fullWidth
autoFocus autoFocus

View File

@ -9,6 +9,9 @@ export enum FocusComponentType {
RECORD_TABLE_CELL = 'record-table-cell', RECORD_TABLE_CELL = 'record-table-cell',
TEXT_AREA = 'text-area', TEXT_AREA = 'text-area',
TEXT_INPUT = 'text-input', TEXT_INPUT = 'text-input',
FORM_FIELD_INPUT = 'form-field-input',
RECORD_BOARD_CARD = 'record-board-card', RECORD_BOARD_CARD = 'record-board-card',
ACTIVITY_RICH_TEXT_EDITOR = 'activity-rich-text-editor', ACTIVITY_RICH_TEXT_EDITOR = 'activity-rich-text-editor',
KEYBOARD_SHORTCUT_MENU = 'keyboard-shortcut-menu',
DIALOG = 'dialog',
} }

View File

@ -1 +1 @@
export const DEBUG_HOTKEY_SCOPE = true; export const DEBUG_HOTKEY_SCOPE = false;

View File

@ -1,32 +0,0 @@
import { act } from 'react-dom/test-utils';
import { fireEvent, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
const hotKeyCallback = jest.fn();
describe('useScopedHotkeys', () => {
it('should work as expected', () => {
renderHook(
() => {
useScopedHotkeys('ctrl+k', hotKeyCallback, AppHotkeyScope.App);
const setHotkeyScope = useSetHotkeyScope();
setHotkeyScope(AppHotkeyScope.App);
},
{
wrapper: RecoilRoot,
},
);
act(() => {
fireEvent.keyDown(document, { key: 'k', code: 'KeyK', ctrlKey: true });
});
expect(hotKeyCallback).toHaveBeenCalled();
});
});

View File

@ -1,80 +0,0 @@
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';
import { internalHotkeysEnabledScopesState } from '../states/internal/internalHotkeysEnabledScopesState';
export const useScopedHotkeyCallback = (
dependencies?: OptionsOrDependencyArray,
) => {
const dependencyArray = Array.isArray(dependencies) ? dependencies : [];
return useRecoilCallback(
({ snapshot }) =>
({
callback,
hotkeysEvent,
keyboardEvent,
scope,
preventDefault,
}: {
keyboardEvent: KeyboardEvent;
hotkeysEvent: Hotkey;
callback: (keyboardEvent: KeyboardEvent, hotkeysEvent: Hotkey) => void;
scope: string;
preventDefault?: boolean;
}) => {
const currentHotkeyScopes = snapshot
.getLoadable(internalHotkeysEnabledScopesState)
.getValue();
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;
}
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,
);
};

View File

@ -1,62 +0,0 @@
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';
type UseHotkeysOptionsWithoutBuggyOptions = Omit<Options, 'enabled'>;
export const useScopedHotkeys = (
keys: Keys,
callback: HotkeyCallback,
scope: string,
dependencies?: unknown[],
options?: UseHotkeysOptionsWithoutBuggyOptions,
) => {
const [pendingHotkey, setPendingHotkey] = useRecoilState(pendingHotkeyState);
const callScopedHotkeyCallback = useScopedHotkeyCallback(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);
},
scope,
preventDefault,
});
},
{
enableOnContentEditable,
enableOnFormTags,
ignoreModifiers,
},
dependencies,
);
};

View File

@ -1,10 +1,10 @@
import { InputHotkeyScope } from '@/ui/input/types/InputHotkeyScope'; import { InputHotkeyScope } from '@/ui/input/types/InputHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2'; import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { useRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum'; import { Key } from 'ts-key-enum';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useScrollWrapperElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperElement'; import { useScrollWrapperElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperElement';
import { AgentChatMessageRole } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/agent-chat-message-role'; import { AgentChatMessageRole } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/agent-chat-message-role';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
@ -117,17 +117,21 @@ export const useAgentChat = (agentId: string) => {
await sendChatMessage(content); await sendChatMessage(content);
}; };
useScopedHotkeys( useHotkeysOnFocusedElement({
[Key.Enter], keys: [Key.Enter],
(event) => { callback: (event: KeyboardEvent) => {
if (!event.ctrlKey && !event.metaKey) { if (!event.ctrlKey && !event.metaKey) {
event.preventDefault(); event.preventDefault();
handleSendMessage(); handleSendMessage();
} }
}, },
InputHotkeyScope.TextInput, focusId: `${agentId}-chat-input`,
[agentChatInput, isLoading], scope: InputHotkeyScope.TextInput,
); dependencies: [agentChatInput, isLoading],
options: {
enableOnFormTags: true,
},
});
return { return {
handleInputChange: (value: string) => setAgentChatInput(value), handleInputChange: (value: string) => setAgentChatInput(value),

View File

@ -285,6 +285,7 @@ export const WorkflowEditActionFormBuilder = ({
<FormFieldInputRowContainer> <FormFieldInputRowContainer>
<FormFieldInputInnerContainer <FormFieldInputInnerContainer
formFieldInputInstanceId={field.id}
hasRightElement={false} hasRightElement={false}
onClick={() => { onClick={() => {
handleFieldClick(field.id); handleFieldClick(field.id);
@ -358,6 +359,7 @@ export const WorkflowEditActionFormBuilder = ({
<FormFieldInputContainer> <FormFieldInputContainer>
<FormFieldInputRowContainer> <FormFieldInputRowContainer>
<FormFieldInputInnerContainer <FormFieldInputInnerContainer
formFieldInputInstanceId="add-field-button"
hasRightElement={false} hasRightElement={false}
onClick={() => { onClick={() => {
const { label, name } = getDefaultFormFieldSettings( const { label, name } = getDefaultFormFieldSettings(

View File

@ -47,7 +47,10 @@ export const WorkflowFormEmptyMessage = () => {
<StyledMessageContainer> <StyledMessageContainer>
<FormFieldInputContainer> <FormFieldInputContainer>
<FormFieldInputRowContainer multiline maxHeight={124}> <FormFieldInputRowContainer multiline maxHeight={124}>
<FormFieldInputInnerContainer hasRightElement={false}> <FormFieldInputInnerContainer
formFieldInputInstanceId="empty-form-message"
hasRightElement={false}
>
<StyledFieldContainer> <StyledFieldContainer>
<StyledMessageContentContainer> <StyledMessageContentContainer>
<StyledMessageTitle data-testid="empty-form-message-title"> <StyledMessageTitle data-testid="empty-form-message-title">

View File

@ -14,11 +14,12 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus'; import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
import { ProfilePictureUploader } from '@/settings/profile/components/ProfilePictureUploader'; import { ProfilePictureUploader } from '@/settings/profile/components/ProfilePictureUploader';
import { PageFocusId } from '@/types/PageFocusId';
import { PageHotkeyScope } from '@/types/PageHotkeyScope'; import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { TextInputV2 } from '@/ui/input/components/TextInputV2'; import { TextInputV2 } from '@/ui/input/components/TextInputV2';
import { Modal } from '@/ui/layout/modal/components/Modal'; import { Modal } from '@/ui/layout/modal/components/Modal';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember'; import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import { ApolloError } from '@apollo/client'; import { ApolloError } from '@apollo/client';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
@ -148,15 +149,23 @@ export const CreateProfile = () => {
const [isEditingMode, setIsEditingMode] = useState(false); const [isEditingMode, setIsEditingMode] = useState(false);
useScopedHotkeys( const handleEnter = () => {
Key.Enter, if (isEditingMode) {
() => { onSubmit(getValues());
}
};
useHotkeysOnFocusedElement({
keys: Key.Enter,
callback: () => {
if (isEditingMode) { if (isEditingMode) {
onSubmit(getValues()); onSubmit(getValues());
} }
}, },
PageHotkeyScope.CreateProfile, focusId: PageFocusId.CreateProfile,
); scope: PageHotkeyScope.CreateProfile,
dependencies: [handleEnter],
});
return ( return (
<Modal.Content isVerticalCentered isHorizontalCentered> <Modal.Content isVerticalCentered isHorizontalCentered>

View File

@ -3,11 +3,12 @@ import { Title } from '@/auth/components/Title';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState'; import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus'; import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
import { PageFocusId } from '@/types/PageFocusId';
import { PageHotkeyScope } from '@/types/PageHotkeyScope'; import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { TextInputV2 } from '@/ui/input/components/TextInputV2'; import { TextInputV2 } from '@/ui/input/components/TextInputV2';
import { Modal } from '@/ui/layout/modal/components/Modal'; import { Modal } from '@/ui/layout/modal/components/Modal';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
@ -161,14 +162,15 @@ export const InviteTeam = () => {
await onSubmit({ emails: [] }); await onSubmit({ emails: [] });
}; };
useScopedHotkeys( useHotkeysOnFocusedElement({
[Key.Enter], keys: Key.Enter,
() => { callback: () => {
handleSubmit(onSubmit)(); handleSubmit(onSubmit)();
}, },
PageHotkeyScope.InviteTeam, focusId: PageFocusId.InviteTeam,
[handleSubmit], scope: PageHotkeyScope.InviteTeam,
); dependencies: [handleSubmit, onSubmit],
});
return ( return (
<Modal.Content isVerticalCentered isHorizontalCentered> <Modal.Content isVerticalCentered isHorizontalCentered>

View File

@ -9,7 +9,6 @@ import { Title } from '@/auth/components/Title';
import { OnboardingSyncEmailsSettingsCard } from '@/onboarding/components/OnboardingSyncEmailsSettingsCard'; import { OnboardingSyncEmailsSettingsCard } from '@/onboarding/components/OnboardingSyncEmailsSettingsCard';
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus'; import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
import { PageHotkeyScope } from '@/types/PageHotkeyScope'; import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { isGoogleCalendarEnabledState } from '@/client-config/states/isGoogleCalendarEnabledState'; import { isGoogleCalendarEnabledState } from '@/client-config/states/isGoogleCalendarEnabledState';
import { isGoogleMessagingEnabledState } from '@/client-config/states/isGoogleMessagingEnabledState'; import { isGoogleMessagingEnabledState } from '@/client-config/states/isGoogleMessagingEnabledState';
@ -17,7 +16,9 @@ import { isMicrosoftCalendarEnabledState } from '@/client-config/states/isMicros
import { isMicrosoftMessagingEnabledState } from '@/client-config/states/isMicrosoftMessagingEnabledState'; import { isMicrosoftMessagingEnabledState } from '@/client-config/states/isMicrosoftMessagingEnabledState';
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth'; import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { AppPath } from '@/types/AppPath'; import { AppPath } from '@/types/AppPath';
import { PageFocusId } from '@/types/PageFocusId';
import { Modal } from '@/ui/layout/modal/components/Modal'; import { Modal } from '@/ui/layout/modal/components/Modal';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { ConnectedAccountProvider } from 'twenty-shared/types'; import { ConnectedAccountProvider } from 'twenty-shared/types';
import { IconGoogle, IconMicrosoft } from 'twenty-ui/display'; import { IconGoogle, IconMicrosoft } from 'twenty-ui/display';
import { MainButton } from 'twenty-ui/input'; import { MainButton } from 'twenty-ui/input';
@ -95,14 +96,15 @@ export const SyncEmails = () => {
const isMicrosoftProviderEnabled = const isMicrosoftProviderEnabled =
isMicrosoftMessagingEnabled || isMicrosoftCalendarEnabled; isMicrosoftMessagingEnabled || isMicrosoftCalendarEnabled;
useScopedHotkeys( useHotkeysOnFocusedElement({
[Key.Enter], keys: Key.Enter,
async () => { callback: async () => {
await continueWithoutSync(); await continueWithoutSync();
}, },
PageHotkeyScope.SyncEmail, focusId: PageFocusId.SyncEmail,
[continueWithoutSync], scope: PageHotkeyScope.SyncEmail,
); dependencies: [continueWithoutSync],
});
return ( return (
<Modal.Content isVerticalCentered isHorizontalCentered> <Modal.Content isVerticalCentered isHorizontalCentered>

View File

@ -5,15 +5,11 @@ import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBa
import { SettingsServerlessFunctionNewForm } from '@/settings/serverless-functions/components/SettingsServerlessFunctionNewForm'; import { SettingsServerlessFunctionNewForm } from '@/settings/serverless-functions/components/SettingsServerlessFunctionNewForm';
import { useCreateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useCreateOneServerlessFunction'; import { useCreateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useCreateOneServerlessFunction';
import { ServerlessFunctionNewFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState'; import { ServerlessFunctionNewFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { SettingsServerlessFunctionHotkeyScope } from '@/settings/serverless-functions/types/SettingsServerlessFunctionHotKeyScope';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useState } from 'react'; import { useState } from 'react';
import { Key } from 'ts-key-enum'; import { isDefined } from 'twenty-shared/utils';
import { useHotkeyScopeOnMount } from '~/hooks/useHotkeyScopeOnMount';
import { useNavigateSettings } from '~/hooks/useNavigateSettings'; import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { isDefined } from 'twenty-shared/utils';
export const SettingsServerlessFunctionsNew = () => { export const SettingsServerlessFunctionsNew = () => {
const navigate = useNavigateSettings(); const navigate = useNavigateSettings();
@ -50,28 +46,6 @@ export const SettingsServerlessFunctionsNew = () => {
const canSave = !!formValues.name && createOneServerlessFunction; const canSave = !!formValues.name && createOneServerlessFunction;
useHotkeyScopeOnMount(
SettingsServerlessFunctionHotkeyScope.ServerlessFunctionNew,
);
useScopedHotkeys(
[Key.Enter],
() => {
if (canSave !== false) {
handleSave();
}
},
SettingsServerlessFunctionHotkeyScope.ServerlessFunctionNew,
[canSave],
);
useScopedHotkeys(
[Key.Escape],
() => {
navigate(SettingsPath.ServerlessFunctions);
},
SettingsServerlessFunctionHotkeyScope.ServerlessFunctionNew,
);
return ( return (
<SubMenuTopBarContainer <SubMenuTopBarContainer
title="New Function" title="New Function"

View File

@ -4,21 +4,15 @@ import { RecoilRoot } from 'recoil';
import { ApolloCoreClientMockedProvider } from '@/object-metadata/hooks/__mocks__/ApolloCoreClientMockedProvider'; import { ApolloCoreClientMockedProvider } from '@/object-metadata/hooks/__mocks__/ApolloCoreClientMockedProvider';
import { InitializeHotkeyStorybookHookEffect } from '../InitializeHotkeyStorybookHook';
import { mockedApolloClient } from '../mockedApolloClient'; import { mockedApolloClient } from '../mockedApolloClient';
export const RootDecorator: Decorator = (Story, context) => { export const RootDecorator: Decorator = (Story, context) => {
const { parameters } = context; const { parameters } = context;
const disableHotkeyInitialization = parameters.disableHotkeyInitialization;
return ( return (
<RecoilRoot initializeState={parameters.initializeState}> <RecoilRoot initializeState={parameters.initializeState}>
<ApolloProvider client={mockedApolloClient}> <ApolloProvider client={mockedApolloClient}>
<ApolloCoreClientMockedProvider> <ApolloCoreClientMockedProvider>
{!disableHotkeyInitialization && (
<InitializeHotkeyStorybookHookEffect />
)}
<Story /> <Story />
</ApolloCoreClientMockedProvider> </ApolloCoreClientMockedProvider>
</ApolloProvider> </ApolloProvider>