@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.
- package/CHANGELOG.md +30 -0
- package/dist/index.d.ts +100 -12
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +3199 -3015
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +12 -12
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +85 -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/features/pdf-export.md +110 -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 +5 -1
- package/lib/types/index.ts +107 -12
- package/lib/utils/export/exportToPdf.test.ts +121 -0
- package/lib/utils/export/exportToPdf.ts +277 -0
- package/lib/utils/export/index.ts +3 -0
- package/lib/utils/export/pdf-types.d.ts +19 -0
- 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
|
-
|
|
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
|
+
});
|