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
This commit is contained in:
@ -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}`}
|
||||
>
|
||||
<RecordBoardStickyHeaderEffect />
|
||||
<RecordBoardScrollToFocusedCardEffect />
|
||||
<RecordBoardDeactivateBoardCardEffect />
|
||||
<StyledContainerContainer>
|
||||
<RecordBoardHeader />
|
||||
<StyledBoardContentContainer>
|
||||
<StyledContainer ref={boardRef}>
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<StyledColumnContainer>
|
||||
{visibleRecordGroupIds.map((recordGroupId) => (
|
||||
{visibleRecordGroupIds.map((recordGroupId, index) => (
|
||||
<RecordBoardColumn
|
||||
key={recordGroupId}
|
||||
recordBoardColumnId={recordGroupId}
|
||||
recordBoardColumnIndex={index}
|
||||
/>
|
||||
))}
|
||||
</StyledColumnContainer>
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -31,9 +31,10 @@ export const RecordBoardHeader = () => {
|
||||
|
||||
return (
|
||||
<StyledHeaderContainer id="record-board-header">
|
||||
{visibleRecordGroupIds.map((recordGroupId) => (
|
||||
{visibleRecordGroupIds.map((recordGroupId, index) => (
|
||||
<RecordBoardColumnHeaderWrapper
|
||||
columnId={recordGroupId}
|
||||
columnIndex={index}
|
||||
key={recordGroupId}
|
||||
/>
|
||||
))}
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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(
|
||||
|
||||
@ -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 = () => {
|
||||
<InView>
|
||||
<StyledBoardCard
|
||||
ref={cardRef}
|
||||
selected={isCurrentCardSelected}
|
||||
data-selected={isCurrentCardSelected}
|
||||
data-focused={isCurrentCardFocused}
|
||||
data-active={isCurrentCardActive}
|
||||
onMouseLeave={onMouseLeaveBoard}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
|
||||
@ -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 (
|
||||
<RecordBoardCardContext.Provider value={{ recordId, isRecordReadOnly }}>
|
||||
<Draggable key={recordId} draggableId={recordId} index={index}>
|
||||
<RecordBoardCardContext.Provider
|
||||
value={{ recordId, isRecordReadOnly, rowIndex, columnIndex }}
|
||||
>
|
||||
<Draggable key={recordId} draggableId={recordId} index={rowIndex}>
|
||||
{(draggableProvided) => (
|
||||
<div
|
||||
<StyledDraggableContainer
|
||||
id={`record-board-card-${columnIndex}-${rowIndex}`}
|
||||
ref={draggableProvided?.innerRef}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...draggableProvided?.dragHandleProps}
|
||||
@ -29,8 +54,11 @@ export const RecordBoardCardDraggableContainer = ({
|
||||
data-selectable-id={recordId}
|
||||
data-select-disable
|
||||
>
|
||||
{isRecordBoardCardFocusActive && (
|
||||
<RecordBoardCardFocusHotkeyEffect />
|
||||
)}
|
||||
<RecordBoardCard />
|
||||
</div>
|
||||
</StyledDraggableContainer>
|
||||
)}
|
||||
</Draggable>
|
||||
</RecordBoardCardContext.Provider>
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -3,6 +3,8 @@ import { createContext } from 'react';
|
||||
type RecordBoardCardContextProps = {
|
||||
recordId: string;
|
||||
isRecordReadOnly: boolean;
|
||||
rowIndex: number;
|
||||
columnIndex: number;
|
||||
};
|
||||
|
||||
export const RecordBoardCardContext =
|
||||
|
||||
@ -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,
|
||||
}}
|
||||
>
|
||||
<Droppable droppableId={recordBoardColumnId}>
|
||||
|
||||
@ -12,7 +12,7 @@ export const RecordBoardColumnCardsMemo = React.memo(
|
||||
<RecordBoardCardDraggableContainer
|
||||
key={recordId}
|
||||
recordId={recordId}
|
||||
index={index}
|
||||
rowIndex={index}
|
||||
/>
|
||||
));
|
||||
},
|
||||
|
||||
@ -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,
|
||||
}}
|
||||
>
|
||||
<RecordBoardColumnHeader />
|
||||
|
||||
@ -6,6 +6,7 @@ type RecordBoardColumnContextProps = {
|
||||
columnDefinition: RecordGroupDefinition;
|
||||
columnId: string;
|
||||
recordIds: string[];
|
||||
columnIndex: number;
|
||||
};
|
||||
|
||||
export const RecordBoardColumnContext =
|
||||
|
||||
@ -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<BoardCardIndexes | null>({
|
||||
key: 'activeRecordBoardCardIndexesComponentState',
|
||||
defaultValue: null,
|
||||
componentInstanceContext: RecordBoardComponentInstanceContext,
|
||||
});
|
||||
@ -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<BoardCardIndexes | null>({
|
||||
key: 'focusedRecordBoardCardIndexesComponentState',
|
||||
defaultValue: null,
|
||||
componentInstanceContext: RecordBoardComponentInstanceContext,
|
||||
});
|
||||
@ -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<boolean, BoardCardIndexes>({
|
||||
key: 'isRecordBoardCardActiveComponentFamilyState',
|
||||
defaultValue: false,
|
||||
componentInstanceContext: RecordBoardComponentInstanceContext,
|
||||
});
|
||||
@ -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<boolean>({
|
||||
key: 'isRecordBoardCardFocusActiveComponentState',
|
||||
defaultValue: false,
|
||||
componentInstanceContext: RecordBoardComponentInstanceContext,
|
||||
});
|
||||
@ -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<boolean, BoardCardIndexes>({
|
||||
key: 'isRecordBoardCardFocusedComponentFamilyState',
|
||||
defaultValue: false,
|
||||
componentInstanceContext: RecordBoardComponentInstanceContext,
|
||||
});
|
||||
@ -0,0 +1,4 @@
|
||||
export type BoardCardIndexes = {
|
||||
rowIndex: number;
|
||||
columnIndex: number;
|
||||
};
|
||||
@ -0,0 +1,3 @@
|
||||
export enum BoardHotkeyScope {
|
||||
BoardFocus = 'board-focus',
|
||||
}
|
||||
@ -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 = ({
|
||||
>
|
||||
<RecordBoard />
|
||||
<RecordIndexRemoveSortingModal />
|
||||
<RecordBoardHotkeyEffect />
|
||||
<RecordBoardBodyEscapeHotkeyEffect />
|
||||
</RecordBoardContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
export enum RecordIndexHotkeyScope {
|
||||
RecordIndex = 'record-index',
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
};
|
||||
};
|
||||
|
||||
@ -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],
|
||||
);
|
||||
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -3,5 +3,4 @@ export enum TableHotkeyScope {
|
||||
CellEditMode = 'cell-edit-mode',
|
||||
CellDateEditMode = 'cell-date-edit-mode',
|
||||
TableFocus = 'table-focus',
|
||||
Table = 'table',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user