@opencorestack/opengridx 1.2.3 → 1.3.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.
@@ -1,5 +1,5 @@
1
1
 
2
- import type { GridColDef, GridRowModel, GridAggregationModel } from '../../types';
2
+ import type { GridColDef, GridRowModel, GridAggregationModel, GridGroupedExportRow } from '../../types';
3
3
  import { formatAggregationValue } from '../../hooks/features/useAggregation';
4
4
 
5
5
  export interface CsvExportOptions {
@@ -9,6 +9,8 @@ export interface CsvExportOptions {
9
9
  selectedRows?: (string | number)[];
10
10
  aggregationResult?: Record<string, unknown> | null;
11
11
  aggregationModel?: GridAggregationModel | null;
12
+ /** When provided, emits group headers, leaf rows, subtotals, and a grand total instead of a flat row list. */
13
+ groupedRows?: GridGroupedExportRow[];
12
14
  }
13
15
 
14
16
  export function exportToCsv<R extends GridRowModel>(
@@ -20,18 +22,10 @@ export function exportToCsv<R extends GridRowModel>(
20
22
  fileName = 'export.csv',
21
23
  includeHeaders = true,
22
24
  delimiter = ',',
23
- selectedRows
25
+ selectedRows,
26
+ groupedRows,
24
27
  } = options;
25
28
 
26
- const rowsToExport = selectedRows && selectedRows.length > 0
27
- ? rows.filter(r => selectedRows.includes(r.id))
28
- : rows;
29
-
30
- if (rowsToExport.length === 0) {
31
- console.warn('No rows to export');
32
- return;
33
- }
34
-
35
29
  const exportColumns = columns.filter(col => {
36
30
  if (col.exportable === false) return false;
37
31
  if (col.field === '__check__' || col.field === '__actions__') return false;
@@ -40,58 +34,110 @@ export function exportToCsv<R extends GridRowModel>(
40
34
 
41
35
  let csvContent = '';
42
36
 
43
- // Add headers
44
- if (includeHeaders) {
45
- const headers = exportColumns.map(col => escapeCSV(col.headerName || col.field));
46
- csvContent += headers.join(delimiter) + '\n';
47
- }
48
-
49
- rowsToExport.forEach(row => {
50
- const values = exportColumns.map(col => {
51
- let value: unknown = (row as GridRowModel)[col.field];
52
-
53
- if (col.valueGetter) {
54
- value = col.valueGetter({ row, field: col.field, value });
37
+ if (groupedRows && groupedRows.length > 0) {
38
+ // Grouped export path
39
+ if (includeHeaders) {
40
+ csvContent += exportColumns.map(col => escapeCSV(col.headerName || col.field)).join(delimiter) + '\n';
41
+ }
42
+ groupedRows.forEach(entry => {
43
+ if (entry.type === 'group-header') {
44
+ const indent = ' '.repeat(entry.depth);
45
+ const label = `${indent}${entry.groupField ?? ''}: ${String(entry.groupValue ?? '')}`;
46
+ const cells = exportColumns.map((_, i) => i === 0 ? escapeCSV(label) : '');
47
+ csvContent += cells.join(delimiter) + '\n';
48
+ } else if (entry.type === 'leaf' && entry.row) {
49
+ const row = entry.row as R;
50
+ const indent = ' '.repeat(entry.depth);
51
+ const cells = exportColumns.map((col, i) => {
52
+ let value: unknown = (row as GridRowModel)[col.field];
53
+ if (col.valueGetter) value = col.valueGetter({ row, field: col.field, value });
54
+ if (col.valueFormatter && value !== undefined && value !== null) {
55
+ value = col.valueFormatter({ value, row, field: col.field });
56
+ }
57
+ return escapeCSV((i === 0 ? indent : '') + String(value ?? ''));
58
+ });
59
+ csvContent += cells.join(delimiter) + '\n';
60
+ } else if (entry.type === 'group-subtotal' && entry.aggregatedValues) {
61
+ const indent = ' '.repeat(entry.depth);
62
+ const aggModel = options.aggregationModel || {};
63
+ const cells = exportColumns.map((col, i) => {
64
+ if (i === 0) return escapeCSV(`${indent}Subtotal`);
65
+ const aggVal = entry.aggregatedValues![col.field];
66
+ if (aggVal !== undefined && aggVal !== null) {
67
+ let formatted: unknown = aggVal;
68
+ if (col.valueFormatter) {
69
+ formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
70
+ } else if (aggModel[col.field]) {
71
+ formatted = formatAggregationValue(aggVal, aggModel[col.field]);
72
+ }
73
+ return escapeCSV(String(formatted));
74
+ }
75
+ return '';
76
+ });
77
+ csvContent += cells.join(delimiter) + '\n';
78
+ } else if (entry.type === 'grand-total' && entry.aggregatedValues) {
79
+ const aggModel = options.aggregationModel || {};
80
+ const cells = exportColumns.map((col, i) => {
81
+ if (i === 0) return escapeCSV('Grand Total');
82
+ const aggVal = entry.aggregatedValues![col.field];
83
+ if (aggVal !== undefined && aggVal !== null) {
84
+ let formatted: unknown = aggVal;
85
+ if (col.valueFormatter) {
86
+ formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
87
+ } else if (aggModel[col.field]) {
88
+ formatted = formatAggregationValue(aggVal, aggModel[col.field]);
89
+ }
90
+ return escapeCSV(String(formatted));
91
+ }
92
+ return '';
93
+ });
94
+ csvContent += cells.join(delimiter) + '\n';
55
95
  }
96
+ });
97
+ } else {
98
+ // Flat export path (existing behavior)
99
+ const rowsToExport = selectedRows && selectedRows.length > 0
100
+ ? rows.filter(r => selectedRows.includes(r.id))
101
+ : rows;
56
102
 
57
- if (col.valueFormatter && value !== undefined && value !== null) {
58
- value = col.valueFormatter({ value, row, field: col.field });
59
- }
103
+ if (rowsToExport.length === 0) {
104
+ console.warn('No rows to export');
105
+ return;
106
+ }
60
107
 
61
- return escapeCSV(String(value ?? ''));
62
- });
63
- csvContent += values.join(delimiter) + '\n';
64
- });
108
+ if (includeHeaders) {
109
+ csvContent += exportColumns.map(col => escapeCSV(col.headerName || col.field)).join(delimiter) + '\n';
110
+ }
65
111
 
66
- // Add Aggregation
67
- if (options.aggregationResult) {
68
- const aggResult = options.aggregationResult;
69
- const aggModel = options.aggregationModel || {};
70
-
71
- // Print Aggregation Function Labels
72
- const aggLabels = exportColumns.map((col) => {
73
- if (aggModel[col.field]) {
74
- return escapeCSV(aggModel[col.field].toUpperCase());
75
- }
76
- return escapeCSV('');
77
- });
78
- csvContent += aggLabels.join(delimiter) + '\n';
79
-
80
- // Print Aggregation Values
81
- const aggValues = exportColumns.map((col) => {
82
- const aggVal = aggResult[col.field];
83
- if (aggVal !== undefined && aggVal !== null) {
84
- let formatted = aggVal;
85
- if (col.valueFormatter) {
86
- formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
87
- } else if (aggModel[col.field]) {
88
- formatted = formatAggregationValue(aggVal, aggModel[col.field]);
112
+ rowsToExport.forEach(row => {
113
+ const values = exportColumns.map(col => {
114
+ let value: unknown = (row as GridRowModel)[col.field];
115
+ if (col.valueGetter) value = col.valueGetter({ row, field: col.field, value });
116
+ if (col.valueFormatter && value !== undefined && value !== null) {
117
+ value = col.valueFormatter({ value, row, field: col.field });
89
118
  }
90
- return escapeCSV(String(formatted));
91
- }
92
- return escapeCSV('');
119
+ return escapeCSV(String(value ?? ''));
120
+ });
121
+ csvContent += values.join(delimiter) + '\n';
93
122
  });
94
- csvContent += aggValues.join(delimiter) + '\n';
123
+
124
+ if (options.aggregationResult) {
125
+ const aggResult = options.aggregationResult;
126
+ const aggModel = options.aggregationModel || {};
127
+ const aggLabels = exportColumns.map(col => escapeCSV(aggModel[col.field] ? aggModel[col.field].toUpperCase() : ''));
128
+ csvContent += aggLabels.join(delimiter) + '\n';
129
+ const aggValues = exportColumns.map(col => {
130
+ const aggVal = aggResult[col.field];
131
+ if (aggVal !== undefined && aggVal !== null) {
132
+ let formatted: unknown = aggVal;
133
+ if (col.valueFormatter) formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
134
+ else if (aggModel[col.field]) formatted = formatAggregationValue(aggVal, aggModel[col.field]);
135
+ return escapeCSV(String(formatted));
136
+ }
137
+ return '';
138
+ });
139
+ csvContent += aggValues.join(delimiter) + '\n';
140
+ }
95
141
  }
96
142
 
97
143
  downloadFile(csvContent, fileName, 'text/csv;charset=utf-8;');
@@ -111,6 +157,8 @@ export interface ExcelExportOptions {
111
157
  selectedRows?: (string | number)[];
112
158
  aggregationResult?: Record<string, unknown> | null;
113
159
  aggregationModel?: GridAggregationModel | null;
160
+ /** When provided, emits group headers, leaf rows, subtotals, and a grand total instead of a flat row list. */
161
+ groupedRows?: GridGroupedExportRow[];
114
162
  }
115
163
 
116
164
  export function exportToExcel<R extends GridRowModel>(
@@ -122,91 +170,123 @@ export function exportToExcel<R extends GridRowModel>(
122
170
  fileName = 'export.xls',
123
171
  sheetName = 'Sheet1',
124
172
  includeHeaders = true,
125
- selectedRows
173
+ selectedRows,
174
+ groupedRows,
126
175
  } = options;
127
176
 
128
- const rowsToExport = selectedRows && selectedRows.length > 0
129
- ? rows.filter(r => selectedRows.includes(r.id))
130
- : rows;
131
-
132
- if (rowsToExport.length === 0) {
133
- console.warn('No rows to export');
134
- return;
135
- }
136
-
137
177
  const exportColumns = columns.filter(col => {
138
178
  if (col.exportable === false) return false;
139
179
  if (col.field === '__check__' || col.field === '__actions__') return false;
140
180
  return true;
141
181
  });
142
182
 
143
- let html = '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40">';
144
- html += '<head><!--[if gte mso 9]><xml><x:ExcelWorkbook><x:ExcelWorksheets><x:ExcelWorksheet>';
145
- html += `<x:Name>${sheetName}</x:Name>`;
146
- html += '<x:WorksheetOptions><x:DisplayGridlines/></x:WorksheetOptions></x:ExcelWorksheet></x:ExcelWorksheets></x:ExcelWorkbook></xml><![endif]-->';
147
- html += '<meta charset="UTF-8"></head><body>';
148
- html += '<table border="1">';
183
+ const header = '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40">'
184
+ + '<head><!--[if gte mso 9]><xml><x:ExcelWorkbook><x:ExcelWorksheets><x:ExcelWorksheet>'
185
+ + `<x:Name>${sheetName}</x:Name>`
186
+ + '<x:WorksheetOptions><x:DisplayGridlines/></x:WorksheetOptions></x:ExcelWorksheet></x:ExcelWorksheets></x:ExcelWorkbook></xml><![endif]-->'
187
+ + '<meta charset="UTF-8"></head><body><table border="1">';
188
+
189
+ let html = header;
149
190
 
150
191
  if (includeHeaders) {
151
192
  html += '<thead><tr>';
152
- exportColumns.forEach(col => {
153
- html += `<th>${escapeHTML(col.headerName || col.field)}</th>`;
154
- });
193
+ exportColumns.forEach(col => { html += `<th>${escapeHTML(col.headerName || col.field)}</th>`; });
155
194
  html += '</tr></thead>';
156
195
  }
157
196
 
158
197
  html += '<tbody>';
159
- rowsToExport.forEach(row => {
160
- html += '<tr>';
161
- exportColumns.forEach(col => {
162
- let value: unknown = (row as GridRowModel)[col.field];
163
-
164
- if (col.valueGetter) {
165
- value = col.valueGetter({ row, field: col.field, value });
166
- }
167
198
 
168
- if (col.valueFormatter && value !== undefined && value !== null) {
169
- value = col.valueFormatter({ value, row, field: col.field });
199
+ if (groupedRows && groupedRows.length > 0) {
200
+ const aggModel = options.aggregationModel || {};
201
+ groupedRows.forEach(entry => {
202
+ if (entry.type === 'group-header') {
203
+ const indent = '  '.repeat(entry.depth * 2);
204
+ const label = `${indent}${entry.groupField ?? ''}: ${String(entry.groupValue ?? '')}`;
205
+ html += `<tr style="font-weight:bold;background:#e8eaf6;">`;
206
+ html += `<td colspan="${exportColumns.length}">${escapeHTML(label)}</td>`;
207
+ html += '</tr>';
208
+ } else if (entry.type === 'leaf' && entry.row) {
209
+ const row = entry.row as R;
210
+ const indent = '  '.repeat(entry.depth * 2);
211
+ html += '<tr>';
212
+ exportColumns.forEach((col, i) => {
213
+ let value: unknown = (row as GridRowModel)[col.field];
214
+ if (col.valueGetter) value = col.valueGetter({ row, field: col.field, value });
215
+ if (col.valueFormatter && value !== undefined && value !== null) {
216
+ value = col.valueFormatter({ value, row, field: col.field });
217
+ }
218
+ html += `<td>${escapeHTML((i === 0 ? indent : '') + String(value ?? ''))}</td>`;
219
+ });
220
+ html += '</tr>';
221
+ } else if ((entry.type === 'group-subtotal' || entry.type === 'grand-total') && entry.aggregatedValues) {
222
+ const isGrand = entry.type === 'grand-total';
223
+ const indent = isGrand ? '' : '  '.repeat(entry.depth * 2);
224
+ const label = isGrand ? 'Grand Total' : `${indent}Subtotal`;
225
+ html += `<tr style="font-weight:bold;background:#f5f5f5;">`;
226
+ exportColumns.forEach((col, i) => {
227
+ if (i === 0) { html += `<td>${escapeHTML(label)}</td>`; return; }
228
+ const aggVal = entry.aggregatedValues![col.field];
229
+ if (aggVal !== undefined && aggVal !== null) {
230
+ let formatted: unknown = aggVal;
231
+ if (col.valueFormatter) formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
232
+ else if (aggModel[col.field]) formatted = formatAggregationValue(aggVal, aggModel[col.field]);
233
+ html += `<td style="text-align:right;">${escapeHTML(String(formatted))}</td>`;
234
+ } else {
235
+ html += '<td></td>';
236
+ }
237
+ });
238
+ html += '</tr>';
170
239
  }
171
-
172
- html += `<td>${escapeHTML(String(value ?? ''))}</td>`;
173
240
  });
174
- });
241
+ } else {
242
+ const rowsToExport = selectedRows && selectedRows.length > 0
243
+ ? rows.filter(r => selectedRows.includes(r.id))
244
+ : rows;
175
245
 
176
- if (options.aggregationResult) {
177
- const aggResult = options.aggregationResult;
178
- const aggModel = options.aggregationModel || {};
246
+ if (rowsToExport.length === 0) {
247
+ console.warn('No rows to export');
248
+ return;
249
+ }
179
250
 
180
- html += '<tr style="font-size: 11px; color: #666; background-color: #f5f5f5;">';
181
- exportColumns.forEach((col) => {
182
- if (aggModel[col.field]) {
183
- html += `<td style="text-align: right; font-weight: bold;">${escapeHTML(aggModel[col.field].toUpperCase())}</td>`;
184
- } else {
185
- html += `<td></td>`;
186
- }
187
- });
188
- html += '</tr>';
189
-
190
- html += '<tr style="font-weight: bold; background-color: #f5f5f5;">';
191
- exportColumns.forEach((col) => {
192
- const aggVal = aggResult[col.field];
193
- if (aggVal !== undefined && aggVal !== null) {
194
- let formatted = aggVal;
195
- if (col.valueFormatter) {
196
- formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
197
- } else if (aggModel[col.field]) {
198
- formatted = formatAggregationValue(aggVal, aggModel[col.field]);
251
+ rowsToExport.forEach(row => {
252
+ html += '<tr>';
253
+ exportColumns.forEach(col => {
254
+ let value: unknown = (row as GridRowModel)[col.field];
255
+ if (col.valueGetter) value = col.valueGetter({ row, field: col.field, value });
256
+ if (col.valueFormatter && value !== undefined && value !== null) {
257
+ value = col.valueFormatter({ value, row, field: col.field });
199
258
  }
200
- html += `<td style="text-align: right;">${escapeHTML(String(formatted))}</td>`;
201
- } else {
202
- html += `<td></td>`;
203
- }
259
+ html += `<td>${escapeHTML(String(value ?? ''))}</td>`;
260
+ });
261
+ html += '</tr>';
204
262
  });
205
- html += '</tr>';
263
+
264
+ if (options.aggregationResult) {
265
+ const aggResult = options.aggregationResult;
266
+ const aggModel = options.aggregationModel || {};
267
+ html += '<tr style="font-size: 11px; color: #666; background-color: #f5f5f5;">';
268
+ exportColumns.forEach(col => {
269
+ html += aggModel[col.field]
270
+ ? `<td style="text-align:right;font-weight:bold;">${escapeHTML(aggModel[col.field].toUpperCase())}</td>`
271
+ : '<td></td>';
272
+ });
273
+ html += '</tr><tr style="font-weight:bold;background-color:#f5f5f5;">';
274
+ exportColumns.forEach(col => {
275
+ const aggVal = aggResult[col.field];
276
+ if (aggVal !== undefined && aggVal !== null) {
277
+ let formatted: unknown = aggVal;
278
+ if (col.valueFormatter) formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
279
+ else if (aggModel[col.field]) formatted = formatAggregationValue(aggVal, aggModel[col.field]);
280
+ html += `<td style="text-align:right;">${escapeHTML(String(formatted))}</td>`;
281
+ } else {
282
+ html += '<td></td>';
283
+ }
284
+ });
285
+ html += '</tr>';
286
+ }
206
287
  }
207
288
 
208
289
  html += '</tbody></table></body></html>';
209
-
210
290
  downloadFile(html, fileName, 'application/vnd.ms-excel');
211
291
  }
212
292
 
@@ -229,6 +309,8 @@ export interface JsonExportOptions {
229
309
  selectedRows?: (string | number)[];
230
310
  aggregationResult?: Record<string, unknown> | null;
231
311
  aggregationModel?: GridAggregationModel | null;
312
+ /** When provided, emits a nested grouping tree instead of a flat array. */
313
+ groupedRows?: GridGroupedExportRow[];
232
314
  }
233
315
 
234
316
  /**
@@ -242,85 +324,113 @@ export function exportToJson<R extends GridRowModel>(
242
324
  const {
243
325
  fileName = 'export.json',
244
326
  pretty = true,
245
- selectedRows
327
+ selectedRows,
328
+ groupedRows,
246
329
  } = options;
247
330
 
248
- // Filter rows if selection is active
249
- const rowsToExport = selectedRows && selectedRows.length > 0
250
- ? rows.filter(r => selectedRows.includes(r.id))
251
- : rows;
252
-
253
- if (rowsToExport.length === 0) {
254
- console.warn('No rows to export');
255
- return;
256
- }
257
-
258
- // Filter columns
259
331
  const exportColumns = columns.filter(col => {
260
332
  if (col.exportable === false) return false;
261
333
  if (col.field === '__check__' || col.field === '__actions__') return false;
262
334
  return true;
263
335
  });
264
336
 
265
- // Build JSON data
266
- const data = rowsToExport.map(row => {
267
- const obj: Record<string, unknown> = {};
268
- exportColumns.forEach(col => {
269
- let value: unknown = (row as GridRowModel)[col.field];
337
+ type GroupNode = {
338
+ group: string;
339
+ field: string;
340
+ value: unknown;
341
+ rows: Record<string, unknown>[];
342
+ subtotals?: Record<string, unknown>;
343
+ children?: GroupNode[];
344
+ };
270
345
 
271
- // Use valueGetter if available
272
- if (col.valueGetter) {
273
- value = col.valueGetter({ row, field: col.field, value });
346
+ type JsonPayload =
347
+ | Record<string, unknown>[]
348
+ | { data: Record<string, unknown>[]; aggregation: { labels: Record<string, unknown>; values: Record<string, unknown> } }
349
+ | { groups: GroupNode[]; grandTotal?: Record<string, unknown> };
350
+
351
+ let outData: JsonPayload;
352
+
353
+ if (groupedRows && groupedRows.length > 0) {
354
+ // Build nested tree from the flat ordered list
355
+ const stack: GroupNode[] = [];
356
+ const roots: GroupNode[] = [];
357
+ let grandTotal: Record<string, unknown> | undefined;
358
+
359
+ groupedRows.forEach(entry => {
360
+ if (entry.type === 'group-header') {
361
+ const node: GroupNode = {
362
+ group: `${entry.groupField ?? ''}: ${String(entry.groupValue ?? '')}`,
363
+ field: entry.groupField ?? '',
364
+ value: entry.groupValue,
365
+ rows: [],
366
+ children: [],
367
+ };
368
+ while (stack.length > entry.depth) stack.pop();
369
+ if (stack.length === 0) {
370
+ roots.push(node);
371
+ } else {
372
+ (stack[stack.length - 1].children ??= []).push(node);
373
+ }
374
+ stack.push(node);
375
+ } else if (entry.type === 'leaf' && entry.row) {
376
+ const row = entry.row as R;
377
+ const obj: Record<string, unknown> = {};
378
+ exportColumns.forEach(col => {
379
+ let value: unknown = (row as GridRowModel)[col.field];
380
+ if (col.valueGetter) value = col.valueGetter({ row, field: col.field, value });
381
+ obj[col.field] = value;
382
+ });
383
+ if (stack.length > 0) stack[stack.length - 1].rows.push(obj);
384
+ } else if (entry.type === 'group-subtotal' && entry.aggregatedValues && stack.length > 0) {
385
+ stack[stack.length - 1].subtotals = { ...entry.aggregatedValues };
386
+ stack.pop();
387
+ } else if (entry.type === 'grand-total' && entry.aggregatedValues) {
388
+ grandTotal = { ...entry.aggregatedValues };
274
389
  }
275
-
276
- obj[col.field] = value;
277
390
  });
278
- return obj;
279
- });
280
-
281
- type ExportJsonPayload =
282
- | Record<string, unknown>[]
283
- | { data: Record<string, unknown>[]; aggregation: { labels: Record<string, unknown>; values: Record<string, unknown> } };
284
-
285
- let outData: ExportJsonPayload = data;
286
391
 
287
- if (options.aggregationResult) {
288
- const aggResult = options.aggregationResult;
289
- const aggModel = options.aggregationModel || {};
392
+ outData = grandTotal ? { groups: roots, grandTotal } : { groups: roots };
393
+ } else {
394
+ const rowsToExport = selectedRows && selectedRows.length > 0
395
+ ? rows.filter(r => selectedRows.includes(r.id))
396
+ : rows;
290
397
 
291
- const summaryLabelObj: Record<string, unknown> = {};
292
- exportColumns.forEach((col) => {
293
- if (aggModel[col.field]) {
294
- summaryLabelObj[col.field] = aggModel[col.field].toUpperCase();
295
- }
296
- });
398
+ if (rowsToExport.length === 0) {
399
+ console.warn('No rows to export');
400
+ return;
401
+ }
297
402
 
298
- const summaryDataObj: Record<string, unknown> = {};
299
- exportColumns.forEach((col) => {
300
- if (aggResult[col.field] !== undefined && aggResult[col.field] !== null) {
301
- let aggVal: unknown = aggResult[col.field];
302
- if (col.valueFormatter) {
303
- aggVal = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
304
- } else if (aggModel[col.field]) {
305
- aggVal = formatAggregationValue(aggVal, aggModel[col.field]);
306
- }
307
- summaryDataObj[col.field] = aggVal;
308
- }
403
+ const data = rowsToExport.map(row => {
404
+ const obj: Record<string, unknown> = {};
405
+ exportColumns.forEach(col => {
406
+ let value: unknown = (row as GridRowModel)[col.field];
407
+ if (col.valueGetter) value = col.valueGetter({ row, field: col.field, value });
408
+ obj[col.field] = value;
409
+ });
410
+ return obj;
309
411
  });
310
412
 
311
- outData = {
312
- data: data,
313
- aggregation: {
314
- labels: summaryLabelObj,
315
- values: summaryDataObj
316
- }
317
- };
413
+ if (options.aggregationResult) {
414
+ const aggResult = options.aggregationResult;
415
+ const aggModel = options.aggregationModel || {};
416
+ const summaryLabelObj: Record<string, unknown> = {};
417
+ exportColumns.forEach(col => { if (aggModel[col.field]) summaryLabelObj[col.field] = aggModel[col.field].toUpperCase(); });
418
+ const summaryDataObj: Record<string, unknown> = {};
419
+ exportColumns.forEach(col => {
420
+ if (aggResult[col.field] !== undefined && aggResult[col.field] !== null) {
421
+ let aggVal: unknown = aggResult[col.field];
422
+ if (col.valueFormatter) aggVal = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
423
+ else if (aggModel[col.field]) aggVal = formatAggregationValue(aggVal, aggModel[col.field]);
424
+ summaryDataObj[col.field] = aggVal;
425
+ }
426
+ });
427
+ outData = { data, aggregation: { labels: summaryLabelObj, values: summaryDataObj } };
428
+ } else {
429
+ outData = data;
430
+ }
318
431
  }
319
432
 
320
- const jsonContent = pretty ? JSON.stringify(outData, null, 2) : JSON.stringify(outData);
321
-
322
- // Download file
323
- downloadFile(jsonContent, fileName, 'application/json;charset=utf-8;');
433
+ downloadFile(pretty ? JSON.stringify(outData, null, 2) : JSON.stringify(outData), fileName, 'application/json;charset=utf-8;');
324
434
  }
325
435
 
326
436
  // ============================================================================
@@ -332,6 +442,8 @@ export interface PrintOptions {
332
442
  selectedRows?: (string | number)[];
333
443
  aggregationResult?: Record<string, unknown> | null;
334
444
  aggregationModel?: GridAggregationModel | null;
445
+ /** When provided, emits group headers, leaf rows, subtotals, and a grand total instead of a flat row list. */
446
+ groupedRows?: GridGroupedExportRow[];
335
447
  }
336
448
 
337
449
  /**
@@ -383,6 +495,8 @@ export async function printGrid<R extends GridRowModel>(
383
495
  let aggregationResult: Record<string, unknown> | null = null;
384
496
  let aggregationModel: GridAggregationModel | null = null;
385
497
 
498
+ let groupedRows: GridGroupedExportRow[] | undefined;
499
+
386
500
  if (typeof titleOrOptions === 'string') {
387
501
  title = titleOrOptions;
388
502
  } else if (typeof titleOrOptions === 'object') {
@@ -390,112 +504,127 @@ export async function printGrid<R extends GridRowModel>(
390
504
  selectedRows = titleOrOptions.selectedRows;
391
505
  aggregationResult = titleOrOptions.aggregationResult || null;
392
506
  aggregationModel = titleOrOptions.aggregationModel || null;
507
+ groupedRows = titleOrOptions.groupedRows;
393
508
  }
394
509
 
395
- // Filter rows if selection is active
396
- const rowsToExport = selectedRows && selectedRows.length > 0
397
- ? rows.filter(r => selectedRows.includes(r.id))
398
- : rows;
399
-
400
- if (rowsToExport.length === 0) {
401
- printWindow.document.body.innerHTML = '<h3>No rows to export</h3>';
402
- return;
403
- }
404
-
405
- // Filter columns
406
510
  const exportColumns = columns.filter(col => {
407
511
  if (col.exportable === false) return false;
408
512
  if (col.field === '__check__' || col.field === '__actions__') return false;
409
513
  return true;
410
514
  });
411
515
 
412
- // Build HTML
413
- let html = `
414
- <!DOCTYPE html>
415
- <html>
416
- <head>
417
- <title>${title || 'Print'}</title>
418
- <style>
419
- body { font-family: Arial, sans-serif; margin: 20px; }
420
- h1 { font-size: 24px; margin-bottom: 20px; }
421
- table { border-collapse: collapse; width: 100%; }
422
- th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
423
- th { background-color: #f5f5f5; font-weight: bold; }
424
- tr:nth-child(even) { background-color: #f9f9f9; }
425
- @media print {
426
- body { margin: 0; }
427
- @page { margin: 1cm; }
428
- }
429
- </style>
430
- </head>
431
- <body>
432
- `;
433
-
434
- if (title) {
435
- html += `<h1>${escapeHTML(title)}</h1>`;
436
- }
437
-
438
- html += '<table>';
439
-
440
- // Headers
441
- html += '<thead><tr>';
442
- exportColumns.forEach(col => {
443
- html += `<th>${escapeHTML(col.headerName || col.field)}</th>`;
444
- });
445
- html += '</tr></thead>';
446
-
447
- // Rows
448
- html += '<tbody>';
449
-
450
- // Process rows (potential bottleneck logic here)
451
- rowsToExport.forEach(row => {
452
- html += '<tr>';
453
- exportColumns.forEach(col => {
454
- let value: unknown = (row as GridRowModel)[col.field];
455
-
456
- if (col.valueGetter) {
457
- value = col.valueGetter({ row, field: col.field, value });
458
- }
459
-
460
- if (col.valueFormatter && value !== undefined && value !== null) {
461
- value = col.valueFormatter({ value, row, field: col.field });
462
- }
463
-
464
- if (col.type === 'image' && value) {
465
- html += `<td><div style="display:flex;justify-content:center;"><img src="${value}" alt="${col.headerName}" style="max-height: 40px; border-radius: 4px;"></div></td>`;
466
- } else {
467
- html += `<td>${escapeHTML(String(value ?? ''))}</td>`;
468
- }
469
- });
470
- html += '</tr>';
471
- });
472
-
473
- if (aggregationResult) {
516
+ let html = `<!DOCTYPE html><html><head><title>${title || 'Print'}</title><style>
517
+ body{font-family:Arial,sans-serif;margin:20px;}
518
+ h1{font-size:24px;margin-bottom:20px;}
519
+ table{border-collapse:collapse;width:100%;}
520
+ th,td{border:1px solid #ddd;padding:8px;text-align:left;}
521
+ th{background-color:#f5f5f5;font-weight:bold;}
522
+ tr:nth-child(even){background-color:#f9f9f9;}
523
+ .group-header{background:#e8eaf6;font-weight:bold;}
524
+ .group-subtotal{background:#f0f4ff;font-style:italic;}
525
+ .grand-total{background:#f5f5f5;font-weight:bold;}
526
+ @media print{body{margin:0;}@page{margin:1cm;}}
527
+ </style></head><body>`;
528
+
529
+ if (title) html += `<h1>${escapeHTML(title)}</h1>`;
530
+ html += '<table><thead><tr>';
531
+ exportColumns.forEach(col => { html += `<th>${escapeHTML(col.headerName || col.field)}</th>`; });
532
+ html += '</tr></thead><tbody>';
533
+
534
+ if (groupedRows && groupedRows.length > 0) {
474
535
  const aggModel = aggregationModel || {};
475
- html += '<tfoot><tr style="font-size: 11px; color: #666; background-color: #f5f5f5;">';
476
- exportColumns.forEach((col) => {
477
- if (aggModel[col.field]) {
478
- html += `<td style="text-align: right; font-weight: bold;">${escapeHTML(aggModel[col.field].toUpperCase())}</td>`;
479
- } else {
480
- html += `<td></td>`;
536
+ groupedRows.forEach(entry => {
537
+ if (entry.type === 'group-header') {
538
+ const indent = '&nbsp;'.repeat(entry.depth * 4);
539
+ const label = `${indent}${escapeHTML(entry.groupField ?? '')}: ${escapeHTML(String(entry.groupValue ?? ''))}`;
540
+ html += `<tr class="group-header"><td colspan="${exportColumns.length}">${label}</td></tr>`;
541
+ } else if (entry.type === 'leaf' && entry.row) {
542
+ const row = entry.row as R;
543
+ const indent = '&nbsp;'.repeat(entry.depth * 4);
544
+ html += '<tr>';
545
+ exportColumns.forEach((col, i) => {
546
+ let value: unknown = (row as GridRowModel)[col.field];
547
+ if (col.valueGetter) value = col.valueGetter({ row, field: col.field, value });
548
+ if (col.valueFormatter && value !== undefined && value !== null) {
549
+ value = col.valueFormatter({ value, row, field: col.field });
550
+ }
551
+ if (col.type === 'image' && value) {
552
+ html += `<td><img src="${value}" style="max-height:40px;border-radius:4px;"></td>`;
553
+ } else {
554
+ html += `<td>${(i === 0 ? indent : '') + escapeHTML(String(value ?? ''))}</td>`;
555
+ }
556
+ });
557
+ html += '</tr>';
558
+ } else if ((entry.type === 'group-subtotal' || entry.type === 'grand-total') && entry.aggregatedValues) {
559
+ const isGrand = entry.type === 'grand-total';
560
+ const indent = isGrand ? '' : '&nbsp;'.repeat(entry.depth * 4);
561
+ const label = isGrand ? 'Grand Total' : `${indent}Subtotal`;
562
+ const cls = isGrand ? 'grand-total' : 'group-subtotal';
563
+ html += `<tr class="${cls}">`;
564
+ exportColumns.forEach((col, i) => {
565
+ if (i === 0) { html += `<td>${label}</td>`; return; }
566
+ const aggVal = entry.aggregatedValues![col.field];
567
+ if (aggVal !== undefined && aggVal !== null) {
568
+ let formatted: unknown = aggVal;
569
+ if (col.valueFormatter) formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
570
+ else if (aggModel[col.field]) formatted = formatAggregationValue(aggVal, aggModel[col.field]);
571
+ html += `<td style="text-align:right;">${escapeHTML(String(formatted))}</td>`;
572
+ } else {
573
+ html += '<td></td>';
574
+ }
575
+ });
576
+ html += '</tr>';
481
577
  }
482
578
  });
483
- html += '</tr><tr style="font-weight: bold; background-color: #f5f5f5;">';
484
- exportColumns.forEach((col) => {
485
- const aggVal = aggregationResult![col.field];
486
- if (aggVal !== undefined && aggVal !== null) {
487
- let formatted = aggVal;
488
- if (col.valueFormatter) {
489
- formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
490
- } else if (aggModel[col.field]) {
491
- formatted = formatAggregationValue(aggVal, aggModel[col.field]);
579
+ } else {
580
+ const rowsToExport = selectedRows && selectedRows.length > 0
581
+ ? rows.filter(r => selectedRows.includes(r.id))
582
+ : rows;
583
+
584
+ if (rowsToExport.length === 0) {
585
+ printWindow.document.body.innerHTML = '<h3>No rows to export</h3>';
586
+ return;
587
+ }
588
+
589
+ rowsToExport.forEach(row => {
590
+ html += '<tr>';
591
+ exportColumns.forEach(col => {
592
+ let value: unknown = (row as GridRowModel)[col.field];
593
+ if (col.valueGetter) value = col.valueGetter({ row, field: col.field, value });
594
+ if (col.valueFormatter && value !== undefined && value !== null) {
595
+ value = col.valueFormatter({ value, row, field: col.field });
492
596
  }
493
- html += `<td style="text-align: right;">${escapeHTML(String(formatted))}</td>`;
494
- } else {
495
- html += `<td></td>`;
496
- }
597
+ if (col.type === 'image' && value) {
598
+ html += `<td><div style="display:flex;justify-content:center;"><img src="${value}" alt="${col.headerName}" style="max-height:40px;border-radius:4px;"></div></td>`;
599
+ } else {
600
+ html += `<td>${escapeHTML(String(value ?? ''))}</td>`;
601
+ }
602
+ });
603
+ html += '</tr>';
497
604
  });
498
- html += '</tr></tfoot>';
605
+
606
+ if (aggregationResult) {
607
+ const aggModel = aggregationModel || {};
608
+ html += '<tfoot><tr style="font-size:11px;color:#666;background-color:#f5f5f5;">';
609
+ exportColumns.forEach(col => {
610
+ html += aggModel[col.field]
611
+ ? `<td style="text-align:right;font-weight:bold;">${escapeHTML(aggModel[col.field].toUpperCase())}</td>`
612
+ : '<td></td>';
613
+ });
614
+ html += '</tr><tr style="font-weight:bold;background-color:#f5f5f5;">';
615
+ exportColumns.forEach(col => {
616
+ const aggVal = aggregationResult![col.field];
617
+ if (aggVal !== undefined && aggVal !== null) {
618
+ let formatted: unknown = aggVal;
619
+ if (col.valueFormatter) formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
620
+ else if (aggModel[col.field]) formatted = formatAggregationValue(aggVal, aggModel[col.field]);
621
+ html += `<td style="text-align:right;">${escapeHTML(String(formatted))}</td>`;
622
+ } else {
623
+ html += '<td></td>';
624
+ }
625
+ });
626
+ html += '</tr></tfoot>';
627
+ }
499
628
  }
500
629
 
501
630
  html += '</tbody></table></body></html>';