@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.
- package/CHANGELOG.md +18 -0
- package/dist/index.d.ts +40 -12
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +2953 -2885
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +11 -11
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +35 -0
- package/docs/components/cell.md +14 -0
- package/docs/components/filter-panel.md +13 -0
- package/docs/components/pagination.md +26 -0
- package/docs/components/row.md +26 -0
- package/docs/roadmap.md +8 -0
- package/lib/components/Cell/Cell.tsx +44 -44
- package/lib/components/Cell/CellErrorBoundary.tsx +53 -0
- package/lib/components/DataGrid/DataGrid.css +11 -0
- package/lib/components/DataGrid/DataGrid.tsx +30 -6
- package/lib/components/DataGrid/GridListView.tsx +4 -0
- package/lib/components/DataGrid/GridPinnedRows.tsx +4 -0
- package/lib/components/DataGrid/GridVirtualRows.tsx +4 -0
- package/lib/components/Pagination/Pagination.test.tsx +64 -0
- package/lib/components/Pagination/Pagination.tsx +15 -4
- package/lib/components/Row/Row.tsx +6 -3
- package/lib/hooks/core/useGridColumns.tsx +11 -11
- package/lib/hooks/core/useGridControlledState.test.ts +86 -0
- package/lib/hooks/core/useGridKeyboardNavigation.test.ts +176 -0
- package/lib/hooks/core/useGridRowPipeline.test.ts +117 -0
- package/lib/hooks/useRowGrouping.test.ts +84 -0
- package/lib/hooks/useRowGrouping.ts +38 -13
- package/lib/hooks/useTreeData.ts +22 -2
- package/lib/index.ts +2 -0
- package/lib/types/index.ts +38 -12
- 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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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 =
|
|
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
|
|
78
|
-
const depth =
|
|
79
|
-
const hasChildren = Boolean(
|
|
80
|
-
const isExpanded = Boolean(
|
|
81
|
-
const groupingField =
|
|
82
|
-
const groupingValue =
|
|
83
|
-
const descendantCount =
|
|
84
|
-
const isGroupRow = Boolean(
|
|
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
|
|
134
|
-
const hasChildren = Boolean(
|
|
135
|
-
const groupingField =
|
|
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
|
+
});
|