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
@@ -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,
]);
};
@@ -1,5 +1,5 @@
import { useCallback, useMemo } from 'react';
import { useRecoilCallback, useRecoilState, useRecoilValue } from 'recoil';
import { useRecoilCallback, useRecoilState } from 'recoil';
import { v4 } from 'uuid';
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 { isFieldValueReadOnly } from '@/object-record/record-field/utils/isFieldValueReadOnly';
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 { Key } from 'ts-key-enum';
import { useDebouncedCallback } from 'use-debounce';
@@ -24,9 +23,6 @@ import { Task } from '@/activities/types/Task';
import { filterAttachmentsToRestore } from '@/activities/utils/filterAttachmentsToRestore';
import { getActivityAttachmentIdsToDelete } from '@/activities/utils/getActivityAttachmentIdsToDelete';
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 { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords';
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 { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import type { PartialBlock } from '@blocknote/core';
import '@blocknote/core/fonts/inter.css';
import '@blocknote/mantine/style.css';
@@ -304,60 +301,56 @@ export const ActivityRichTextEditor = ({
uploadFile: handleEditorBuiltInUploadFile,
});
const commandMenuPage = useRecoilValue(commandMenuPageState);
useScopedHotkeys(
Key.Escape,
() => {
useHotkeysOnFocusedElement({
keys: Key.Escape,
callback: () => {
editor.domElement?.blur();
},
ActivityEditorHotkeyScope.ActivityBody,
);
focusId: activityId,
scope: ActivityEditorHotkeyScope.ActivityBody,
dependencies: [editor],
});
useScopedHotkeys(
'*',
(keyboardEvent) => {
// TODO: remove once stacked hotkeys / focusKeys are in place
if (commandMenuPage !== CommandMenuPages.EditRichText) {
return;
}
const handleAllKeys = (keyboardEvent: KeyboardEvent) => {
if (keyboardEvent.key === Key.Escape) {
return;
}
if (keyboardEvent.key === Key.Escape) {
return;
}
const isWritingText =
!isNonTextWritingKey(keyboardEvent.key) &&
!keyboardEvent.ctrlKey &&
!keyboardEvent.metaKey;
const isWritingText =
!isNonTextWritingKey(keyboardEvent.key) &&
!keyboardEvent.ctrlKey &&
!keyboardEvent.metaKey;
if (!isWritingText) {
return;
}
if (!isWritingText) {
return;
}
keyboardEvent.preventDefault();
keyboardEvent.stopPropagation();
keyboardEvent.stopImmediatePropagation();
keyboardEvent.preventDefault();
keyboardEvent.stopPropagation();
keyboardEvent.stopImmediatePropagation();
const newBlockId = v4();
const newBlock = {
id: newBlockId,
type: 'paragraph' as const,
content: keyboardEvent.key,
};
const newBlockId = v4();
const newBlock = {
id: newBlockId,
type: 'paragraph' as const,
content: keyboardEvent.key,
};
const lastBlock = editor.document[editor.document.length - 1];
editor.insertBlocks([newBlock], lastBlock);
const lastBlock = editor.document[editor.document.length - 1];
editor.insertBlocks([newBlock], lastBlock);
editor.setTextCursorPosition(newBlockId, 'end');
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({
objectNameSingular: activityObjectNameSingular,
objectRecordId: activityId,
@@ -31,8 +31,10 @@ import { useFocusedRecordTableRow } from '@/object-record/record-table/hooks/use
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { AppBasePath } from '@/types/AppBasePath';
import { AppPath } from '@/types/AppPath';
import { PageFocusId } from '@/types/PageFocusId';
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 { isDefined } from 'twenty-shared/utils';
import { AnalyticsType } from '~/generated/graphql';
@@ -48,8 +50,6 @@ export const PageChangeEffect = () => {
const [previousLocation, setPreviousLocation] = useState('');
const setHotkeyScope = useSetHotkeyScope();
const location = useLocation();
const pageChangeEffectNavigateLocation =
@@ -92,6 +92,8 @@ export const PageChangeEffect = () => {
const { closeCommandMenu } = useCommandMenu();
const { resetFocusStackToFocusItem } = useResetFocusStackToFocusItem();
const { resetFocusStackToRecordIndex } = useResetFocusStackToRecordIndex();
useEffect(() => {
@@ -140,55 +142,200 @@ export const PageChangeEffect = () => {
break;
}
case isMatchingLocation(location, AppPath.RecordShowPage): {
setHotkeyScope(PageHotkeyScope.RecordShowPage, {
goto: true,
keyboardShortcutMenu: true,
searchRecords: true,
resetFocusStackToFocusItem({
focusStackItem: {
focusId: PageFocusId.RecordShowPage,
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;
}
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;
}
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;
}
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;
}
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;
}
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;
}
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;
}
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;
}
case location.pathname.startsWith(AppBasePath.Settings): {
setHotkeyScope(PageHotkeyScope.Settings, {
goto: false,
keyboardShortcutMenu: false,
commandMenu: false,
commandMenuOpen: false,
searchRecords: false,
resetFocusStackToFocusItem({
focusStackItem: {
focusId: PageFocusId.Settings,
componentInstance: {
componentType: FocusComponentType.PAGE,
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;
}
}
}, [
location,
setHotkeyScope,
previousLocation,
contextStoreCurrentViewType,
resetTableSelections,
@@ -198,6 +345,7 @@ export const PageChangeEffect = () => {
deactivateBoardCard,
unfocusBoardCard,
resetFocusStackToRecordIndex,
resetFocusStackToFocusItem,
]);
useEffect(() => {
@@ -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 { 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 { KeyboardMenuGroup } from './KeyboardShortcutMenuGroup';
import { KeyboardMenuItem } from './KeyboardShortcutMenuItem';
@@ -15,15 +18,15 @@ export const KeyboardShortcutMenuOpenContent = () => {
const { toggleKeyboardShortcutMenu, closeKeyboardShortcutMenu } =
useKeyboardShortcutMenu();
useGlobalHotkeys(
[Key.Escape],
() => {
useHotkeysOnFocusedElement({
keys: [Key.Escape],
callback: () => {
closeKeyboardShortcutMenu();
},
false,
AppHotkeyScope.KeyboardShortcutMenuOpen,
[closeKeyboardShortcutMenu],
);
focusId: KEYBOARD_SHORTCUT_MENU_INSTANCE_ID,
scope: AppHotkeyScope.KeyboardShortcutMenuOpen,
dependencies: [closeKeyboardShortcutMenu],
});
return (
<>
@@ -8,18 +8,24 @@ import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { useKeyboardShortcutMenu } from '../useKeyboardShortcutMenu';
const mockSetHotkeyScopeAndMemorizePreviousScope = jest.fn();
const mockPushFocusItemToFocusStack = jest.fn();
const mockRemoveFocusItemFromFocusStackById = jest.fn();
const mockGoBackToPreviousHotkeyScope = jest.fn();
jest.mock('@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope', () => ({
usePreviousHotkeyScope: () => ({
setHotkeyScopeAndMemorizePreviousScope:
mockSetHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope: mockGoBackToPreviousHotkeyScope,
jest.mock('@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack', () => ({
usePushFocusItemToFocusStack: () => ({
pushFocusItemToFocusStack: mockPushFocusItemToFocusStack,
}),
}));
jest.mock(
'@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById',
() => ({
useRemoveFocusItemFromFocusStackById: () => ({
removeFocusItemFromFocusStackById: mockRemoveFocusItemFromFocusStackById,
}),
}),
);
const renderHookConfig = () => {
const { result } = renderHook(
() => {
@@ -39,6 +45,10 @@ const renderHookConfig = () => {
};
describe('useKeyboardShortcutMenu', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should toggle keyboard shortcut menu correctly', async () => {
const { result } = renderHookConfig();
expect(result.current.toggleKeyboardShortcutMenu).toBeDefined();
@@ -48,8 +58,19 @@ describe('useKeyboardShortcutMenu', () => {
result.current.toggleKeyboardShortcutMenu();
});
expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith({
scope: AppHotkeyScope.KeyboardShortcutMenu,
expect(mockPushFocusItemToFocusStack).toHaveBeenCalledWith({
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);
@@ -57,8 +78,8 @@ describe('useKeyboardShortcutMenu', () => {
result.current.toggleKeyboardShortcutMenu();
});
expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith({
scope: AppHotkeyScope.KeyboardShortcutMenu,
expect(mockRemoveFocusItemFromFocusStackById).toHaveBeenCalledWith({
focusId: 'keyboard-shortcut-menu',
});
expect(result.current.isKeyboardShortcutMenuOpened).toBe(false);
});
@@ -69,8 +90,19 @@ describe('useKeyboardShortcutMenu', () => {
result.current.openKeyboardShortcutMenu();
});
expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith({
scope: AppHotkeyScope.KeyboardShortcutMenu,
expect(mockPushFocusItemToFocusStack).toHaveBeenCalledWith({
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);
@@ -78,7 +110,9 @@ describe('useKeyboardShortcutMenu', () => {
result.current.closeKeyboardShortcutMenu();
});
expect(mockGoBackToPreviousHotkeyScope).toHaveBeenCalled();
expect(mockRemoveFocusItemFromFocusStackById).toHaveBeenCalledWith({
focusId: 'keyboard-shortcut-menu',
});
expect(result.current.isKeyboardShortcutMenuOpened).toBe(false);
});
});
@@ -1,25 +1,39 @@
import { useRecoilCallback } from 'recoil';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
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';
export const KEYBOARD_SHORTCUT_MENU_INSTANCE_ID = 'keyboard-shortcut-menu';
export const useKeyboardShortcutMenu = () => {
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
const openKeyboardShortcutMenu = useRecoilCallback(
({ set }) =>
() => {
set(isKeyboardShortcutMenuOpenedState, true);
setHotkeyScopeAndMemorizePreviousScope({
scope: AppHotkeyScope.KeyboardShortcutMenu,
pushFocusItemToFocusStack({
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(
@@ -31,10 +45,12 @@ export const useKeyboardShortcutMenu = () => {
if (isKeyboardShortcutMenuOpened) {
set(isKeyboardShortcutMenuOpenedState, false);
goBackToPreviousHotkeyScope();
removeFocusItemFromFocusStackById({
focusId: KEYBOARD_SHORTCUT_MENU_INSTANCE_ID,
});
}
},
[goBackToPreviousHotkeyScope],
[removeFocusItemFromFocusStackById],
);
const toggleKeyboardShortcutMenu = useRecoilCallback(
@@ -4,8 +4,8 @@ import { Key } from 'ts-key-enum';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { useRecordBoardCardNavigation } from '@/object-record/record-board/hooks/useRecordBoardCardNavigation';
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 { PageFocusId } from '@/types/PageFocusId';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
export const RecordBoardHotkeyEffect = () => {
@@ -18,7 +18,7 @@ export const RecordBoardHotkeyEffect = () => {
callback: () => {
move('down');
},
focusId: RECORD_INDEX_FOCUS_ID,
focusId: PageFocusId.RecordIndex,
scope: RecordIndexHotkeyScope.RecordIndex,
dependencies: [move],
});
@@ -1,65 +1,28 @@
import styled from '@emotion/styled';
import { useCallback, useRef } from 'react';
import { useRecordGroupActions } from '@/object-record/record-group/hooks/useRecordGroupActions';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
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 { MenuItem } from 'twenty-ui/navigation';
const StyledMenuContainer = styled.div`
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);
export const RecordBoardColumnDropdownMenu = () => {
const recordGroupActions = useRecordGroupActions({
viewType: ViewType.Kanban,
});
const closeMenu = useCallback(() => {
onClose();
}, [onClose]);
useListenClickOutside({
refs: [boardColumnMenuRef],
callback: closeMenu,
listenerId: 'record-board-column-dropdown-menu',
});
return (
<StyledMenuContainer ref={boardColumnMenuRef}>
<OverlayContainer>
<DropdownContent selectDisabled>
<DropdownMenuItemsContainer>
{recordGroupActions.map((action) => (
<MenuItem
key={action.id}
onClick={() => {
action.callback();
closeMenu();
}}
LeftIcon={action.icon}
text={action.label}
/>
))}
</DropdownMenuItemsContainer>
</DropdownContent>
</OverlayContainer>
</StyledMenuContainer>
<DropdownContent selectDisabled>
<DropdownMenuItemsContainer>
{recordGroupActions.map((action) => (
<MenuItem
key={action.id}
onClick={() => {
action.callback();
}}
LeftIcon={action.icon}
text={action.label}
/>
))}
</DropdownMenuItemsContainer>
</DropdownContent>
);
};
@@ -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 { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext';
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 { 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 { IconDotsVertical, IconPlus } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
@@ -66,32 +66,11 @@ const StyledTag = styled(Tag)`
export const RecordBoardColumnHeader = () => {
const { columnDefinition } = useContext(RecordBoardColumnContext);
const [isBoardColumnMenuOpen, setIsBoardColumnMenuOpen] = useState(false);
const [isHeaderHovered, setIsHeaderHovered] = useState(false);
const { objectMetadataItem, selectFieldMetadataItem } =
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 } =
useAggregateRecordsForRecordBoardColumn();
@@ -105,6 +84,10 @@ export const RecordBoardColumnHeader = () => {
objectMetadataItem: objectMetadataItem,
});
const { toggleDropdown } = useToggleDropdown();
const dropdownId = `record-board-column-dropdown-${columnDefinition.id}`;
return (
<StyledColumn>
<StyledHeader
@@ -113,25 +96,36 @@ export const RecordBoardColumnHeader = () => {
>
<StyledHeaderContainer>
<StyledLeftContainer>
<StyledTag
onClick={handleBoardColumnMenuOpen}
variant={
columnDefinition.type === RecordGroupDefinitionType.Value
? 'solid'
: 'outline'
}
color={
columnDefinition.type === RecordGroupDefinitionType.Value
? columnDefinition.color
: 'transparent'
}
text={columnDefinition.title}
weight={
columnDefinition.type === RecordGroupDefinitionType.Value
? 'regular'
: 'medium'
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="bottom-start"
dropdownOffset={{
x: 0,
y: 10,
}}
clickableComponent={
<StyledTag
variant={
columnDefinition.type === RecordGroupDefinitionType.Value
? 'solid'
: 'outline'
}
color={
columnDefinition.type === RecordGroupDefinitionType.Value
? columnDefinition.color
: 'transparent'
}
text={columnDefinition.title}
weight={
columnDefinition.type === RecordGroupDefinitionType.Value
? 'regular'
: 'medium'
}
/>
}
dropdownComponents={<RecordBoardColumnDropdownMenu />}
/>
<RecordBoardColumnHeaderAggregateDropdown
aggregateValue={aggregateValue}
dropdownId={`record-board-column-aggregate-dropdown-${columnDefinition.id}`}
@@ -145,7 +139,11 @@ export const RecordBoardColumnHeader = () => {
<LightIconButton
accent="tertiary"
Icon={IconDotsVertical}
onClick={handleBoardColumnMenuOpen}
onClick={() => {
toggleDropdown({
dropdownComponentInstanceIdFromProps: dropdownId,
});
}}
/>
{hasObjectUpdatePermissions && (
<LightIconButton
@@ -164,12 +162,6 @@ export const RecordBoardColumnHeader = () => {
</StyledRightContainer>
</StyledHeaderContainer>
</StyledHeader>
{isBoardColumnMenuOpen && (
<RecordBoardColumnDropdownMenu
onClose={handleBoardColumnMenuClose}
stageId={columnDefinition.id}
/>
)}
</StyledColumn>
);
};
@@ -1,36 +1,24 @@
import { Key } from 'ts-key-enum';
import { useDropdownContextStateManagement } from '@/dropdown-context-state-management/hooks/useDropdownContextStateManagement';
import {
RecordBoardColumnHeaderAggregateDropdownContext,
RecordBoardColumnHeaderAggregateDropdownContextValue,
} 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 { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useLingui } from '@lingui/react/macro';
import { MenuItem } from 'twenty-ui/navigation';
export const RecordBoardColumnHeaderAggregateDropdownMenuContent = () => {
const { t } = useLingui();
const { onContentChange, closeDropdown } =
const { onContentChange } =
useDropdownContextStateManagement<RecordBoardColumnHeaderAggregateDropdownContextValue>(
{
context: RecordBoardColumnHeaderAggregateDropdownContext,
},
);
useScopedHotkeys(
[Key.Escape],
() => {
closeDropdown();
},
TableOptionsHotkeyScope.Dropdown,
);
return (
<DropdownContent>
<DropdownMenuItemsContainer>
@@ -10,18 +10,15 @@ import { getAggregateOperationLabel } from '@/object-record/record-board/record-
import { recordIndexKanbanAggregateOperationState } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
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 { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
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 { useUpdateViewAggregate } from '@/views/hooks/useUpdateViewAggregate';
import isEmpty from 'lodash.isempty';
import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { IconCheck, IconChevronLeft } from 'twenty-ui/display';
export const RecordBoardColumnHeaderAggregateDropdownOptionsContent = ({
@@ -38,14 +35,6 @@ export const RecordBoardColumnHeaderAggregateDropdownOptionsContent = ({
},
);
useScopedHotkeys(
[Key.Escape],
() => {
closeDropdown();
},
TableOptionsHotkeyScope.Dropdown,
);
const setAggregateOperation = useSetRecoilComponentStateV2(
aggregateOperationComponentState,
);
@@ -85,6 +85,7 @@ export const FormBooleanFieldInput = ({
<FormFieldInputRowContainer>
<FormFieldInputInnerContainer
formFieldInputInstanceId={instanceId}
hasRightElement={isDefined(VariablePicker) && !readonly}
>
{draftValue.type === 'static' ? (
@@ -296,6 +296,7 @@ export const FormDateTimeFieldInput = ({
<FormFieldInputRowContainer>
<StyledInputContainer
formFieldInputInstanceId={instanceId}
ref={datePickerWrapperRef}
hasRightElement={isDefined(VariablePicker) && !readonly}
>
@@ -1,5 +1,7 @@
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 styled from '@emotion/styled';
import { forwardRef, HTMLAttributes, Ref } from 'react';
@@ -9,9 +11,12 @@ type FormFieldInputInnerContainerProps = {
multiline?: boolean;
readonly?: boolean;
preventSetHotkeyScope?: boolean;
formFieldInputInstanceId: string;
};
const StyledFormFieldInputInnerContainer = styled.div<FormFieldInputInnerContainerProps>`
const StyledFormFieldInputInnerContainer = styled.div<
Omit<FormFieldInputInnerContainerProps, 'formFieldInputInstanceId'>
>`
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-top-left-radius: ${({ theme }) => theme.border.radius.sm};
@@ -48,20 +53,25 @@ export const FormFieldInputInnerContainer = forwardRef(
readonly,
preventSetHotkeyScope = false,
onClick,
formFieldInputInstanceId,
}: HTMLAttributes<HTMLDivElement> & FormFieldInputInnerContainerProps,
ref: Ref<HTMLDivElement>,
) => {
const {
goBackToPreviousHotkeyScope,
setHotkeyScopeAndMemorizePreviousScope,
} = usePreviousHotkeyScope();
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
const handleFocus = (e: React.FocusEvent<HTMLDivElement>) => {
onFocus?.(e);
if (!preventSetHotkeyScope) {
setHotkeyScopeAndMemorizePreviousScope({
scope: FormFieldInputHotKeyScope.FormFieldInput,
pushFocusItemToFocusStack({
focusId: formFieldInputInstanceId,
component: {
type: FocusComponentType.FORM_FIELD_INPUT,
instanceId: formFieldInputInstanceId,
},
hotkeyScope: { scope: FormFieldInputHotKeyScope.FormFieldInput },
});
}
};
@@ -70,7 +80,9 @@ export const FormFieldInputInnerContainer = forwardRef(
onBlur?.(e);
if (!preventSetHotkeyScope) {
goBackToPreviousHotkeyScope();
removeFocusItemFromFocusStackById({
focusId: formFieldInputInstanceId,
});
}
};
@@ -14,7 +14,9 @@ import { MultiSelectInput } from '@/ui/field/input/components/MultiSelectInput';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
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 { useTheme } from '@emotion/react';
import { isArray } from '@sniptt/guards';
@@ -88,10 +90,9 @@ export const FormMultiSelectFieldInput = ({
const hotkeyScope =
FormMultiSelectFieldInputHotKeyScope.FormMultiSelectFieldInput;
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
const [draftValue, setDraftValue] = useState<
| {
@@ -128,8 +129,13 @@ export const FormMultiSelectFieldInput = ({
editingMode: 'edit',
});
setHotkeyScopeAndMemorizePreviousScope({
scope: hotkeyScope,
pushFocusItemToFocusStack({
focusId: instanceId,
component: {
type: FocusComponentType.FORM_FIELD_INPUT,
instanceId,
},
hotkeyScope: { scope: hotkeyScope },
});
};
@@ -157,7 +163,7 @@ export const FormMultiSelectFieldInput = ({
editingMode: 'view',
});
goBackToPreviousHotkeyScope();
removeFocusItemFromFocusStackById({ focusId: instanceId });
};
const handleVariableTagInsert = (variableName: string) => {
@@ -201,6 +207,7 @@ export const FormMultiSelectFieldInput = ({
<FormFieldInputRowContainer>
<FormFieldInputInnerContainer
formFieldInputInstanceId={instanceId}
hasRightElement={isDefined(VariablePicker) && !readonly}
>
{draftValue.type === 'static' ? (
@@ -121,6 +121,7 @@ export const FormNumberFieldInput = ({
<FormFieldInputRowContainer>
<FormFieldInputInnerContainer
formFieldInputInstanceId={instanceId}
hasRightElement={isDefined(VariablePicker) && !readonly}
onBlur={onBlur}
>
@@ -71,6 +71,7 @@ export const FormRawJsonFieldInput = ({
<FormFieldInputRowContainer multiline>
<FormFieldInputInnerContainer
formFieldInputInstanceId={instanceId}
hasRightElement={isDefined(VariablePicker) && !readonly}
multiline
onBlur={onBlur}
@@ -7,8 +7,8 @@ import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/
import { InputLabel } from '@/ui/input/components/InputLabel';
import { Select } from '@/ui/input/components/Select';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import { useTheme } from '@emotion/react';
import { useId, useState } from 'react';
@@ -40,7 +40,8 @@ export const FormSelectFieldInput = ({
const hotkeyScope = InlineCellHotkeyScope.InlineCell;
const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope();
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
const [draftValue, setDraftValue] = useState<
| {
@@ -72,7 +73,7 @@ export const FormSelectFieldInput = ({
editingMode: 'view',
});
goBackToPreviousHotkeyScope();
removeFocusItemFromFocusStackById({ focusId: instanceId });
onChange(option);
};
@@ -87,7 +88,7 @@ export const FormSelectFieldInput = ({
editingMode: 'view',
});
goBackToPreviousHotkeyScope();
removeFocusItemFromFocusStackById({ focusId: instanceId });
};
const selectedOption = options.find(
@@ -119,14 +120,13 @@ export const FormSelectFieldInput = ({
onChange(variableName);
};
useScopedHotkeys(
Key.Escape,
() => {
onCancel();
},
hotkeyScope,
[onCancel],
);
useHotkeysOnFocusedElement({
keys: Key.Escape,
callback: onCancel,
focusId: instanceId,
scope: hotkeyScope,
dependencies: [onCancel],
});
return (
<FormFieldInputContainer>
@@ -149,6 +149,7 @@ export const FormSelectFieldInput = ({
/>
) : (
<FormFieldInputInnerContainer
formFieldInputInstanceId={instanceId}
hasRightElement={isDefined(VariablePicker) && !readonly}
>
<VariableChipStandalone
@@ -150,7 +150,11 @@ export const FormSingleRecordPicker = ({
{label ? <InputLabel>{label}</InputLabel> : null}
<FormFieldInputRowContainer>
{disabled ? (
<StyledFormSelectContainer hasRightElement={false} readonly>
<StyledFormSelectContainer
formFieldInputInstanceId={componentId}
hasRightElement={false}
readonly
>
<FormSingleRecordFieldChip
draftValue={draftValue}
selectedRecord={selectedRecord}
@@ -169,6 +173,7 @@ export const FormSingleRecordPicker = ({
dropdownOffset={{ y: parseInt(theme.spacing(1), 10) }}
clickableComponent={
<StyledFormSelectContainer
formFieldInputInstanceId={componentId}
hasRightElement={isDefined(VariablePicker) && !disabled}
preventSetHotkeyScope={true}
>
@@ -71,6 +71,7 @@ export const FormTextFieldInput = ({
<FormFieldInputRowContainer multiline={multiline}>
<FormFieldInputInnerContainer
formFieldInputInstanceId={instanceId}
hasRightElement={isDefined(VariablePicker) && !readonly}
multiline={multiline}
onBlur={onBlur}
@@ -95,6 +95,7 @@ export const FormUuidFieldInput = ({
<FormFieldInputRowContainer>
<FormFieldInputInnerContainer
formFieldInputInstanceId={instanceId}
hasRightElement={isDefined(VariablePicker) && !readonly}
>
{draftValue.type === 'static' ? (
@@ -1 +0,0 @@
export const RECORD_INDEX_FOCUS_ID = 'record-index';
@@ -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 { PageFocusId } from '@/types/PageFocusId';
import { useResetFocusStackToFocusItem } from '@/ui/utilities/focus/hooks/useResetFocusStackToFocusItem';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
@@ -9,10 +9,10 @@ export const useResetFocusStackToRecordIndex = () => {
const resetFocusStackToRecordIndex = () => {
resetFocusStackToFocusItem({
focusStackItem: {
focusId: RECORD_INDEX_FOCUS_ID,
focusId: PageFocusId.RecordIndex,
componentInstance: {
componentType: FocusComponentType.PAGE,
componentInstanceId: RECORD_INDEX_FOCUS_ID,
componentInstanceId: PageFocusId.RecordIndex,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: true,
@@ -14,7 +14,6 @@ import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/Recor
import { RecordTableComponentInstance } from '@/object-record/record-table/components/RecordTableComponentInstance';
import { RecordTableContextProvider } from '@/object-record/record-table/components/RecordTableContextProvider';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { RecordUpdateContext } from '../contexts/EntityUpdateMutationHookContext';
import { useRecordTable } from '../hooks/useRecordTable';
@@ -48,16 +47,6 @@ export const RecordTableWithWrappers = ({
selectAllRows();
};
useScopedHotkeys(
'ctrl+a,meta+a',
handleSelectAllRows,
RecordIndexHotkeyScope.RecordIndex,
[],
{
enableOnFormTags: false,
},
);
useHotkeysOnFocusedElement({
keys: ['ctrl+a,meta+a'],
callback: handleSelectAllRows,
@@ -1,10 +1,10 @@
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 { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
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 { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
@@ -28,7 +28,7 @@ export const RecordTableBodyEscapeHotkeyEffect = () => {
useHotkeysOnFocusedElement({
keys: [Key.Escape],
callback: handleEscape,
focusId: RECORD_INDEX_FOCUS_ID,
focusId: PageFocusId.RecordIndex,
scope: RecordIndexHotkeyScope.RecordIndex,
dependencies: [handleEscape],
options: {
@@ -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 { useRecordTableRowFocusHotkeys } from '@/object-record/record-table/hooks/useRecordTableRowFocusHotkeys';
import { PageFocusId } from '@/types/PageFocusId';
export const RecordTableBodyFocusKeyboardEffect = () => {
useRecordTableRowFocusHotkeys({
focusId: RECORD_INDEX_FOCUS_ID,
focusId: PageFocusId.RecordIndex,
hotkeyScope: RecordIndexHotkeyScope.RecordIndex,
});
@@ -1,15 +1,11 @@
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 { 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 { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
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 { Key } from 'ts-key-enum';
import { IconChevronLeft } from 'twenty-ui/display';
export const RecordTableColumnAggregateFooterDropdownSubmenuContent = ({
@@ -19,19 +15,10 @@ export const RecordTableColumnAggregateFooterDropdownSubmenuContent = ({
aggregateOperations: ExtendedAggregateOperations[];
title: string;
}) => {
const { dropdownId, resetContent } = useContext(
const { resetContent } = useContext(
RecordTableColumnAggregateFooterDropdownContext,
);
const { closeDropdown } = useCloseDropdown();
useScopedHotkeys(
[Key.Escape],
() => {
resetContent();
closeDropdown(dropdownId);
},
TableOptionsHotkeyScope.Dropdown,
);
return (
<DropdownContent>
<DropdownMenuHeader
@@ -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 { useViewFieldAggregateOperation } from '@/object-record/record-table/record-table-footer/hooks/useViewFieldAggregateOperation';
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 { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { t } from '@lingui/core/macro';
import { useContext, useMemo } from 'react';
import { Key } from 'ts-key-enum';
import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils';
import { IconCheck } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
@@ -28,14 +25,6 @@ export const RecordTableColumnAggregateFooterMenuContent = () => {
const { closeDropdown } = useCloseDropdown();
const { objectMetadataItem } = useRecordTableContextOrThrow();
useScopedHotkeys(
[Key.Escape],
() => {
closeDropdown(dropdownId);
},
TableOptionsHotkeyScope.Dropdown,
);
const availableAggregateOperation = useMemo(
() =>
getAvailableAggregateOperationsForFieldMetadataType({
@@ -3,14 +3,10 @@ import {
SettingsServerlessFunctionCodeEditor,
} from '@/settings/serverless-functions/components/SettingsServerlessFunctionCodeEditor';
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 { 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 styled from '@emotion/styled';
import { Key } from 'ts-key-enum';
import {
H2Title,
IconGitCommit,
@@ -19,8 +15,6 @@ import {
} from 'twenty-ui/display';
import { Button, CoreEditorHeader } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { useHotkeyScopeOnMount } from '~/hooks/useHotkeyScopeOnMount';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
const StyledTabList = styled(TabList)`
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 (
<Section>
<H2Title
@@ -2,16 +2,12 @@ import { SettingsServerlessFunctionNewForm } from '@/settings/serverless-functio
import { SettingsServerlessFunctionTabEnvironmentVariablesSection } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTabEnvironmentVariablesSection';
import { useDeleteOneServerlessFunction } from '@/settings/serverless-functions/hooks/useDeleteOneServerlessFunction';
import { ServerlessFunctionFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { SettingsServerlessFunctionHotkeyScope } from '@/settings/serverless-functions/types/SettingsServerlessFunctionHotKeyScope';
import { SettingsPath } from '@/types/SettingsPath';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
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 { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { useHotkeyScopeOnMount } from '~/hooks/useHotkeyScopeOnMount';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
const DELETE_FUNCTION_MODAL_ID = 'delete-function-modal';
@@ -36,25 +32,6 @@ export const SettingsServerlessFunctionSettingsTab = ({
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 (
<>
<SettingsServerlessFunctionNewForm
@@ -1,15 +1,9 @@
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 styled from '@emotion/styled';
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 { Button, CodeEditor, CoreEditorHeader } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
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 (
<Section>
<H2Title
@@ -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',
}
@@ -2,11 +2,11 @@ import styled from '@emotion/styled';
import { motion } from 'framer-motion';
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_FOCUS_ID } from '@/ui/feedback/dialog-manager/constants/DialogFocusId';
import { DIALOG_LISTENER_ID } from '@/ui/feedback/dialog-manager/constants/DialogListenerId';
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 { useRef } from 'react';
import { isDefined } from 'twenty-shared/utils';
@@ -96,31 +96,37 @@ export const Dialog = ({
closed: { y: '50vh' },
};
useScopedHotkeys(
Key.Enter,
(event: KeyboardEvent) => {
const confirmButton = buttons.find((button) => button.role === 'confirm');
const handleEnter = (event: KeyboardEvent) => {
const confirmButton = buttons.find((button) => button.role === 'confirm');
event.preventDefault();
event.preventDefault();
if (isDefined(confirmButton)) {
confirmButton?.onClick?.(event);
onClose?.();
}
},
DialogHotkeyScope.Dialog,
[],
);
useScopedHotkeys(
Key.Escape,
(event: KeyboardEvent) => {
event.preventDefault();
if (isDefined(confirmButton)) {
confirmButton?.onClick?.(event);
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);
@@ -1,26 +1,34 @@
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 { 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 { DialogHotkeyScope } from '../types/DialogHotkeyScope';
export const DialogManagerEffect = () => {
const { dialogInternal } = useDialogManagerScopedStates();
const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope();
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
useEffect(() => {
if (dialogInternal.queue.length === 0) {
return;
}
setHotkeyScopeAndMemorizePreviousScope({
scope: DialogHotkeyScope.Dialog,
pushFocusItemToFocusStack({
focusId: DIALOG_FOCUS_ID,
component: {
type: FocusComponentType.DIALOG,
instanceId: DIALOG_FOCUS_ID,
},
hotkeyScope: {
scope: DialogHotkeyScope.Dialog,
},
memoizeKey: DIALOG_MANAGER_HOTKEY_SCOPE_MEMOIZE_KEY,
});
}, [dialogInternal.queue, setHotkeyScopeAndMemorizePreviousScope]);
}, [dialogInternal.queue, pushFocusItemToFocusStack]);
return <></>;
};
@@ -0,0 +1 @@
export const DIALOG_FOCUS_ID = 'dialog';
@@ -1,10 +1,10 @@
import { useRecoilCallback } from 'recoil';
import { v4 } from 'uuid';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { DIALOG_MANAGER_HOTKEY_SCOPE_MEMOIZE_KEY } from '@/ui/feedback/dialog-manager/constants/DialogManagerHotkeyScopeMemoizeKey';
import { 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 { dialogInternalScopedState } from '../states/dialogInternalScopedState';
import { DialogOptions } from '../types/DialogOptions';
@@ -19,7 +19,8 @@ export const useDialogManager = (props?: useDialogManagerProps) => {
props?.dialogManagerScopeId,
);
const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope();
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
const closeDialog = useRecoilCallback(
({ set }) =>
@@ -29,9 +30,9 @@ export const useDialogManager = (props?: useDialogManagerProps) => {
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(
@@ -1,11 +1,13 @@
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 { FocusEvent, useRef } from 'react';
import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-shared/utils';
import { IconComponent, TablerIconsProps } from 'twenty-ui/display';
import { useHotkeyScopeOnMount } from '~/hooks/useHotkeyScopeOnMount';
type NavigationDrawerInputProps = {
className?: string;
@@ -19,6 +21,8 @@ type NavigationDrawerInputProps = {
hotkeyScope: string;
};
const NAVIGATION_DRAWER_INPUT_FOCUS_ID = 'navigation-drawer-input';
export const NavigationDrawerInput = ({
className,
placeholder,
@@ -32,37 +36,64 @@ export const NavigationDrawerInput = ({
}: NavigationDrawerInputProps) => {
const inputRef = useRef<HTMLInputElement>(null);
useHotkeyScopeOnMount(hotkeyScope);
useScopedHotkeys(
[Key.Escape],
() => {
useHotkeysOnFocusedElement({
keys: Key.Escape,
callback: () => {
onCancel(value);
removeFocusItemFromFocusStackById({
focusId: NAVIGATION_DRAWER_INPUT_FOCUS_ID,
});
},
hotkeyScope,
);
focusId: NAVIGATION_DRAWER_INPUT_FOCUS_ID,
scope: hotkeyScope,
});
useScopedHotkeys(
[Key.Enter],
() => {
useHotkeysOnFocusedElement({
keys: Key.Enter,
callback: () => {
onSubmit(value);
removeFocusItemFromFocusStackById({
focusId: NAVIGATION_DRAWER_INPUT_FOCUS_ID,
});
},
hotkeyScope,
);
focusId: NAVIGATION_DRAWER_INPUT_FOCUS_ID,
scope: hotkeyScope,
});
useListenClickOutside({
refs: [inputRef],
callback: (event) => {
event.stopImmediatePropagation();
onClickOutside(event, value);
removeFocusItemFromFocusStackById({
focusId: NAVIGATION_DRAWER_INPUT_FOCUS_ID,
});
},
listenerId: 'navigation-drawer-input',
});
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
const handleFocus = (event: FocusEvent<HTMLInputElement>) => {
if (isDefined(value)) {
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 (
@@ -74,6 +105,7 @@ export const NavigationDrawerInput = ({
onChange={onChange}
placeholder={placeholder}
onFocus={handleFocus}
onBlur={handleBlur}
sizeVariant="md"
fullWidth
autoFocus
@@ -9,6 +9,9 @@ export enum FocusComponentType {
RECORD_TABLE_CELL = 'record-table-cell',
TEXT_AREA = 'text-area',
TEXT_INPUT = 'text-input',
FORM_FIELD_INPUT = 'form-field-input',
RECORD_BOARD_CARD = 'record-board-card',
ACTIVITY_RICH_TEXT_EDITOR = 'activity-rich-text-editor',
KEYBOARD_SHORTCUT_MENU = 'keyboard-shortcut-menu',
DIALOG = 'dialog',
}
@@ -1 +1 @@
export const DEBUG_HOTKEY_SCOPE = true;
export const DEBUG_HOTKEY_SCOPE = false;
@@ -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();
});
});
@@ -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,
);
};
@@ -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,
);
};
@@ -1,10 +1,10 @@
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 { useCallback, useState } from 'react';
import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
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 { v4 } from 'uuid';
@@ -117,17 +117,21 @@ export const useAgentChat = (agentId: string) => {
await sendChatMessage(content);
};
useScopedHotkeys(
[Key.Enter],
(event) => {
useHotkeysOnFocusedElement({
keys: [Key.Enter],
callback: (event: KeyboardEvent) => {
if (!event.ctrlKey && !event.metaKey) {
event.preventDefault();
handleSendMessage();
}
},
InputHotkeyScope.TextInput,
[agentChatInput, isLoading],
);
focusId: `${agentId}-chat-input`,
scope: InputHotkeyScope.TextInput,
dependencies: [agentChatInput, isLoading],
options: {
enableOnFormTags: true,
},
});
return {
handleInputChange: (value: string) => setAgentChatInput(value),
@@ -285,6 +285,7 @@ export const WorkflowEditActionFormBuilder = ({
<FormFieldInputRowContainer>
<FormFieldInputInnerContainer
formFieldInputInstanceId={field.id}
hasRightElement={false}
onClick={() => {
handleFieldClick(field.id);
@@ -358,6 +359,7 @@ export const WorkflowEditActionFormBuilder = ({
<FormFieldInputContainer>
<FormFieldInputRowContainer>
<FormFieldInputInnerContainer
formFieldInputInstanceId="add-field-button"
hasRightElement={false}
onClick={() => {
const { label, name } = getDefaultFormFieldSettings(
@@ -47,7 +47,10 @@ export const WorkflowFormEmptyMessage = () => {
<StyledMessageContainer>
<FormFieldInputContainer>
<FormFieldInputRowContainer multiline maxHeight={124}>
<FormFieldInputInnerContainer hasRightElement={false}>
<FormFieldInputInnerContainer
formFieldInputInstanceId="empty-form-message"
hasRightElement={false}
>
<StyledFieldContainer>
<StyledMessageContentContainer>
<StyledMessageTitle data-testid="empty-form-message-title">
@@ -14,11 +14,12 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
import { ProfilePictureUploader } from '@/settings/profile/components/ProfilePictureUploader';
import { PageFocusId } from '@/types/PageFocusId';
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { TextInputV2 } from '@/ui/input/components/TextInputV2';
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 { ApolloError } from '@apollo/client';
import { Trans, useLingui } from '@lingui/react/macro';
@@ -148,15 +149,23 @@ export const CreateProfile = () => {
const [isEditingMode, setIsEditingMode] = useState(false);
useScopedHotkeys(
Key.Enter,
() => {
const handleEnter = () => {
if (isEditingMode) {
onSubmit(getValues());
}
};
useHotkeysOnFocusedElement({
keys: Key.Enter,
callback: () => {
if (isEditingMode) {
onSubmit(getValues());
}
},
PageHotkeyScope.CreateProfile,
);
focusId: PageFocusId.CreateProfile,
scope: PageHotkeyScope.CreateProfile,
dependencies: [handleEnter],
});
return (
<Modal.Content isVerticalCentered isHorizontalCentered>
@@ -3,11 +3,12 @@ import { Title } from '@/auth/components/Title';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
import { PageFocusId } from '@/types/PageFocusId';
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { TextInputV2 } from '@/ui/input/components/TextInputV2';
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 styled from '@emotion/styled';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -161,14 +162,15 @@ export const InviteTeam = () => {
await onSubmit({ emails: [] });
};
useScopedHotkeys(
[Key.Enter],
() => {
useHotkeysOnFocusedElement({
keys: Key.Enter,
callback: () => {
handleSubmit(onSubmit)();
},
PageHotkeyScope.InviteTeam,
[handleSubmit],
);
focusId: PageFocusId.InviteTeam,
scope: PageHotkeyScope.InviteTeam,
dependencies: [handleSubmit, onSubmit],
});
return (
<Modal.Content isVerticalCentered isHorizontalCentered>
@@ -9,7 +9,6 @@ import { Title } from '@/auth/components/Title';
import { OnboardingSyncEmailsSettingsCard } from '@/onboarding/components/OnboardingSyncEmailsSettingsCard';
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { isGoogleCalendarEnabledState } from '@/client-config/states/isGoogleCalendarEnabledState';
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 { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { AppPath } from '@/types/AppPath';
import { PageFocusId } from '@/types/PageFocusId';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { IconGoogle, IconMicrosoft } from 'twenty-ui/display';
import { MainButton } from 'twenty-ui/input';
@@ -95,14 +96,15 @@ export const SyncEmails = () => {
const isMicrosoftProviderEnabled =
isMicrosoftMessagingEnabled || isMicrosoftCalendarEnabled;
useScopedHotkeys(
[Key.Enter],
async () => {
useHotkeysOnFocusedElement({
keys: Key.Enter,
callback: async () => {
await continueWithoutSync();
},
PageHotkeyScope.SyncEmail,
[continueWithoutSync],
);
focusId: PageFocusId.SyncEmail,
scope: PageHotkeyScope.SyncEmail,
dependencies: [continueWithoutSync],
});
return (
<Modal.Content isVerticalCentered isHorizontalCentered>
@@ -5,15 +5,11 @@ import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBa
import { SettingsServerlessFunctionNewForm } from '@/settings/serverless-functions/components/SettingsServerlessFunctionNewForm';
import { useCreateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useCreateOneServerlessFunction';
import { ServerlessFunctionNewFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { SettingsServerlessFunctionHotkeyScope } from '@/settings/serverless-functions/types/SettingsServerlessFunctionHotKeyScope';
import { SettingsPath } from '@/types/SettingsPath';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useState } from 'react';
import { Key } from 'ts-key-enum';
import { useHotkeyScopeOnMount } from '~/hooks/useHotkeyScopeOnMount';
import { isDefined } from 'twenty-shared/utils';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { isDefined } from 'twenty-shared/utils';
export const SettingsServerlessFunctionsNew = () => {
const navigate = useNavigateSettings();
@@ -50,28 +46,6 @@ export const SettingsServerlessFunctionsNew = () => {
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 (
<SubMenuTopBarContainer
title="New Function"
@@ -4,21 +4,15 @@ import { RecoilRoot } from 'recoil';
import { ApolloCoreClientMockedProvider } from '@/object-metadata/hooks/__mocks__/ApolloCoreClientMockedProvider';
import { InitializeHotkeyStorybookHookEffect } from '../InitializeHotkeyStorybookHook';
import { mockedApolloClient } from '../mockedApolloClient';
export const RootDecorator: Decorator = (Story, context) => {
const { parameters } = context;
const disableHotkeyInitialization = parameters.disableHotkeyInitialization;
return (
<RecoilRoot initializeState={parameters.initializeState}>
<ApolloProvider client={mockedApolloClient}>
<ApolloCoreClientMockedProvider>
{!disableHotkeyInitialization && (
<InitializeHotkeyStorybookHookEffect />
)}
<Story />
</ApolloCoreClientMockedProvider>
</ApolloProvider>