|
| 1 | +import { |
| 2 | + ColumnDef, |
| 3 | + getCoreRowModel, |
| 4 | + getExpandedRowModel, |
| 5 | + getGroupedRowModel, |
| 6 | + GroupingState, |
| 7 | + Row, |
| 8 | + RowSelectionState, |
| 9 | + Table, |
| 10 | + useReactTable, |
| 11 | +} from '@tanstack/react-table'; |
| 12 | +import { act, renderHook } from '@testing-library/react'; |
| 13 | +import { useState } from 'react'; |
| 14 | +import { describe, expect, test } from 'vitest'; |
| 15 | +import { selectRowRange } from '../grid-row-utils'; |
| 16 | + |
| 17 | +interface TestRow { |
| 18 | + _key: string; |
| 19 | + Name: string; |
| 20 | + Dept: string; |
| 21 | +} |
| 22 | + |
| 23 | +const data: TestRow[] = [ |
| 24 | + { _key: '1', Name: 'A', Dept: 'X' }, |
| 25 | + { _key: '2', Name: 'B', Dept: 'X' }, |
| 26 | + { _key: '3', Name: 'C', Dept: 'Y' }, |
| 27 | + { _key: '4', Name: 'D', Dept: 'Y' }, |
| 28 | + { _key: '5', Name: 'E', Dept: 'Y' }, |
| 29 | +]; |
| 30 | + |
| 31 | +const columns: ColumnDef<TestRow>[] = [ |
| 32 | + { id: 'Name', accessorKey: 'Name' }, |
| 33 | + { id: 'Dept', accessorKey: 'Dept' }, |
| 34 | +]; |
| 35 | + |
| 36 | +function makeTable(options: { enableRowSelection?: boolean | ((row: Row<TestRow>) => boolean); grouping?: GroupingState } = {}) { |
| 37 | + const { enableRowSelection = true, grouping } = options; |
| 38 | + const { result } = renderHook(() => { |
| 39 | + const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); |
| 40 | + return useReactTable<TestRow>({ |
| 41 | + data, |
| 42 | + columns, |
| 43 | + state: { rowSelection, grouping: grouping ?? [], expanded: grouping ? true : {} }, |
| 44 | + getRowId: (row) => row._key, |
| 45 | + enableRowSelection, |
| 46 | + onRowSelectionChange: setRowSelection, |
| 47 | + getCoreRowModel: getCoreRowModel(), |
| 48 | + ...(grouping ? { getGroupedRowModel: getGroupedRowModel(), getExpandedRowModel: getExpandedRowModel() } : {}), |
| 49 | + }); |
| 50 | + }); |
| 51 | + return result; |
| 52 | +} |
| 53 | + |
| 54 | +/** Toggle a single row's selection (sets the anchor's starting state for a range test). */ |
| 55 | +function toggle(table: Table<TestRow>, rowId: string, selected: boolean) { |
| 56 | + act(() => table.getRow(rowId, true).toggleSelected(selected)); |
| 57 | +} |
| 58 | + |
| 59 | +function selectedKeys(table: Table<TestRow>) { |
| 60 | + return Object.keys(table.getState().rowSelection) |
| 61 | + .filter((key) => table.getState().rowSelection[key]) |
| 62 | + .sort(); |
| 63 | +} |
| 64 | + |
| 65 | +describe('selectRowRange', () => { |
| 66 | + test('fills a forward range (anchor above target) with the anchor selected value', () => { |
| 67 | + const result = makeTable(); |
| 68 | + toggle(result.current, '2', true); |
| 69 | + |
| 70 | + let applied = false; |
| 71 | + act(() => { |
| 72 | + applied = selectRowRange(result.current, '2', '4'); |
| 73 | + }); |
| 74 | + |
| 75 | + expect(applied).toBe(true); |
| 76 | + expect(selectedKeys(result.current)).toEqual(['2', '3', '4']); |
| 77 | + }); |
| 78 | + |
| 79 | + test('fills a backward range (target above anchor) inclusively', () => { |
| 80 | + const result = makeTable(); |
| 81 | + toggle(result.current, '4', true); |
| 82 | + |
| 83 | + act(() => { |
| 84 | + selectRowRange(result.current, '4', '2'); |
| 85 | + }); |
| 86 | + |
| 87 | + expect(selectedKeys(result.current)).toEqual(['2', '3', '4']); |
| 88 | + }); |
| 89 | + |
| 90 | + test('clears the range when the anchor is unselected', () => { |
| 91 | + const result = makeTable(); |
| 92 | + // Pre-select 2,3,4 then unselect the anchor (2) so its value is "unchecked". |
| 93 | + toggle(result.current, '2', true); |
| 94 | + toggle(result.current, '3', true); |
| 95 | + toggle(result.current, '4', true); |
| 96 | + toggle(result.current, '2', false); |
| 97 | + |
| 98 | + act(() => { |
| 99 | + selectRowRange(result.current, '2', '4'); |
| 100 | + }); |
| 101 | + |
| 102 | + expect(selectedKeys(result.current)).toEqual([]); |
| 103 | + }); |
| 104 | + |
| 105 | + test('skips non-selectable rows in the range', () => { |
| 106 | + const result = makeTable({ enableRowSelection: (row) => row.id !== '3' }); |
| 107 | + toggle(result.current, '2', true); |
| 108 | + |
| 109 | + act(() => { |
| 110 | + selectRowRange(result.current, '2', '4'); |
| 111 | + }); |
| 112 | + |
| 113 | + expect(result.current.getRow('3', true).getIsSelected()).toBe(false); |
| 114 | + expect(selectedKeys(result.current)).toEqual(['2', '4']); |
| 115 | + }); |
| 116 | + |
| 117 | + test('skips group header rows in the range', () => { |
| 118 | + const result = makeTable({ grouping: ['Dept'] }); |
| 119 | + // Rendered order with groups expanded: [group X, 1, 2, group Y, 3, 4, 5]. A range from leaf 2 to |
| 120 | + // leaf 3 spans the "Y" group header, which must not be added to the selection. |
| 121 | + toggle(result.current, '2', true); |
| 122 | + |
| 123 | + act(() => { |
| 124 | + selectRowRange(result.current, '2', '3'); |
| 125 | + }); |
| 126 | + |
| 127 | + expect(selectedKeys(result.current)).toEqual(['2', '3']); |
| 128 | + // No group-row id (e.g. "Dept:Y") leaked into the selection. |
| 129 | + expect(selectedKeys(result.current).every((key) => !key.includes(':'))).toBe(true); |
| 130 | + }); |
| 131 | + |
| 132 | + test('returns false and leaves selection untouched when the anchor is not in the row model', () => { |
| 133 | + const result = makeTable(); |
| 134 | + toggle(result.current, '2', true); |
| 135 | + |
| 136 | + let applied = true; |
| 137 | + act(() => { |
| 138 | + applied = selectRowRange(result.current, 'missing-anchor', '4'); |
| 139 | + }); |
| 140 | + |
| 141 | + expect(applied).toBe(false); |
| 142 | + expect(selectedKeys(result.current)).toEqual(['2']); |
| 143 | + }); |
| 144 | + |
| 145 | + test('returns false when the target is not in the row model', () => { |
| 146 | + const result = makeTable(); |
| 147 | + toggle(result.current, '2', true); |
| 148 | + |
| 149 | + let applied = true; |
| 150 | + act(() => { |
| 151 | + applied = selectRowRange(result.current, '2', 'missing-target'); |
| 152 | + }); |
| 153 | + |
| 154 | + expect(applied).toBe(false); |
| 155 | + expect(selectedKeys(result.current)).toEqual(['2']); |
| 156 | + }); |
| 157 | +}); |
0 commit comments