@opencorestack/opengridx 1.2.2 → 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.
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { ColumnVisibilityPanel } from './ColumnVisibilityPanel';
4
+ import type { GridColDef } from '../../types';
5
+
6
+ const columns: GridColDef[] = [
7
+ { field: 'name', headerName: 'Name' },
8
+ { field: 'department', headerName: 'Department' },
9
+ { field: 'groupKey', headerName: 'Group Key', hideable: false },
10
+ ];
11
+
12
+ const visibleColumns = new Set(['name', 'department', 'groupKey']);
13
+
14
+ const noop = () => {};
15
+
16
+ describe('ColumnVisibilityPanel', () => {
17
+ it('excludes hideable:false columns from the list by default', () => {
18
+ render(
19
+ <ColumnVisibilityPanel
20
+ columns={columns}
21
+ visibleColumns={visibleColumns}
22
+ onVisibilityChange={noop}
23
+ onShowAll={noop}
24
+ onHideAll={noop}
25
+ />
26
+ );
27
+ expect(screen.getByText('Name')).toBeInTheDocument();
28
+ expect(screen.getByText('Department')).toBeInTheDocument();
29
+ expect(screen.queryByText('Group Key')).not.toBeInTheDocument();
30
+ });
31
+
32
+ it('shows hideable:false columns as disabled when showNonHideableColumns is true', () => {
33
+ render(
34
+ <ColumnVisibilityPanel
35
+ columns={columns}
36
+ visibleColumns={visibleColumns}
37
+ onVisibilityChange={noop}
38
+ onShowAll={noop}
39
+ onHideAll={noop}
40
+ showNonHideableColumns
41
+ />
42
+ );
43
+ expect(screen.getByText('Name')).toBeInTheDocument();
44
+ expect(screen.getByText('Department')).toBeInTheDocument();
45
+ expect(screen.getByText('Group Key')).toBeInTheDocument();
46
+
47
+ // 3 columns shown + 1 "Show/Hide All" checkbox in footer = 4 checkboxes
48
+ // The 3rd column (Group Key, hideable:false) must be the disabled one
49
+ const checkboxes = screen.getAllByRole('checkbox');
50
+ const disabledCheckboxes = checkboxes.filter(cb => (cb as HTMLInputElement).disabled);
51
+ expect(disabledCheckboxes).toHaveLength(1);
52
+ });
53
+
54
+ it('does not render a disabled checkbox for hideable:false columns by default', () => {
55
+ render(
56
+ <ColumnVisibilityPanel
57
+ columns={columns}
58
+ visibleColumns={visibleColumns}
59
+ onVisibilityChange={noop}
60
+ onShowAll={noop}
61
+ onHideAll={noop}
62
+ />
63
+ );
64
+ const checkboxes = screen.getAllByRole('checkbox');
65
+ const disabledCheckboxes = checkboxes.filter(cb => (cb as HTMLInputElement).disabled);
66
+ expect(disabledCheckboxes).toHaveLength(0);
67
+ });
68
+
69
+ it('never shows hideable:false columns even when search would match them', () => {
70
+ // Without showNonHideableColumns, hideable:false columns are not in the base list
71
+ // so they cannot appear regardless of search. This test verifies the base list,
72
+ // not a dynamic search interaction.
73
+ render(
74
+ <ColumnVisibilityPanel
75
+ columns={columns}
76
+ visibleColumns={visibleColumns}
77
+ onVisibilityChange={noop}
78
+ onShowAll={noop}
79
+ onHideAll={noop}
80
+ />
81
+ );
82
+ // "Group Key" is hideable:false — must not appear in the default panel
83
+ expect(screen.queryByText('Group Key')).not.toBeInTheDocument();
84
+ });
85
+ });
@@ -11,6 +11,8 @@ export interface ColumnVisibilityPanelProps<R extends GridRowModel = GridRowMode
11
11
  onHideAll: () => void;
12
12
  onColumnReorder?: (fromField: string, toField: string) => void;
13
13
  onColumnOrderReset?: () => void;
14
+ /** Show columns with `hideable: false` in the panel as disabled rows. Default: false. */
15
+ showNonHideableColumns?: boolean;
14
16
  }
15
17
 
16
18
  function SearchIcon() {
@@ -44,7 +46,7 @@ function DragHandleIcon() {
44
46
  export function ColumnVisibilityPanel<R extends GridRowModel = GridRowModel>(
45
47
  props: ColumnVisibilityPanelProps<R>
46
48
  ) {
47
- const { columns, visibleColumns, onVisibilityChange, onShowAll, onHideAll, onColumnReorder, onColumnOrderReset } = props;
49
+ const { columns, visibleColumns, onVisibilityChange, onShowAll, onHideAll, onColumnReorder, onColumnOrderReset, showNonHideableColumns } = props;
48
50
  const [searchQuery, setSearchQuery] = useState('');
49
51
  const [dragOverField, setDragOverField] = useState<string | null>(null);
50
52
  const dragFieldRef = useRef<string | null>(null);
@@ -53,12 +55,13 @@ export function ColumnVisibilityPanel<R extends GridRowModel = GridRowModel>(
53
55
  const allVisible = hideableColumns.every(col => visibleColumns.has(col.field));
54
56
 
55
57
  const filteredColumns = useMemo(() => {
56
- if (!searchQuery) return columns;
58
+ const base = showNonHideableColumns ? columns : hideableColumns;
59
+ if (!searchQuery) return base;
57
60
  const lowerQuery = searchQuery.toLowerCase();
58
- return columns.filter(col =>
61
+ return base.filter(col =>
59
62
  (col.headerName || col.field).toLowerCase().includes(lowerQuery)
60
63
  );
61
- }, [columns, searchQuery]);
64
+ }, [columns, hideableColumns, searchQuery, showNonHideableColumns]);
62
65
 
63
66
  const handleToggleAll = (e: React.ChangeEvent<HTMLInputElement>) => {
64
67
  if (e.target.checked) {
@@ -27,7 +27,7 @@ import { GridListView } from './GridListView';
27
27
  import { GridPinnedRows } from './GridPinnedRows';
28
28
  import { GridVirtualRows } from './GridVirtualRows';
29
29
  import { GridStandaloneColumnPanel } from './GridStandaloneColumnPanel';
30
- import type { DataGridProps, GridRowModel, GridRowId, GridSortDirection, GridColDef, GridRowParams, GridCellParams, GridDataSource, GridAggregationResult, GridFilterModel, GridTreeNode, GridSortItem, GridRowMeta } from '../../types';
30
+ import type { DataGridProps, GridRowModel, GridRowId, GridSortDirection, GridColDef, GridRowParams, GridCellParams, GridDataSource, GridAggregationResult, GridFilterModel, GridTreeNode, GridSortItem, GridRowMeta, GridGroupedExportRow } from '../../types';
31
31
 
32
32
  const EMPTY_ROW_META_MAP: Map<GridRowId, GridRowMeta> = new Map();
33
33
 
@@ -499,7 +499,53 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
499
499
  useEffect(() => {
500
500
  gridData.apiRef.current.getAggregationResult = () => hasAggregation ? aggregationResult : null;
501
501
  gridData.apiRef.current.getAggregationModel = () => hasAggregation ? aggregationModel : null;
502
- }, [aggregationResult, aggregationModel, hasAggregation, gridData.apiRef]);
502
+ gridData.apiRef.current.getGroupedExportRows = (): GridGroupedExportRow[] | null => {
503
+ if (!isRowGrouping) return null;
504
+ const { treeNodes, groupingRows } = rowGroupingHandlers;
505
+ const rowLookup = new Map<GridRowId, GridRowModel>();
506
+ sortedUnpinnedRows.forEach(r => rowLookup.set((r as GridRowModel).id as GridRowId, r as GridRowModel));
507
+
508
+ const result: GridGroupedExportRow[] = [];
509
+
510
+ const traverse = (ids: GridRowId[]) => {
511
+ ids.forEach(id => {
512
+ const node = treeNodes.get(id);
513
+ if (!node) return;
514
+ const isGroup = Boolean(node.children && node.children.length > 0);
515
+ if (isGroup) {
516
+ result.push({
517
+ type: 'group-header',
518
+ depth: node.depth,
519
+ groupField: node.groupingField,
520
+ groupValue: node.groupingValue,
521
+ });
522
+ traverse(node.children!);
523
+ if (hasAggregation && node.aggregatedValues) {
524
+ result.push({
525
+ type: 'group-subtotal',
526
+ depth: node.depth,
527
+ groupField: node.groupingField,
528
+ groupValue: node.groupingValue,
529
+ aggregatedValues: node.aggregatedValues,
530
+ });
531
+ }
532
+ } else {
533
+ const row = rowLookup.get(id) ?? groupingRows.get(id);
534
+ if (row) {
535
+ result.push({ type: 'leaf', depth: node.depth, row });
536
+ }
537
+ }
538
+ });
539
+ };
540
+
541
+ traverse(Array.from(treeNodes.keys()).filter(id => treeNodes.get(id)?.parentId === null));
542
+
543
+ if (hasAggregation && aggregationResult) {
544
+ result.push({ type: 'grand-total', depth: 0, aggregatedValues: aggregationResult });
545
+ }
546
+ return result;
547
+ };
548
+ }, [aggregationResult, aggregationModel, hasAggregation, gridData.apiRef, isRowGrouping, rowGroupingHandlers, sortedUnpinnedRows]);
503
549
 
504
550
  const layout = useLayout({
505
551
  rowHeight,
@@ -74,6 +74,8 @@ export interface GridToolbarProps {
74
74
  renderQuickFilter?: (props: ToolbarQuickFilterRenderProps) => React.ReactNode;
75
75
  /** Additional CSS class applied to the toolbar root element. */
76
76
  className?: string;
77
+ /** Show columns with `hideable: false` in the Columns panel as disabled rows. Default: false. */
78
+ showNonHideableColumns?: boolean;
77
79
  }
78
80
 
79
81
  const AGGREGATION_FUNCTIONS = ['none', 'sum', 'avg', 'count', 'min', 'max'] as const;
@@ -248,7 +250,8 @@ function ColumnsPanelWrapper({
248
250
  onHideAll,
249
251
  onColumnReorder,
250
252
  onColumnOrderReset,
251
- onClose
253
+ onClose,
254
+ showNonHideableColumns,
252
255
  }: {
253
256
  anchorRef: React.RefObject<HTMLElement | null>;
254
257
  columns: GridColDef[];
@@ -259,6 +262,7 @@ function ColumnsPanelWrapper({
259
262
  onColumnReorder?: (fromField: string, toField: string) => void;
260
263
  onColumnOrderReset?: () => void;
261
264
  onClose: () => void;
265
+ showNonHideableColumns?: boolean;
262
266
  }) {
263
267
  const panelRef = useRef<HTMLDivElement>(null);
264
268
  const [pos, setPos] = useState<PanelPosition>({ top: 0, right: 0 });
@@ -317,6 +321,7 @@ function ColumnsPanelWrapper({
317
321
  onHideAll={onHideAll}
318
322
  onColumnReorder={onColumnReorder}
319
323
  onColumnOrderReset={onColumnOrderReset}
324
+ showNonHideableColumns={showNonHideableColumns}
320
325
  />
321
326
  </div>
322
327
  );
@@ -464,6 +469,7 @@ export function GridToolbar({
464
469
  renderExportButton,
465
470
  renderQuickFilter,
466
471
  className,
472
+ showNonHideableColumns,
467
473
  }: GridToolbarProps) {
468
474
  const [aggOpen, setAggOpen] = useState(false);
469
475
  const [pivotOpen, setPivotOpen] = useState(false);
@@ -631,6 +637,7 @@ export function GridToolbar({
631
637
  onColumnReorder={onColumnReorder}
632
638
  onColumnOrderReset={onColumnOrderReset}
633
639
  onClose={() => { setColsOpen(false); onColumnsPanelClose?.(); }}
640
+ showNonHideableColumns={showNonHideableColumns}
634
641
  />
635
642
  )}
636
643
  </div>
@@ -300,6 +300,7 @@ export function useDataGrid<R extends GridRowModel = GridRowModel>(params: UseDa
300
300
  getAllRows: () => Array.from(stateRef.current.rows.idRowsLookup.values()),
301
301
  getVisibleRows: () => Array.from(stateRef.current.rows.idRowsLookup.values()),
302
302
  getAllFilteredRows: () => Array.from(stateRef.current.rows.idRowsLookup.values()),
303
+ getGroupedExportRows: () => null,
303
304
  getAggregationResult: () => null,
304
305
  getAggregationModel: () => null,
305
306
  getColumn: (field: string) => stateRef.current.columns.lookup.get(field) || null,
@@ -376,6 +376,33 @@ export interface PdfExportOptions {
376
376
  * Base font size in points for table body. Default: 9
377
377
  */
378
378
  fontSize?: number;
379
+
380
+ /**
381
+ * Pre-built grouped export rows from `apiRef.current.getGroupedExportRows()`.
382
+ * When provided, the PDF table reflects the grouping structure (group headers,
383
+ * leaf rows, per-group subtotals, grand total) instead of a flat row list.
384
+ * Has no effect when the grid has no active `rowGroupingModel`.
385
+ */
386
+ groupedRows?: GridGroupedExportRow[];
387
+ }
388
+
389
+ /**
390
+ * A single entry in the ordered list returned by `GridApi.getGroupedExportRows()`.
391
+ * Traverse in array order to reproduce the on-screen grouping structure.
392
+ */
393
+ export interface GridGroupedExportRow {
394
+ /** Discriminator: the kind of row this entry represents. */
395
+ type: 'group-header' | 'leaf' | 'group-subtotal' | 'grand-total';
396
+ /** Nesting depth (0 = top-level group, 1 = nested group, etc.). */
397
+ depth: number;
398
+ /** The grouping field for this level (absent on 'leaf' and 'grand-total'). */
399
+ groupField?: string;
400
+ /** The raw grouping value for this level (absent on 'leaf' and 'grand-total'). */
401
+ groupValue?: unknown;
402
+ /** Per-group aggregation results (present on 'group-subtotal' and 'grand-total'). */
403
+ aggregatedValues?: Record<string, unknown>;
404
+ /** The original data row (only present when `type === 'leaf'`). */
405
+ row?: GridRowModel;
379
406
  }
380
407
 
381
408
  export type GridPinnedPosition = 'left' | 'right';
@@ -819,6 +846,13 @@ export interface GridApi {
819
846
  getVisibleRows: () => GridRowModel[];
820
847
  /** Returns all filtered and sorted rows, ignoring pagination. Use this for full-dataset exports. */
821
848
  getAllFilteredRows: () => GridRowModel[];
849
+ /**
850
+ * Returns an ordered flat list of `GridGroupedExportRow` entries that mirrors the
851
+ * on-screen grouping tree: group-headers, leaf rows, per-group subtotals, and a
852
+ * grand-total entry at the end. Returns `null` when no `rowGroupingModel` is active.
853
+ * Pass the result to export functions that accept a `groupedRows` option.
854
+ */
855
+ getGroupedExportRows: () => GridGroupedExportRow[] | null;
822
856
  /** Returns the current aggregation results. */
823
857
  getAggregationResult: () => Record<string, unknown> | null;
824
858
  /** 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); // #1e293b
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); // #64748b
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} • ${rowsToExport.length} row${rowsToExport.length !== 1 ? 's' : ''}`,
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
- // Separator line
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
- const body = rowsToExport.map(row =>
209
- exportColumns.map(col => resolveValue(row, col))
210
- );
211
-
212
- // --- Aggregation footer ---
208
+ let body: string[][];
213
209
  let foot: string[][] | undefined;
214
- if (aggregationResult && aggregationModel) {
215
- const footRow = exportColumns.map((col, i) => {
216
- if (i === 0) {
217
- // First column shows TOTAL label if any aggregation exists
218
- const hasAgg = exportColumns.some(c => aggregationModel[c.field]);
219
- return hasAgg ? 'TOTAL' : '';
220
- }
221
- const aggVal = aggregationResult[col.field];
222
- if (aggVal === undefined || aggVal === null) return '';
223
- let formatted: unknown = aggVal;
224
- if (col.valueFormatter) {
225
- formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
226
- } else if (aggregationModel[col.field]) {
227
- formatted = formatAggregationValue(aggVal, aggregationModel[col.field] as string);
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
- foot = [footRow];
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
+ });