@opencorestack/opengridx 1.3.0 → 2.0.1
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 +53 -0
- package/dist/index.d.ts +22 -6
- package/dist/opengridx.es.js +3673 -3617
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +16 -16
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +7 -9
- package/docs/components/datagrid.md +20 -2
- package/docs/components/header.md +3 -1
- package/docs/components/row.md +4 -9
- package/docs/features/pinning.md +0 -1
- package/docs/features/selection.md +22 -0
- package/docs/features/sorting-pagination.md +35 -0
- package/docs/features/tree-data-grouping.md +47 -4
- package/lib/components/DataGrid/DataGrid.tsx +86 -18
- package/lib/components/DataGrid/DataGrid.v2.test.tsx +127 -0
- package/lib/components/Header/Header.test.tsx +135 -0
- package/lib/components/Header/Header.tsx +20 -5
- package/lib/hooks/core/useGridColumns.tsx +2 -2
- package/lib/hooks/core/useGridControlledState.ts +3 -3
- package/lib/hooks/features/useAggregation.test.ts +40 -0
- package/lib/hooks/features/useAggregation.ts +14 -1
- package/lib/hooks/useRowGrouping.test.ts +53 -0
- package/lib/hooks/useRowGrouping.ts +22 -3
- package/lib/types/index.ts +18 -8
- package/llms.txt +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
4
|
+
import { DataGrid } from './DataGrid';
|
|
5
|
+
import type { GridColDef } from '../../types';
|
|
6
|
+
|
|
7
|
+
const COLUMNS: GridColDef[] = [
|
|
8
|
+
{ field: 'id', headerName: 'ID', width: 80 },
|
|
9
|
+
{ field: 'name', headerName: 'Name', width: 150 },
|
|
10
|
+
{ field: 'dept', headerName: 'Dept', width: 120 },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const ROWS = [
|
|
14
|
+
{ id: 1, name: 'Alice', dept: 'Eng' },
|
|
15
|
+
{ id: 2, name: 'Bob', dept: 'HR' },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
// ── density ──────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
describe('DataGrid — density prop', () => {
|
|
21
|
+
it('renders without throwing for compact density', () => {
|
|
22
|
+
expect(() =>
|
|
23
|
+
render(<DataGrid rows={ROWS} columns={COLUMNS} density="compact" />)
|
|
24
|
+
).not.toThrow();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('renders without throwing for comfortable density', () => {
|
|
28
|
+
expect(() =>
|
|
29
|
+
render(<DataGrid rows={ROWS} columns={COLUMNS} density="comfortable" />)
|
|
30
|
+
).not.toThrow();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('applies compact row height via CSS variable', () => {
|
|
34
|
+
const { container } = render(
|
|
35
|
+
<DataGrid rows={ROWS} columns={COLUMNS} density="compact" />
|
|
36
|
+
);
|
|
37
|
+
const root = container.firstElementChild as HTMLElement;
|
|
38
|
+
expect(root.style.getPropertyValue('--ogx-row-height')).toBe('32px');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('applies comfortable row height via CSS variable', () => {
|
|
42
|
+
const { container } = render(
|
|
43
|
+
<DataGrid rows={ROWS} columns={COLUMNS} density="comfortable" />
|
|
44
|
+
);
|
|
45
|
+
const root = container.firstElementChild as HTMLElement;
|
|
46
|
+
expect(root.style.getPropertyValue('--ogx-row-height')).toBe('72px');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('uses rowHeight as default when density is standard', () => {
|
|
50
|
+
const { container } = render(
|
|
51
|
+
<DataGrid rows={ROWS} columns={COLUMNS} density="standard" rowHeight={52} />
|
|
52
|
+
);
|
|
53
|
+
const root = container.firstElementChild as HTMLElement;
|
|
54
|
+
expect(root.style.getPropertyValue('--ogx-row-height')).toBe('52px');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ── disableRowSelectionOnClick ────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
describe('DataGrid — disableRowSelectionOnClick', () => {
|
|
61
|
+
it('clicking a row changes selection when prop is false (default)', () => {
|
|
62
|
+
const onChange = vi.fn();
|
|
63
|
+
render(
|
|
64
|
+
<DataGrid
|
|
65
|
+
rows={ROWS}
|
|
66
|
+
columns={COLUMNS}
|
|
67
|
+
onRowSelectionModelChange={onChange}
|
|
68
|
+
/>
|
|
69
|
+
);
|
|
70
|
+
const rows = screen.getAllByRole('row');
|
|
71
|
+
// rows[0] is header, rows[1] is Alice
|
|
72
|
+
fireEvent.click(rows[1]);
|
|
73
|
+
expect(onChange).toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('clicking a row does NOT change selection when disableRowSelectionOnClick=true', () => {
|
|
77
|
+
const onChange = vi.fn();
|
|
78
|
+
render(
|
|
79
|
+
<DataGrid
|
|
80
|
+
rows={ROWS}
|
|
81
|
+
columns={COLUMNS}
|
|
82
|
+
disableRowSelectionOnClick
|
|
83
|
+
onRowSelectionModelChange={onChange}
|
|
84
|
+
/>
|
|
85
|
+
);
|
|
86
|
+
const rows = screen.getAllByRole('row');
|
|
87
|
+
fireEvent.click(rows[1]);
|
|
88
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ── disableMultipleRowSelection ───────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
describe('DataGrid — disableMultipleRowSelection', () => {
|
|
95
|
+
it('selecting a second row replaces the first when disableMultipleRowSelection=true', () => {
|
|
96
|
+
const selections: unknown[][] = [];
|
|
97
|
+
render(
|
|
98
|
+
<DataGrid
|
|
99
|
+
rows={ROWS}
|
|
100
|
+
columns={COLUMNS}
|
|
101
|
+
disableMultipleRowSelection
|
|
102
|
+
onRowSelectionModelChange={(model) => selections.push(model)}
|
|
103
|
+
/>
|
|
104
|
+
);
|
|
105
|
+
const rows = screen.getAllByRole('row');
|
|
106
|
+
fireEvent.click(rows[1]); // select Alice (id=1)
|
|
107
|
+
fireEvent.click(rows[2]); // select Bob (id=2) — should replace
|
|
108
|
+
expect(selections.at(-1)).toEqual([2]);
|
|
109
|
+
expect(selections.at(-1)?.length).toBe(1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('clicking already-selected row deselects it when disableMultipleRowSelection=true', () => {
|
|
113
|
+
const selections: unknown[][] = [];
|
|
114
|
+
render(
|
|
115
|
+
<DataGrid
|
|
116
|
+
rows={ROWS}
|
|
117
|
+
columns={COLUMNS}
|
|
118
|
+
disableMultipleRowSelection
|
|
119
|
+
onRowSelectionModelChange={(model) => selections.push(model)}
|
|
120
|
+
/>
|
|
121
|
+
);
|
|
122
|
+
const rows = screen.getAllByRole('row');
|
|
123
|
+
fireEvent.click(rows[1]); // select
|
|
124
|
+
fireEvent.click(rows[1]); // deselect
|
|
125
|
+
expect(selections.at(-1)).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
4
|
+
import { Header } from './Header';
|
|
5
|
+
import type { GridColDef } from '../../types';
|
|
6
|
+
|
|
7
|
+
const COLUMNS: GridColDef[] = [
|
|
8
|
+
{ field: 'name', headerName: 'Name', width: 150 },
|
|
9
|
+
{ field: 'age', headerName: 'Age', width: 100 },
|
|
10
|
+
{ field: 'dept', headerName: 'Dept', width: 120 },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
describe('Header — multi-sort shift-click', () => {
|
|
14
|
+
it('plain click calls onSort with single field (replaces model)', () => {
|
|
15
|
+
const onSort = vi.fn();
|
|
16
|
+
const onSortAdd = vi.fn();
|
|
17
|
+
render(
|
|
18
|
+
<Header
|
|
19
|
+
columns={COLUMNS}
|
|
20
|
+
sortModel={[]}
|
|
21
|
+
onSort={onSort}
|
|
22
|
+
onSortAdd={onSortAdd}
|
|
23
|
+
/>
|
|
24
|
+
);
|
|
25
|
+
fireEvent.click(screen.getByText('Name'));
|
|
26
|
+
expect(onSort).toHaveBeenCalledWith('name', 'asc');
|
|
27
|
+
expect(onSortAdd).not.toHaveBeenCalled();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('shift-click calls onSortAdd (accumulates)', () => {
|
|
31
|
+
const onSort = vi.fn();
|
|
32
|
+
const onSortAdd = vi.fn();
|
|
33
|
+
render(
|
|
34
|
+
<Header
|
|
35
|
+
columns={COLUMNS}
|
|
36
|
+
sortModel={[{ field: 'name', sort: 'asc' }]}
|
|
37
|
+
onSort={onSort}
|
|
38
|
+
onSortAdd={onSortAdd}
|
|
39
|
+
/>
|
|
40
|
+
);
|
|
41
|
+
fireEvent.click(screen.getByText('Age'), { shiftKey: true });
|
|
42
|
+
expect(onSortAdd).toHaveBeenCalledWith('age', 'asc');
|
|
43
|
+
expect(onSort).not.toHaveBeenCalled();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('shift-click on already-sorted field cycles direction without replacing others', () => {
|
|
47
|
+
const onSort = vi.fn();
|
|
48
|
+
const onSortAdd = vi.fn();
|
|
49
|
+
render(
|
|
50
|
+
<Header
|
|
51
|
+
columns={COLUMNS}
|
|
52
|
+
sortModel={[
|
|
53
|
+
{ field: 'name', sort: 'asc' },
|
|
54
|
+
{ field: 'age', sort: 'asc' },
|
|
55
|
+
]}
|
|
56
|
+
onSort={onSort}
|
|
57
|
+
onSortAdd={onSortAdd}
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
fireEvent.click(screen.getByText('Age'), { shiftKey: true });
|
|
61
|
+
expect(onSortAdd).toHaveBeenCalledWith('age', 'desc');
|
|
62
|
+
expect(onSort).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('shift-click on desc-sorted field removes it from model', () => {
|
|
66
|
+
const onSort = vi.fn();
|
|
67
|
+
const onSortAdd = vi.fn();
|
|
68
|
+
render(
|
|
69
|
+
<Header
|
|
70
|
+
columns={COLUMNS}
|
|
71
|
+
sortModel={[
|
|
72
|
+
{ field: 'name', sort: 'asc' },
|
|
73
|
+
{ field: 'age', sort: 'desc' },
|
|
74
|
+
]}
|
|
75
|
+
onSort={onSort}
|
|
76
|
+
onSortAdd={onSortAdd}
|
|
77
|
+
/>
|
|
78
|
+
);
|
|
79
|
+
fireEvent.click(screen.getByText('Age'), { shiftKey: true });
|
|
80
|
+
expect(onSortAdd).toHaveBeenCalledWith('age', null);
|
|
81
|
+
expect(onSort).not.toHaveBeenCalled();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('plain click on one column while another is sorted resets to single-key', () => {
|
|
85
|
+
const onSort = vi.fn();
|
|
86
|
+
const onSortAdd = vi.fn();
|
|
87
|
+
render(
|
|
88
|
+
<Header
|
|
89
|
+
columns={COLUMNS}
|
|
90
|
+
sortModel={[{ field: 'name', sort: 'asc' }]}
|
|
91
|
+
onSort={onSort}
|
|
92
|
+
onSortAdd={onSortAdd}
|
|
93
|
+
/>
|
|
94
|
+
);
|
|
95
|
+
fireEvent.click(screen.getByText('Age'));
|
|
96
|
+
expect(onSort).toHaveBeenCalledWith('age', 'asc');
|
|
97
|
+
expect(onSortAdd).not.toHaveBeenCalled();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('does not call onSort/onSortAdd for sortable:false columns', () => {
|
|
101
|
+
const onSort = vi.fn();
|
|
102
|
+
const onSortAdd = vi.fn();
|
|
103
|
+
const cols: GridColDef[] = [
|
|
104
|
+
{ field: 'id', headerName: 'ID', sortable: false },
|
|
105
|
+
];
|
|
106
|
+
render(<Header columns={cols} sortModel={[]} onSort={onSort} onSortAdd={onSortAdd} />);
|
|
107
|
+
fireEvent.click(screen.getByText('ID'));
|
|
108
|
+
expect(onSort).not.toHaveBeenCalled();
|
|
109
|
+
expect(onSortAdd).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('Header — description tooltip', () => {
|
|
114
|
+
it('renders title attribute from column description', () => {
|
|
115
|
+
const cols: GridColDef[] = [
|
|
116
|
+
{ field: 'salary', headerName: 'Salary', description: 'Annual salary in USD' },
|
|
117
|
+
];
|
|
118
|
+
const { container } = render(
|
|
119
|
+
<Header columns={cols} sortModel={[]} />
|
|
120
|
+
);
|
|
121
|
+
const headerCell = container.querySelector('[data-field="salary"]');
|
|
122
|
+
expect(headerCell?.getAttribute('title')).toBe('Annual salary in USD');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('has no title attribute when description is absent', () => {
|
|
126
|
+
const cols: GridColDef[] = [
|
|
127
|
+
{ field: 'name', headerName: 'Name' },
|
|
128
|
+
];
|
|
129
|
+
const { container } = render(
|
|
130
|
+
<Header columns={cols} sortModel={[]} />
|
|
131
|
+
);
|
|
132
|
+
const headerCell = container.querySelector('[data-field="name"]');
|
|
133
|
+
expect(headerCell?.getAttribute('title')).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -134,6 +134,8 @@ export interface HeaderProps<R extends GridRowModel = GridRowModel> {
|
|
|
134
134
|
onDrop?: (targetField: string) => (event: React.DragEvent) => void;
|
|
135
135
|
focusedCell?: { id: string | number; field: string } | null;
|
|
136
136
|
onHeaderClick?: (field: string) => void;
|
|
137
|
+
onSortAdd?: (field: string, direction: GridSortDirection) => void;
|
|
138
|
+
multiSort?: boolean;
|
|
137
139
|
onHideColumn?: (field: string) => void;
|
|
138
140
|
onPinColumn?: (field: string, side: 'left' | 'right' | null) => void;
|
|
139
141
|
onManageColumns?: () => void;
|
|
@@ -166,6 +168,8 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
|
|
|
166
168
|
onDrop,
|
|
167
169
|
focusedCell,
|
|
168
170
|
onHeaderClick,
|
|
171
|
+
onSortAdd,
|
|
172
|
+
multiSort = false,
|
|
169
173
|
onHideColumn,
|
|
170
174
|
onPinColumn,
|
|
171
175
|
onManageColumns,
|
|
@@ -193,8 +197,8 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
|
|
|
193
197
|
}
|
|
194
198
|
}, [focusedCell]);
|
|
195
199
|
|
|
196
|
-
const handleColumnClick = (colDef: GridColDef<R>) => () => {
|
|
197
|
-
if (colDef.sortable === false
|
|
200
|
+
const handleColumnClick = (colDef: GridColDef<R>) => (e: React.MouseEvent) => {
|
|
201
|
+
if (colDef.sortable === false) return;
|
|
198
202
|
|
|
199
203
|
const currentSort = sortModel.find(item => item.field === colDef.field);
|
|
200
204
|
let newDirection: GridSortDirection = 'asc';
|
|
@@ -207,15 +211,25 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
|
|
|
207
211
|
}
|
|
208
212
|
}
|
|
209
213
|
|
|
210
|
-
|
|
214
|
+
if ((e.shiftKey || multiSort) && onSortAdd) {
|
|
215
|
+
onSortAdd(colDef.field, newDirection);
|
|
216
|
+
} else if (onSort) {
|
|
217
|
+
onSort(colDef.field, newDirection);
|
|
218
|
+
}
|
|
211
219
|
};
|
|
212
220
|
|
|
213
221
|
const getSortIcon = (field: string) => {
|
|
214
222
|
const sortItem = sortModel.find(item => item.field === field);
|
|
215
223
|
if (!sortItem) return null;
|
|
216
224
|
|
|
225
|
+
const sortIndex = sortModel.length > 1 ? sortModel.indexOf(sortItem) + 1 : null;
|
|
226
|
+
|
|
217
227
|
return (
|
|
218
|
-
<span className={`ogx__sort-icon ogx__sort-icon--${sortItem.sort}`} aria-hidden="true"
|
|
228
|
+
<span className={`ogx__sort-icon ogx__sort-icon--${sortItem.sort}`} aria-hidden="true">
|
|
229
|
+
{sortIndex !== null && (
|
|
230
|
+
<span className="ogx__sort-badge">{sortIndex}</span>
|
|
231
|
+
)}
|
|
232
|
+
</span>
|
|
219
233
|
);
|
|
220
234
|
};
|
|
221
235
|
|
|
@@ -420,7 +434,7 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
|
|
|
420
434
|
}
|
|
421
435
|
onHeaderClick?.(colDef.field);
|
|
422
436
|
if (isSortable) {
|
|
423
|
-
handleColumnClick(colDef)();
|
|
437
|
+
handleColumnClick(colDef)(e);
|
|
424
438
|
}
|
|
425
439
|
};
|
|
426
440
|
|
|
@@ -433,6 +447,7 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
|
|
|
433
447
|
key={colDef.field}
|
|
434
448
|
className={classNames}
|
|
435
449
|
style={style}
|
|
450
|
+
title={colDef.description}
|
|
436
451
|
onClick={handleHeaderClick}
|
|
437
452
|
role="columnheader"
|
|
438
453
|
draggable={!!onDragStart}
|
|
@@ -95,10 +95,10 @@ export function useGridColumns<R extends GridRowModel>(
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
if (isRowGrouping && hasChildren && groupingField) {
|
|
98
|
+
const groupLabel = meta?.groupLabel ?? `${groupingField}: ${String(groupingValue)}`;
|
|
98
99
|
content = (
|
|
99
100
|
<div className="ogx__group-cell-content">
|
|
100
|
-
{
|
|
101
|
-
{String(groupingValue)}
|
|
101
|
+
{groupLabel}
|
|
102
102
|
{descendantCount !== undefined ? ` (${descendantCount})` : ''}
|
|
103
103
|
</div>
|
|
104
104
|
);
|
|
@@ -81,7 +81,7 @@ export function useGridControlledState(params: UseGridControlledStateParams): Us
|
|
|
81
81
|
onPinnedColumnsChange,
|
|
82
82
|
pivotModel: propPivotModel,
|
|
83
83
|
onPivotModelChange,
|
|
84
|
-
paginationModel: propPaginationModel
|
|
84
|
+
paginationModel: propPaginationModel,
|
|
85
85
|
onPaginationModelChange,
|
|
86
86
|
rowSelectionModel: propRowSelectionModel,
|
|
87
87
|
onRowSelectionModelChange,
|
|
@@ -144,9 +144,9 @@ export function useGridControlledState(params: UseGridControlledStateParams): Us
|
|
|
144
144
|
}, [onPivotModelChange]);
|
|
145
145
|
|
|
146
146
|
// ── Pagination ───────────────────────────────────────────────────────────
|
|
147
|
-
const isPaginationControlled = propPaginationModel !== undefined
|
|
147
|
+
const isPaginationControlled = propPaginationModel !== undefined;
|
|
148
148
|
const [internalPaginationModel, setInternalPaginationModel] = useState<GridPaginationModel>(
|
|
149
|
-
() => initialState?.pagination?.paginationModel ?? propPaginationModel
|
|
149
|
+
() => initialState?.pagination?.paginationModel ?? propPaginationModel ?? { page: 0, pageSize: 100 }
|
|
150
150
|
);
|
|
151
151
|
const effectivePaginationModel = isPaginationControlled ? propPaginationModel : internalPaginationModel;
|
|
152
152
|
|
|
@@ -132,4 +132,44 @@ describe('useAggregation — client-side', () => {
|
|
|
132
132
|
);
|
|
133
133
|
expect(result.current.aggregationResult).toEqual(serverResults);
|
|
134
134
|
});
|
|
135
|
+
|
|
136
|
+
it('skips aggregation when function not in availableAggregationFunctions', () => {
|
|
137
|
+
const columns = [{ field: 'salary', availableAggregationFunctions: ['avg', 'count'] }];
|
|
138
|
+
const { result } = renderHook(() =>
|
|
139
|
+
useAggregation({
|
|
140
|
+
rows: ROWS,
|
|
141
|
+
columns,
|
|
142
|
+
aggregationModel: { salary: 'sum' },
|
|
143
|
+
isServerSide: false,
|
|
144
|
+
})
|
|
145
|
+
);
|
|
146
|
+
// 'sum' is not in the allowed list, so the result should be absent
|
|
147
|
+
expect(result.current.aggregationResult.salary).toBeUndefined();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('computes aggregation when function is in availableAggregationFunctions', () => {
|
|
151
|
+
const columns = [{ field: 'salary', availableAggregationFunctions: ['sum', 'avg'] }];
|
|
152
|
+
const { result } = renderHook(() =>
|
|
153
|
+
useAggregation({
|
|
154
|
+
rows: ROWS,
|
|
155
|
+
columns,
|
|
156
|
+
aggregationModel: { salary: 'sum' },
|
|
157
|
+
isServerSide: false,
|
|
158
|
+
})
|
|
159
|
+
);
|
|
160
|
+
expect(result.current.aggregationResult.salary).toBe(260000);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('does not restrict aggregation when availableAggregationFunctions is absent', () => {
|
|
164
|
+
const columns = [{ field: 'salary' }];
|
|
165
|
+
const { result } = renderHook(() =>
|
|
166
|
+
useAggregation({
|
|
167
|
+
rows: ROWS,
|
|
168
|
+
columns,
|
|
169
|
+
aggregationModel: { salary: 'sum' },
|
|
170
|
+
isServerSide: false,
|
|
171
|
+
})
|
|
172
|
+
);
|
|
173
|
+
expect(result.current.aggregationResult.salary).toBe(260000);
|
|
174
|
+
});
|
|
135
175
|
});
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { useMemo, useEffect, useRef, useCallback, useState } from 'react';
|
|
3
3
|
import type {
|
|
4
4
|
GridRowModel,
|
|
5
|
+
GridColDef,
|
|
5
6
|
GridAggregationModel,
|
|
6
7
|
GridAggregationResult,
|
|
7
8
|
GridFilterModel,
|
|
@@ -46,6 +47,7 @@ export function formatAggregationValue(value: unknown, fnName: string): string {
|
|
|
46
47
|
|
|
47
48
|
export interface UseAggregationParams<R extends GridRowModel> {
|
|
48
49
|
rows: R[];
|
|
50
|
+
columns?: GridColDef[];
|
|
49
51
|
aggregationModel: GridAggregationModel;
|
|
50
52
|
isServerSide: boolean;
|
|
51
53
|
dataSource?: GridDataSource<R>;
|
|
@@ -65,6 +67,7 @@ export function useAggregation<R extends GridRowModel>(
|
|
|
65
67
|
): UseAggregationReturn {
|
|
66
68
|
const {
|
|
67
69
|
rows,
|
|
70
|
+
columns,
|
|
68
71
|
aggregationModel,
|
|
69
72
|
isServerSide,
|
|
70
73
|
dataSource,
|
|
@@ -73,11 +76,21 @@ export function useAggregation<R extends GridRowModel>(
|
|
|
73
76
|
serverAggregationResults,
|
|
74
77
|
} = params;
|
|
75
78
|
|
|
79
|
+
const columnsLookup = useMemo(() => {
|
|
80
|
+
const map = new Map<string, GridColDef>();
|
|
81
|
+
(columns ?? []).forEach(col => map.set(col.field, col));
|
|
82
|
+
return map;
|
|
83
|
+
}, [columns]);
|
|
84
|
+
|
|
76
85
|
const clientResult = useMemo<GridAggregationResult>(() => {
|
|
77
86
|
if (isServerSide || Object.keys(aggregationModel).length === 0) return {};
|
|
78
87
|
|
|
79
88
|
const result: GridAggregationResult = {};
|
|
80
89
|
for (const [field, fnName] of Object.entries(aggregationModel)) {
|
|
90
|
+
const colDef = columnsLookup.get(field);
|
|
91
|
+
if (colDef?.availableAggregationFunctions && !colDef.availableAggregationFunctions.includes(fnName)) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
81
94
|
const fn = AGGREGATION_FUNCTIONS[fnName as BuiltInAggFn];
|
|
82
95
|
if (!fn) {
|
|
83
96
|
console.warn(`[useAggregation] Unknown aggregation function: "${fnName}"`);
|
|
@@ -87,7 +100,7 @@ export function useAggregation<R extends GridRowModel>(
|
|
|
87
100
|
result[field] = fn(values);
|
|
88
101
|
}
|
|
89
102
|
return result;
|
|
90
|
-
}, [rows, aggregationModel, isServerSide]);
|
|
103
|
+
}, [rows, columnsLookup, aggregationModel, isServerSide]);
|
|
91
104
|
|
|
92
105
|
const [serverResult, setServerResult] = useState<GridAggregationResult>({});
|
|
93
106
|
const [isLoading, setIsLoading] = useState(false);
|
|
@@ -81,4 +81,57 @@ describe('useRowGrouping — rowMetaMap', () => {
|
|
|
81
81
|
}
|
|
82
82
|
});
|
|
83
83
|
});
|
|
84
|
+
|
|
85
|
+
it('populates groupLabel from groupingValueFormatter when provided', () => {
|
|
86
|
+
const formatter = ({ field, value }: { field: string; value: unknown }) =>
|
|
87
|
+
`${String(field).toUpperCase()}: ${String(value)}`;
|
|
88
|
+
const columns = [{ field: 'dept', groupingValueFormatter: formatter }];
|
|
89
|
+
const { result } = renderHook(() =>
|
|
90
|
+
useRowGrouping({ ...BASE_PARAMS, columns })
|
|
91
|
+
);
|
|
92
|
+
const map = result.current.rowMetaMap;
|
|
93
|
+
let foundFormatted = false;
|
|
94
|
+
map.forEach((meta) => {
|
|
95
|
+
if (meta.isGroupRow) {
|
|
96
|
+
expect(meta.groupLabel).toMatch(/^DEPT:/);
|
|
97
|
+
foundFormatted = true;
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
expect(foundFormatted).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('uses default field: value label when groupingValueFormatter is absent', () => {
|
|
104
|
+
const { result } = renderHook(() => useRowGrouping(BASE_PARAMS));
|
|
105
|
+
const map = result.current.rowMetaMap;
|
|
106
|
+
map.forEach((meta) => {
|
|
107
|
+
if (meta.isGroupRow) {
|
|
108
|
+
expect(meta.groupLabel).toMatch(/^dept:/);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('useRowGrouping — groupable: false', () => {
|
|
115
|
+
it('skips a column with groupable:false — no group rows created for that field', () => {
|
|
116
|
+
const columns = [{ field: 'dept', groupable: false as const }];
|
|
117
|
+
const { result } = renderHook(() =>
|
|
118
|
+
useRowGrouping({ ...BASE_PARAMS, columns })
|
|
119
|
+
);
|
|
120
|
+
const map = result.current.rowMetaMap;
|
|
121
|
+
// With groupable:false on 'dept', no group rows should exist
|
|
122
|
+
let groupCount = 0;
|
|
123
|
+
map.forEach((meta) => { if (meta.isGroupRow) groupCount++; });
|
|
124
|
+
expect(groupCount).toBe(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('still groups when groupable is true (explicit)', () => {
|
|
128
|
+
const columns = [{ field: 'dept', groupable: true as const }];
|
|
129
|
+
const { result } = renderHook(() =>
|
|
130
|
+
useRowGrouping({ ...BASE_PARAMS, columns })
|
|
131
|
+
);
|
|
132
|
+
const map = result.current.rowMetaMap;
|
|
133
|
+
let groupCount = 0;
|
|
134
|
+
map.forEach((meta) => { if (meta.isGroupRow) groupCount++; });
|
|
135
|
+
expect(groupCount).toBe(2);
|
|
136
|
+
});
|
|
84
137
|
});
|
|
@@ -7,7 +7,8 @@ import type {
|
|
|
7
7
|
GridTreeNode,
|
|
8
8
|
GridFilterModel,
|
|
9
9
|
GridSortItem,
|
|
10
|
-
GridRowMeta
|
|
10
|
+
GridRowMeta,
|
|
11
|
+
GridColDef
|
|
11
12
|
} from '../types';
|
|
12
13
|
import { isRowMatchingFilter } from '../utils/filtering';
|
|
13
14
|
import { compareValues } from '../utils/sorting';
|
|
@@ -15,6 +16,7 @@ import { compareValues } from '../utils/sorting';
|
|
|
15
16
|
export interface UseRowGroupingParams<R extends GridRowModel> {
|
|
16
17
|
rows: R[];
|
|
17
18
|
getRowId: (row: R) => GridRowId;
|
|
19
|
+
columns?: GridColDef<R>[];
|
|
18
20
|
rowGroupingModel?: GridRowGroupingModel;
|
|
19
21
|
aggregationModel?: GridAggregationModel;
|
|
20
22
|
defaultGroupingExpansionDepth?: number;
|
|
@@ -38,6 +40,7 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
|
|
|
38
40
|
const {
|
|
39
41
|
rows,
|
|
40
42
|
getRowId,
|
|
43
|
+
columns,
|
|
41
44
|
rowGroupingModel = EMPTY_ROW_GROUPING_MODEL,
|
|
42
45
|
aggregationModel = EMPTY_AGGREGATION_MODEL,
|
|
43
46
|
defaultGroupingExpansionDepth = 0,
|
|
@@ -46,6 +49,12 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
|
|
|
46
49
|
getAggregationPosition = defaultGetAggregationPosition
|
|
47
50
|
} = params;
|
|
48
51
|
|
|
52
|
+
const columnsLookup = useMemo(() => {
|
|
53
|
+
const map = new Map<string, GridColDef<R>>();
|
|
54
|
+
(columns ?? []).forEach(col => map.set(col.field, col));
|
|
55
|
+
return map;
|
|
56
|
+
}, [columns]);
|
|
57
|
+
|
|
49
58
|
const [expandedGroupIds, setExpandedGroupIds] = useState<Set<GridRowId>>(new Set());
|
|
50
59
|
|
|
51
60
|
const { treeNodes, rootIds, groupingRows } = useMemo(() => {
|
|
@@ -82,6 +91,13 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
|
|
|
82
91
|
}
|
|
83
92
|
|
|
84
93
|
const field = rowGroupingModel[depth];
|
|
94
|
+
|
|
95
|
+
// Skip fields where the column has explicitly opted out of grouping.
|
|
96
|
+
const colDef = columnsLookup.get(field);
|
|
97
|
+
if (colDef && colDef.groupable === false) {
|
|
98
|
+
return groupRows(currentRows, depth + 1, parentId);
|
|
99
|
+
}
|
|
100
|
+
|
|
85
101
|
const groups = new Map<string, R[]>();
|
|
86
102
|
|
|
87
103
|
// Group current rows by value of the current field
|
|
@@ -143,7 +159,9 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
|
|
|
143
159
|
aggregatedValues,
|
|
144
160
|
isExpanded: false,
|
|
145
161
|
children: [],
|
|
146
|
-
label:
|
|
162
|
+
label: colDef?.groupingValueFormatter
|
|
163
|
+
? colDef.groupingValueFormatter({ field, value: rawValue })
|
|
164
|
+
: `${field}: ${String(rawValue)}`
|
|
147
165
|
};
|
|
148
166
|
|
|
149
167
|
treeNode.aggregationPosition = getAggregationPosition(treeNode);
|
|
@@ -165,7 +183,7 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
|
|
|
165
183
|
|
|
166
184
|
return { treeNodes, rootIds, groupingRows };
|
|
167
185
|
|
|
168
|
-
}, [rows, rowGroupingModel, aggregationModel, getRowId, getAggregationPosition]);
|
|
186
|
+
}, [rows, rowGroupingModel, aggregationModel, getRowId, getAggregationPosition, columnsLookup]);
|
|
169
187
|
|
|
170
188
|
useEffect(() => {
|
|
171
189
|
if (defaultGroupingExpansionDepth === -1) {
|
|
@@ -307,6 +325,7 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
|
|
|
307
325
|
treeDepth: node.depth,
|
|
308
326
|
groupingField: node.groupingField,
|
|
309
327
|
groupingValue: node.groupingValue,
|
|
328
|
+
groupLabel: node.label,
|
|
310
329
|
descendantCount: node.descendantCount,
|
|
311
330
|
isGroupRow: isGroup,
|
|
312
331
|
isExpanded: isGroup ? expandedGroupIds.has(id) : undefined,
|
package/lib/types/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ export interface GridRowMeta {
|
|
|
29
29
|
treeDepth?: number;
|
|
30
30
|
groupingField?: string;
|
|
31
31
|
groupingValue?: unknown;
|
|
32
|
+
groupLabel?: string;
|
|
32
33
|
descendantCount?: number;
|
|
33
34
|
isExpanded?: boolean;
|
|
34
35
|
isGroupRow?: boolean;
|
|
@@ -81,16 +82,22 @@ export interface GridColDef<R extends GridRowModel = GridRowModel> {
|
|
|
81
82
|
field: string;
|
|
82
83
|
/** Text displayed in the column header. */
|
|
83
84
|
headerName?: string;
|
|
84
|
-
/** Tooltip
|
|
85
|
+
/** Tooltip shown on header hover — used as the column's accessibility label. */
|
|
85
86
|
description?: string;
|
|
86
|
-
|
|
87
87
|
/** If true, this column can be used as a grouping dimension. */
|
|
88
88
|
groupable?: boolean;
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Custom formatter for the group-header label when this column is used as a grouping field.
|
|
92
|
+
* Receives the field name and grouped value; return the string to display.
|
|
93
|
+
* Falls back to `"field: value"` when omitted.
|
|
94
|
+
*/
|
|
95
|
+
groupingValueFormatter?: (params: { field: string; value: unknown }) => string;
|
|
96
|
+
|
|
90
97
|
/** If true, this column can be summarized using aggregation functions. */
|
|
91
98
|
aggregable?: boolean;
|
|
92
|
-
/**
|
|
93
|
-
availableAggregationFunctions?: string[];
|
|
99
|
+
/** Restricts which aggregation functions are offered for this column (e.g., ['sum', 'avg']). */
|
|
100
|
+
availableAggregationFunctions?: string[];
|
|
94
101
|
|
|
95
102
|
/** Width in pixels or a percentage string. Defaults to automatic calculation. */
|
|
96
103
|
width?: number | string;
|
|
@@ -608,6 +615,13 @@ export interface DataGridProps<R extends GridRowModel = GridRowModel> {
|
|
|
608
615
|
sortModel?: GridSortItem[];
|
|
609
616
|
/** Callback fired when the sorting model changes. */
|
|
610
617
|
onSortModelChange?: (model: GridSortItem[]) => void;
|
|
618
|
+
/**
|
|
619
|
+
* When true, clicking any sortable column header appends/cycles that column in the
|
|
620
|
+
* sort model instead of replacing it — no Shift key required. Plain click still
|
|
621
|
+
* cycles asc → desc → removed for that column while all other active sort keys stay.
|
|
622
|
+
* Shift+click continues to work regardless of this prop.
|
|
623
|
+
*/
|
|
624
|
+
multiSort?: boolean;
|
|
611
625
|
|
|
612
626
|
/** Filtering mode: 'client' (default) or 'server'. */
|
|
613
627
|
filterMode?: 'client' | 'server';
|
|
@@ -641,8 +655,6 @@ export interface DataGridProps<R extends GridRowModel = GridRowModel> {
|
|
|
641
655
|
|
|
642
656
|
/** Defines which rows are pinned to the top or bottom. */
|
|
643
657
|
pinnedRows?: GridRowPinning;
|
|
644
|
-
/** Callback fired when row pinning changes. */
|
|
645
|
-
onPinnedRowsChange?: (model: GridRowPinning) => void;
|
|
646
658
|
|
|
647
659
|
/** If true, a column of checkboxes is added for row selection. */
|
|
648
660
|
checkboxSelection?: boolean;
|
|
@@ -720,8 +732,6 @@ export interface DataGridProps<R extends GridRowModel = GridRowModel> {
|
|
|
720
732
|
|
|
721
733
|
/** Controlled state for row grouping. */
|
|
722
734
|
rowGroupingModel?: GridRowGroupingModel;
|
|
723
|
-
/** Callback fired when row grouping changes. */
|
|
724
|
-
onRowGroupingModelChange?: (model: GridRowGroupingModel) => void;
|
|
725
735
|
/** Controlled state for data aggregation (e.g., { salary: 'sum' }). */
|
|
726
736
|
aggregationModel?: GridAggregationModel;
|
|
727
737
|
/** Callback fired when aggregation model changes. */
|