From b0cce3d74a8255277314d825e000a269075def94 Mon Sep 17 00:00:00 2001 From: Naifer <161821705+omarNaifer12@users.noreply.github.com> Date: Mon, 16 Jun 2025 14:51:37 +0100 Subject: [PATCH] Enable multiple row selection with Shift + checkbox click (#12492) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../internal/useResetTableRowSelection.ts | 9 +++ .../components/RecordTableCellCheckbox.tsx | 13 +++- .../components/RecordTableRowHotkeyEffect.tsx | 15 ++++- .../hooks/useSetCurrentRowSelected.ts | 61 +++++++++++++++++-- .../lastSelectedRowIndexComponentState.ts | 10 +++ 5 files changed, 98 insertions(+), 10 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-row/states/lastSelectedRowIndexComponentState.ts diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useResetTableRowSelection.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useResetTableRowSelection.ts index e3c1095ee..187193e6a 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useResetTableRowSelection.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useResetTableRowSelection.ts @@ -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, ], ); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellCheckbox.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellCheckbox.tsx index c03164785..532c8fe9b 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellCheckbox.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellCheckbox.tsx @@ -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) => { + setCurrentRowSelected({ + newSelectedState: !isSelected, + shouldSelectRange: isDefined(event?.shiftKey) && event.shiftKey, + }); + }, + [isSelected, setCurrentRowSelected], + ); return ( diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowHotkeyEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowHotkeyEffect.tsx index 54056f15b..763d7d18c 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowHotkeyEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowHotkeyEffect.tsx @@ -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, ); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/hooks/useSetCurrentRowSelected.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/hooks/useSetCurrentRowSelected.ts index c096d0b0d..1ca68e2e4 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/hooks/useSetCurrentRowSelected.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/hooks/useSetCurrentRowSelected.ts @@ -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 { diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/states/lastSelectedRowIndexComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/states/lastSelectedRowIndexComponentState.ts new file mode 100644 index 000000000..c4b1f3866 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/states/lastSelectedRowIndexComponentState.ts @@ -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, +});