@opencorestack/opengridx 1.0.7 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/index.d.ts +100 -12
  3. package/dist/opengridx.css +1 -1
  4. package/dist/opengridx.es.js +3199 -3015
  5. package/dist/opengridx.es.js.map +1 -1
  6. package/dist/opengridx.umd.js +12 -12
  7. package/dist/opengridx.umd.js.map +1 -1
  8. package/docs/API_REFERENCE.md +85 -0
  9. package/docs/components/cell.md +14 -0
  10. package/docs/components/filter-panel.md +13 -0
  11. package/docs/components/pagination.md +26 -0
  12. package/docs/components/row.md +26 -0
  13. package/docs/features/pdf-export.md +110 -0
  14. package/docs/roadmap.md +8 -0
  15. package/lib/components/Cell/Cell.tsx +44 -44
  16. package/lib/components/Cell/CellErrorBoundary.tsx +53 -0
  17. package/lib/components/DataGrid/DataGrid.css +11 -0
  18. package/lib/components/DataGrid/DataGrid.tsx +30 -6
  19. package/lib/components/DataGrid/GridListView.tsx +4 -0
  20. package/lib/components/DataGrid/GridPinnedRows.tsx +4 -0
  21. package/lib/components/DataGrid/GridVirtualRows.tsx +4 -0
  22. package/lib/components/Pagination/Pagination.test.tsx +64 -0
  23. package/lib/components/Pagination/Pagination.tsx +15 -4
  24. package/lib/components/Row/Row.tsx +6 -3
  25. package/lib/hooks/core/useGridColumns.tsx +11 -11
  26. package/lib/hooks/core/useGridControlledState.test.ts +86 -0
  27. package/lib/hooks/core/useGridKeyboardNavigation.test.ts +176 -0
  28. package/lib/hooks/core/useGridRowPipeline.test.ts +117 -0
  29. package/lib/hooks/useRowGrouping.test.ts +84 -0
  30. package/lib/hooks/useRowGrouping.ts +38 -13
  31. package/lib/hooks/useTreeData.ts +22 -2
  32. package/lib/index.ts +5 -1
  33. package/lib/types/index.ts +107 -12
  34. package/lib/utils/export/exportToPdf.test.ts +121 -0
  35. package/lib/utils/export/exportToPdf.ts +277 -0
  36. package/lib/utils/export/index.ts +3 -0
  37. package/lib/utils/export/pdf-types.d.ts +19 -0
  38. package/package.json +9 -1
@@ -10,6 +10,7 @@ import type {
10
10
  GridRowPinning,
11
11
  GridDetailPanelParams,
12
12
  GridDetailPanelHeight,
13
+ GridRowMeta,
13
14
  } from '../../types';
14
15
  import type { CellColSpanInfo, RowSpanningCaches } from '../../hooks/features/useGridSpanning';
15
16
 
@@ -36,6 +37,7 @@ export interface GridPinnedRowsProps<R extends GridRowModel> {
36
37
  colspanMap?: Map<GridRowId, Record<string, CellColSpanInfo>>;
37
38
  rowSpanningCaches?: RowSpanningCaches;
38
39
  rowHeight: number;
40
+ rowMetaMap: Map<GridRowId, GridRowMeta>;
39
41
  }
40
42
 
41
43
  export function GridPinnedRows<R extends GridRowModel>({
@@ -61,6 +63,7 @@ export function GridPinnedRows<R extends GridRowModel>({
61
63
  colspanMap,
62
64
  rowSpanningCaches,
63
65
  rowHeight,
66
+ rowMetaMap,
64
67
  }: GridPinnedRowsProps<R>) {
65
68
  if (rows.length === 0) return null;
66
69
 
@@ -91,6 +94,7 @@ export function GridPinnedRows<R extends GridRowModel>({
91
94
  colspanMap={colspanMap}
92
95
  rowSpanningCaches={rowSpanningCaches}
93
96
  rowHeight={rowHeight}
97
+ rowMeta={rowMetaMap.get(row.id)}
94
98
  />
95
99
  ))}
96
100
  </div>
@@ -12,6 +12,7 @@ import type {
12
12
  GridRowPinning,
13
13
  GridDetailPanelParams,
14
14
  GridDetailPanelHeight,
15
+ GridRowMeta,
15
16
  } from '../../types';
16
17
  import type { GridVisibleRow } from '../../hooks/core/useGridVisibleRows';
17
18
  import type { UseRowReorderReturn } from '../../hooks/useRowReorder';
@@ -60,6 +61,7 @@ export interface GridVirtualRowsProps<R extends GridRowModel> {
60
61
  sortedUnpinnedRowCount: number;
61
62
  infiniteScrollSkeletonCount: number;
62
63
  unpinnedRowsLength: number;
64
+ rowMetaMap: Map<GridRowId, GridRowMeta>;
63
65
  }
64
66
 
65
67
  export function GridVirtualRows<R extends GridRowModel>({
@@ -97,6 +99,7 @@ export function GridVirtualRows<R extends GridRowModel>({
97
99
  sortedUnpinnedRowCount,
98
100
  infiniteScrollSkeletonCount,
99
101
  unpinnedRowsLength,
102
+ rowMetaMap,
100
103
  }: GridVirtualRowsProps<R>) {
101
104
  const skeletonColumns: GridColDef<R>[] = baseColumns.length > 0
102
105
  ? baseColumns
@@ -179,6 +182,7 @@ export function GridVirtualRows<R extends GridRowModel>({
179
182
  colspanMap={colspanMap}
180
183
  rowSpanningCaches={rowSpanningCaches}
181
184
  rowHeight={rowHeight}
185
+ rowMeta={rowMetaMap.get(row.id)}
182
186
  />
183
187
  ))
184
188
  )}
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { Pagination } from './Pagination';
4
+
5
+ const NOOP = () => {};
6
+
7
+ describe('Pagination', () => {
8
+ it('renders "0 of 0" when rowCount is 0', () => {
9
+ render(
10
+ <Pagination
11
+ page={0}
12
+ pageSize={10}
13
+ rowCount={0}
14
+ onPageChange={NOOP}
15
+ onPageSizeChange={NOOP}
16
+ />
17
+ );
18
+ expect(screen.getByText('0 of 0')).toBeTruthy();
19
+ });
20
+
21
+ it('calls localeText.paginationOf with (0, 0, 0) when rowCount is 0', () => {
22
+ const paginationOf = vi.fn().mockReturnValue('custom empty');
23
+ render(
24
+ <Pagination
25
+ page={0}
26
+ pageSize={10}
27
+ rowCount={0}
28
+ onPageChange={NOOP}
29
+ onPageSizeChange={NOOP}
30
+ localeText={{ paginationOf }}
31
+ />
32
+ );
33
+ expect(paginationOf).toHaveBeenCalledWith(0, 0, 0);
34
+ expect(screen.getByText('custom empty')).toBeTruthy();
35
+ });
36
+
37
+ it('renders correct row range label for non-empty data', () => {
38
+ render(
39
+ <Pagination
40
+ page={0}
41
+ pageSize={10}
42
+ rowCount={25}
43
+ onPageChange={NOOP}
44
+ onPageSizeChange={NOOP}
45
+ />
46
+ );
47
+ expect(screen.getByText('1–10 of 25')).toBeTruthy();
48
+ });
49
+
50
+ it('calls localeText.paginationOf with correct args for non-empty data', () => {
51
+ const paginationOf = vi.fn().mockReturnValue('custom range');
52
+ render(
53
+ <Pagination
54
+ page={1}
55
+ pageSize={10}
56
+ rowCount={25}
57
+ onPageChange={NOOP}
58
+ onPageSizeChange={NOOP}
59
+ localeText={{ paginationOf }}
60
+ />
61
+ );
62
+ expect(paginationOf).toHaveBeenCalledWith(11, 20, 25);
63
+ });
64
+ });
@@ -1,6 +1,7 @@
1
1
 
2
2
  import React, { useId } from 'react';
3
3
  import { Button } from '../ui/Button';
4
+ import type { GridLocaleText } from '../../types';
4
5
 
5
6
  export interface PaginationProps {
6
7
  page: number;
@@ -11,6 +12,7 @@ export interface PaginationProps {
11
12
  pageSizeOptions?: number[];
12
13
  showFirstButton?: boolean;
13
14
  showLastButton?: boolean;
15
+ localeText?: Pick<GridLocaleText, 'paginationRowsPerPage' | 'paginationOf' | 'paginationPage'>;
14
16
  }
15
17
 
16
18
  export function Pagination(props: PaginationProps) {
@@ -23,7 +25,8 @@ export function Pagination(props: PaginationProps) {
23
25
  onPageSizeChange,
24
26
  pageSizeOptions = [10, 25, 50, 100],
25
27
  showFirstButton = true,
26
- showLastButton = true
28
+ showLastButton = true,
29
+ localeText,
27
30
  } = props;
28
31
 
29
32
  const pageCount = Math.max(1, Math.ceil(rowCount / pageSize));
@@ -33,6 +36,14 @@ export function Pagination(props: PaginationProps) {
33
36
  const lastRowIndex = Math.min(firstRowIndex + pageSize, rowCount);
34
37
  const displayedRows = rowCount === 0 ? 0 : `${firstRowIndex + 1}–${lastRowIndex}`;
35
38
 
39
+ const rowsPerPageLabel = localeText?.paginationRowsPerPage ?? 'Rows per page:';
40
+ const ofLabel = localeText?.paginationOf
41
+ ? localeText.paginationOf(rowCount === 0 ? 0 : firstRowIndex + 1, lastRowIndex, rowCount)
42
+ : `${displayedRows} of ${rowCount}`;
43
+ const pageLabel = localeText?.paginationPage
44
+ ? localeText.paginationPage(currentPage + 1, pageCount)
45
+ : `Page ${currentPage + 1} of ${pageCount}`;
46
+
36
47
  const handleFirstPage = () => {
37
48
  onPageChange(0);
38
49
  };
@@ -63,7 +74,7 @@ export function Pagination(props: PaginationProps) {
63
74
  { }
64
75
  <div className="ogx-pagination__page-size">
65
76
  <label htmlFor={pageSizeId} className="ogx-pagination__label">
66
- Rows per page:
77
+ {rowsPerPageLabel}
67
78
  </label>
68
79
  <select
69
80
  id={pageSizeId}
@@ -82,7 +93,7 @@ export function Pagination(props: PaginationProps) {
82
93
 
83
94
  { }
84
95
  <div className="ogx-pagination__info" aria-live="polite" aria-atomic="true">
85
- {displayedRows} of {rowCount}
96
+ {ofLabel}
86
97
  </div>
87
98
 
88
99
  { }
@@ -116,7 +127,7 @@ export function Pagination(props: PaginationProps) {
116
127
  </Button>
117
128
 
118
129
  <div className="ogx-pagination__page-info" aria-current="page">
119
- Page {currentPage + 1} of {pageCount}
130
+ {pageLabel}
120
131
  </div>
121
132
 
122
133
  <Button
@@ -5,7 +5,7 @@ import { Checkbox } from '../ui/Checkbox';
5
5
  import { ExpandIcon } from '../ui/ExpandIcon';
6
6
  import { DragHandleIcon } from '../ui/DragHandleIcon';
7
7
  import { DetailPanel } from '../DetailPanel/DetailPanel';
8
- import type { GridRowModel, GridColDef, GridRowId, GridColumnPinning, GridRowPinning, GridRowParams, GridCellParams, GridDetailPanelHeight } from '../../types';
8
+ import type { GridRowModel, GridColDef, GridRowId, GridColumnPinning, GridRowPinning, GridRowParams, GridCellParams, GridDetailPanelHeight, GridRowMeta } from '../../types';
9
9
  import type { CellColSpanInfo, RowSpanningCaches } from '../../hooks/features/useGridSpanning';
10
10
  import { isColumnPinned, calculatePinnedPositions, isRowPinned } from '../../utils/pinning';
11
11
 
@@ -51,6 +51,7 @@ export interface RowProps<R extends GridRowModel = GridRowModel> {
51
51
  colspanMap?: Map<GridRowId, Record<string, CellColSpanInfo>>;
52
52
  rowSpanningCaches?: RowSpanningCaches;
53
53
  rowHeight?: number;
54
+ rowMeta?: GridRowMeta;
54
55
  }
55
56
 
56
57
  export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
@@ -91,7 +92,8 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
91
92
 
92
93
  colspanMap,
93
94
  rowSpanningCaches,
94
- rowHeight = 52
95
+ rowHeight = 52,
96
+ rowMeta
95
97
  } = props;
96
98
 
97
99
  const checkboxId = useId();
@@ -121,7 +123,7 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
121
123
  );
122
124
  }, [columns, columnWidths, pinnedColumns, checkboxSelection, pinCheckboxColumn, hasDetailPanel, pinExpandColumn, rowReordering]);
123
125
 
124
- const isGroupRow = row._hasChildren === true;
126
+ const isGroupRow = rowMeta?.hasChildren === true;
125
127
 
126
128
  const handleCellEditStart = React.useCallback((field: string, value: unknown) => {
127
129
  if (!isGroupRow) {
@@ -358,6 +360,7 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
358
360
  colSpanInfo={colSpanInfo}
359
361
  rowSpan={rowSpan}
360
362
  isHiddenByRowSpan={isHiddenByRowSpan}
363
+ rowMeta={rowMeta}
361
364
  />
362
365
  );
363
366
  })}
@@ -74,14 +74,14 @@ export function useGridColumns<R extends GridRowModel>(
74
74
  return {
75
75
  ...col,
76
76
  renderCell: (cellParams: GridRenderCellParams<R>) => {
77
- const r = cellParams.row as Record<string, unknown>;
78
- const depth = typeof r._treeDepth === 'number' ? r._treeDepth : 0;
79
- const hasChildren = Boolean(r._hasChildren);
80
- const isExpanded = Boolean(r._isExpanded);
81
- const groupingField = typeof r._groupingField === 'string' ? r._groupingField : undefined;
82
- const groupingValue = r._groupingValue;
83
- const descendantCount = typeof r._descendantCount === 'number' ? r._descendantCount : undefined;
84
- const isGroupRow = Boolean(r._isGroupRow);
77
+ const meta = cellParams.rowMeta;
78
+ const depth = meta?.treeDepth ?? 0;
79
+ const hasChildren = Boolean(meta?.hasChildren);
80
+ const isExpanded = Boolean(meta?.isExpanded);
81
+ const groupingField = meta?.groupingField;
82
+ const groupingValue = meta?.groupingValue;
83
+ const descendantCount = meta?.descendantCount;
84
+ const isGroupRow = Boolean(meta?.isGroupRow);
85
85
 
86
86
  let content: React.ReactNode = col.renderCell ? col.renderCell(cellParams) : cellParams.value as React.ReactNode;
87
87
 
@@ -130,9 +130,9 @@ export function useGridColumns<R extends GridRowModel>(
130
130
  return {
131
131
  ...col,
132
132
  renderCell: (cellParams: GridRenderCellParams<R>) => {
133
- const r = cellParams.row as Record<string, unknown>;
134
- const hasChildren = Boolean(r._hasChildren);
135
- const groupingField = typeof r._groupingField === 'string' ? r._groupingField : undefined;
133
+ const meta = cellParams.rowMeta;
134
+ const hasChildren = Boolean(meta?.hasChildren);
135
+ const groupingField = meta?.groupingField;
136
136
 
137
137
  if (isRowGrouping && hasChildren) {
138
138
  if (col.field === groupingField) return null;
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { renderHook, act } from '@testing-library/react';
3
+ import { useGridControlledState } from './useGridControlledState';
4
+
5
+ const BASE_PARAMS = {
6
+ sortModel: undefined,
7
+ paginationModel: undefined,
8
+ rowSelectionModel: undefined,
9
+ };
10
+
11
+ describe('useGridControlledState — sort', () => {
12
+ it('uncontrolled: setInternalSortModel updates sortModel', () => {
13
+ const { result } = renderHook(() => useGridControlledState(BASE_PARAMS));
14
+ expect(result.current.sortModel).toEqual([]);
15
+ act(() => {
16
+ result.current.setInternalSortModel([{ field: 'name', sort: 'asc' }]);
17
+ });
18
+ expect(result.current.sortModel).toEqual([{ field: 'name', sort: 'asc' }]);
19
+ });
20
+
21
+ it('controlled: external sortModel prop always wins', () => {
22
+ const externalSort = [{ field: 'age', sort: 'desc' as const }];
23
+ const { result } = renderHook(() =>
24
+ useGridControlledState({ ...BASE_PARAMS, sortModel: externalSort })
25
+ );
26
+ expect(result.current.isSortControlled).toBe(true);
27
+ expect(result.current.sortModel).toEqual(externalSort);
28
+ act(() => {
29
+ result.current.setInternalSortModel([{ field: 'name', sort: 'asc' }]);
30
+ });
31
+ // External prop still wins
32
+ expect(result.current.sortModel).toEqual(externalSort);
33
+ });
34
+
35
+ it('initialState.sorting seeds the internal sort model', () => {
36
+ const { result } = renderHook(() =>
37
+ useGridControlledState({
38
+ ...BASE_PARAMS,
39
+ initialState: { sorting: { sortModel: [{ field: 'name', sort: 'asc' }] } },
40
+ })
41
+ );
42
+ expect(result.current.sortModel).toEqual([{ field: 'name', sort: 'asc' }]);
43
+ });
44
+ });
45
+
46
+ describe('useGridControlledState — pagination', () => {
47
+ it('uncontrolled: handlePaginationModelChange updates effectivePaginationModel', () => {
48
+ const { result } = renderHook(() => useGridControlledState(BASE_PARAMS));
49
+ act(() => {
50
+ result.current.handlePaginationModelChange({ page: 2, pageSize: 25 });
51
+ });
52
+ expect(result.current.effectivePaginationModel).toEqual({ page: 2, pageSize: 25 });
53
+ });
54
+
55
+ it('controlled: external paginationModel always wins', () => {
56
+ const externalPagination = { page: 3, pageSize: 50 };
57
+ const { result } = renderHook(() =>
58
+ useGridControlledState({ ...BASE_PARAMS, paginationModel: externalPagination })
59
+ );
60
+ expect(result.current.effectivePaginationModel).toEqual(externalPagination);
61
+ act(() => {
62
+ result.current.handlePaginationModelChange({ page: 0, pageSize: 10 });
63
+ });
64
+ expect(result.current.effectivePaginationModel).toEqual(externalPagination);
65
+ });
66
+
67
+ it('initialState.pagination seeds page and pageSize', () => {
68
+ const { result } = renderHook(() =>
69
+ useGridControlledState({
70
+ ...BASE_PARAMS,
71
+ initialState: { pagination: { paginationModel: { page: 1, pageSize: 25 } } },
72
+ })
73
+ );
74
+ expect(result.current.effectivePaginationModel).toEqual({ page: 1, pageSize: 25 });
75
+ });
76
+ });
77
+
78
+ describe('useGridControlledState — selection', () => {
79
+ it('uncontrolled: setInternalRowSelectionModel updates selectedRowIds', () => {
80
+ const { result } = renderHook(() => useGridControlledState(BASE_PARAMS));
81
+ act(() => {
82
+ result.current.setInternalRowSelectionModel([1, 2, 3]);
83
+ });
84
+ expect(result.current.selectedRowIds).toEqual(new Set([1, 2, 3]));
85
+ });
86
+ });
@@ -0,0 +1,176 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { renderHook, act } from '@testing-library/react';
3
+ import React from 'react';
4
+ import { useGridKeyboardNavigation } from './useGridKeyboardNavigation';
5
+
6
+ // Helper: build a minimal React.KeyboardEvent mock
7
+ function keyEvent(key: string): React.KeyboardEvent {
8
+ return {
9
+ key,
10
+ preventDefault: vi.fn(),
11
+ stopPropagation: vi.fn(),
12
+ nativeEvent: new KeyboardEvent('keydown', { key }),
13
+ isDefaultPrevented: () => false,
14
+ isPropagationStopped: () => false,
15
+ persist: vi.fn(),
16
+ bubbles: true,
17
+ cancelable: true,
18
+ defaultPrevented: false,
19
+ eventPhase: 0,
20
+ isTrusted: false,
21
+ timeStamp: 0,
22
+ type: 'keydown',
23
+ currentTarget: null,
24
+ target: null,
25
+ shiftKey: false,
26
+ ctrlKey: false,
27
+ metaKey: false,
28
+ altKey: false,
29
+ } as unknown as React.KeyboardEvent;
30
+ }
31
+
32
+ const ROWS = [
33
+ { id: 1, name: 'Alice' },
34
+ { id: 2, name: 'Bob' },
35
+ { id: 3, name: 'Carol' },
36
+ ];
37
+
38
+ const NAV_COLS = [
39
+ { field: 'name' },
40
+ { field: 'age' },
41
+ ];
42
+
43
+ const MOCK_EDITING = {
44
+ editingCell: null,
45
+ startCellEdit: vi.fn(),
46
+ stopCellEdit: vi.fn(),
47
+ };
48
+
49
+ const MOCK_VIRTUALIZATION = {
50
+ cumulativeHeights: [52, 104, 156],
51
+ pinnedTopHeight: 0,
52
+ columnMetrics: null,
53
+ };
54
+
55
+ const BASE_PARAMS = {
56
+ allRenderableRows: ROWS,
57
+ navigationColumns: NAV_COLS,
58
+ checkboxSelection: false,
59
+ selectedRowIds: new Set<number | string>(),
60
+ handleSelectionChange: vi.fn(),
61
+ handleDetailPanelToggle: vi.fn(),
62
+ editingHandlers: MOCK_EDITING,
63
+ setKeyboardMode: vi.fn(),
64
+ sortModel: [],
65
+ handleSort: vi.fn(),
66
+ isCellEditable: undefined,
67
+ pagination: false,
68
+ pageSize: 100,
69
+ virtualization: MOCK_VIRTUALIZATION,
70
+ viewportRef: { current: null } as React.RefObject<HTMLDivElement | null>,
71
+ };
72
+
73
+ describe('useGridKeyboardNavigation — arrow keys', () => {
74
+ it('ArrowDown moves focusedCell to the next row', () => {
75
+ const { result } = renderHook(() => useGridKeyboardNavigation(BASE_PARAMS));
76
+ act(() => { result.current.setFocusedCell({ id: 1, field: 'name' }); });
77
+ act(() => { result.current.handleKeyDown(keyEvent('ArrowDown')); });
78
+ expect(result.current.focusedCell).toEqual({ id: 2, field: 'name' });
79
+ });
80
+
81
+ it('ArrowUp at header row does not move above it', () => {
82
+ const { result } = renderHook(() => useGridKeyboardNavigation(BASE_PARAMS));
83
+ // HEADER is the topmost position; ArrowUp from there should not move
84
+ act(() => { result.current.setFocusedCell({ id: 'HEADER', field: 'name' }); });
85
+ act(() => { result.current.handleKeyDown(keyEvent('ArrowUp')); });
86
+ expect(result.current.focusedCell?.id).toBe('HEADER');
87
+ });
88
+
89
+ it('ArrowDown at last row — no change', () => {
90
+ const { result } = renderHook(() => useGridKeyboardNavigation(BASE_PARAMS));
91
+ // ROWS has 3 entries; last row id is 3
92
+ const lastRow = ROWS[ROWS.length - 1];
93
+ act(() => { result.current.setFocusedCell({ id: lastRow.id, field: 'name' }); });
94
+ act(() => { result.current.handleKeyDown(keyEvent('ArrowDown')); });
95
+ expect(result.current.focusedCell?.id).toBe(lastRow.id);
96
+ });
97
+
98
+ it('ArrowRight moves focusedCell to next column', () => {
99
+ const { result } = renderHook(() => useGridKeyboardNavigation(BASE_PARAMS));
100
+ act(() => { result.current.setFocusedCell({ id: 1, field: 'name' }); });
101
+ act(() => { result.current.handleKeyDown(keyEvent('ArrowRight')); });
102
+ expect(result.current.focusedCell?.field).toBe('age');
103
+ });
104
+
105
+ it('ArrowLeft at header first column does not move left', () => {
106
+ const { result } = renderHook(() => useGridKeyboardNavigation(BASE_PARAMS));
107
+ // HEADER + first col is the leftmost position; wrap-left would require r=-2 which is out of bounds
108
+ act(() => { result.current.setFocusedCell({ id: 'HEADER', field: 'name' }); });
109
+ act(() => { result.current.handleKeyDown(keyEvent('ArrowLeft')); });
110
+ expect(result.current.focusedCell?.field).toBe('name');
111
+ });
112
+ });
113
+
114
+ describe('useGridKeyboardNavigation — Home / End', () => {
115
+ it('Home moves to first column', () => {
116
+ const { result } = renderHook(() => useGridKeyboardNavigation(BASE_PARAMS));
117
+ act(() => { result.current.setFocusedCell({ id: 1, field: 'age' }); });
118
+ act(() => { result.current.handleKeyDown(keyEvent('Home')); });
119
+ expect(result.current.focusedCell?.field).toBe('name');
120
+ });
121
+
122
+ it('End moves to last column', () => {
123
+ const { result } = renderHook(() => useGridKeyboardNavigation(BASE_PARAMS));
124
+ act(() => { result.current.setFocusedCell({ id: 1, field: 'name' }); });
125
+ act(() => { result.current.handleKeyDown(keyEvent('End')); });
126
+ expect(result.current.focusedCell?.field).toBe('age');
127
+ });
128
+ });
129
+
130
+ describe('useGridKeyboardNavigation — editing', () => {
131
+ it('Enter calls startCellEdit on an editable cell', () => {
132
+ const startCellEdit = vi.fn();
133
+ const params = {
134
+ ...BASE_PARAMS,
135
+ editingHandlers: { ...MOCK_EDITING, startCellEdit },
136
+ navigationColumns: [{ field: 'name', editable: true }],
137
+ };
138
+ const { result } = renderHook(() => useGridKeyboardNavigation(params));
139
+ act(() => { result.current.setFocusedCell({ id: 1, field: 'name' }); });
140
+ act(() => { result.current.handleKeyDown(keyEvent('Enter')); });
141
+ expect(startCellEdit).toHaveBeenCalledWith(
142
+ expect.objectContaining({ id: 1, field: 'name' })
143
+ );
144
+ });
145
+
146
+ it('Escape calls stopCellEdit with cancel=true when editing', () => {
147
+ const stopCellEdit = vi.fn();
148
+ const params = {
149
+ ...BASE_PARAMS,
150
+ editingHandlers: {
151
+ editingCell: { id: 1, field: 'name', value: 'Alice' },
152
+ startCellEdit: vi.fn(),
153
+ stopCellEdit,
154
+ },
155
+ };
156
+ const { result } = renderHook(() => useGridKeyboardNavigation(params));
157
+ act(() => { result.current.setFocusedCell({ id: 1, field: 'name' }); });
158
+ act(() => { result.current.handleKeyDown(keyEvent('Escape')); });
159
+ expect(stopCellEdit).toHaveBeenCalledWith({ cancel: true });
160
+ });
161
+ });
162
+
163
+ describe('useGridKeyboardNavigation — selection', () => {
164
+ it('Space toggles checkbox selection on focused row', () => {
165
+ const handleSelectionChange = vi.fn();
166
+ const { result } = renderHook(() => useGridKeyboardNavigation({
167
+ ...BASE_PARAMS,
168
+ checkboxSelection: true,
169
+ handleSelectionChange,
170
+ }));
171
+ // Space only fires handleSelectionChange when field === '__checkbox_col__'
172
+ act(() => { result.current.setFocusedCell({ id: 2, field: '__checkbox_col__' }); });
173
+ act(() => { result.current.handleKeyDown(keyEvent(' ')); });
174
+ expect(handleSelectionChange).toHaveBeenCalledWith(2, true);
175
+ });
176
+ });
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { renderHook } from '@testing-library/react';
3
+ import { useGridRowPipeline } from './useGridRowPipeline';
4
+
5
+ const DEFAULT_PAGINATION = { page: 0, pageSize: 3 };
6
+
7
+ const ROWS = [
8
+ { id: 1, name: 'Alice', age: 30 },
9
+ { id: 2, name: 'Bob', age: 25 },
10
+ { id: 3, name: 'Carol', age: 35 },
11
+ { id: 4, name: 'Dan', age: 28 },
12
+ { id: 5, name: 'Eve', age: 22 },
13
+ ];
14
+
15
+ const BASE_PARAMS = {
16
+ effectiveRows: ROWS,
17
+ activeHierarchyHandlers: null,
18
+ filterMode: 'client',
19
+ filterModel: { items: [] },
20
+ sortModel: [],
21
+ sortingMode: 'client',
22
+ pagination: false,
23
+ paginationMode: 'client',
24
+ effectivePaginationModel: DEFAULT_PAGINATION,
25
+ isLoading: false,
26
+ pageSize: 3,
27
+ };
28
+
29
+ describe('useGridRowPipeline — filtering', () => {
30
+ it('passes all rows through when filterModel is empty', () => {
31
+ const { result } = renderHook(() => useGridRowPipeline(BASE_PARAMS));
32
+ expect(result.current.filteredRows).toHaveLength(5);
33
+ });
34
+
35
+ it('filters rows by string contains operator', () => {
36
+ const { result } = renderHook(() => useGridRowPipeline({
37
+ ...BASE_PARAMS,
38
+ filterModel: { items: [{ id: 'f1', field: 'name', operator: 'contains' as const, value: 'al' }] },
39
+ }));
40
+ // 'Alice' contains 'al' (case-insensitive); 'Carol' does not ('car-o-l')
41
+ expect(result.current.filteredRows.map(r => r.id)).toEqual([1]);
42
+ });
43
+
44
+ it('filterMode=server passes rows through unfiltered', () => {
45
+ const { result } = renderHook(() => useGridRowPipeline({
46
+ ...BASE_PARAMS,
47
+ filterMode: 'server',
48
+ // dataSource required: hook only bypasses filtering when both filterMode=server AND dataSource are set
49
+ dataSource: {} as never,
50
+ filterModel: { items: [{ id: 'f1', field: 'name', operator: 'contains' as const, value: 'Alice' }] },
51
+ }));
52
+ expect(result.current.filteredRows).toHaveLength(5);
53
+ });
54
+ });
55
+
56
+ describe('useGridRowPipeline — sorting', () => {
57
+ it('sorts rows ascending by field', () => {
58
+ const { result } = renderHook(() => useGridRowPipeline({
59
+ ...BASE_PARAMS,
60
+ sortModel: [{ field: 'age', sort: 'asc' }],
61
+ }));
62
+ const ages = result.current.sortedUnpinnedRows.map(r => r.age);
63
+ expect(ages).toEqual([22, 25, 28, 30, 35]);
64
+ });
65
+
66
+ it('sorts rows descending by field', () => {
67
+ const { result } = renderHook(() => useGridRowPipeline({
68
+ ...BASE_PARAMS,
69
+ sortModel: [{ field: 'age', sort: 'desc' }],
70
+ }));
71
+ const ages = result.current.sortedUnpinnedRows.map(r => r.age);
72
+ expect(ages).toEqual([35, 30, 28, 25, 22]);
73
+ });
74
+
75
+ it('sortingMode=server passes rows through unsorted', () => {
76
+ const { result } = renderHook(() => useGridRowPipeline({
77
+ ...BASE_PARAMS,
78
+ sortingMode: 'server',
79
+ // dataSource required: hook only bypasses sorting when both sortingMode=server AND dataSource are set
80
+ dataSource: {} as never,
81
+ sortModel: [{ field: 'age', sort: 'asc' }],
82
+ }));
83
+ // Rows stay in original order
84
+ expect(result.current.sortedUnpinnedRows.map(r => r.id)).toEqual([1, 2, 3, 4, 5]);
85
+ });
86
+ });
87
+
88
+ describe('useGridRowPipeline — pagination', () => {
89
+ it('returns first pageSize rows on page 0', () => {
90
+ const { result } = renderHook(() => useGridRowPipeline({
91
+ ...BASE_PARAMS,
92
+ pagination: true,
93
+ effectivePaginationModel: { page: 0, pageSize: 3 },
94
+ }));
95
+ expect(result.current.paginatedUnpinnedRows.map(r => r.id)).toEqual([1, 2, 3]);
96
+ });
97
+
98
+ it('returns correct offset slice on page 1', () => {
99
+ const { result } = renderHook(() => useGridRowPipeline({
100
+ ...BASE_PARAMS,
101
+ pagination: true,
102
+ effectivePaginationModel: { page: 1, pageSize: 3 },
103
+ }));
104
+ expect(result.current.paginatedUnpinnedRows.map(r => r.id)).toEqual([4, 5]);
105
+ });
106
+
107
+ it('paginationMode=server returns all rows unsliced', () => {
108
+ const { result } = renderHook(() => useGridRowPipeline({
109
+ ...BASE_PARAMS,
110
+ pagination: true,
111
+ paginationMode: 'server',
112
+ dataSource: {} as never,
113
+ effectivePaginationModel: { page: 0, pageSize: 2 },
114
+ }));
115
+ expect(result.current.paginatedUnpinnedRows).toHaveLength(5);
116
+ });
117
+ });