@opencorestack/opengridx 1.0.7 → 1.1.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 (33) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/index.d.ts +40 -12
  3. package/dist/opengridx.css +1 -1
  4. package/dist/opengridx.es.js +2953 -2885
  5. package/dist/opengridx.es.js.map +1 -1
  6. package/dist/opengridx.umd.js +11 -11
  7. package/dist/opengridx.umd.js.map +1 -1
  8. package/docs/API_REFERENCE.md +35 -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/roadmap.md +8 -0
  14. package/lib/components/Cell/Cell.tsx +44 -44
  15. package/lib/components/Cell/CellErrorBoundary.tsx +53 -0
  16. package/lib/components/DataGrid/DataGrid.css +11 -0
  17. package/lib/components/DataGrid/DataGrid.tsx +30 -6
  18. package/lib/components/DataGrid/GridListView.tsx +4 -0
  19. package/lib/components/DataGrid/GridPinnedRows.tsx +4 -0
  20. package/lib/components/DataGrid/GridVirtualRows.tsx +4 -0
  21. package/lib/components/Pagination/Pagination.test.tsx +64 -0
  22. package/lib/components/Pagination/Pagination.tsx +15 -4
  23. package/lib/components/Row/Row.tsx +6 -3
  24. package/lib/hooks/core/useGridColumns.tsx +11 -11
  25. package/lib/hooks/core/useGridControlledState.test.ts +86 -0
  26. package/lib/hooks/core/useGridKeyboardNavigation.test.ts +176 -0
  27. package/lib/hooks/core/useGridRowPipeline.test.ts +117 -0
  28. package/lib/hooks/useRowGrouping.test.ts +84 -0
  29. package/lib/hooks/useRowGrouping.ts +38 -13
  30. package/lib/hooks/useTreeData.ts +22 -2
  31. package/lib/index.ts +2 -0
  32. package/lib/types/index.ts +38 -12
  33. package/package.json +1 -1
@@ -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
+ });
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { renderHook } from '@testing-library/react';
3
+ import { useRowGrouping } from './useRowGrouping';
4
+
5
+ const ROWS = [
6
+ { id: 1, dept: 'Engineering', name: 'Alice' },
7
+ { id: 2, dept: 'Engineering', name: 'Bob' },
8
+ { id: 3, dept: 'HR', name: 'Carol' },
9
+ ];
10
+
11
+ const GET_ROW_ID = (row: { id: number }) => row.id;
12
+ const GROUPING_MODEL = ['dept'];
13
+ const AGGREGATION_MODEL = {};
14
+
15
+ const BASE_PARAMS = {
16
+ rows: ROWS,
17
+ getRowId: GET_ROW_ID,
18
+ rowGroupingModel: GROUPING_MODEL,
19
+ aggregationModel: AGGREGATION_MODEL,
20
+ };
21
+
22
+ describe('useRowGrouping — rowMetaMap', () => {
23
+ it('includes treeDepth for group rows', () => {
24
+ const { result } = renderHook(() => useRowGrouping(BASE_PARAMS));
25
+ const map = result.current.rowMetaMap;
26
+ // Group rows are at depth 0 (top-level)
27
+ let foundGroupAtDepth0 = false;
28
+ map.forEach((meta, _id) => {
29
+ if (meta.isGroupRow) {
30
+ expect(meta.treeDepth).toBe(0);
31
+ foundGroupAtDepth0 = true;
32
+ }
33
+ });
34
+ expect(foundGroupAtDepth0).toBe(true);
35
+ });
36
+
37
+ it('includes treeDepth for leaf rows', () => {
38
+ const { result } = renderHook(() => useRowGrouping(BASE_PARAMS));
39
+ const map = result.current.rowMetaMap;
40
+ // Leaf rows (original rows) are at depth 1 (under the group)
41
+ let foundLeafAtDepth1 = false;
42
+ map.forEach((meta, _id) => {
43
+ if (!meta.isGroupRow) {
44
+ expect(meta.treeDepth).toBe(1);
45
+ foundLeafAtDepth1 = true;
46
+ }
47
+ });
48
+ expect(foundLeafAtDepth1).toBe(true);
49
+ });
50
+
51
+ it('sets isGroupRow true for group rows and false for leaves', () => {
52
+ const { result } = renderHook(() => useRowGrouping(BASE_PARAMS));
53
+ const map = result.current.rowMetaMap;
54
+ // Leaf rows have numeric ids (1, 2, 3)
55
+ expect(map.get(1)?.isGroupRow).toBe(false);
56
+ expect(map.get(2)?.isGroupRow).toBe(false);
57
+ expect(map.get(3)?.isGroupRow).toBe(false);
58
+ // Group rows use auto-generated string ids
59
+ let groupCount = 0;
60
+ map.forEach((meta) => {
61
+ if (meta.isGroupRow) groupCount++;
62
+ });
63
+ // Two groups: Engineering and HR
64
+ expect(groupCount).toBe(2);
65
+ });
66
+
67
+ it('includes all rows (group + leaf) in the map', () => {
68
+ const { result } = renderHook(() => useRowGrouping(BASE_PARAMS));
69
+ const map = result.current.rowMetaMap;
70
+ // 3 leaf rows + 2 group rows = 5 entries
71
+ expect(map.size).toBe(5);
72
+ });
73
+
74
+ it('has hasChildren true for group rows and false for leaf rows', () => {
75
+ const { result } = renderHook(() => useRowGrouping(BASE_PARAMS));
76
+ const map = result.current.rowMetaMap;
77
+ expect(map.get(1)?.hasChildren).toBe(false);
78
+ map.forEach((meta) => {
79
+ if (meta.isGroupRow) {
80
+ expect(meta.hasChildren).toBe(true);
81
+ }
82
+ });
83
+ });
84
+ });