Enable multiple row selection with Shift + checkbox click (#12492)

resolve #12291
This PR enables the selection of multiple rows by clicking checkboxes
while holding the Shift key.

A new Recoil state was created to store the lastRecordSelectedId.
When the user clicks a checkbox while pressing Shift, the index of
lastRecordSelectedId is retrieved, and a loop is executed between the
current row index and the last selected index to select all rows in
between.



https://github.com/user-attachments/assets/97bdf2a0-f6a6-4f9f-8045-3804268ef924

---------

Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com>
Co-authored-by: bosiraphael <raphael.bosi@gmail.com>
This commit is contained in:
Naifer
2025-06-16 14:51:37 +01:00
committed by GitHub
parent 16bccc19e8
commit b0cce3d74a
5 changed files with 98 additions and 10 deletions

View File

@ -11,6 +11,7 @@ import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotV
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState'; import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
import { lastSelectedRowIndexComponentState } from '../../record-table-row/states/lastSelectedRowIndexComponentState';
export const useResetTableRowSelection = (recordTableId?: string) => { export const useResetTableRowSelection = (recordTableId?: string) => {
const recordTableIdFromContext = useAvailableComponentInstanceIdOrThrow( const recordTableIdFromContext = useAvailableComponentInstanceIdOrThrow(
@ -40,6 +41,12 @@ export const useResetTableRowSelection = (recordTableId?: string) => {
), ),
); );
const lastSelectedRowIndexComponentCallbackState =
useRecoilComponentCallbackStateV2(
lastSelectedRowIndexComponentState,
recordTableIdFromContext,
);
return useRecoilCallback( return useRecoilCallback(
({ set, snapshot }) => ({ set, snapshot }) =>
() => { () => {
@ -55,11 +62,13 @@ export const useResetTableRowSelection = (recordTableId?: string) => {
set(hasUserSelectedAllRowsState, false); set(hasUserSelectedAllRowsState, false);
set(isActionMenuDropdownOpenState, false); set(isActionMenuDropdownOpenState, false);
set(lastSelectedRowIndexComponentCallbackState, null);
}, },
[ [
recordIndexAllRecordIdsSelector, recordIndexAllRecordIdsSelector,
hasUserSelectedAllRowsState, hasUserSelectedAllRowsState,
isActionMenuDropdownOpenState, isActionMenuDropdownOpenState,
lastSelectedRowIndexComponentCallbackState,
isRowSelectedFamilyState, isRowSelectedFamilyState,
], ],
); );

View File

@ -4,6 +4,7 @@ import { useCallback } from 'react';
import { useRecordTableRowContextOrThrow } from '@/object-record/record-table/contexts/RecordTableRowContext'; import { useRecordTableRowContextOrThrow } from '@/object-record/record-table/contexts/RecordTableRowContext';
import { RecordTableTd } from '@/object-record/record-table/record-table-cell/components/RecordTableTd'; import { RecordTableTd } from '@/object-record/record-table/record-table-cell/components/RecordTableTd';
import { useSetCurrentRowSelected } from '@/object-record/record-table/record-table-row/hooks/useSetCurrentRowSelected'; import { useSetCurrentRowSelected } from '@/object-record/record-table/record-table-row/hooks/useSetCurrentRowSelected';
import { isDefined } from 'twenty-shared/utils';
import { Checkbox } from 'twenty-ui/input'; import { Checkbox } from 'twenty-ui/input';
export const TABLE_CELL_CHECKBOX_MIN_WIDTH = '24px'; export const TABLE_CELL_CHECKBOX_MIN_WIDTH = '24px';
@ -26,9 +27,15 @@ export const RecordTableCellCheckbox = () => {
const { setCurrentRowSelected } = useSetCurrentRowSelected(); const { setCurrentRowSelected } = useSetCurrentRowSelected();
const handleClick = useCallback(() => { const handleClick = useCallback(
setCurrentRowSelected(!isSelected); (event?: React.MouseEvent<HTMLDivElement>) => {
}, [isSelected, setCurrentRowSelected]); setCurrentRowSelected({
newSelectedState: !isSelected,
shouldSelectRange: isDefined(event?.shiftKey) && event.shiftKey,
});
},
[isSelected, setCurrentRowSelected],
);
return ( return (
<StyledRecordTableTd isSelected={isSelected} hasRightBorder={false}> <StyledRecordTableTd isSelected={isSelected} hasRightBorder={false}>

View File

@ -28,7 +28,20 @@ export const RecordTableRowHotkeyEffect = () => {
useScopedHotkeys( useScopedHotkeys(
'x', 'x',
() => { () => {
setCurrentRowSelected(!isSelected); setCurrentRowSelected({
newSelectedState: !isSelected,
});
},
TableHotkeyScope.TableFocus,
);
useScopedHotkeys(
`${Key.Shift}+x`,
() => {
setCurrentRowSelected({
newSelectedState: !isSelected,
shouldSelectRange: true,
});
}, },
TableHotkeyScope.TableFocus, TableHotkeyScope.TableFocus,
); );

View File

@ -1,30 +1,79 @@
import { useRecoilCallback } from 'recoil'; import { useRecoilCallback } from 'recoil';
import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector';
import { useRecordTableRowContextOrThrow } from '@/object-record/record-table/contexts/RecordTableRowContext'; import { useRecordTableRowContextOrThrow } from '@/object-record/record-table/contexts/RecordTableRowContext';
import { isRowSelectedComponentFamilyState } from '@/object-record/record-table/record-table-row/states/isRowSelectedComponentFamilyState'; import { isRowSelectedComponentFamilyState } from '@/object-record/record-table/record-table-row/states/isRowSelectedComponentFamilyState';
import { lastSelectedRowIndexComponentState } from '@/object-record/record-table/record-table-row/states/lastSelectedRowIndexComponentState';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue'; import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { isDefined } from 'twenty-shared/utils';
export const useSetCurrentRowSelected = () => { export const useSetCurrentRowSelected = () => {
const { recordId } = useRecordTableRowContextOrThrow(); const { recordId, rowIndex } = useRecordTableRowContextOrThrow();
const isRowSelectedFamilyState = useRecoilComponentCallbackStateV2( const isRowSelectedFamilyState = useRecoilComponentCallbackStateV2(
isRowSelectedComponentFamilyState, isRowSelectedComponentFamilyState,
); );
const recordIndexAllRecordIdsState = useRecoilComponentCallbackStateV2(
recordIndexAllRecordIdsComponentSelector,
);
const lastSelectedRowIndexComponentCallbackState =
useRecoilComponentCallbackStateV2(lastSelectedRowIndexComponentState);
const setCurrentRowSelected = useRecoilCallback( const setCurrentRowSelected = useRecoilCallback(
({ set, snapshot }) => ({ set, snapshot }) =>
(newSelectedState: boolean) => { ({
const isRowSelected = getSnapshotValue( newSelectedState,
shouldSelectRange = false,
}: {
newSelectedState: boolean;
shouldSelectRange?: boolean;
}) => {
const allRecordIds = getSnapshotValue(
snapshot,
recordIndexAllRecordIdsState,
);
const isCurrentRowSelected = getSnapshotValue(
snapshot, snapshot,
isRowSelectedFamilyState(recordId), isRowSelectedFamilyState(recordId),
); );
if (isRowSelected !== newSelectedState) { const lastSelectedIndex = snapshot
.getLoadable(lastSelectedRowIndexComponentCallbackState)
.getValue();
if (shouldSelectRange && isDefined(lastSelectedIndex)) {
const startIndex = Math.min(lastSelectedIndex, rowIndex);
const endIndex = Math.max(lastSelectedIndex, rowIndex);
const shouldSelect = !isCurrentRowSelected;
for (let i = startIndex; i <= endIndex; i++) {
set(isRowSelectedFamilyState(allRecordIds[i]), shouldSelect);
}
set(lastSelectedRowIndexComponentCallbackState, rowIndex);
return;
}
if (isCurrentRowSelected !== newSelectedState) {
set(isRowSelectedFamilyState(recordId), newSelectedState); set(isRowSelectedFamilyState(recordId), newSelectedState);
set(
lastSelectedRowIndexComponentCallbackState,
newSelectedState ? rowIndex : null,
);
} }
}, },
[recordId, isRowSelectedFamilyState], [
recordIndexAllRecordIdsState,
isRowSelectedFamilyState,
recordId,
lastSelectedRowIndexComponentCallbackState,
rowIndex,
],
); );
return { return {

View File

@ -0,0 +1,10 @@
import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const lastSelectedRowIndexComponentState = createComponentStateV2<
number | null | undefined
>({
key: 'record-table/last-selected-row-index',
defaultValue: null,
componentInstanceContext: RecordTableComponentInstanceContext,
});