@opencorestack/opengridx 1.2.3 → 2.0.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 +57 -0
- package/dist/index.d.ts +63 -6
- package/dist/opengridx.es.js +3740 -3516
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +26 -29
- 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 +134 -20
- 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/useDataGrid.ts +1 -0
- 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 +52 -8
- package/lib/utils/export/exportToPdf.ts +67 -30
- package/lib/utils/export/groupedExport.test.ts +144 -0
- package/lib/utils/export/index.ts +400 -271
- package/llms.txt +1 -1
- package/package.json +1 -1
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;
|
|
@@ -376,6 +383,33 @@ export interface PdfExportOptions {
|
|
|
376
383
|
* Base font size in points for table body. Default: 9
|
|
377
384
|
*/
|
|
378
385
|
fontSize?: number;
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Pre-built grouped export rows from `apiRef.current.getGroupedExportRows()`.
|
|
389
|
+
* When provided, the PDF table reflects the grouping structure (group headers,
|
|
390
|
+
* leaf rows, per-group subtotals, grand total) instead of a flat row list.
|
|
391
|
+
* Has no effect when the grid has no active `rowGroupingModel`.
|
|
392
|
+
*/
|
|
393
|
+
groupedRows?: GridGroupedExportRow[];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* A single entry in the ordered list returned by `GridApi.getGroupedExportRows()`.
|
|
398
|
+
* Traverse in array order to reproduce the on-screen grouping structure.
|
|
399
|
+
*/
|
|
400
|
+
export interface GridGroupedExportRow {
|
|
401
|
+
/** Discriminator: the kind of row this entry represents. */
|
|
402
|
+
type: 'group-header' | 'leaf' | 'group-subtotal' | 'grand-total';
|
|
403
|
+
/** Nesting depth (0 = top-level group, 1 = nested group, etc.). */
|
|
404
|
+
depth: number;
|
|
405
|
+
/** The grouping field for this level (absent on 'leaf' and 'grand-total'). */
|
|
406
|
+
groupField?: string;
|
|
407
|
+
/** The raw grouping value for this level (absent on 'leaf' and 'grand-total'). */
|
|
408
|
+
groupValue?: unknown;
|
|
409
|
+
/** Per-group aggregation results (present on 'group-subtotal' and 'grand-total'). */
|
|
410
|
+
aggregatedValues?: Record<string, unknown>;
|
|
411
|
+
/** The original data row (only present when `type === 'leaf'`). */
|
|
412
|
+
row?: GridRowModel;
|
|
379
413
|
}
|
|
380
414
|
|
|
381
415
|
export type GridPinnedPosition = 'left' | 'right';
|
|
@@ -581,6 +615,13 @@ export interface DataGridProps<R extends GridRowModel = GridRowModel> {
|
|
|
581
615
|
sortModel?: GridSortItem[];
|
|
582
616
|
/** Callback fired when the sorting model changes. */
|
|
583
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;
|
|
584
625
|
|
|
585
626
|
/** Filtering mode: 'client' (default) or 'server'. */
|
|
586
627
|
filterMode?: 'client' | 'server';
|
|
@@ -614,8 +655,6 @@ export interface DataGridProps<R extends GridRowModel = GridRowModel> {
|
|
|
614
655
|
|
|
615
656
|
/** Defines which rows are pinned to the top or bottom. */
|
|
616
657
|
pinnedRows?: GridRowPinning;
|
|
617
|
-
/** Callback fired when row pinning changes. */
|
|
618
|
-
onPinnedRowsChange?: (model: GridRowPinning) => void;
|
|
619
658
|
|
|
620
659
|
/** If true, a column of checkboxes is added for row selection. */
|
|
621
660
|
checkboxSelection?: boolean;
|
|
@@ -693,8 +732,6 @@ export interface DataGridProps<R extends GridRowModel = GridRowModel> {
|
|
|
693
732
|
|
|
694
733
|
/** Controlled state for row grouping. */
|
|
695
734
|
rowGroupingModel?: GridRowGroupingModel;
|
|
696
|
-
/** Callback fired when row grouping changes. */
|
|
697
|
-
onRowGroupingModelChange?: (model: GridRowGroupingModel) => void;
|
|
698
735
|
/** Controlled state for data aggregation (e.g., { salary: 'sum' }). */
|
|
699
736
|
aggregationModel?: GridAggregationModel;
|
|
700
737
|
/** Callback fired when aggregation model changes. */
|
|
@@ -819,6 +856,13 @@ export interface GridApi {
|
|
|
819
856
|
getVisibleRows: () => GridRowModel[];
|
|
820
857
|
/** Returns all filtered and sorted rows, ignoring pagination. Use this for full-dataset exports. */
|
|
821
858
|
getAllFilteredRows: () => GridRowModel[];
|
|
859
|
+
/**
|
|
860
|
+
* Returns an ordered flat list of `GridGroupedExportRow` entries that mirrors the
|
|
861
|
+
* on-screen grouping tree: group-headers, leaf rows, per-group subtotals, and a
|
|
862
|
+
* grand-total entry at the end. Returns `null` when no `rowGroupingModel` is active.
|
|
863
|
+
* Pass the result to export functions that accept a `groupedRows` option.
|
|
864
|
+
*/
|
|
865
|
+
getGroupedExportRows: () => GridGroupedExportRow[] | null;
|
|
822
866
|
/** Returns the current aggregation results. */
|
|
823
867
|
getAggregationResult: () => Record<string, unknown> | null;
|
|
824
868
|
/** Returns the active aggregation configuration. */
|
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
GridRowModel,
|
|
4
4
|
GridFilterModel,
|
|
5
5
|
PdfExportOptions,
|
|
6
|
+
GridGroupedExportRow,
|
|
6
7
|
} from '../../types';
|
|
7
8
|
import { formatAggregationValue } from '../../hooks/features/useAggregation';
|
|
8
9
|
|
|
@@ -137,6 +138,7 @@ export async function exportToPdf<R extends GridRowModel>(
|
|
|
137
138
|
headerBackgroundColor = '#4f46e5',
|
|
138
139
|
headerTextColor = '#ffffff',
|
|
139
140
|
fontSize = 9,
|
|
141
|
+
groupedRows,
|
|
140
142
|
} = options;
|
|
141
143
|
|
|
142
144
|
const doc = new JsPDF({ orientation, unit: 'mm', format: 'a4' });
|
|
@@ -157,7 +159,6 @@ export async function exportToPdf<R extends GridRowModel>(
|
|
|
157
159
|
let cursorX = MARGIN;
|
|
158
160
|
let cursorY = MARGIN + 8;
|
|
159
161
|
|
|
160
|
-
// Logo
|
|
161
162
|
if (logoUrl) {
|
|
162
163
|
try {
|
|
163
164
|
doc.addImage(logoUrl, 'PNG', cursorX, MARGIN, 10, 10);
|
|
@@ -167,26 +168,26 @@ export async function exportToPdf<R extends GridRowModel>(
|
|
|
167
168
|
}
|
|
168
169
|
}
|
|
169
170
|
|
|
170
|
-
// Title
|
|
171
171
|
doc.setFontSize(16);
|
|
172
172
|
doc.setFont('helvetica', 'bold');
|
|
173
|
-
doc.setTextColor(30, 41, 59);
|
|
173
|
+
doc.setTextColor(30, 41, 59);
|
|
174
174
|
doc.text(title, cursorX, cursorY);
|
|
175
175
|
cursorY += 6;
|
|
176
176
|
|
|
177
|
-
// Subtitle: date + row count
|
|
178
177
|
doc.setFontSize(8);
|
|
179
178
|
doc.setFont('helvetica', 'normal');
|
|
180
|
-
doc.setTextColor(100, 116, 139);
|
|
179
|
+
doc.setTextColor(100, 116, 139);
|
|
181
180
|
const exportedAt = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
|
181
|
+
const rowCount = groupedRows
|
|
182
|
+
? groupedRows.filter(e => e.type === 'leaf').length
|
|
183
|
+
: rowsToExport.length;
|
|
182
184
|
doc.text(
|
|
183
|
-
`Exported: ${exportedAt} • ${
|
|
185
|
+
`Exported: ${exportedAt} • ${rowCount} row${rowCount !== 1 ? 's' : ''}`,
|
|
184
186
|
cursorX,
|
|
185
187
|
cursorY
|
|
186
188
|
);
|
|
187
189
|
cursorY += 5;
|
|
188
190
|
|
|
189
|
-
// Filter summary
|
|
190
191
|
if (filterModel) {
|
|
191
192
|
const summary = formatFilterSummary(filterModel, columns);
|
|
192
193
|
if (summary) {
|
|
@@ -195,8 +196,7 @@ export async function exportToPdf<R extends GridRowModel>(
|
|
|
195
196
|
}
|
|
196
197
|
}
|
|
197
198
|
|
|
198
|
-
|
|
199
|
-
doc.setDrawColor(226, 232, 240); // #e2e8f0
|
|
199
|
+
doc.setDrawColor(226, 232, 240);
|
|
200
200
|
doc.setLineWidth(0.3);
|
|
201
201
|
doc.line(MARGIN, cursorY + 2, pageWidth - MARGIN, cursorY + 2);
|
|
202
202
|
startY = cursorY + 6;
|
|
@@ -205,30 +205,67 @@ export async function exportToPdf<R extends GridRowModel>(
|
|
|
205
205
|
// --- Build table data ---
|
|
206
206
|
const head = [exportColumns.map(col => col.headerName ?? col.field)];
|
|
207
207
|
|
|
208
|
-
|
|
209
|
-
exportColumns.map(col => resolveValue(row, col))
|
|
210
|
-
);
|
|
211
|
-
|
|
212
|
-
// --- Aggregation footer ---
|
|
208
|
+
let body: string[][];
|
|
213
209
|
let foot: string[][] | undefined;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
if (
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
210
|
+
|
|
211
|
+
if (groupedRows && groupedRows.length > 0) {
|
|
212
|
+
// Grouped export: flatten the ordered list into body rows with indentation
|
|
213
|
+
body = [];
|
|
214
|
+
const aggMod = aggregationModel || {};
|
|
215
|
+
groupedRows.forEach((entry: GridGroupedExportRow) => {
|
|
216
|
+
if (entry.type === 'group-header') {
|
|
217
|
+
const indent = ' '.repeat(entry.depth * 2);
|
|
218
|
+
const label = `${indent}${entry.groupField ?? ''}: ${String(entry.groupValue ?? '')}`;
|
|
219
|
+
body.push(exportColumns.map((_, i) => i === 0 ? label : ''));
|
|
220
|
+
} else if (entry.type === 'leaf' && entry.row) {
|
|
221
|
+
const row = entry.row as R;
|
|
222
|
+
const indent = ' '.repeat(entry.depth * 2);
|
|
223
|
+
body.push(exportColumns.map((col, i) => (i === 0 ? indent : '') + resolveValue(row, col)));
|
|
224
|
+
} else if (entry.type === 'group-subtotal' && entry.aggregatedValues) {
|
|
225
|
+
const indent = ' '.repeat(entry.depth * 2);
|
|
226
|
+
body.push(exportColumns.map((col, i) => {
|
|
227
|
+
if (i === 0) return `${indent}Subtotal`;
|
|
228
|
+
const aggVal = entry.aggregatedValues![col.field];
|
|
229
|
+
if (aggVal === undefined || aggVal === null) return '';
|
|
230
|
+
let formatted: unknown = aggVal;
|
|
231
|
+
if (col.valueFormatter) formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
|
|
232
|
+
else if (aggMod[col.field]) formatted = formatAggregationValue(aggVal, aggMod[col.field] as string);
|
|
233
|
+
return String(formatted);
|
|
234
|
+
}));
|
|
235
|
+
} else if (entry.type === 'grand-total' && entry.aggregatedValues) {
|
|
236
|
+
const footRow = exportColumns.map((col, i) => {
|
|
237
|
+
if (i === 0) return 'Grand Total';
|
|
238
|
+
const aggVal = entry.aggregatedValues![col.field];
|
|
239
|
+
if (aggVal === undefined || aggVal === null) return '';
|
|
240
|
+
let formatted: unknown = aggVal;
|
|
241
|
+
if (col.valueFormatter) formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
|
|
242
|
+
else if (aggMod[col.field]) formatted = formatAggregationValue(aggVal, aggMod[col.field] as string);
|
|
243
|
+
return String(formatted);
|
|
244
|
+
});
|
|
245
|
+
foot = [footRow];
|
|
228
246
|
}
|
|
229
|
-
return String(formatted);
|
|
230
247
|
});
|
|
231
|
-
|
|
248
|
+
} else {
|
|
249
|
+
body = rowsToExport.map(row => exportColumns.map(col => resolveValue(row, col)));
|
|
250
|
+
|
|
251
|
+
if (aggregationResult && aggregationModel) {
|
|
252
|
+
const footRow = exportColumns.map((col, i) => {
|
|
253
|
+
if (i === 0) {
|
|
254
|
+
const hasAgg = exportColumns.some(c => aggregationModel[c.field]);
|
|
255
|
+
return hasAgg ? 'TOTAL' : '';
|
|
256
|
+
}
|
|
257
|
+
const aggVal = aggregationResult[col.field];
|
|
258
|
+
if (aggVal === undefined || aggVal === null) return '';
|
|
259
|
+
let formatted: unknown = aggVal;
|
|
260
|
+
if (col.valueFormatter) {
|
|
261
|
+
formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
|
|
262
|
+
} else if (aggregationModel[col.field]) {
|
|
263
|
+
formatted = formatAggregationValue(aggVal, aggregationModel[col.field] as string);
|
|
264
|
+
}
|
|
265
|
+
return String(formatted);
|
|
266
|
+
});
|
|
267
|
+
foot = [footRow];
|
|
268
|
+
}
|
|
232
269
|
}
|
|
233
270
|
|
|
234
271
|
// --- Column widths ---
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { exportToCsv, exportToJson } from './index';
|
|
3
|
+
import type { GridColDef, GridGroupedExportRow, GridRowModel } from '../../types';
|
|
4
|
+
|
|
5
|
+
// --- shared fixtures ---
|
|
6
|
+
|
|
7
|
+
type Row = GridRowModel & { id: number; dept: string; salary: number };
|
|
8
|
+
|
|
9
|
+
const columns: GridColDef<Row>[] = [
|
|
10
|
+
{ field: 'dept', headerName: 'Department' },
|
|
11
|
+
{ field: 'salary', headerName: 'Salary', type: 'number' },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const flatRows: Row[] = [
|
|
15
|
+
{ id: 1, dept: 'Engineering', salary: 90000 },
|
|
16
|
+
{ id: 2, dept: 'Engineering', salary: 85000 },
|
|
17
|
+
{ id: 3, dept: 'Marketing', salary: 70000 },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const groupedRows: GridGroupedExportRow[] = [
|
|
21
|
+
{ type: 'group-header', depth: 0, groupField: 'dept', groupValue: 'Engineering' },
|
|
22
|
+
{ type: 'leaf', depth: 1, row: { id: 1, dept: 'Engineering', salary: 90000 } },
|
|
23
|
+
{ type: 'leaf', depth: 1, row: { id: 2, dept: 'Engineering', salary: 85000 } },
|
|
24
|
+
{ type: 'group-subtotal', depth: 0, groupField: 'dept', groupValue: 'Engineering', aggregatedValues: { salary: 175000 } },
|
|
25
|
+
{ type: 'group-header', depth: 0, groupField: 'dept', groupValue: 'Marketing' },
|
|
26
|
+
{ type: 'leaf', depth: 1, row: { id: 3, dept: 'Marketing', salary: 70000 } },
|
|
27
|
+
{ type: 'group-subtotal', depth: 0, groupField: 'dept', groupValue: 'Marketing', aggregatedValues: { salary: 70000 } },
|
|
28
|
+
{ type: 'grand-total', depth: 0, aggregatedValues: { salary: 245000 } },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
// --- intercept URL.createObjectURL so we can read Blob content ---
|
|
32
|
+
|
|
33
|
+
let capturedBlob: Blob | null = null;
|
|
34
|
+
const origCreateObjectURL = URL.createObjectURL;
|
|
35
|
+
const origRevokeObjectURL = URL.revokeObjectURL;
|
|
36
|
+
const origClick = HTMLAnchorElement.prototype.click;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
capturedBlob = null;
|
|
40
|
+
URL.createObjectURL = (blob: Blob) => { capturedBlob = blob; return 'blob:mock'; };
|
|
41
|
+
URL.revokeObjectURL = () => {};
|
|
42
|
+
HTMLAnchorElement.prototype.click = () => {};
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
URL.createObjectURL = origCreateObjectURL;
|
|
47
|
+
URL.revokeObjectURL = origRevokeObjectURL;
|
|
48
|
+
HTMLAnchorElement.prototype.click = origClick;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// --- CSV tests ---
|
|
52
|
+
|
|
53
|
+
describe('exportToCsv — flat (no groupedRows)', () => {
|
|
54
|
+
it('produces a header row followed by one row per data record', async () => {
|
|
55
|
+
exportToCsv(flatRows, columns, { fileName: 'test.csv' });
|
|
56
|
+
const text = await capturedBlob!.text();
|
|
57
|
+
const lines = text.split('\n').filter(Boolean);
|
|
58
|
+
expect(lines[0]).toBe('Department,Salary');
|
|
59
|
+
expect(lines.length).toBe(4); // header + 3 data rows
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('does not emit group-header markers or Subtotal labels', async () => {
|
|
63
|
+
exportToCsv(flatRows, columns, { fileName: 'flat.csv' });
|
|
64
|
+
const text = await capturedBlob!.text();
|
|
65
|
+
expect(text).not.toContain('dept:');
|
|
66
|
+
expect(text).not.toContain('Subtotal');
|
|
67
|
+
expect(text).not.toContain('Grand Total');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('exportToCsv — grouped', () => {
|
|
72
|
+
it('emits group-header rows, leaf rows, subtotals, and grand total', async () => {
|
|
73
|
+
exportToCsv(flatRows, columns, {
|
|
74
|
+
fileName: 'grouped.csv',
|
|
75
|
+
groupedRows,
|
|
76
|
+
aggregationModel: { salary: 'sum' },
|
|
77
|
+
});
|
|
78
|
+
const text = await capturedBlob!.text();
|
|
79
|
+
const lines = text.split('\n').filter(Boolean);
|
|
80
|
+
|
|
81
|
+
// Header
|
|
82
|
+
expect(lines[0]).toBe('Department,Salary');
|
|
83
|
+
// Group header for Engineering (depth 0 → no leading spaces, just "dept: Engineering")
|
|
84
|
+
expect(lines[1]).toContain('dept: Engineering');
|
|
85
|
+
// Leaf rows: salary values present
|
|
86
|
+
expect(lines[2]).toContain('90000');
|
|
87
|
+
expect(lines[3]).toContain('85000');
|
|
88
|
+
// Subtotal row for Engineering (formatAggregationValue adds commas: "175,000")
|
|
89
|
+
expect(lines[4]).toContain('Subtotal');
|
|
90
|
+
expect(lines[4]).toContain('175');
|
|
91
|
+
// Grand total
|
|
92
|
+
const last = lines[lines.length - 1];
|
|
93
|
+
expect(last).toContain('Grand Total');
|
|
94
|
+
expect(last).toContain('245');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('total line count matches structure (1 header + 2 group-headers + 3 leaves + 2 subtotals + 1 grand-total)', async () => {
|
|
98
|
+
exportToCsv(flatRows, columns, { groupedRows, aggregationModel: { salary: 'sum' } });
|
|
99
|
+
const text = await capturedBlob!.text();
|
|
100
|
+
const lines = text.split('\n').filter(Boolean);
|
|
101
|
+
expect(lines.length).toBe(9);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// --- JSON tests ---
|
|
106
|
+
|
|
107
|
+
describe('exportToJson — flat (no groupedRows)', () => {
|
|
108
|
+
it('emits a plain array with one entry per row', async () => {
|
|
109
|
+
exportToJson(flatRows, columns, {});
|
|
110
|
+
const text = await capturedBlob!.text();
|
|
111
|
+
const parsed = JSON.parse(text) as unknown[];
|
|
112
|
+
expect(Array.isArray(parsed)).toBe(true);
|
|
113
|
+
expect(parsed.length).toBe(3);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('exportToJson — grouped', () => {
|
|
118
|
+
it('emits { groups, grandTotal } with two top-level groups', async () => {
|
|
119
|
+
exportToJson(flatRows, columns, {
|
|
120
|
+
groupedRows,
|
|
121
|
+
aggregationModel: { salary: 'sum' },
|
|
122
|
+
});
|
|
123
|
+
const text = await capturedBlob!.text();
|
|
124
|
+
const parsed = JSON.parse(text) as {
|
|
125
|
+
groups: { group: string; rows: unknown[]; subtotals?: Record<string, unknown> }[];
|
|
126
|
+
grandTotal?: Record<string, unknown>;
|
|
127
|
+
};
|
|
128
|
+
expect(parsed.groups).toHaveLength(2);
|
|
129
|
+
expect(parsed.groups[0].group).toBe('dept: Engineering');
|
|
130
|
+
expect(parsed.groups[0].rows).toHaveLength(2);
|
|
131
|
+
expect(parsed.groups[0].subtotals?.salary).toBe(175000);
|
|
132
|
+
expect(parsed.groups[1].group).toBe('dept: Marketing');
|
|
133
|
+
expect(parsed.groups[1].rows).toHaveLength(1);
|
|
134
|
+
expect(parsed.grandTotal?.salary).toBe(245000);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('falls through to flat array when groupedRows is absent', async () => {
|
|
138
|
+
exportToJson(flatRows, columns, {});
|
|
139
|
+
const text = await capturedBlob!.text();
|
|
140
|
+
const parsed = JSON.parse(text) as unknown[];
|
|
141
|
+
expect(Array.isArray(parsed)).toBe(true);
|
|
142
|
+
expect(parsed.length).toBe(3);
|
|
143
|
+
});
|
|
144
|
+
});
|