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

View File

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

View File

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

View File

@ -1,30 +1,79 @@
import { useRecoilCallback } from 'recoil';
import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector';
import { useRecordTableRowContextOrThrow } from '@/object-record/record-table/contexts/RecordTableRowContext';
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 { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { isDefined } from 'twenty-shared/utils';
export const useSetCurrentRowSelected = () => {
const { recordId } = useRecordTableRowContextOrThrow();
const { recordId, rowIndex } = useRecordTableRowContextOrThrow();
const isRowSelectedFamilyState = useRecoilComponentCallbackStateV2(
isRowSelectedComponentFamilyState,
);
const recordIndexAllRecordIdsState = useRecoilComponentCallbackStateV2(
recordIndexAllRecordIdsComponentSelector,
);
const lastSelectedRowIndexComponentCallbackState =
useRecoilComponentCallbackStateV2(lastSelectedRowIndexComponentState);
const setCurrentRowSelected = useRecoilCallback(
({ set, snapshot }) =>
(newSelectedState: boolean) => {
const isRowSelected = getSnapshotValue(
({
newSelectedState,
shouldSelectRange = false,
}: {
newSelectedState: boolean;
shouldSelectRange?: boolean;
}) => {
const allRecordIds = getSnapshotValue(
snapshot,
recordIndexAllRecordIdsState,
);
const isCurrentRowSelected = getSnapshotValue(
snapshot,
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(
lastSelectedRowIndexComponentCallbackState,
newSelectedState ? rowIndex : null,
);
}
},
[recordId, isRowSelectedFamilyState],
[
recordIndexAllRecordIdsState,
isRowSelectedFamilyState,
recordId,
lastSelectedRowIndexComponentCallbackState,
rowIndex,
],
);
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,
});