Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions libs/ui/src/lib/data-table/grid/__tests__/grid-row-utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import {
ColumnDef,
getCoreRowModel,
getExpandedRowModel,
getGroupedRowModel,
GroupingState,
Row,
RowSelectionState,
Table,
useReactTable,
} from '@tanstack/react-table';
import { act, renderHook } from '@testing-library/react';
import { useState } from 'react';
import { describe, expect, test } from 'vitest';
import { selectRowRange } from '../grid-row-utils';

interface TestRow {
_key: string;
Name: string;
Dept: string;
}

const data: TestRow[] = [
{ _key: '1', Name: 'A', Dept: 'X' },
{ _key: '2', Name: 'B', Dept: 'X' },
{ _key: '3', Name: 'C', Dept: 'Y' },
{ _key: '4', Name: 'D', Dept: 'Y' },
{ _key: '5', Name: 'E', Dept: 'Y' },
];

const columns: ColumnDef<TestRow>[] = [
{ id: 'Name', accessorKey: 'Name' },
{ id: 'Dept', accessorKey: 'Dept' },
];

function makeTable(options: { enableRowSelection?: boolean | ((row: Row<TestRow>) => boolean); grouping?: GroupingState } = {}) {
const { enableRowSelection = true, grouping } = options;
const { result } = renderHook(() => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
return useReactTable<TestRow>({
data,
columns,
state: { rowSelection, grouping: grouping ?? [], expanded: grouping ? true : {} },
getRowId: (row) => row._key,
enableRowSelection,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
...(grouping ? { getGroupedRowModel: getGroupedRowModel(), getExpandedRowModel: getExpandedRowModel() } : {}),
});
});
return result;
}

/** Toggle a single row's selection (sets the anchor's starting state for a range test). */
function toggle(table: Table<TestRow>, rowId: string, selected: boolean) {
act(() => table.getRow(rowId, true).toggleSelected(selected));
}

function selectedKeys(table: Table<TestRow>) {
return Object.keys(table.getState().rowSelection)
.filter((key) => table.getState().rowSelection[key])
.sort();
}

describe('selectRowRange', () => {
test('fills a forward range (anchor above target) with the anchor selected value', () => {
const result = makeTable();
toggle(result.current, '2', true);

let applied = false;
act(() => {
applied = selectRowRange(result.current, '2', '4');
});

expect(applied).toBe(true);
expect(selectedKeys(result.current)).toEqual(['2', '3', '4']);
});

test('fills a backward range (target above anchor) inclusively', () => {
const result = makeTable();
toggle(result.current, '4', true);

act(() => {
selectRowRange(result.current, '4', '2');
});

expect(selectedKeys(result.current)).toEqual(['2', '3', '4']);
});

test('clears the range when the anchor is unselected', () => {
const result = makeTable();
// Pre-select 2,3,4 then unselect the anchor (2) so its value is "unchecked".
toggle(result.current, '2', true);
toggle(result.current, '3', true);
toggle(result.current, '4', true);
toggle(result.current, '2', false);

act(() => {
selectRowRange(result.current, '2', '4');
});

expect(selectedKeys(result.current)).toEqual([]);
});

test('skips non-selectable rows in the range', () => {
const result = makeTable({ enableRowSelection: (row) => row.id !== '3' });
toggle(result.current, '2', true);

act(() => {
selectRowRange(result.current, '2', '4');
});

expect(result.current.getRow('3', true).getIsSelected()).toBe(false);
expect(selectedKeys(result.current)).toEqual(['2', '4']);
});

test('skips group header rows in the range', () => {
const result = makeTable({ grouping: ['Dept'] });
// Rendered order with groups expanded: [group X, 1, 2, group Y, 3, 4, 5]. A range from leaf 2 to
// leaf 3 spans the "Y" group header, which must not be added to the selection.
toggle(result.current, '2', true);

act(() => {
selectRowRange(result.current, '2', '3');
});

expect(selectedKeys(result.current)).toEqual(['2', '3']);
// No group-row id (e.g. "Dept:Y") leaked into the selection.
expect(selectedKeys(result.current).every((key) => !key.includes(':'))).toBe(true);
});

test('returns false and leaves selection untouched when the anchor is not in the row model', () => {
const result = makeTable();
toggle(result.current, '2', true);

let applied = true;
act(() => {
applied = selectRowRange(result.current, 'missing-anchor', '4');
});

expect(applied).toBe(false);
expect(selectedKeys(result.current)).toEqual(['2']);
});

test('returns false when the target is not in the row model', () => {
const result = makeTable();
toggle(result.current, '2', true);

let applied = true;
act(() => {
applied = selectRowRange(result.current, '2', 'missing-target');
});

expect(applied).toBe(false);
expect(selectedKeys(result.current)).toEqual(['2']);
});
});
12 changes: 10 additions & 2 deletions libs/ui/src/lib/data-table/grid/components/GridContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -333,9 +333,17 @@ export function GridContainer<TRow = RowWithKey>({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keyboardNav.activeCell?.columnId]);

// Anchor for shift-click range selection — the last row whose checkbox was toggled when a range wasn't applied.
// A private ref exposed via stable getter/setter (a context value can't be mutated directly), so
// every select cell's range-select handler agrees on the anchor.
const rowSelectionAnchorRef = useRef<string | null>(null);
const getRowSelectionAnchor = useCallback(() => rowSelectionAnchorRef.current, []);
const setRowSelectionAnchor = useCallback((rowId: string | null) => {
rowSelectionAnchorRef.current = rowId;
}, []);
const runtime: GridRuntime<TRow> = useMemo(
() => ({ table, gridId, getRowKey, columns: orderedColumns }),
[table, gridId, getRowKey, orderedColumns],
() => ({ table, gridId, getRowKey, columns: orderedColumns, getRowSelectionAnchor, setRowSelectionAnchor }),
[table, gridId, getRowKey, orderedColumns, getRowSelectionAnchor, setRowSelectionAnchor],
);

const rowModelRows = table.getRowModel().rows;
Expand Down
5 changes: 5 additions & 0 deletions libs/ui/src/lib/data-table/grid/grid-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ export interface GridRuntime<TRow = RowWithKey> {
getRowKey: (row: TRow) => string;
/** Ordered, visible author-facing columns (kept in sync with TanStack column order). */
columns: ColumnWithFilter<TRow>[];
/** Read the shift-click range-selection anchor — the last row whose checkbox was toggled when a
* range wasn't applied, or null. Backed by a stable ref in GridContainer so every select cell agrees on it. */
getRowSelectionAnchor?: () => string | null;
/** Set the shift-click range-selection anchor (or clear it with null). */
setRowSelectionAnchor?: (rowId: string | null) => void;
}

export const GridRuntimeContext = createContext<GridRuntime | undefined>(undefined);
Expand Down
35 changes: 35 additions & 0 deletions libs/ui/src/lib/data-table/grid/grid-row-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,41 @@ export function getSortedFilteredLeafRows<TRow>(table: Table<TRow>): Row<TRow>[]
return leafRows;
}

/**
* Shift-click range selection: set every row between `anchorRowId` and `targetRowId` (inclusive, in the
* current filtered/sorted/flattened display order) to the SAME selected state as the anchor row. Group
* header rows and non-selectable rows are skipped. Returns `false` (without mutating selection) when
* either id is no longer in the row model (e.g. the anchor was filtered out) so the caller can fall back
* to a plain single-row toggle.
*/
export function selectRowRange<TRow>(table: Table<TRow>, anchorRowId: string, targetRowId: string): boolean {
const rows = table.getRowModel().rows;
const anchorIndex = rows.findIndex((row) => row.id === anchorRowId);
const targetIndex = rows.findIndex((row) => row.id === targetRowId);
if (anchorIndex === -1 || targetIndex === -1) {
return false;
}
const selected = rows[anchorIndex].getIsSelected();
const start = Math.min(anchorIndex, targetIndex);
const end = Math.max(anchorIndex, targetIndex);
table.setRowSelection((prev) => {
const next = { ...prev };
for (let i = start; i <= end; i++) {
const row = rows[i];
if (row.getIsGrouped() || !row.getCanSelect()) {
continue;
}
if (selected) {
next[row.id] = true;
} else {
delete next[row.id];
}
}
return next;
});
return true;
}

const SFDC_EMPTY_ID = '000000000000000AAA';

// TanStack Table calls getRowId on every render to re-resolve its row model. Without this cache, rows
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { copyRecordsToClipboard } from '@jetstream/shared/ui-utils';
import type { Column, Row, Table } from '@tanstack/react-table';
import { FocusEvent as ReactFocusEvent, KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { ActiveCell } from '../components/GridRow';
import { getSummaryRowId, getSummaryRowIndex, HEADER_ROW_ID, isSummaryRowId } from '../grid-constants';
import { getSummaryRowId, getSummaryRowIndex, HEADER_ROW_ID, isSummaryRowId, SELECT_COLUMN_KEY } from '../grid-constants';
import { ColSpanArgs } from '../grid-types';

export type GridMode = 'navigation' | 'actionable';
Expand Down Expand Up @@ -516,7 +516,10 @@ export function useGridKeyboardNavigation<TRow>({
return;
}
interactionSourceRef.current = 'mouse';
if (shiftKey) {
// The select column owns Shift for checkbox range selection (SelectFormatter); never let a
// Shift-click there extend the rectangular cell selection. Still set the active cell so keyboard
// navigation continues from the checkbox.
if (shiftKey && columnId !== SELECT_COLUMN_KEY) {
applySelection(rowId, columnId, true);
} else {
applySelection(rowId, columnId, false);
Expand Down
60 changes: 47 additions & 13 deletions libs/ui/src/lib/data-table/grid/renderers/CellRenderers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import lodashGet from 'lodash/get';
import isBoolean from 'lodash/isBoolean';
import isFunction from 'lodash/isFunction';
import isString from 'lodash/isString';
import { Fragment, memo, ReactNode, useContext, useState } from 'react';
import { Fragment, memo, ReactNode, useContext, useRef, useState } from 'react';
import Checkbox from '../../../form/checkbox/Checkbox';
import Modal from '../../../modal/Modal';
import { Popover } from '../../../popover/Popover';
Expand All @@ -18,8 +18,8 @@ import Spinner from '../../../widgets/Spinner';
import Tooltip from '../../../widgets/Tooltip';
import { dataTableDateFormatter } from '../../data-table-formatters';
import { ACTION_COLUMN_KEY, DEFAULT_ROW_HEIGHT, SELECT_COLUMN_KEY } from '../grid-constants';
import { GridRecordActionContext } from '../grid-context';
import { getRowId, getSfdcRetUrl } from '../grid-row-utils';
import { GridRecordActionContext, GridRuntimeContext } from '../grid-context';
import { getRowId, getSfdcRetUrl, selectRowRange } from '../grid-row-utils';
import { ColumnWithFilter, DataTableCellProps, DataTableGroupCellProps, RowWithKey } from '../grid-types';
import { getRowErrorMessages } from '../validate-cell-value';

Expand Down Expand Up @@ -387,18 +387,52 @@ export function withCellValidation<TRow extends RowWithKey>(
}

/** Row-selection checkbox renderer. The built-in select column (GridCell cellKind) usually handles this;
* kept for compatibility with the spreadable SelectColumn definition. */
* kept for compatibility with the spreadable SelectColumn definition.
*
* Supports shift-click range selection: holding Shift and clicking sets every row between the anchor
* (the last row toggled when a range wasn't applied) and this row to the anchor's selected value. The Shift state is captured
* from the wrapper's `mousedown` rather than the change event, because the visible control is the SLDS
* `<label>`/faux checkbox and label-forwarded clicks drop modifier keys. */
export function SelectFormatter({ row, tanstackRow }: DataTableCellProps<any>): ReactNode {
const runtime = useContext(GridRuntimeContext);
const shiftKeyRef = useRef(false);

const handleChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
Comment thread
paustint marked this conversation as resolved.
const checked = event.currentTarget.checked;
// `detail > 0` is a real pointer click; keyboard Space yields a synthetic click with detail 0, where
// the shift ref would be stale — so range-select only on genuine mouse interactions.
const wasMouseClick = (event.nativeEvent as MouseEvent).detail > 0;
const anchorRowId = runtime?.getRowSelectionAnchor?.();
if (
wasMouseClick &&
shiftKeyRef.current &&
anchorRowId &&
anchorRowId !== tanstackRow.id &&
runtime?.table &&
selectRowRange(runtime.table, anchorRowId, tanstackRow.id)
) {
// Range applied — keep the anchor fixed so the user can re-shift-click to adjust the range.
return;
}
tanstackRow.toggleSelected(checked);
runtime?.setRowSelectionAnchor?.(tanstackRow.id);
};
Comment thread
paustint marked this conversation as resolved.

return (
<Checkbox
id={`checkbox-select-${getRowId(row)}`}
label="Select row"
hideLabel
tabIndex={-1}
checked={tanstackRow.getIsSelected()}
disabled={!tanstackRow.getCanSelect()}
onChange={(checked) => tanstackRow.toggleSelected(checked)}
/>
<span
className="jgrid-cell-select slds-grid slds-grid_align-center slds-grid_vertical-align-center h-100 w-100"
onMouseDown={(event) => (shiftKeyRef.current = event.shiftKey)}
>
<Checkbox
id={`checkbox-select-${getRowId(row)}`}
label="Select row"
hideLabel
tabIndex={-1}
checked={tanstackRow.getIsSelected()}
disabled={!tanstackRow.getCanSelect()}
onChangeNative={handleChange}
/>
</span>
);
}

Expand Down
Loading