From 4d352cb4e48c1549e22fbcdd0543cdb74ab3049e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Mon, 12 May 2025 19:02:14 +0200 Subject: [PATCH] Keyboard Navigation and Shortcuts Implementation on the board (#11930) # Keyboard Navigation and Shortcuts Implementation on the board This PR implements keyboard navigation and shortcuts for the Record Board component, enabling users to navigate and interact with board cards using keyboard inputs. ## Key changes ### Navigation Architecture - Added `useBoardCardNavigation` hook for directional navigation (arrow keys) - Implemented scroll behavior to automatically focus visible cards - Created card focus/active states with component-level management ### State Management - Added new component states: `focusedBoardCardIndexesComponentState`, `activeBoardCardIndexesComponentState`, `isBoardCardActiveComponentFamilyState` - Extended index tracking with column/row position indicators - Create hooks to manage these states ### Hotkey Implementation - Created new `RecordBoardHotkeyEffect` component for hotkey handling - Added `BoardHotkeyScope` - Implemented Escape key handling to clear selections - Replaced table-specific `TableHotkeyScope` with more generic `RecordIndexHotkeyScope`. This is because, before, the hotkey scope was always set to Table inside the page change effect, and we used the table hotkey scope for board shortcuts, which doesn't make a lot of sense. Since we don't know upon navigation on which type of view we are navigating, I introduced this generic hotkey scope which can be used on the table and on the board. ### Page Navigation Integration - Modified `PageChangeEffect` to handle both table and board view types - Added cleanup for board state upon navigating away from record pages ### Component Updates - Updated `RecordBoardColumn` to track indexes for position-based navigation - Added `RecordBoardScrollToFocusedCardEffect` for auto-scrolling to focused cards - Added `RecordBoardDeactivateBoardCardEffect` for cleanup This implementation maintains feature parity with table row navigation while accounting for the 2D navigation needs of the board view. ## New behaviors ### Arrow keys navigation https://github.com/user-attachments/assets/929ee00d-2f82-43b9-8cde-f7bc8818052f ### Record selection with X https://github.com/user-attachments/assets/0b534c4d-2865-43ac-8ba3-09cb8c121f06 ### Command + Enter opens the record https://github.com/user-attachments/assets/0df01d1c-0437-4444-beb1-ce74bcfb91a4 ### Escape unselect the records and unfocus the card https://github.com/user-attachments/assets/e2bb176b-b6f7-49ca-9549-803eb31bfc23 --- .../actions/components/ActionModal.tsx | 3 +- .../effect-components/PageChangeEffect.tsx | 35 ++- .../hooks/useNavigateCommandMenu.ts | 8 +- .../useKeyboardShortcutMenu.test.tsx | 3 +- .../hooks/useKeyboardShortcutMenu.ts | 66 +++--- .../record-board/components/RecordBoard.tsx | 38 ++- .../RecordBoardBodyEscapeHotkeyEffect.tsx | 52 +++++ .../components/RecordBoardHeader.tsx | 3 +- .../components/RecordBoardHotkeyEffect.tsx | 121 ++++++++++ .../RecordBoardScrollToFocusedCardEffect.tsx | 45 ++++ .../hooks/useActiveRecordBoardCard.ts | 64 ++++++ .../hooks/useFocusedRecordBoardCard.ts | 70 ++++++ .../hooks/useRecordBoardCardNavigation.ts | 216 ++++++++++++++++++ .../hooks/useRecordBoardSelection.ts | 11 +- .../components/RecordBoardCard.tsx | 72 ++++-- .../RecordBoardCardDraggableContainer.tsx | 40 +++- .../RecordBoardCardFocusHotkeyEffect.tsx | 57 +++++ .../RecordBoardDeactivateBoardCardEffect.tsx | 15 ++ .../contexts/RecordBoardCardContext.ts | 2 + .../components/RecordBoardColumn.tsx | 3 + .../components/RecordBoardColumnCardsMemo.tsx | 2 +- .../RecordBoardColumnHeaderWrapper.tsx | 3 + .../contexts/RecordBoardColumnContext.ts | 1 + ...iveRecordBoardCardIndexesComponentState.ts | 10 + ...sedRecordBoardCardIndexesComponentState.ts | 10 + ...cordBoardCardActiveComponentFamilyState.ts | 10 + ...ecordBoardCardFocusActiveComponentState.ts | 9 + ...ordBoardCardFocusedComponentFamilyState.ts | 10 + .../record-board/types/BoardCardIndexes.ts | 4 + .../record-board/types/BoardHotkeyScope.ts | 3 + .../components/RecordIndexBoardContainer.tsx | 5 +- .../types/RecordIndexHotkeyScope.ts | 3 + .../components/RecordTableWithWrappers.tsx | 13 +- .../hooks/internal/useLeaveTableFocus.ts | 4 +- .../record-table/hooks/useRecordTable.ts | 5 +- .../RecordTableBodyEscapeHotkeyEffect.tsx | 7 +- .../RecordTableBodyFocusKeyboardEffect.tsx | 3 +- .../hooks/useMoveHoverToCurrentCell.ts | 5 +- .../components/RecordTableTr.tsx | 10 +- .../record-table/types/TableHotkeyScope.ts | 1 - 40 files changed, 933 insertions(+), 109 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardBodyEscapeHotkeyEffect.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHotkeyEffect.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardScrollToFocusedCardEffect.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-board/hooks/useActiveRecordBoardCard.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/hooks/useFocusedRecordBoardCard.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardCardNavigation.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardFocusHotkeyEffect.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardDeactivateBoardCardEffect.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-board/states/activeRecordBoardCardIndexesComponentState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/states/focusedRecordBoardCardIndexesComponentState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardCardActiveComponentFamilyState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardCardFocusActiveComponentState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardCardFocusedComponentFamilyState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/types/BoardCardIndexes.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/types/BoardHotkeyScope.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-index/types/RecordIndexHotkeyScope.ts diff --git a/packages/twenty-front/src/modules/action-menu/actions/components/ActionModal.tsx b/packages/twenty-front/src/modules/action-menu/actions/components/ActionModal.tsx index 14787efc4..c21113dea 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/components/ActionModal.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/components/ActionModal.tsx @@ -33,9 +33,8 @@ export const ActionModal = ({ const { closeActionMenu } = useCloseActionMenu(); const handleConfirmClick = () => { - closeActionMenu(); onConfirmClick(); - setIsOpen(false); + closeActionMenu(); }; const actionConfig = useContext(ActionConfigContext); diff --git a/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx b/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx index d99712590..0749116df 100644 --- a/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx +++ b/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx @@ -17,11 +17,16 @@ import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoad import { isCaptchaRequiredForPath } from '@/captcha/utils/isCaptchaRequiredForPath'; import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId'; import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState'; +import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState'; +import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType'; import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural'; +import { useActiveRecordBoardCard } from '@/object-record/record-board/hooks/useActiveRecordBoardCard'; +import { useFocusedRecordBoardCard } from '@/object-record/record-board/hooks/useFocusedRecordBoardCard'; +import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection'; +import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/RecordIndexHotkeyScope'; import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection'; import { useActiveRecordTableRow } from '@/object-record/record-table/hooks/useActiveRecordTableRow'; import { useFocusedRecordTableRow } from '@/object-record/record-table/hooks/useFocusedRecordTableRow'; -import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope'; import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId'; import { AppBasePath } from '@/types/AppBasePath'; import { AppPath } from '@/types/AppPath'; @@ -64,6 +69,11 @@ export const PageChangeEffect = () => { MAIN_CONTEXT_STORE_INSTANCE_ID, ); + const contextStoreCurrentViewType = useRecoilComponentValueV2( + contextStoreCurrentViewTypeComponentState, + MAIN_CONTEXT_STORE_INSTANCE_ID, + ); + const recordIndexId = getRecordIndexIdFromObjectNamePluralAndViewId( objectNamePlural, contextStoreCurrentViewId || '', @@ -73,6 +83,10 @@ export const PageChangeEffect = () => { const { unfocusRecordTableRow } = useFocusedRecordTableRow(recordIndexId); const { deactivateRecordTableRow } = useActiveRecordTableRow(recordIndexId); + const { resetRecordSelection } = useRecordBoardSelection(recordIndexId); + const { deactivateBoardCard } = useActiveRecordBoardCard(recordIndexId); + const { unfocusBoardCard } = useFocusedRecordBoardCard(recordIndexId); + const { executeTasksOnAnyLocationChange } = useExecuteTasksOnAnyLocationChange(); @@ -100,9 +114,16 @@ export const PageChangeEffect = () => { ); if (isLeavingRecordIndexPage) { - resetTableSelections(); - unfocusRecordTableRow(); - deactivateRecordTableRow(); + if (contextStoreCurrentViewType === ContextStoreViewType.Table) { + resetTableSelections(); + unfocusRecordTableRow(); + deactivateRecordTableRow(); + } + if (contextStoreCurrentViewType === ContextStoreViewType.Kanban) { + resetRecordSelection(); + deactivateBoardCard(); + unfocusBoardCard(); + } } }, [ isMatchingLocation, @@ -110,12 +131,16 @@ export const PageChangeEffect = () => { resetTableSelections, unfocusRecordTableRow, deactivateRecordTableRow, + contextStoreCurrentViewType, + resetRecordSelection, + deactivateBoardCard, + unfocusBoardCard, ]); useEffect(() => { switch (true) { case isMatchingLocation(AppPath.RecordIndexPage): { - setHotkeyScope(TableHotkeyScope.Table, { + setHotkeyScope(RecordIndexHotkeyScope.RecordIndex, { goto: true, keyboardShortcutMenu: true, }); diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useNavigateCommandMenu.ts b/packages/twenty-front/src/modules/command-menu/hooks/useNavigateCommandMenu.ts index 36bb69231..c92fb987d 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/useNavigateCommandMenu.ts +++ b/packages/twenty-front/src/modules/command-menu/hooks/useNavigateCommandMenu.ts @@ -51,6 +51,10 @@ export const useNavigateCommandMenu = () => { commandMenuCloseAnimationCompleteCleanup(); } + if (isCommandMenuOpened) { + return; + } + setHotkeyScopeAndMemorizePreviousScope( CommandMenuHotkeyScope.CommandMenuFocused, { @@ -58,10 +62,6 @@ export const useNavigateCommandMenu = () => { }, ); - if (isCommandMenuOpened) { - return; - } - copyContextStoreStates({ instanceIdToCopyFrom: MAIN_CONTEXT_STORE_INSTANCE_ID, instanceIdToCopyTo: COMMAND_MENU_COMPONENT_INSTANCE_ID, diff --git a/packages/twenty-front/src/modules/keyboard-shortcut-menu/hooks/__tests__/useKeyboardShortcutMenu.test.tsx b/packages/twenty-front/src/modules/keyboard-shortcut-menu/hooks/__tests__/useKeyboardShortcutMenu.test.tsx index 49d98de15..d38b3b577 100644 --- a/packages/twenty-front/src/modules/keyboard-shortcut-menu/hooks/__tests__/useKeyboardShortcutMenu.test.tsx +++ b/packages/twenty-front/src/modules/keyboard-shortcut-menu/hooks/__tests__/useKeyboardShortcutMenu.test.tsx @@ -1,5 +1,6 @@ import { expect } from '@storybook/test'; -import { act, renderHook } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; import { RecoilRoot, useRecoilValue } from 'recoil'; import { isKeyboardShortcutMenuOpenedState } from '@/keyboard-shortcut-menu/states/isKeyboardShortcutMenuOpenedState'; diff --git a/packages/twenty-front/src/modules/keyboard-shortcut-menu/hooks/useKeyboardShortcutMenu.ts b/packages/twenty-front/src/modules/keyboard-shortcut-menu/hooks/useKeyboardShortcutMenu.ts index 347e40db0..0d568a473 100644 --- a/packages/twenty-front/src/modules/keyboard-shortcut-menu/hooks/useKeyboardShortcutMenu.ts +++ b/packages/twenty-front/src/modules/keyboard-shortcut-menu/hooks/useKeyboardShortcutMenu.ts @@ -1,4 +1,4 @@ -import { useRecoilState, useRecoilValue } from 'recoil'; +import { useRecoilCallback } from 'recoil'; import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope'; @@ -6,38 +6,52 @@ import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope'; import { isKeyboardShortcutMenuOpenedState } from '../states/isKeyboardShortcutMenuOpenedState'; export const useKeyboardShortcutMenu = () => { - const [, setIsKeyboardShortcutMenuOpened] = useRecoilState( - isKeyboardShortcutMenuOpenedState, - ); - const isKeyboardShortcutMenuOpened = useRecoilValue( - isKeyboardShortcutMenuOpenedState, - ); const { setHotkeyScopeAndMemorizePreviousScope, goBackToPreviousHotkeyScope, } = usePreviousHotkeyScope(); - const toggleKeyboardShortcutMenu = () => { - if (isKeyboardShortcutMenuOpened === false) { - setIsKeyboardShortcutMenuOpened(true); - setHotkeyScopeAndMemorizePreviousScope( - AppHotkeyScope.KeyboardShortcutMenu, - ); - } else { - setIsKeyboardShortcutMenuOpened(false); - goBackToPreviousHotkeyScope(); - } - }; + const openKeyboardShortcutMenu = useRecoilCallback( + ({ set }) => + () => { + set(isKeyboardShortcutMenuOpenedState, true); + setHotkeyScopeAndMemorizePreviousScope( + AppHotkeyScope.KeyboardShortcutMenu, + ); + }, + [setHotkeyScopeAndMemorizePreviousScope], + ); - const openKeyboardShortcutMenu = () => { - setIsKeyboardShortcutMenuOpened(true); - setHotkeyScopeAndMemorizePreviousScope(AppHotkeyScope.KeyboardShortcutMenu); - }; + const closeKeyboardShortcutMenu = useRecoilCallback( + ({ set, snapshot }) => + () => { + const isKeyboardShortcutMenuOpened = snapshot + .getLoadable(isKeyboardShortcutMenuOpenedState) + .getValue(); - const closeKeyboardShortcutMenu = () => { - setIsKeyboardShortcutMenuOpened(false); - goBackToPreviousHotkeyScope(); - }; + if (isKeyboardShortcutMenuOpened) { + set(isKeyboardShortcutMenuOpenedState, false); + goBackToPreviousHotkeyScope(); + } + }, + [goBackToPreviousHotkeyScope], + ); + + const toggleKeyboardShortcutMenu = useRecoilCallback( + ({ snapshot }) => + () => { + const isKeyboardShortcutMenuOpened = snapshot + .getLoadable(isKeyboardShortcutMenuOpenedState) + .getValue(); + + if (isKeyboardShortcutMenuOpened === false) { + openKeyboardShortcutMenu(); + } else { + closeKeyboardShortcutMenu(); + } + }, + [closeKeyboardShortcutMenu, openKeyboardShortcutMenu], + ); return { toggleKeyboardShortcutMenu, diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx index c87033b9b..04b57f57f 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx @@ -5,10 +5,14 @@ import { useRecoilCallback, useSetRecoilState } from 'recoil'; import { getActionMenuIdFromRecordIndexId } from '@/action-menu/utils/getActionMenuIdFromRecordIndexId'; import { RecordBoardHeader } from '@/object-record/record-board/components/RecordBoardHeader'; +import { RecordBoardScrollToFocusedCardEffect } from '@/object-record/record-board/components/RecordBoardScrollToFocusedCardEffect'; import { RecordBoardStickyHeaderEffect } from '@/object-record/record-board/components/RecordBoardStickyHeaderEffect'; import { RECORD_BOARD_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-board/constants/RecordBoardClickOutsideListenerId'; import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; +import { useActiveRecordBoardCard } from '@/object-record/record-board/hooks/useActiveRecordBoardCard'; +import { useFocusedRecordBoardCard } from '@/object-record/record-board/hooks/useFocusedRecordBoardCard'; import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection'; +import { RecordBoardDeactivateBoardCardEffect } from '@/object-record/record-board/record-board-card/components/RecordBoardDeactivateBoardCardEffect'; import { RecordBoardColumn } from '@/object-record/record-board/record-board-column/components/RecordBoardColumn'; import { RecordBoardScope } from '@/object-record/record-board/scopes/RecordBoardScope'; import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext'; @@ -16,14 +20,11 @@ import { getDraggedRecordPosition } from '@/object-record/record-board/utils/get import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/states/recordGroupDefinitionFamilyState'; import { visibleRecordGroupIdsComponentFamilySelector } from '@/object-record/record-group/states/selectors/visibleRecordGroupIdsComponentFamilySelector'; import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState'; -import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { isRemoveSortingModalOpenState } from '@/object-record/record-table/states/isRemoveSortingModalOpenState'; -import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope'; import { useDropdownV2 } from '@/ui/layout/dropdown/hooks/useDropdownV2'; import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect'; -import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; @@ -71,6 +72,9 @@ export const RecordBoard = () => { const { closeDropdown } = useDropdownV2(); + const { deactivateBoardCard } = useActiveRecordBoardCard(recordBoardId); + const { unfocusBoardCard } = useFocusedRecordBoardCard(recordBoardId); + const handleDragSelectionStart = () => { closeDropdown(actionMenuId); @@ -91,10 +95,6 @@ export const RecordBoard = () => { recordIndexRecordIdsByGroupComponentFamilyState, ); - const recordIndexAllRecordIdsState = useRecoilComponentCallbackStateV2( - recordIndexAllRecordIdsComponentSelector, - ); - const { resetRecordSelection, setRecordAsSelected } = useRecordBoardSelection(recordBoardId); @@ -115,26 +115,11 @@ export const RecordBoard = () => { refs: [], callback: () => { resetRecordSelection(); + deactivateBoardCard(); + unfocusBoardCard(); }, }); - const selectAll = useRecoilCallback( - ({ snapshot }) => - () => { - const allRecordIds = getSnapshotValue( - snapshot, - recordIndexAllRecordIdsState, - ); - - for (const recordId of allRecordIds) { - setRecordAsSelected(recordId, true); - } - }, - [recordIndexAllRecordIdsState, setRecordAsSelected], - ); - - useScopedHotkeys('ctrl+a,meta+a', selectAll, TableHotkeyScope.Table); - const setIsRemoveSortingModalOpen = useSetRecoilState( isRemoveSortingModalOpenState, ); @@ -228,16 +213,19 @@ export const RecordBoard = () => { componentInstanceId={`scroll-wrapper-record-board-${recordBoardId}`} > + + - {visibleRecordGroupIds.map((recordGroupId) => ( + {visibleRecordGroupIds.map((recordGroupId, index) => ( ))} diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardBodyEscapeHotkeyEffect.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardBodyEscapeHotkeyEffect.tsx new file mode 100644 index 000000000..717aaf11f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardBodyEscapeHotkeyEffect.tsx @@ -0,0 +1,52 @@ +import { useContext } from 'react'; +import { Key } from 'ts-key-enum'; + +import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; +import { useFocusedRecordBoardCard } from '@/object-record/record-board/hooks/useFocusedRecordBoardCard'; +import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection'; +import { recordBoardSelectedRecordIdsComponentSelector } from '@/object-record/record-board/states/selectors/recordBoardSelectedRecordIdsComponentSelector'; +import { BoardHotkeyScope } from '@/object-record/record-board/types/BoardHotkeyScope'; +import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/RecordIndexHotkeyScope'; +import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; + +export const RecordBoardBodyEscapeHotkeyEffect = () => { + const { recordBoardId } = useContext(RecordBoardContext); + + const { resetRecordSelection } = useRecordBoardSelection(recordBoardId); + + const { unfocusBoardCard } = useFocusedRecordBoardCard(recordBoardId); + + const selectedRecordIds = useRecoilComponentValueV2( + recordBoardSelectedRecordIdsComponentSelector, + recordBoardId, + ); + + const isAtLeastOneRecordSelected = selectedRecordIds.length > 0; + + useScopedHotkeys( + [Key.Escape], + () => { + unfocusBoardCard(); + if (isAtLeastOneRecordSelected) { + resetRecordSelection(); + } + }, + RecordIndexHotkeyScope.RecordIndex, + [isAtLeastOneRecordSelected, resetRecordSelection, unfocusBoardCard], + ); + + useScopedHotkeys( + [Key.Escape], + () => { + unfocusBoardCard(); + if (isAtLeastOneRecordSelected) { + resetRecordSelection(); + } + }, + BoardHotkeyScope.BoardFocus, + [isAtLeastOneRecordSelected, resetRecordSelection, unfocusBoardCard], + ); + + return null; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHeader.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHeader.tsx index 3ca0b6e68..187606a64 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHeader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHeader.tsx @@ -31,9 +31,10 @@ export const RecordBoardHeader = () => { return ( - {visibleRecordGroupIds.map((recordGroupId) => ( + {visibleRecordGroupIds.map((recordGroupId, index) => ( ))} diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHotkeyEffect.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHotkeyEffect.tsx new file mode 100644 index 000000000..6ceebcdc1 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHotkeyEffect.tsx @@ -0,0 +1,121 @@ +import { useContext } from 'react'; +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 { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection'; +import { BoardHotkeyScope } from '@/object-record/record-board/types/BoardHotkeyScope'; +import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; +import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/RecordIndexHotkeyScope'; +import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; +import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; +import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue'; +import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; +import { useRecoilCallback } from 'recoil'; + +export const RecordBoardHotkeyEffect = () => { + const { recordBoardId } = useContext(RecordBoardContext); + + const { move } = useRecordBoardCardNavigation(recordBoardId); + + const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope(); + + const recordIndexAllRecordIdsState = useRecoilComponentCallbackStateV2( + recordIndexAllRecordIdsComponentSelector, + ); + + const { setRecordAsSelected } = useRecordBoardSelection(recordBoardId); + + const selectAll = useRecoilCallback( + ({ snapshot }) => + () => { + const allRecordIds = getSnapshotValue( + snapshot, + recordIndexAllRecordIdsState, + ); + + for (const recordId of allRecordIds) { + setRecordAsSelected(recordId, true); + } + }, + [recordIndexAllRecordIdsState, setRecordAsSelected], + ); + + useScopedHotkeys( + 'ctrl+a,meta+a', + selectAll, + RecordIndexHotkeyScope.RecordIndex, + ); + + useScopedHotkeys('ctrl+a,meta+a', selectAll, BoardHotkeyScope.BoardFocus); + + useScopedHotkeys( + Key.ArrowLeft, + () => { + setHotkeyScopeAndMemorizePreviousScope(BoardHotkeyScope.BoardFocus); + move('left'); + }, + RecordIndexHotkeyScope.RecordIndex, + ); + + useScopedHotkeys( + Key.ArrowRight, + () => { + setHotkeyScopeAndMemorizePreviousScope(BoardHotkeyScope.BoardFocus); + move('right'); + }, + RecordIndexHotkeyScope.RecordIndex, + ); + + useScopedHotkeys( + Key.ArrowUp, + () => { + setHotkeyScopeAndMemorizePreviousScope(BoardHotkeyScope.BoardFocus); + move('up'); + }, + RecordIndexHotkeyScope.RecordIndex, + ); + + useScopedHotkeys( + Key.ArrowDown, + () => { + setHotkeyScopeAndMemorizePreviousScope(BoardHotkeyScope.BoardFocus); + move('down'); + }, + RecordIndexHotkeyScope.RecordIndex, + ); + + useScopedHotkeys( + Key.ArrowLeft, + () => { + move('left'); + }, + BoardHotkeyScope.BoardFocus, + ); + + useScopedHotkeys( + Key.ArrowRight, + () => { + move('right'); + }, + BoardHotkeyScope.BoardFocus, + ); + + useScopedHotkeys( + Key.ArrowUp, + () => { + move('up'); + }, + BoardHotkeyScope.BoardFocus, + ); + + useScopedHotkeys( + Key.ArrowDown, + () => { + move('down'); + }, + BoardHotkeyScope.BoardFocus, + ); + + return null; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardScrollToFocusedCardEffect.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardScrollToFocusedCardEffect.tsx new file mode 100644 index 000000000..1d6cdbf1d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardScrollToFocusedCardEffect.tsx @@ -0,0 +1,45 @@ +import { useEffect } from 'react'; + +import { RecordBoardScopeInternalContext } from '@/object-record/record-board/scopes/scope-internal-context/RecordBoardScopeInternalContext'; +import { focusedRecordBoardCardIndexesComponentState } from '@/object-record/record-board/states/focusedRecordBoardCardIndexesComponentState'; +import { isRecordBoardCardFocusActiveComponentState } from '@/object-record/record-board/states/isRecordBoardCardFocusActiveComponentState'; +import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; + +export const RecordBoardScrollToFocusedCardEffect = () => { + const recordBoardId = useAvailableScopeIdOrThrow( + RecordBoardScopeInternalContext, + ); + + const focusedCardIndexes = useRecoilComponentValueV2( + focusedRecordBoardCardIndexesComponentState, + recordBoardId, + ); + + const isFocusActive = useRecoilComponentValueV2( + isRecordBoardCardFocusActiveComponentState, + recordBoardId, + ); + + useEffect(() => { + if (!isFocusActive || !focusedCardIndexes) { + return; + } + + const { rowIndex, columnIndex } = focusedCardIndexes; + + const focusElement = document.getElementById( + `record-board-card-${columnIndex}-${rowIndex}`, + ); + + if (!focusElement) { + return; + } + + if (focusElement instanceof HTMLElement) { + focusElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + }, [focusedCardIndexes, isFocusActive]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useActiveRecordBoardCard.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useActiveRecordBoardCard.ts new file mode 100644 index 000000000..03ce8f623 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useActiveRecordBoardCard.ts @@ -0,0 +1,64 @@ +import { activeRecordBoardCardIndexesComponentState } from '@/object-record/record-board/states/activeRecordBoardCardIndexesComponentState'; +import { isRecordBoardCardActiveComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardActiveComponentFamilyState'; +import { BoardCardIndexes } from '@/object-record/record-board/types/BoardCardIndexes'; +import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; +import { useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + +export const useActiveRecordBoardCard = (recordBoardId?: string) => { + const isCardActiveState = useRecoilComponentCallbackStateV2( + isRecordBoardCardActiveComponentFamilyState, + recordBoardId, + ); + + const activeBoardCardIndexesState = useRecoilComponentCallbackStateV2( + activeRecordBoardCardIndexesComponentState, + recordBoardId, + ); + + const deactivateBoardCard = useRecoilCallback( + ({ set, snapshot }) => + () => { + const activeBoardCardIndexes = snapshot + .getLoadable(activeBoardCardIndexesState) + .getValue(); + + if (!isDefined(activeBoardCardIndexes)) { + return; + } + + set(activeBoardCardIndexesState, null); + set(isCardActiveState(activeBoardCardIndexes), false); + }, + [activeBoardCardIndexesState, isCardActiveState], + ); + + const activateBoardCard = useRecoilCallback( + ({ set, snapshot }) => + (boardCardIndexes: BoardCardIndexes) => { + const activeBoardCardIndexes = snapshot + .getLoadable(activeBoardCardIndexesState) + .getValue(); + + if ( + activeBoardCardIndexes?.rowIndex === boardCardIndexes.rowIndex && + activeBoardCardIndexes?.columnIndex === boardCardIndexes.columnIndex + ) { + return; + } + + if (isDefined(activeBoardCardIndexes)) { + set(isCardActiveState(activeBoardCardIndexes), false); + } + + set(activeBoardCardIndexesState, boardCardIndexes); + set(isCardActiveState(boardCardIndexes), true); + }, + [activeBoardCardIndexesState, isCardActiveState], + ); + + return { + activateBoardCard, + deactivateBoardCard, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useFocusedRecordBoardCard.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useFocusedRecordBoardCard.ts new file mode 100644 index 000000000..059d33537 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useFocusedRecordBoardCard.ts @@ -0,0 +1,70 @@ +import { focusedRecordBoardCardIndexesComponentState } from '@/object-record/record-board/states/focusedRecordBoardCardIndexesComponentState'; +import { isRecordBoardCardFocusActiveComponentState } from '@/object-record/record-board/states/isRecordBoardCardFocusActiveComponentState'; +import { isRecordBoardCardFocusedComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardFocusedComponentFamilyState'; +import { BoardCardIndexes } from '@/object-record/record-board/types/BoardCardIndexes'; +import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; +import { useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + +export const useFocusedRecordBoardCard = (recordBoardId?: string) => { + const isCardFocusedState = useRecoilComponentCallbackStateV2( + isRecordBoardCardFocusedComponentFamilyState, + recordBoardId, + ); + + const focusedBoardCardIndexesState = useRecoilComponentCallbackStateV2( + focusedRecordBoardCardIndexesComponentState, + recordBoardId, + ); + + const isCardFocusActiveState = useRecoilComponentCallbackStateV2( + isRecordBoardCardFocusActiveComponentState, + recordBoardId, + ); + + const unfocusBoardCard = useRecoilCallback( + ({ set, snapshot }) => + () => { + const focusedBoardCardIndexes = snapshot + .getLoadable(focusedBoardCardIndexesState) + .getValue(); + + if (!isDefined(focusedBoardCardIndexes)) { + return; + } + + set(focusedBoardCardIndexesState, null); + set(isCardFocusedState(focusedBoardCardIndexes), false); + set(isCardFocusActiveState, false); + }, + [focusedBoardCardIndexesState, isCardFocusedState, isCardFocusActiveState], + ); + + const focusBoardCard = useRecoilCallback( + ({ set, snapshot }) => + (boardCardIndexes: BoardCardIndexes) => { + const focusedBoardCardIndexes = snapshot + .getLoadable(focusedBoardCardIndexesState) + .getValue(); + + if ( + isDefined(focusedBoardCardIndexes) && + (focusedBoardCardIndexes.rowIndex !== boardCardIndexes.rowIndex || + focusedBoardCardIndexes.columnIndex !== + boardCardIndexes.columnIndex) + ) { + set(isCardFocusedState(focusedBoardCardIndexes), false); + } + + set(focusedBoardCardIndexesState, boardCardIndexes); + set(isCardFocusedState(boardCardIndexes), true); + set(isCardFocusActiveState, true); + }, + [focusedBoardCardIndexesState, isCardFocusedState, isCardFocusActiveState], + ); + + return { + focusBoardCard, + unfocusBoardCard, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardCardNavigation.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardCardNavigation.ts new file mode 100644 index 000000000..5dc486d1c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardCardNavigation.ts @@ -0,0 +1,216 @@ +import { useRecoilCallback } from 'recoil'; + +import { useFocusedRecordBoardCard } from '@/object-record/record-board/hooks/useFocusedRecordBoardCard'; +import { focusedRecordBoardCardIndexesComponentState } from '@/object-record/record-board/states/focusedRecordBoardCardIndexesComponentState'; +import { visibleRecordGroupIdsComponentFamilySelector } from '@/object-record/record-group/states/selectors/visibleRecordGroupIdsComponentFamilySelector'; +import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState'; +import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; +import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2'; +import { ViewType } from '@/views/types/ViewType'; +import { isDefined } from 'twenty-shared/utils'; + +type NavigationDirection = 'up' | 'down' | 'left' | 'right'; + +export const useRecordBoardCardNavigation = (recordBoardId?: string) => { + const { focusBoardCard } = useFocusedRecordBoardCard(recordBoardId); + + const focusedBoardCardIndexesState = useRecoilComponentCallbackStateV2( + focusedRecordBoardCardIndexesComponentState, + recordBoardId, + ); + + const visibleRecordGroupIds = useRecoilComponentFamilyValueV2( + visibleRecordGroupIdsComponentFamilySelector, + ViewType.Kanban, + ); + + const recordIdsByGroupState = useRecoilComponentCallbackStateV2( + recordIndexRecordIdsByGroupComponentFamilyState, + ); + + const moveHorizontally = useRecoilCallback( + ({ snapshot }) => + (direction: 'left' | 'right') => { + const focusedBoardCardIndexes = snapshot + .getLoadable(focusedBoardCardIndexesState) + .getValue(); + + if (!isDefined(focusedBoardCardIndexes)) { + if (visibleRecordGroupIds.length === 0) { + return; + } + + const firstGroupId = visibleRecordGroupIds[0]; + const recordIdsInFirstGroup = snapshot + .getLoadable(recordIdsByGroupState(firstGroupId)) + .getValue(); + + if ( + !Array.isArray(recordIdsInFirstGroup) || + recordIdsInFirstGroup.length === 0 + ) { + return; + } + + focusBoardCard({ + columnIndex: 0, + rowIndex: 0, + }); + return; + } + + if (visibleRecordGroupIds.length === 0) { + return; + } + + let newColumnIndex = + direction === 'right' + ? focusedBoardCardIndexes.columnIndex + 1 + : focusedBoardCardIndexes.columnIndex - 1; + + if (newColumnIndex < 0) { + newColumnIndex = 0; + } else if (newColumnIndex >= visibleRecordGroupIds.length) { + newColumnIndex = visibleRecordGroupIds.length - 1; + } + + if (newColumnIndex === focusedBoardCardIndexes.columnIndex) { + return; + } + + let foundColumnWithRecords = false; + const initialColumnIndex = newColumnIndex; + + while (!foundColumnWithRecords) { + const currentGroupId = visibleRecordGroupIds[newColumnIndex]; + const recordIdsInGroup = snapshot + .getLoadable(recordIdsByGroupState(currentGroupId)) + .getValue(); + + if (Array.isArray(recordIdsInGroup) && recordIdsInGroup.length > 0) { + foundColumnWithRecords = true; + } else { + newColumnIndex = + direction === 'right' ? newColumnIndex + 1 : newColumnIndex - 1; + + if ( + newColumnIndex < 0 || + newColumnIndex >= visibleRecordGroupIds.length + ) { + return; + } + + if ( + (direction === 'right' && newColumnIndex <= initialColumnIndex) || + (direction === 'left' && newColumnIndex >= initialColumnIndex) + ) { + return; + } + } + } + + const currentGroupId = visibleRecordGroupIds[newColumnIndex]; + const recordIdsInGroup = snapshot + .getLoadable(recordIdsByGroupState(currentGroupId)) + .getValue(); + + let newRowIndex = focusedBoardCardIndexes.rowIndex; + if (newRowIndex >= recordIdsInGroup.length) { + newRowIndex = recordIdsInGroup.length - 1; + } + + focusBoardCard({ + columnIndex: newColumnIndex, + rowIndex: newRowIndex, + }); + }, + [ + focusedBoardCardIndexesState, + visibleRecordGroupIds, + recordIdsByGroupState, + focusBoardCard, + ], + ); + + const moveVertically = useRecoilCallback( + ({ snapshot }) => + (direction: 'up' | 'down') => { + const focusedBoardCardIndexes = snapshot + .getLoadable(focusedBoardCardIndexesState) + .getValue(); + + if (!isDefined(focusedBoardCardIndexes)) { + if (visibleRecordGroupIds.length === 0) return; + + const firstGroupId = visibleRecordGroupIds[0]; + const recordIdsInFirstGroup = snapshot + .getLoadable(recordIdsByGroupState(firstGroupId)) + .getValue(); + + if ( + !Array.isArray(recordIdsInFirstGroup) || + recordIdsInFirstGroup.length === 0 + ) { + return; + } + + focusBoardCard({ + columnIndex: 0, + rowIndex: 0, + }); + + return; + } + + if (visibleRecordGroupIds.length === 0) return; + + const currentGroupId = + visibleRecordGroupIds[focusedBoardCardIndexes.columnIndex]; + const recordIdsInGroup = snapshot + .getLoadable(recordIdsByGroupState(currentGroupId)) + .getValue(); + + if (!Array.isArray(recordIdsInGroup) || recordIdsInGroup.length === 0) { + return; + } + + let newRowIndex = + direction === 'down' + ? focusedBoardCardIndexes.rowIndex + 1 + : focusedBoardCardIndexes.rowIndex - 1; + + if (newRowIndex < 0) { + newRowIndex = 0; + } else if (newRowIndex >= recordIdsInGroup.length) { + newRowIndex = recordIdsInGroup.length - 1; + } + + if (newRowIndex === focusedBoardCardIndexes.rowIndex) { + return; + } + + focusBoardCard({ + columnIndex: focusedBoardCardIndexes.columnIndex, + rowIndex: newRowIndex, + }); + }, + [ + focusedBoardCardIndexesState, + visibleRecordGroupIds, + recordIdsByGroupState, + focusBoardCard, + ], + ); + + const move = (direction: NavigationDirection) => { + if (direction === 'left' || direction === 'right') { + moveHorizontally(direction); + } else if (direction === 'up' || direction === 'down') { + moveVertically(direction); + } + }; + + return { + move, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardSelection.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardSelection.ts index fc7a42347..f3dea2e17 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardSelection.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardSelection.ts @@ -2,13 +2,20 @@ import { useRecoilCallback } from 'recoil'; import { getActionMenuDropdownIdFromActionMenuId } from '@/action-menu/utils/getActionMenuDropdownIdFromActionMenuId'; import { getActionMenuIdFromRecordIndexId } from '@/action-menu/utils/getActionMenuIdFromRecordIndexId'; +import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext'; import { isRecordBoardCardSelectedComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardSelectedComponentFamilyState'; import { recordBoardSelectedRecordIdsComponentSelector } from '@/object-record/record-board/states/selectors/recordBoardSelectedRecordIdsComponentSelector'; import { useDropdownV2 } from '@/ui/layout/dropdown/hooks/useDropdownV2'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; -export const useRecordBoardSelection = (recordBoardId: string) => { +export const useRecordBoardSelection = (recordBoardId?: string) => { + const instanceIdFromProps = useAvailableComponentInstanceIdOrThrow( + RecordBoardComponentInstanceContext, + recordBoardId, + ); + const isRecordBoardCardSelectedFamilyState = useRecoilComponentCallbackStateV2( isRecordBoardCardSelectedComponentFamilyState, @@ -24,7 +31,7 @@ export const useRecordBoardSelection = (recordBoardId: string) => { const { closeDropdown } = useDropdownV2(); const dropdownId = getActionMenuDropdownIdFromActionMenuId( - getActionMenuIdFromRecordIndexId(recordBoardId), + getActionMenuIdFromRecordIndexId(instanceIdFromProps), ); const resetRecordSelection = useRecoilCallback( diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx index d066372b3..b77f0a166 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx @@ -3,6 +3,8 @@ import { getActionMenuDropdownIdFromActionMenuId } from '@/action-menu/utils/get import { getActionMenuIdFromRecordIndexId } from '@/action-menu/utils/getActionMenuIdFromRecordIndexId'; import { RecordBoardCardContext } from '@/object-record/record-board/record-board-card/contexts/RecordBoardCardContext'; import { RecordBoardScopeInternalContext } from '@/object-record/record-board/scopes/scope-internal-context/RecordBoardScopeInternalContext'; +import { isRecordBoardCardActiveComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardActiveComponentFamilyState'; +import { isRecordBoardCardFocusedComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardFocusedComponentFamilyState'; import { isRecordBoardCardSelectedComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardSelectedComponentFamilyState'; import { isRecordBoardCompactModeActiveComponentState } from '@/object-record/record-board/states/isRecordBoardCompactModeActiveComponentState'; import { recordBoardVisibleFieldDefinitionsComponentSelector } from '@/object-record/record-board/states/selectors/recordBoardVisibleFieldDefinitionsComponentSelector'; @@ -19,6 +21,7 @@ import { useDropdownV2 } from '@/ui/layout/dropdown/hooks/useDropdownV2'; import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; import { useScrollWrapperElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperElement'; import { useRecoilComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyStateV2'; +import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState'; import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType'; @@ -30,27 +33,44 @@ import { AnimatedEaseInOut } from 'twenty-ui/utilities'; import { useDebouncedCallback } from 'use-debounce'; import { useNavigateApp } from '~/hooks/useNavigateApp'; -const StyledBoardCard = styled.div<{ selected: boolean }>` - background-color: ${({ theme, selected }) => - selected ? theme.accent.quaternary : theme.background.secondary}; - border: 1px solid - ${({ theme, selected }) => - selected ? theme.adaptiveColors.blue3 : theme.border.color.medium}; +const StyledBoardCard = styled.div<{ + isDragging?: boolean; +}>` + background-color: ${({ theme }) => theme.background.secondary}; + border: 1px solid ${({ theme }) => theme.border.color.medium}; border-radius: ${({ theme }) => theme.border.radius.sm}; box-shadow: ${({ theme }) => theme.boxShadow.light}; color: ${({ theme }) => theme.font.color.primary}; - &:hover { - background-color: ${({ theme, selected }) => - selected && theme.accent.tertiary}; - border: 1px solid - ${({ theme, selected }) => - selected ? theme.adaptiveColors.blue3 : theme.border.color.strong}; - } cursor: pointer; + &[data-selected='true'] { + background-color: ${({ theme }) => theme.accent.quaternary}; + } + + &[data-focused='true'] { + background-color: ${({ theme }) => theme.background.tertiary}; + } + + &[data-active='true'] { + background-color: ${({ theme }) => theme.accent.quaternary}; + border: 1px solid ${({ theme }) => theme.adaptiveColors.blue3}; + } + + &:hover { + border: 1px solid ${({ theme }) => theme.border.color.strong}; + + &[data-active='true'] { + border: 1px solid ${({ theme }) => theme.adaptiveColors.blue3}; + } + } + .checkbox-container { transition: all ease-in-out 160ms; - opacity: ${({ selected }) => (selected ? 1 : 0)}; + opacity: 0; + } + + &[data-selected='true'] .checkbox-container { + opacity: 1; } &:hover .checkbox-container { @@ -75,7 +95,9 @@ export const RecordBoardCard = () => { const navigate = useNavigateApp(); const { openRecordInCommandMenu } = useOpenRecordInCommandMenu(); - const { recordId } = useContext(RecordBoardCardContext); + const { recordId, rowIndex, columnIndex } = useContext( + RecordBoardCardContext, + ); const visibleFieldDefinitions = useRecoilComponentValueV2( recordBoardVisibleFieldDefinitionsComponentSelector, @@ -93,6 +115,22 @@ export const RecordBoardCard = () => { recordId, ); + const isCurrentCardFocused = useRecoilComponentFamilyValueV2( + isRecordBoardCardFocusedComponentFamilyState, + { + rowIndex, + columnIndex, + }, + ); + + const isCurrentCardActive = useRecoilComponentFamilyValueV2( + isRecordBoardCardActiveComponentFamilyState, + { + rowIndex, + columnIndex, + }, + ); + const { objectNameSingular } = useRecordIndexContextOrThrow(); const recordBoardId = useAvailableScopeIdOrThrow( @@ -167,7 +205,9 @@ export const RecordBoardCard = () => { diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardDraggableContainer.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardDraggableContainer.tsx index ccced3426..7219f60b1 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardDraggableContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardDraggableContainer.tsx @@ -1,25 +1,50 @@ +import styled from '@emotion/styled'; import { Draggable } from '@hello-pangea/dnd'; +import { useContext } from 'react'; import { RecordBoardCard } from '@/object-record/record-board/record-board-card/components/RecordBoardCard'; +import { RecordBoardCardFocusHotkeyEffect } from '@/object-record/record-board/record-board-card/components/RecordBoardCardFocusHotkeyEffect'; import { RecordBoardCardContext } from '@/object-record/record-board/record-board-card/contexts/RecordBoardCardContext'; +import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext'; +import { isRecordBoardCardFocusedComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardFocusedComponentFamilyState'; import { useIsRecordReadOnly } from '@/object-record/record-field/hooks/useIsRecordReadOnly'; +import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2'; + +const StyledDraggableContainer = styled.div` + scroll-margin-left: 8px; + scroll-margin-right: 8px; + scroll-margin-top: 40px; +`; export const RecordBoardCardDraggableContainer = ({ recordId, - index, + rowIndex, }: { recordId: string; - index: number; + rowIndex: number; }) => { const isRecordReadOnly = useIsRecordReadOnly({ recordId, }); + const { columnIndex } = useContext(RecordBoardColumnContext); + + const isRecordBoardCardFocusActive = useRecoilComponentFamilyValueV2( + isRecordBoardCardFocusedComponentFamilyState, + { + rowIndex, + columnIndex, + }, + ); + return ( - - + + {(draggableProvided) => ( -
+ {isRecordBoardCardFocusActive && ( + + )} -
+ )}
diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardFocusHotkeyEffect.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardFocusHotkeyEffect.tsx new file mode 100644 index 000000000..fffa7eb11 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardFocusHotkeyEffect.tsx @@ -0,0 +1,57 @@ +import { useContext } from 'react'; +import { Key } from 'ts-key-enum'; + +import { useOpenRecordInCommandMenu } from '@/command-menu/hooks/useOpenRecordInCommandMenu'; +import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; +import { useActiveRecordBoardCard } from '@/object-record/record-board/hooks/useActiveRecordBoardCard'; +import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection'; +import { RecordBoardCardContext } from '@/object-record/record-board/record-board-card/contexts/RecordBoardCardContext'; +import { isRecordBoardCardSelectedComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardSelectedComponentFamilyState'; +import { BoardHotkeyScope } from '@/object-record/record-board/types/BoardHotkeyScope'; +import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; +import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2'; +export const RecordBoardCardFocusHotkeyEffect = () => { + const { objectMetadataItem } = useContext(RecordBoardContext); + + const { recordId, rowIndex, columnIndex } = useContext( + RecordBoardCardContext, + ); + + const { openRecordInCommandMenu } = useOpenRecordInCommandMenu(); + + const { activateBoardCard } = useActiveRecordBoardCard(); + + const { setRecordAsSelected } = useRecordBoardSelection(); + + const isRecordBoardCardSelected = useRecoilComponentFamilyValueV2( + isRecordBoardCardSelectedComponentFamilyState, + recordId, + ); + + useScopedHotkeys( + 'x', + () => { + setRecordAsSelected(recordId, !isRecordBoardCardSelected); + }, + BoardHotkeyScope.BoardFocus, + ); + + useScopedHotkeys( + [Key.Enter, `${Key.Control}+${Key.Enter}`, `${Key.Meta}+${Key.Enter}`], + () => { + openRecordInCommandMenu({ + recordId: recordId, + objectNameSingular: objectMetadataItem.nameSingular, + isNewRecord: false, + }); + + activateBoardCard({ + rowIndex, + columnIndex, + }); + }, + BoardHotkeyScope.BoardFocus, + ); + + return null; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardDeactivateBoardCardEffect.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardDeactivateBoardCardEffect.tsx new file mode 100644 index 000000000..52fb10987 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardDeactivateBoardCardEffect.tsx @@ -0,0 +1,15 @@ +import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; +import { useActiveRecordBoardCard } from '@/object-record/record-board/hooks/useActiveRecordBoardCard'; +import { useListenRightDrawerClose } from '@/ui/layout/right-drawer/hooks/useListenRightDrawerClose'; +import { useContext } from 'react'; + +export const RecordBoardDeactivateBoardCardEffect = () => { + const { recordBoardId } = useContext(RecordBoardContext); + const { deactivateBoardCard } = useActiveRecordBoardCard(recordBoardId); + + useListenRightDrawerClose(() => { + deactivateBoardCard(); + }); + + return null; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/contexts/RecordBoardCardContext.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/contexts/RecordBoardCardContext.ts index db5c0ce77..ac038962f 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/contexts/RecordBoardCardContext.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/contexts/RecordBoardCardContext.ts @@ -3,6 +3,8 @@ import { createContext } from 'react'; type RecordBoardCardContextProps = { recordId: string; isRecordReadOnly: boolean; + rowIndex: number; + columnIndex: number; }; export const RecordBoardCardContext = diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumn.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumn.tsx index 2bde69425..313a5cc4b 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumn.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumn.tsx @@ -22,10 +22,12 @@ const StyledColumn = styled.div` type RecordBoardColumnProps = { recordBoardColumnId: string; + recordBoardColumnIndex: number; }; export const RecordBoardColumn = ({ recordBoardColumnId, + recordBoardColumnIndex, }: RecordBoardColumnProps) => { const recordGroupDefinition = useRecoilValue( recordGroupDefinitionFamilyState(recordBoardColumnId), @@ -46,6 +48,7 @@ export const RecordBoardColumn = ({ columnDefinition: recordGroupDefinition, columnId: recordBoardColumnId, recordIds: recordIdsByGroup, + columnIndex: recordBoardColumnIndex, }} > diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardsMemo.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardsMemo.tsx index 815b74c64..d0967551d 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardsMemo.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardsMemo.tsx @@ -12,7 +12,7 @@ export const RecordBoardColumnCardsMemo = React.memo( )); }, diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderWrapper.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderWrapper.tsx index 80b263297..e9952130d 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderWrapper.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderWrapper.tsx @@ -8,10 +8,12 @@ import { isDefined } from 'twenty-shared/utils'; type RecordBoardColumnHeaderWrapperProps = { columnId: string; + columnIndex: number; }; export const RecordBoardColumnHeaderWrapper = ({ columnId, + columnIndex, }: RecordBoardColumnHeaderWrapperProps) => { const recordGroupDefinition = useRecoilValue( recordGroupDefinitionFamilyState(columnId), @@ -32,6 +34,7 @@ export const RecordBoardColumnHeaderWrapper = ({ columnId, columnDefinition: recordGroupDefinition, recordIds: recordIdsByGroup, + columnIndex, }} > diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext.ts index 4140e0cf1..568170bea 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext.ts @@ -6,6 +6,7 @@ type RecordBoardColumnContextProps = { columnDefinition: RecordGroupDefinition; columnId: string; recordIds: string[]; + columnIndex: number; }; export const RecordBoardColumnContext = diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/activeRecordBoardCardIndexesComponentState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/activeRecordBoardCardIndexesComponentState.ts new file mode 100644 index 000000000..be299045f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/activeRecordBoardCardIndexesComponentState.ts @@ -0,0 +1,10 @@ +import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext'; +import { BoardCardIndexes } from '@/object-record/record-board/types/BoardCardIndexes'; +import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2'; + +export const activeRecordBoardCardIndexesComponentState = + createComponentStateV2({ + key: 'activeRecordBoardCardIndexesComponentState', + defaultValue: null, + componentInstanceContext: RecordBoardComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/focusedRecordBoardCardIndexesComponentState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/focusedRecordBoardCardIndexesComponentState.ts new file mode 100644 index 000000000..3809d3aae --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/focusedRecordBoardCardIndexesComponentState.ts @@ -0,0 +1,10 @@ +import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext'; +import { BoardCardIndexes } from '@/object-record/record-board/types/BoardCardIndexes'; +import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2'; + +export const focusedRecordBoardCardIndexesComponentState = + createComponentStateV2({ + key: 'focusedRecordBoardCardIndexesComponentState', + defaultValue: null, + componentInstanceContext: RecordBoardComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardCardActiveComponentFamilyState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardCardActiveComponentFamilyState.ts new file mode 100644 index 000000000..0cdc01036 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardCardActiveComponentFamilyState.ts @@ -0,0 +1,10 @@ +import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext'; +import { BoardCardIndexes } from '@/object-record/record-board/types/BoardCardIndexes'; +import { createComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentFamilyStateV2'; + +export const isRecordBoardCardActiveComponentFamilyState = + createComponentFamilyStateV2({ + key: 'isRecordBoardCardActiveComponentFamilyState', + defaultValue: false, + componentInstanceContext: RecordBoardComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardCardFocusActiveComponentState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardCardFocusActiveComponentState.ts new file mode 100644 index 000000000..9a943c6e9 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardCardFocusActiveComponentState.ts @@ -0,0 +1,9 @@ +import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext'; +import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2'; + +export const isRecordBoardCardFocusActiveComponentState = + createComponentStateV2({ + key: 'isRecordBoardCardFocusActiveComponentState', + defaultValue: false, + componentInstanceContext: RecordBoardComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardCardFocusedComponentFamilyState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardCardFocusedComponentFamilyState.ts new file mode 100644 index 000000000..ab697d923 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardCardFocusedComponentFamilyState.ts @@ -0,0 +1,10 @@ +import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext'; +import { BoardCardIndexes } from '@/object-record/record-board/types/BoardCardIndexes'; +import { createComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentFamilyStateV2'; + +export const isRecordBoardCardFocusedComponentFamilyState = + createComponentFamilyStateV2({ + key: 'isRecordBoardCardFocusedComponentFamilyState', + defaultValue: false, + componentInstanceContext: RecordBoardComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/types/BoardCardIndexes.ts b/packages/twenty-front/src/modules/object-record/record-board/types/BoardCardIndexes.ts new file mode 100644 index 000000000..b7aee0104 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/types/BoardCardIndexes.ts @@ -0,0 +1,4 @@ +export type BoardCardIndexes = { + rowIndex: number; + columnIndex: number; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/types/BoardHotkeyScope.ts b/packages/twenty-front/src/modules/object-record/record-board/types/BoardHotkeyScope.ts new file mode 100644 index 000000000..b58840d74 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/types/BoardHotkeyScope.ts @@ -0,0 +1,3 @@ +export enum BoardHotkeyScope { + BoardFocus = 'board-focus', +} diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainer.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainer.tsx index edd219383..3a9426497 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainer.tsx @@ -5,10 +5,11 @@ import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { RecordBoard } from '@/object-record/record-board/components/RecordBoard'; +import { RecordBoardBodyEscapeHotkeyEffect } from '@/object-record/record-board/components/RecordBoardBodyEscapeHotkeyEffect'; +import { RecordBoardHotkeyEffect } from '@/object-record/record-board/components/RecordBoardHotkeyEffect'; import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; import { RecordIndexRemoveSortingModal } from '@/object-record/record-index/components/RecordIndexRemoveSortingModal'; import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState'; - type RecordIndexBoardContainerProps = { recordBoardId: string; viewBarId: string; @@ -55,6 +56,8 @@ export const RecordIndexBoardContainer = ({ > + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/types/RecordIndexHotkeyScope.ts b/packages/twenty-front/src/modules/object-record/record-index/types/RecordIndexHotkeyScope.ts new file mode 100644 index 000000000..fcdd9d87f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/types/RecordIndexHotkeyScope.ts @@ -0,0 +1,3 @@ +export enum RecordIndexHotkeyScope { + RecordIndex = 'record-index', +} diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableWithWrappers.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableWithWrappers.tsx index fd07bc291..16e71e5c7 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableWithWrappers.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableWithWrappers.tsx @@ -10,6 +10,7 @@ import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; import { useSaveCurrentViewFields } from '@/views/hooks/useSaveCurrentViewFields'; import { mapColumnDefinitionsToViewFields } from '@/views/utils/mapColumnDefinitionToViewField'; +import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/RecordIndexHotkeyScope'; import { RecordTableComponentInstance } from '@/object-record/record-table/components/RecordTableComponentInstance'; import { RecordTableContextProvider } from '@/object-record/record-table/components/RecordTableContextProvider'; import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope'; @@ -50,7 +51,17 @@ export const RecordTableWithWrappers = ({ useScopedHotkeys( 'ctrl+a,meta+a', handleSelectAllRows, - TableHotkeyScope.Table, + RecordIndexHotkeyScope.RecordIndex, + [], + { + enableOnFormTags: false, + }, + ); + + useScopedHotkeys( + 'ctrl+a,meta+a', + handleSelectAllRows, + TableHotkeyScope.TableFocus, [], { enableOnFormTags: false, diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useLeaveTableFocus.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useLeaveTableFocus.ts index 20283baf3..f59ee32a1 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useLeaveTableFocus.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useLeaveTableFocus.ts @@ -1,10 +1,10 @@ +import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/RecordIndexHotkeyScope'; import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection'; import { useActiveRecordTableRow } from '@/object-record/record-table/hooks/useActiveRecordTableRow'; import { useFocusedRecordTableRow } from '@/object-record/record-table/hooks/useFocusedRecordTableRow'; import { useSetIsRecordTableFocusActive } from '@/object-record/record-table/record-table-cell/hooks/useSetIsRecordTableFocusActive'; import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; import { recordTableHoverPositionComponentState } from '@/object-record/record-table/states/recordTableHoverPositionComponentState'; -import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; @@ -49,6 +49,6 @@ export const useLeaveTableFocus = (recordTableId?: string) => { setRecordTableHoverPosition(null); - setHotkeyScope(TableHotkeyScope.Table); + setHotkeyScope(RecordIndexHotkeyScope.RecordIndex); }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTable.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTable.ts index 61970bafd..93a12a9b5 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTable.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTable.ts @@ -7,6 +7,7 @@ import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; +import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/RecordIndexHotkeyScope'; import { useUpsertRecordFromState } from '../../hooks/useUpsertRecordFromState'; import { ColumnDefinition } from '../types/ColumnDefinition'; import { TableHotkeyScope } from '../types/TableHotkeyScope'; @@ -168,7 +169,7 @@ export const useRecordTable = (props?: useRecordTableProps) => { setHotkeyScopeAndMemorizePreviousScope(TableHotkeyScope.TableFocus); move('up'); }, - TableHotkeyScope.Table, + RecordIndexHotkeyScope.RecordIndex, [move], ); @@ -178,7 +179,7 @@ export const useRecordTable = (props?: useRecordTableProps) => { setHotkeyScopeAndMemorizePreviousScope(TableHotkeyScope.TableFocus); move('down'); }, - TableHotkeyScope.Table, + RecordIndexHotkeyScope.RecordIndex, [move], ); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyEscapeHotkeyEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyEscapeHotkeyEffect.tsx index 0fa9d56e3..78c3edf07 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyEscapeHotkeyEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyEscapeHotkeyEffect.tsx @@ -1,12 +1,13 @@ import { Key } from 'ts-key-enum'; +import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/RecordIndexHotkeyScope'; import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; import { useFocusedRecordTableRow } from '@/object-record/record-table/hooks/useFocusedRecordTableRow'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; import { isAtLeastOneTableRowSelectedSelector } from '@/object-record/record-table/record-table-row/states/isAtLeastOneTableRowSelectedSelector'; -import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; + export const RecordTableBodyEscapeHotkeyEffect = () => { const { recordTableId } = useRecordTableContextOrThrow(); @@ -28,9 +29,9 @@ export const RecordTableBodyEscapeHotkeyEffect = () => { resetTableRowSelection(); } }, - TableHotkeyScope.Table, + RecordIndexHotkeyScope.RecordIndex, [isAtLeastOneRecordSelected, resetTableRowSelection, unfocusRecordTableRow], ); - return <>; + return null; }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyFocusKeyboardEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyFocusKeyboardEffect.tsx index 08424ac9c..98ae39819 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyFocusKeyboardEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyFocusKeyboardEffect.tsx @@ -1,3 +1,4 @@ +import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/RecordIndexHotkeyScope'; import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; import { useFocusedRecordTableRow } from '@/object-record/record-table/hooks/useFocusedRecordTableRow'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; @@ -37,7 +38,7 @@ export const RecordTableBodyFocusKeyboardEffect = () => { restoreRecordTableRowFocusFromCellPosition(); setIsFocusActiveForCurrentPosition(false); } else { - setHotkeyScope(TableHotkeyScope.Table, { + setHotkeyScope(RecordIndexHotkeyScope.RecordIndex, { goto: true, keyboardShortcutMenu: true, }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useMoveHoverToCurrentCell.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useMoveHoverToCurrentCell.ts index efb848e8d..a8e4f1e2e 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useMoveHoverToCurrentCell.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useMoveHoverToCurrentCell.ts @@ -4,6 +4,7 @@ import { TableCellPosition } from '@/object-record/record-table/types/TableCellP import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState'; import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue'; +import { RecordIndexHotkeyScope } from '@/object-record/record-index/types/RecordIndexHotkeyScope'; import { recordTableHoverPositionComponentState } from '@/object-record/record-table/states/recordTableHoverPositionComponentState'; import { isSomeCellInEditModeComponentSelector } from '@/object-record/record-table/states/selectors/isSomeCellInEditModeComponentSelector'; import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope'; @@ -38,8 +39,8 @@ export const useMoveHoverToCurrentCell = (recordTableId: string) => { if ( currentHotkeyScope.scope !== TableHotkeyScope.TableFocus && currentHotkeyScope.scope !== TableHotkeyScope.CellEditMode && - currentHotkeyScope.scope !== TableHotkeyScope.Table && - currentHotkeyScope.scope !== AppHotkeyScope.CommandMenuOpen + currentHotkeyScope.scope !== AppHotkeyScope.CommandMenuOpen && + currentHotkeyScope.scope !== RecordIndexHotkeyScope.RecordIndex ) { return; } diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableTr.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableTr.tsx index 301580143..2e5571991 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableTr.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableTr.tsx @@ -139,13 +139,9 @@ export const RecordTableTr = forwardRef< data-virtualized-id={recordId} isDragging={isDragging} ref={ref} - data-active={isActive ? 'true' : 'false'} - data-focused={ - isRowFocusActive && isFocused && !isActive ? 'true' : 'false' - } - data-next-row-active-or-focused={ - isNextRowActiveOrFocused ? 'true' : 'false' - } + data-active={isActive} + data-focused={isRowFocusActive && isFocused && !isActive} + data-next-row-active-or-focused={isNextRowActiveOrFocused} // eslint-disable-next-line react/jsx-props-no-spreading {...props} > diff --git a/packages/twenty-front/src/modules/object-record/record-table/types/TableHotkeyScope.ts b/packages/twenty-front/src/modules/object-record/record-table/types/TableHotkeyScope.ts index b1bae4957..ad2fe85eb 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/types/TableHotkeyScope.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/types/TableHotkeyScope.ts @@ -3,5 +3,4 @@ export enum TableHotkeyScope { CellEditMode = 'cell-edit-mode', CellDateEditMode = 'cell-date-edit-mode', TableFocus = 'table-focus', - Table = 'table', }