@opencorestack/opengridx 1.0.6 → 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 +3212 -3130
- 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 +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/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +0 -2
- package/lib/components/DataGrid/DataGrid.css +47 -0
- package/lib/components/DataGrid/DataGrid.tsx +34 -8
- package/lib/components/DataGrid/GridAggregationFooter.tsx +47 -9
- package/lib/components/DataGrid/GridListView.tsx +4 -0
- package/lib/components/DataGrid/GridPinnedRows.tsx +4 -0
- package/lib/components/DataGrid/GridStandaloneColumnPanel.tsx +3 -1
- package/lib/components/DataGrid/GridVirtualRows.tsx +4 -0
- package/lib/components/FilterPanel/FilterPanel.tsx +5 -4
- package/lib/components/Header/Header.tsx +4 -3
- package/lib/components/Pagination/Pagination.test.tsx +64 -0
- package/lib/components/Pagination/Pagination.tsx +19 -7
- package/lib/components/Row/Row.tsx +9 -5
- package/lib/components/Toolbar/GlobalSearch.tsx +0 -2
- package/lib/components/Toolbar/GridToolbar.tsx +0 -4
- 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
|
@@ -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
|
+
});
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { useState, useMemo, useEffect, useCallback } from 'react';
|
|
2
|
-
import type {
|
|
3
|
-
GridRowModel,
|
|
4
|
-
GridRowId,
|
|
5
|
-
GridRowGroupingModel,
|
|
6
|
-
GridAggregationModel,
|
|
2
|
+
import type {
|
|
3
|
+
GridRowModel,
|
|
4
|
+
GridRowId,
|
|
5
|
+
GridRowGroupingModel,
|
|
6
|
+
GridAggregationModel,
|
|
7
7
|
GridTreeNode,
|
|
8
8
|
GridFilterModel,
|
|
9
|
-
GridSortItem
|
|
9
|
+
GridSortItem,
|
|
10
|
+
GridRowMeta
|
|
10
11
|
} from '../types';
|
|
11
12
|
import { isRowMatchingFilter } from '../utils/filtering';
|
|
12
13
|
import { compareValues } from '../utils/sorting';
|
|
@@ -27,12 +28,18 @@ const defaultGetAggregationPosition = (groupNode: GridTreeNode | null): 'inline'
|
|
|
27
28
|
return groupNode?.depth === -1 ? 'footer' : 'inline';
|
|
28
29
|
};
|
|
29
30
|
|
|
31
|
+
// Stable empty defaults — module-level constants prevent new object identity
|
|
32
|
+
// on every render, which would otherwise invalidate useMemo deps and cause
|
|
33
|
+
// infinite re-render loops when callers omit optional params.
|
|
34
|
+
const EMPTY_ROW_GROUPING_MODEL: GridRowGroupingModel = [];
|
|
35
|
+
const EMPTY_AGGREGATION_MODEL: GridAggregationModel = {};
|
|
36
|
+
|
|
30
37
|
export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingParams<R>) {
|
|
31
|
-
const {
|
|
32
|
-
rows,
|
|
33
|
-
getRowId,
|
|
34
|
-
rowGroupingModel =
|
|
35
|
-
aggregationModel =
|
|
38
|
+
const {
|
|
39
|
+
rows,
|
|
40
|
+
getRowId,
|
|
41
|
+
rowGroupingModel = EMPTY_ROW_GROUPING_MODEL,
|
|
42
|
+
aggregationModel = EMPTY_AGGREGATION_MODEL,
|
|
36
43
|
defaultGroupingExpansionDepth = 0,
|
|
37
44
|
filterModel,
|
|
38
45
|
sortModel,
|
|
@@ -291,12 +298,30 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
|
|
|
291
298
|
|
|
292
299
|
const getNode = useCallback((id: GridRowId) => treeNodes.get(id), [treeNodes]);
|
|
293
300
|
|
|
301
|
+
const rowMetaMap = useMemo<Map<GridRowId, GridRowMeta>>(() => {
|
|
302
|
+
const map = new Map<GridRowId, GridRowMeta>();
|
|
303
|
+
treeNodes.forEach((node, id) => {
|
|
304
|
+
const isGroup = Boolean(node.children && node.children.length > 0);
|
|
305
|
+
map.set(id, {
|
|
306
|
+
hasChildren: isGroup,
|
|
307
|
+
treeDepth: node.depth,
|
|
308
|
+
groupingField: node.groupingField,
|
|
309
|
+
groupingValue: node.groupingValue,
|
|
310
|
+
descendantCount: node.descendantCount,
|
|
311
|
+
isGroupRow: isGroup,
|
|
312
|
+
isExpanded: isGroup ? expandedGroupIds.has(id) : undefined,
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
return map;
|
|
316
|
+
}, [treeNodes, expandedGroupIds]);
|
|
317
|
+
|
|
294
318
|
return useMemo(() => ({
|
|
295
319
|
treeNodes,
|
|
296
320
|
groupingRows,
|
|
297
321
|
toggleExpansion,
|
|
298
322
|
isGroupExpanded,
|
|
299
323
|
getVisibleRows,
|
|
300
|
-
getNode
|
|
301
|
-
|
|
324
|
+
getNode,
|
|
325
|
+
rowMetaMap,
|
|
326
|
+
}), [treeNodes, groupingRows, toggleExpansion, isGroupExpanded, getVisibleRows, getNode, rowMetaMap]);
|
|
302
327
|
}
|
package/lib/hooks/useTreeData.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useMemo, useState, useCallback, useEffect } from 'react';
|
|
2
2
|
import { GridRowModel, GridRowId, GridTreeNode, GridFilterModel, GridSortItem } from '../types';
|
|
3
|
+
import type { GridRowMeta } from '../types';
|
|
3
4
|
import { isRowMatchingFilter } from '../utils/filtering';
|
|
4
5
|
import { compareValues } from '../utils/sorting';
|
|
5
6
|
|
|
@@ -311,6 +312,24 @@ export function useTreeData<R extends GridRowModel>(props: UseTreeDataProps<R>)
|
|
|
311
312
|
return path;
|
|
312
313
|
}, [treeNodes]);
|
|
313
314
|
|
|
315
|
+
const rowMetaMap = useMemo<Map<GridRowId, GridRowMeta>>(() => {
|
|
316
|
+
const map = new Map<GridRowId, GridRowMeta>();
|
|
317
|
+
const groupRowIds = new Set(groupingRows.map(r => r.id));
|
|
318
|
+
treeNodes.forEach((node, id) => {
|
|
319
|
+
map.set(id, {
|
|
320
|
+
hasChildren: Boolean(
|
|
321
|
+
(node.children && node.children.length > 0) ||
|
|
322
|
+
(node.serverChildrenCount && node.serverChildrenCount > 0)
|
|
323
|
+
),
|
|
324
|
+
treeDepth: node.depth,
|
|
325
|
+
descendantCount: node.children ? node.children.length : undefined,
|
|
326
|
+
isGroupRow: groupRowIds.has(id),
|
|
327
|
+
isExpanded: expandedGroupIds.has(id),
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
return map;
|
|
331
|
+
}, [treeNodes, groupingRows, expandedGroupIds]);
|
|
332
|
+
|
|
314
333
|
return useMemo(() => ({
|
|
315
334
|
treeNodes,
|
|
316
335
|
groupingRows,
|
|
@@ -318,6 +337,7 @@ export function useTreeData<R extends GridRowModel>(props: UseTreeDataProps<R>)
|
|
|
318
337
|
isGroupExpanded,
|
|
319
338
|
getVisibleRows,
|
|
320
339
|
getNode,
|
|
321
|
-
getNodePath
|
|
322
|
-
|
|
340
|
+
getNodePath,
|
|
341
|
+
rowMetaMap,
|
|
342
|
+
}), [treeNodes, groupingRows, toggleExpansion, isGroupExpanded, getVisibleRows, getNode, getNodePath, rowMetaMap]);
|
|
323
343
|
}
|
package/lib/index.ts
CHANGED
package/lib/types/index.ts
CHANGED
|
@@ -7,26 +7,33 @@ export type GridRowId = string | number;
|
|
|
7
7
|
export interface GridRowModel {
|
|
8
8
|
/** Uniquely identifies the row. */
|
|
9
9
|
id: GridRowId;
|
|
10
|
-
/** Internal: row has child nodes (tree data / row grouping). */
|
|
11
|
-
_hasChildren?: boolean;
|
|
12
10
|
/** Internal: row is a skeleton placeholder during infinite-scroll loading. */
|
|
13
11
|
_isSkeleton?: boolean;
|
|
14
|
-
/** Internal: depth in the tree or grouping hierarchy. */
|
|
15
|
-
_treeDepth?: number;
|
|
16
|
-
/** Internal: whether the group/tree node is currently expanded. */
|
|
17
|
-
_isExpanded?: boolean;
|
|
18
|
-
/** Internal: field used for grouping at this level. */
|
|
19
|
-
_groupingField?: string;
|
|
20
|
-
/** Internal: grouping field value at this level. */
|
|
21
|
-
_groupingValue?: unknown;
|
|
22
|
-
/** Internal: total descendant count for tree/grouping nodes. */
|
|
23
|
-
_descendantCount?: number;
|
|
24
12
|
/** Internal: server-reported child count for lazy-loaded tree nodes. */
|
|
25
13
|
serverChildrenCount?: number;
|
|
26
14
|
/** Any other dynamic row properties. */
|
|
27
15
|
[key: string]: unknown;
|
|
28
16
|
}
|
|
29
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Internal hierarchy metadata for a row. Exposed via `params.rowMeta` in `renderCell`.
|
|
20
|
+
* Not present (`undefined`) for flat rows with no active tree-data or row-grouping.
|
|
21
|
+
*
|
|
22
|
+
* @since v1.1 — replaces the `_hasChildren` / `_treeDepth` / `_groupingField` etc.
|
|
23
|
+
* properties that were previously (and incorrectly) typed on `GridRowModel`.
|
|
24
|
+
* Those underscore fields remain on the row object at runtime in v1.1 for
|
|
25
|
+
* backward compatibility; they will be removed from the runtime in v2.0.
|
|
26
|
+
*/
|
|
27
|
+
export interface GridRowMeta {
|
|
28
|
+
hasChildren?: boolean;
|
|
29
|
+
treeDepth?: number;
|
|
30
|
+
groupingField?: string;
|
|
31
|
+
groupingValue?: unknown;
|
|
32
|
+
descendantCount?: number;
|
|
33
|
+
isExpanded?: boolean;
|
|
34
|
+
isGroupRow?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
30
37
|
export type GridAlignment = 'left' | 'center' | 'right';
|
|
31
38
|
|
|
32
39
|
/** Represents a row node in the grid's hierarchical tree structure. */
|
|
@@ -231,6 +238,11 @@ export interface GridRenderCellParams<R extends GridRowModel = GridRowModel> {
|
|
|
231
238
|
rowIndex: number;
|
|
232
239
|
/** The index of the column. */
|
|
233
240
|
colIndex: number;
|
|
241
|
+
/**
|
|
242
|
+
* Hierarchy metadata for this row. `undefined` for flat rows (no tree-data or row-grouping active).
|
|
243
|
+
* @since v1.1
|
|
244
|
+
*/
|
|
245
|
+
rowMeta?: GridRowMeta;
|
|
234
246
|
}
|
|
235
247
|
|
|
236
248
|
export interface GridRenderHeaderParams {
|
|
@@ -463,6 +475,18 @@ export interface GridRowScrollEndParams {
|
|
|
463
475
|
viewportHeight: number;
|
|
464
476
|
}
|
|
465
477
|
|
|
478
|
+
/** Overrideable user-visible strings for internationalisation. All fields are optional; defaults match the built-in English strings. */
|
|
479
|
+
export interface GridLocaleText {
|
|
480
|
+
/** Label before the rows-per-page select. Default: `"Rows per page:"` */
|
|
481
|
+
paginationRowsPerPage?: string;
|
|
482
|
+
/** Range label. Receives (from, to, count). Default: `` (f, t, c) => `${f}–${t} of ${c}` `` */
|
|
483
|
+
paginationOf?: (from: number, to: number, count: number) => string;
|
|
484
|
+
/** Page counter label. Receives (page, pageCount). Default: `` (p, pc) => `Page ${p} of ${pc}` `` */
|
|
485
|
+
paginationPage?: (page: number, pageCount: number) => string;
|
|
486
|
+
/** Empty-state label. When set, overrides the `noRowsLabel` prop on DataGrid. Default: `"No Data"` */
|
|
487
|
+
noRowsLabel?: string;
|
|
488
|
+
}
|
|
489
|
+
|
|
466
490
|
/**
|
|
467
491
|
* Main properties for the DataGrid component.
|
|
468
492
|
*/
|
|
@@ -555,6 +579,8 @@ export interface DataGridProps<R extends GridRowModel = GridRowModel> {
|
|
|
555
579
|
ariaLabel?: string;
|
|
556
580
|
/** Message displayed when there are no rows to show. */
|
|
557
581
|
noRowsLabel?: string;
|
|
582
|
+
/** Overrides for user-visible strings (i18n). All fields are optional. */
|
|
583
|
+
localeText?: GridLocaleText;
|
|
558
584
|
|
|
559
585
|
/** Advanced: The starting internal state of the grid. */
|
|
560
586
|
initialState?: import('../state/types').GridInitialState;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opencorestack/opengridx",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "OpenGridX: High-performance React data infrastructure. Unlock advanced Row Grouping, Excel Export, and Column Pinning without the usual \"Pro\" gatekeeping. Built for speed, scale, and complete architectural freedom. Fully open, virtualization-ready, and feature-complete.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/opengridx.umd.js",
|