argent-grid 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.github/workflows/pages.yml +68 -0
  2. package/AGENTS.md +179 -0
  3. package/README.md +222 -0
  4. package/demo-app/README.md +70 -0
  5. package/demo-app/angular.json +78 -0
  6. package/demo-app/e2e/benchmark.spec.ts +53 -0
  7. package/demo-app/e2e/demo-page.spec.ts +77 -0
  8. package/demo-app/e2e/grid-features.spec.ts +269 -0
  9. package/demo-app/package-lock.json +14023 -0
  10. package/demo-app/package.json +36 -0
  11. package/demo-app/playwright-test-menu.js +19 -0
  12. package/demo-app/playwright.config.ts +23 -0
  13. package/demo-app/src/app/app.component.ts +10 -0
  14. package/demo-app/src/app/app.config.ts +13 -0
  15. package/demo-app/src/app/app.routes.ts +7 -0
  16. package/demo-app/src/app/demo-page/demo-page.component.css +313 -0
  17. package/demo-app/src/app/demo-page/demo-page.component.html +124 -0
  18. package/demo-app/src/app/demo-page/demo-page.component.ts +366 -0
  19. package/demo-app/src/index.html +19 -0
  20. package/demo-app/src/main.ts +6 -0
  21. package/demo-app/tsconfig.json +31 -0
  22. package/ng-package.json +8 -0
  23. package/package.json +60 -0
  24. package/plan.md +131 -0
  25. package/setup-vitest.ts +18 -0
  26. package/src/lib/argent-grid.module.ts +21 -0
  27. package/src/lib/components/argent-grid.component.css +483 -0
  28. package/src/lib/components/argent-grid.component.html +320 -0
  29. package/src/lib/components/argent-grid.component.spec.ts +189 -0
  30. package/src/lib/components/argent-grid.component.ts +1188 -0
  31. package/src/lib/directives/ag-grid-compatibility.directive.ts +92 -0
  32. package/src/lib/rendering/canvas-renderer.ts +962 -0
  33. package/src/lib/rendering/render/blit.spec.ts +453 -0
  34. package/src/lib/rendering/render/blit.ts +393 -0
  35. package/src/lib/rendering/render/cells.ts +369 -0
  36. package/src/lib/rendering/render/index.ts +105 -0
  37. package/src/lib/rendering/render/lines.ts +363 -0
  38. package/src/lib/rendering/render/theme.spec.ts +282 -0
  39. package/src/lib/rendering/render/theme.ts +201 -0
  40. package/src/lib/rendering/render/types.ts +279 -0
  41. package/src/lib/rendering/render/walk.spec.ts +360 -0
  42. package/src/lib/rendering/render/walk.ts +360 -0
  43. package/src/lib/rendering/utils/damage-tracker.spec.ts +444 -0
  44. package/src/lib/rendering/utils/damage-tracker.ts +423 -0
  45. package/src/lib/rendering/utils/index.ts +7 -0
  46. package/src/lib/services/grid.service.spec.ts +1039 -0
  47. package/src/lib/services/grid.service.ts +1284 -0
  48. package/src/lib/types/ag-grid-types.ts +970 -0
  49. package/src/public-api.ts +22 -0
  50. package/tsconfig.json +32 -0
  51. package/tsconfig.lib.json +11 -0
  52. package/tsconfig.spec.json +8 -0
  53. package/vitest.config.ts +55 -0
@@ -0,0 +1,1284 @@
1
+ import { Injectable, Inject, Optional } from '@angular/core';
2
+ import { Subject } from 'rxjs';
3
+ import { Workbook } from 'exceljs';
4
+ import { GridApi,
5
+ GridOptions,
6
+ ColDef,
7
+ ColGroupDef,
8
+ Column,
9
+ IRowNode,
10
+ FilterModel,
11
+ FilterModelItem,
12
+ SortModelItem,
13
+ GridState,
14
+ RowDataTransaction,
15
+ RowDataTransactionResult,
16
+ CsvExportParams,
17
+ ExcelExportParams,
18
+ GroupRowNode,
19
+ CellRange
20
+ } from '../types/ag-grid-types';
21
+
22
+ @Injectable()
23
+ export class GridService<TData = any> {
24
+ private columns: Map<string, Column> = new Map();
25
+ private rowData: TData[] = [];
26
+ private rowNodes: Map<string, IRowNode<TData>> = new Map();
27
+ private displayedRowNodes: IRowNode<TData>[] = [];
28
+ private columnDefs: (ColDef<TData> | ColGroupDef<TData>)[] | null = null;
29
+ private sortModel: SortModelItem[] = [];
30
+ private filterModel: FilterModel = {};
31
+ private filteredRowData: TData[] = [];
32
+ private selectedRows: Set<string> = new Set();
33
+ private expandedGroups: Set<string> = new Set();
34
+ private cellRanges: CellRange[] = [];
35
+ private gridId: string = '';
36
+ private gridOptions: GridOptions<TData> | null = null;
37
+ public gridStateChanged$ = new Subject<{ type: string, key?: string, value?: any }>();
38
+
39
+ // Row height cache
40
+ private cumulativeRowHeights: number[] = [];
41
+ private totalHeight = 0;
42
+
43
+ // Grouping cache
44
+ private cachedGroupedData: (TData | GroupRowNode<TData>)[] | null = null;
45
+ private groupingDirty = true;
46
+
47
+ // Pivoting state
48
+ private pivotColumnDefs: (ColDef<TData> | ColGroupDef<TData>)[] | null = null;
49
+ private isPivotMode = false;
50
+
51
+ createApi(
52
+ columnDefs: (ColDef<TData> | ColGroupDef<TData>)[] | null,
53
+ rowData: TData[] | null,
54
+ gridOptions?: GridOptions<TData> | null
55
+ ): GridApi<TData> {
56
+ this.columnDefs = columnDefs;
57
+ this.rowData = rowData ? [...rowData] : [];
58
+ this.filteredRowData = [...this.rowData];
59
+ this.displayedRowNodes = [];
60
+ this.gridId = this.generateGridId();
61
+ this.gridOptions = gridOptions ? { ...gridOptions } : {};
62
+ this.isPivotMode = !!this.gridOptions.pivotMode;
63
+
64
+ this.initializeColumns();
65
+
66
+ // Trigger initial pipeline run
67
+ this.applySorting();
68
+ this.applyFiltering(); // This will trigger grouping if needed and initialize nodes
69
+
70
+ return this.createGridApi();
71
+ }
72
+
73
+ private generateGridId(): string {
74
+ return `argent-grid-${Math.random().toString(36).substr(2, 9)}`;
75
+ }
76
+
77
+ private initializeColumns(): void {
78
+ if (!this.columnDefs) {
79
+ return;
80
+ }
81
+
82
+ this.columns.clear();
83
+
84
+ const groupColumns = this.getGroupColumns();
85
+ const isGrouping = groupColumns.length > 0;
86
+ const groupDisplayType = this.gridOptions?.groupDisplayType || 'singleColumn';
87
+
88
+ // 1. Handle Auto Group Column (for singleColumn display)
89
+ if (isGrouping && (groupDisplayType === 'singleColumn' || !this.gridOptions?.groupDisplayType)) {
90
+ const autoGroupDef = this.gridOptions?.autoGroupColumnDef || {};
91
+ const autoGroupCol: Column = {
92
+ colId: 'ag-Grid-AutoColumn',
93
+ field: 'ag-Grid-AutoColumn',
94
+ headerName: autoGroupDef.headerName || 'Group',
95
+ width: autoGroupDef.width || 200,
96
+ minWidth: autoGroupDef.minWidth,
97
+ maxWidth: autoGroupDef.maxWidth,
98
+ pinned: this.normalizePinned(autoGroupDef.pinned || 'left'),
99
+ visible: true,
100
+ sort: null
101
+ };
102
+ this.columns.set(autoGroupCol.colId, autoGroupCol);
103
+ }
104
+
105
+ // 2. Process regular columns
106
+ const columnsToProcess = (this.isPivotMode && this.pivotColumnDefs) ?
107
+ [...this.columnDefs, ...this.pivotColumnDefs] :
108
+ this.columnDefs;
109
+
110
+ columnsToProcess.forEach((def, index) => {
111
+ if ('children' in def) {
112
+ // Column group
113
+ def.children.forEach((child, childIndex) => {
114
+ this.addColumn(child, index * 100 + childIndex, isGrouping);
115
+ });
116
+ } else {
117
+ this.addColumn(def, index, isGrouping);
118
+ }
119
+ });
120
+ }
121
+
122
+ private normalizePinned(pinned: boolean | 'left' | 'right' | null | undefined): 'left' | 'right' | false {
123
+ if (pinned === 'left' || pinned === true) return 'left';
124
+ if (pinned === 'right') return 'right';
125
+ return false;
126
+ }
127
+
128
+ private addColumn(def: ColDef<TData>, index: number, isGrouping: boolean): void {
129
+ const colId = def.colId || def.field?.toString() || `col-${index}`;
130
+
131
+ // Auto-hide columns that are being grouped (AG Grid default)
132
+ let visible = !def.hide;
133
+ if (isGrouping && def.rowGroup && visible && this.gridOptions?.groupHideOpenParents !== false) {
134
+ visible = false;
135
+ }
136
+
137
+ // Auto-hide columns that are being pivoted
138
+ if (this.isPivotMode && def.pivot && visible) {
139
+ visible = false;
140
+ }
141
+
142
+ // Auto-hide value columns if in pivot mode (they appear under pivot keys)
143
+ if (this.isPivotMode && def.aggFunc && visible && !colId.startsWith('pivot_')) {
144
+ visible = false;
145
+ }
146
+
147
+ // In pivot mode, hide columns that are not part of grouping or pivot results
148
+ if (this.isPivotMode && visible && !def.rowGroup && !colId.startsWith('pivot_') && colId !== 'ag-Grid-AutoColumn') {
149
+ visible = false;
150
+ }
151
+
152
+ const column: Column = {
153
+ colId,
154
+ field: def.field?.toString(),
155
+ headerName: def.headerName,
156
+ width: def.width || 150,
157
+ minWidth: def.minWidth,
158
+ maxWidth: def.maxWidth,
159
+ pinned: this.normalizePinned(def.pinned),
160
+ visible: visible,
161
+ sort: (typeof def.sort === 'object' && def.sort !== null) ? (def.sort as any).sort : def.sort || null,
162
+ sortIndex: def.sortIndex ?? undefined,
163
+ aggFunc: typeof def.aggFunc === 'string' ? def.aggFunc : null
164
+ };
165
+ this.columns.set(colId, column);
166
+ }
167
+
168
+ private getRowId(data: TData, index: number): string {
169
+ // 1. Try custom callback from gridOptions
170
+ if (this.gridOptions?.getRowId) {
171
+ return this.gridOptions.getRowId({ data });
172
+ }
173
+
174
+ // 2. Try to get ID from data, fallback to index
175
+ const anyData = data as any;
176
+ return anyData?.id?.toString() || anyData?.Id?.toString() || `row-${index}`;
177
+ }
178
+
179
+ private createGridApi(): GridApi<TData> {
180
+ return {
181
+ // Column API
182
+ getColumnDefs: () => this.columnDefs,
183
+ setColumnDefs: (colDefs) => {
184
+ this.columnDefs = colDefs;
185
+ this.groupingDirty = true;
186
+ this.initializeColumns();
187
+ },
188
+ getColumn: (key) => {
189
+ const colId = typeof key === 'string' ? key : key.colId;
190
+ return this.columns.get(colId) || null;
191
+ },
192
+ getAllColumns: () => Array.from(this.columns.values()),
193
+ getDisplayedRowAtIndex: (index) => {
194
+ return this.displayedRowNodes[index] || null;
195
+ },
196
+
197
+ // Row Data API
198
+ getRowData: () => [...this.filteredRowData],
199
+ setRowData: (rowData) => {
200
+ this.rowData = rowData;
201
+ this.filteredRowData = [...rowData];
202
+ this.groupingDirty = true;
203
+ this.applySorting();
204
+ this.applyFiltering();
205
+ },
206
+ applyTransaction: (transaction) => this.applyTransaction(transaction),
207
+ getDisplayedRowCount: () => this.displayedRowNodes.length,
208
+ getAggregations: () => this.calculateColumnAggregations(this.filteredRowData),
209
+ getRowNode: (id) => this.rowNodes.get(id) || null,
210
+
211
+ // Selection API
212
+ getSelectedRows: () => Array.from(this.rowNodes.values()).filter(n => n.selected),
213
+ getSelectedNodes: () => Array.from(this.rowNodes.values()).filter(n => n.selected),
214
+ selectAll: () => {
215
+ this.rowNodes.forEach(node => {
216
+ node.selected = true;
217
+ this.selectedRows.add(node.id!);
218
+ });
219
+ this.gridStateChanged$.next({ type: 'selectionChanged' });
220
+ },
221
+ deselectAll: () => {
222
+ this.rowNodes.forEach(node => {
223
+ node.selected = false;
224
+ });
225
+ this.selectedRows.clear();
226
+ this.gridStateChanged$.next({ type: 'selectionChanged' });
227
+ },
228
+
229
+ // Filter API
230
+ setFilterModel: (model) => {
231
+ this.filterModel = model;
232
+ this.applyFiltering();
233
+ this.gridStateChanged$.next({ type: 'filterChanged' });
234
+ },
235
+ getFilterModel: () => ({ ...this.filterModel }),
236
+ onFilterChanged: () => {
237
+ this.applyFiltering();
238
+ this.gridStateChanged$.next({ type: 'filterChanged' });
239
+ },
240
+ isFilterPresent: () => Object.keys(this.filterModel).length > 0,
241
+
242
+ // Sort API
243
+ setSortModel: (model) => {
244
+ this.sortModel = model;
245
+ this.applySorting();
246
+ this.applyFiltering(); // Re-filter and re-group after sort
247
+ this.gridStateChanged$.next({ type: 'sortChanged' });
248
+ },
249
+ getSortModel: () => [...this.sortModel],
250
+ onSortChanged: () => {
251
+ this.applySorting();
252
+ this.applyFiltering(); // Re-filter and re-group after sort
253
+ this.gridStateChanged$.next({ type: 'sortChanged' });
254
+ },
255
+
256
+ // Pagination API
257
+ paginationGetPageSize: () => 100,
258
+ paginationSetPageSize: () => {},
259
+ paginationGetCurrentPage: () => 0,
260
+ paginationGetTotalPages: () => 1,
261
+ paginationGoToFirstPage: () => {},
262
+ paginationGoToLastPage: () => {},
263
+ paginationGoToNextPage: () => {},
264
+ paginationGoToPreviousPage: () => {},
265
+
266
+ // Export API
267
+ exportDataAsCsv: (params) => this.exportAsCsv(params),
268
+ exportDataAsExcel: (params) => this.exportAsExcel(params),
269
+
270
+ // Clipboard API
271
+ copyToClipboard: () => {},
272
+ cutToClipboard: () => {},
273
+ pasteFromClipboard: () => {},
274
+
275
+ // Grid State API
276
+ getState: () => this.getGridState(),
277
+ applyState: (state) => this.applyGridState(state),
278
+
279
+ // Focus API
280
+ setFocusedCell: () => {},
281
+ getFocusedCell: () => null,
282
+
283
+ // Refresh API
284
+ refreshCells: () => {},
285
+ refreshRows: (params) => {
286
+ if (params?.rowNodes) {
287
+ params.rowNodes.forEach(node => {
288
+ // Trigger cell refresh
289
+ });
290
+ }
291
+ },
292
+ refreshHeader: () => {},
293
+
294
+ // Scroll API
295
+ ensureIndexVisible: () => {},
296
+ ensureColumnVisible: () => {},
297
+
298
+ // Destroy API
299
+ destroy: () => {
300
+ this.columns.clear();
301
+ this.rowNodes.clear();
302
+ this.rowData = [];
303
+ },
304
+
305
+ // Grid Information
306
+ getGridId: () => this.gridId,
307
+ getGridOption: (key) => this.gridOptions ? this.gridOptions[key] : undefined as any,
308
+ setGridOption: (key, value) => {
309
+ if (!this.gridOptions) {
310
+ this.gridOptions = {} as GridOptions<TData>;
311
+ }
312
+ this.gridOptions[key] = value;
313
+ this.gridStateChanged$.next({ type: 'optionChanged', key: key as string, value });
314
+ },
315
+
316
+ // Group Expansion
317
+ setRowNodeExpanded: (node, expanded) => {
318
+ if (node.id && (node.group || node.master)) {
319
+ if (expanded) {
320
+ this.expandedGroups.add(node.id);
321
+ } else {
322
+ this.expandedGroups.delete(node.id);
323
+ }
324
+
325
+ if (node.group) {
326
+ this.applyGrouping();
327
+ } else {
328
+ this.initializeRowNodesFromFilteredData();
329
+ }
330
+
331
+ this.gridStateChanged$.next({ type: 'groupExpanded', value: expanded });
332
+ }
333
+ },
334
+
335
+ // Row Height API
336
+ getRowY: (index) => this.getRowY(index),
337
+ getRowAtY: (y) => this.getRowAtY(y),
338
+ getTotalHeight: () => this.getTotalHeight(),
339
+
340
+ // Pivot API
341
+ setPivotMode: (pivotMode) => {
342
+ if (this.isPivotMode !== pivotMode) {
343
+ this.isPivotMode = pivotMode;
344
+ this.groupingDirty = true;
345
+ this.cachedGroupedData = null;
346
+ this.applyGrouping();
347
+ this.initializeColumns();
348
+ this.gridStateChanged$.next({ type: 'pivotModeChanged', value: pivotMode });
349
+ }
350
+ },
351
+ isPivotMode: () => this.isPivotMode,
352
+
353
+ // Range Selection API
354
+ getCellRanges: () => this.cellRanges.length > 0 ? [...this.cellRanges] : null,
355
+ addCellRange: (range) => {
356
+ this.cellRanges = [range]; // For now only support single range
357
+ this.gridStateChanged$.next({ type: 'rangeSelectionChanged' });
358
+ },
359
+ clearRangeSelection: () => {
360
+ if (this.cellRanges.length > 0) {
361
+ this.cellRanges = [];
362
+ this.gridStateChanged$.next({ type: 'rangeSelectionChanged' });
363
+ }
364
+ }
365
+ };
366
+ }
367
+
368
+ private applyTransaction(transaction: RowDataTransaction<TData>): RowDataTransactionResult | null {
369
+ const result: RowDataTransactionResult = {
370
+ add: [],
371
+ update: [],
372
+ remove: []
373
+ };
374
+
375
+ let dataChanged = false;
376
+
377
+ if (transaction.add) {
378
+ transaction.add.forEach((data, index) => {
379
+ const id = this.getRowId(data, this.rowData.length + index);
380
+ this.rowData.push(data);
381
+ dataChanged = true;
382
+
383
+ // We'll create the actual node during the pipeline re-run
384
+ // but we can return a placeholder result for now as AG Grid does
385
+ });
386
+ }
387
+
388
+ if (transaction.update) {
389
+ transaction.update.forEach(data => {
390
+ const id = this.getRowId(data, 0);
391
+ const index = this.rowData.findIndex(r => this.getRowId(r, 0) === id);
392
+ if (index !== -1) {
393
+ this.rowData[index] = data;
394
+ dataChanged = true;
395
+
396
+ const existingNode = this.rowNodes.get(id);
397
+ if (existingNode) {
398
+ existingNode.data = data;
399
+ result.update.push(existingNode);
400
+ }
401
+ }
402
+ });
403
+ }
404
+
405
+ if (transaction.remove) {
406
+ transaction.remove.forEach(data => {
407
+ const anyData = data as any;
408
+ const dataId = anyData?.id;
409
+ const id = this.getRowId(data, 0);
410
+
411
+ const index = this.rowData.findIndex(r => this.getRowId(r, 0) === id);
412
+ if (index !== -1) {
413
+ const removedData = this.rowData.splice(index, 1)[0];
414
+ dataChanged = true;
415
+
416
+ const node = this.rowNodes.get(id);
417
+ if (node) {
418
+ this.rowNodes.delete(id);
419
+ result.remove.push(node);
420
+ }
421
+ }
422
+ });
423
+ }
424
+
425
+ if (dataChanged) {
426
+ this.groupingDirty = true;
427
+ this.applySorting();
428
+ this.applyFiltering();
429
+
430
+ // Populate result.add after pipeline has run so we have the nodes
431
+ if (transaction.add) {
432
+ transaction.add.forEach(data => {
433
+ const id = this.getRowId(data, 0);
434
+ const node = this.rowNodes.get(id);
435
+ if (node) result.add.push(node);
436
+ });
437
+ }
438
+
439
+ this.gridStateChanged$.next({ type: 'transactionApplied' });
440
+ }
441
+
442
+ return result;
443
+ }
444
+
445
+ private applySorting(): void {
446
+ this.groupingDirty = true;
447
+ if (this.sortModel.length === 0) {
448
+ return;
449
+ }
450
+
451
+ // Sort rowData based on sort model
452
+ this.rowData.sort((a, b) => {
453
+ for (const sortItem of this.sortModel) {
454
+ const column = this.columns.get(sortItem.colId);
455
+ if (!column?.field) continue;
456
+
457
+ const field = column.field as keyof TData;
458
+ const valueA = a[field] as any;
459
+ const valueB = b[field] as any;
460
+
461
+ const comparison = this.compareValues(valueA, valueB);
462
+ if (comparison !== 0) {
463
+ return sortItem.sort === 'desc' ? -comparison : comparison;
464
+ }
465
+ }
466
+ return 0;
467
+ });
468
+
469
+ // Also update filtered data if no filter present
470
+ if (Object.keys(this.filterModel).length === 0) {
471
+ this.filteredRowData = [...this.rowData];
472
+ }
473
+ }
474
+
475
+ private applyFiltering(): void {
476
+ this.groupingDirty = true;
477
+ if (Object.keys(this.filterModel).length === 0) {
478
+ // No filters, use all data
479
+ this.filteredRowData = [...this.rowData];
480
+ } else {
481
+ // Apply filters with AND logic
482
+ this.filteredRowData = this.rowData.filter(row => {
483
+ return Object.keys(this.filterModel).every(colId => {
484
+ const filterItem = this.filterModel[colId];
485
+ if (!filterItem) return true;
486
+
487
+ const column = this.columns.get(colId);
488
+ if (!column?.field) return true;
489
+
490
+ const value = (row as any)[column.field];
491
+ return this.matchesFilter(value, filterItem);
492
+ });
493
+ });
494
+ }
495
+
496
+ // Apply grouping after filtering
497
+ this.applyGrouping();
498
+ }
499
+
500
+ private updateRowHeightCache(): void {
501
+ const defaultHeight = this.gridOptions?.rowHeight || 32;
502
+ this.cumulativeRowHeights = [];
503
+ let currentTotal = 0;
504
+
505
+ this.displayedRowNodes.forEach(node => {
506
+ this.cumulativeRowHeights.push(currentTotal);
507
+ const height = node.rowHeight || defaultHeight;
508
+ currentTotal += height;
509
+ });
510
+
511
+ this.totalHeight = currentTotal;
512
+ }
513
+
514
+ private getRowY(index: number): number {
515
+ if (index < 0 || index >= this.cumulativeRowHeights.length) return 0;
516
+ return this.cumulativeRowHeights[index];
517
+ }
518
+
519
+ private getRowAtY(y: number): number {
520
+ if (this.cumulativeRowHeights.length === 0) return 0;
521
+
522
+ // Binary search for the row at position y
523
+ let low = 0;
524
+ let high = this.cumulativeRowHeights.length - 1;
525
+
526
+ while (low <= high) {
527
+ const mid = Math.floor((low + high) / 2);
528
+ const rowY = this.cumulativeRowHeights[mid];
529
+ const nextRowY = mid < this.cumulativeRowHeights.length - 1 ?
530
+ this.cumulativeRowHeights[mid + 1] : this.totalHeight;
531
+
532
+ if (y >= rowY && y < nextRowY) {
533
+ return mid;
534
+ } else if (y < rowY) {
535
+ high = mid - 1;
536
+ } else {
537
+ low = mid + 1;
538
+ }
539
+ }
540
+
541
+ return low >= this.cumulativeRowHeights.length ? this.cumulativeRowHeights.length - 1 : low;
542
+ }
543
+
544
+ private getTotalHeight(): number {
545
+ return this.totalHeight;
546
+ }
547
+
548
+ private getGroupColumns(): string[] {
549
+ if (!this.columnDefs) return [];
550
+
551
+ const groupCols: string[] = [];
552
+ this.columnDefs.forEach(def => {
553
+ if ('rowGroup' in def && def.rowGroup === true && def.field) {
554
+ groupCols.push(def.field as string);
555
+ }
556
+ });
557
+ return groupCols;
558
+ }
559
+
560
+ private getPivotColumns(): string[] {
561
+ if (!this.columnDefs) return [];
562
+
563
+ const pivotCols: string[] = [];
564
+ this.columnDefs.forEach(def => {
565
+ if ('pivot' in def && def.pivot === true && def.field) {
566
+ pivotCols.push(def.field as string);
567
+ }
568
+ });
569
+ return pivotCols;
570
+ }
571
+
572
+ private getValueColumns(): ColDef<TData>[] {
573
+ if (!this.columnDefs) return [];
574
+
575
+ const valueCols: ColDef<TData>[] = [];
576
+ this.columnDefs.forEach(def => {
577
+ if (!('children' in def) && def.aggFunc && def.field) {
578
+ valueCols.push(def);
579
+ }
580
+ });
581
+ return valueCols;
582
+ }
583
+
584
+ private generatePivotColumnDefs(): void {
585
+ const pivotColumns = this.getPivotColumns();
586
+ const valueColumns = this.getValueColumns();
587
+
588
+ if (pivotColumns.length === 0 || valueColumns.length === 0) {
589
+ this.pivotColumnDefs = null;
590
+ return;
591
+ }
592
+
593
+ // 1. Find all unique pivot keys
594
+ const pivotKeys = new Set<string>();
595
+ this.filteredRowData.forEach(row => {
596
+ const key = pivotColumns.map(col => (row as any)[col]).join('_');
597
+ pivotKeys.add(key);
598
+ });
599
+
600
+ const sortedPivotKeys = Array.from(pivotKeys).sort();
601
+
602
+ // 2. Generate column groups for each pivot key
603
+ const newPivotColDefs: (ColDef<TData> | ColGroupDef<TData>)[] = [];
604
+
605
+ sortedPivotKeys.forEach(pivotKey => {
606
+ const children: ColDef<TData>[] = valueColumns.map(valCol => {
607
+ const valueName = valCol.headerName || String(valCol.field);
608
+ return {
609
+ ...valCol,
610
+ colId: `pivot_${pivotKey}_${String(valCol.field)}`,
611
+ headerName: `${pivotKey} (${valueName})`,
612
+ // We use a custom field accessor for pivoted data
613
+ field: `pivotData.${pivotKey}.${String(valCol.field)}` as any,
614
+ pivot: false, // These are the results, not the pivot sources
615
+ rowGroup: false
616
+ };
617
+ });
618
+
619
+ newPivotColDefs.push({
620
+ headerName: pivotKey,
621
+ children: children
622
+ });
623
+ });
624
+
625
+ this.pivotColumnDefs = newPivotColDefs;
626
+ }
627
+
628
+ private applyGrouping(): void {
629
+ const groupColumns = this.getGroupColumns();
630
+
631
+ if (this.isPivotMode) {
632
+ this.generatePivotColumnDefs();
633
+ // If we have pivot columns but weren't grouping, we need to initialize them
634
+ this.initializeColumns();
635
+ }
636
+
637
+ if (groupColumns.length === 0 && !this.isPivotMode) {
638
+ // No grouping, use filtered data
639
+ this.cachedGroupedData = null;
640
+ this.groupingDirty = true;
641
+ this.initializeRowNodesFromFilteredData();
642
+ return;
643
+ }
644
+
645
+ // Only re-group if filters or data changed
646
+ if (this.groupingDirty || !this.cachedGroupedData) {
647
+ this.cachedGroupedData = this.groupByColumns(this.filteredRowData, groupColumns, 0);
648
+
649
+ if (this.isPivotMode) {
650
+ // Already called above, but we do it again after grouping to be sure
651
+ // (though in Pivot Mode we usually want grouping)
652
+ this.generatePivotColumnDefs();
653
+ this.initializeColumns(); // Re-initialize with new pivot columns
654
+ this.gridStateChanged$.next({ type: 'columnsChanged' });
655
+ }
656
+
657
+ this.groupingDirty = false;
658
+ }
659
+
660
+ // Re-initialize from cache (respects current expansion state)
661
+ this.updateExpansionStateInCache(this.cachedGroupedData);
662
+ this.initializeRowNodesFromGroupedData();
663
+ }
664
+
665
+ private updateExpansionStateInCache(groupedData: (TData | GroupRowNode<TData>)[]): void {
666
+ for (const item of groupedData) {
667
+ if (this.isGroupRowNode(item)) {
668
+ item.expanded = this.expandedGroups.has(item.id);
669
+ this.updateExpansionStateInCache(item.children);
670
+ }
671
+ }
672
+ }
673
+
674
+ private groupByColumns(
675
+ data: TData[],
676
+ groupColumns: string[],
677
+ level: number
678
+ ): (TData | GroupRowNode<TData>)[] {
679
+ if (level >= groupColumns.length || data.length === 0) {
680
+ if (this.isPivotMode && level === 0 && data.length > 0 && groupColumns.length === 0) {
681
+ // Pivot mode without row groups - return a single root group summarizer
682
+ const rootGroup: GroupRowNode<TData> = {
683
+ id: 'pivot-total-summary',
684
+ groupKey: 'Summary (All rows)',
685
+ groupField: 'ag-Grid-AutoColumn',
686
+ level: 0,
687
+ children: data,
688
+ expanded: true,
689
+ aggregation: this.calculateAggregations(data, 'Summary'),
690
+ pivotData: this.calculatePivotData(data)
691
+ };
692
+ return [rootGroup];
693
+ }
694
+ return data;
695
+ }
696
+
697
+ const groupField = groupColumns[level];
698
+ const groups = new Map<any, TData[]>();
699
+
700
+ // Group data by the current field
701
+ data.forEach(item => {
702
+ const key = (item as any)[groupField];
703
+ if (!groups.has(key)) {
704
+ groups.set(key, []);
705
+ }
706
+ groups.get(key)!.push(item);
707
+ });
708
+
709
+ // Create group nodes
710
+ const result: (TData | GroupRowNode<TData>)[] = [];
711
+ groups.forEach((items, key) => {
712
+ const children = this.groupByColumns(items, groupColumns, level + 1);
713
+
714
+ const groupNode: GroupRowNode<TData> = {
715
+ id: `group-${groupField}-${key}-${level}`,
716
+ groupKey: key,
717
+ groupField,
718
+ level,
719
+ children,
720
+ expanded: this.expandedGroups.has(`group-${groupField}-${key}-${level}`),
721
+ aggregation: this.calculateAggregations(items, groupField),
722
+ pivotData: this.isPivotMode ? this.calculatePivotData(items) : undefined
723
+ };
724
+
725
+ result.push(groupNode);
726
+ });
727
+
728
+ return result;
729
+ }
730
+
731
+ private calculateAggregations(data: TData[], groupField: string): { [field: string]: any } {
732
+ return this.calculateColumnAggregations(data);
733
+ }
734
+
735
+ private calculatePivotData(data: TData[]): { [pivotKey: string]: { [field: string]: any } } {
736
+ const pivotColumns = this.getPivotColumns();
737
+ const pivotGroups = new Map<string, TData[]>();
738
+
739
+ // Sub-group by pivot columns within this row group
740
+ data.forEach(item => {
741
+ const key = pivotColumns.map(col => (item as any)[col]).join('_');
742
+ if (!pivotGroups.has(key)) {
743
+ pivotGroups.set(key, []);
744
+ }
745
+ pivotGroups.get(key)!.push(item);
746
+ });
747
+
748
+ const pivotData: { [pivotKey: string]: { [field: string]: any } } = {};
749
+ pivotGroups.forEach((items, key) => {
750
+ pivotData[key] = this.calculateColumnAggregations(items);
751
+ });
752
+
753
+ return pivotData;
754
+ }
755
+
756
+ public calculateColumnAggregations(data: TData[]): { [field: string]: any } {
757
+ const aggregations: { [field: string]: any } = {};
758
+
759
+ if (!this.columnDefs) return aggregations;
760
+
761
+ this.columnDefs.forEach(def => {
762
+ // Skip column groups
763
+ if ('children' in def) return;
764
+
765
+ if (!def.field || !def.aggFunc) return;
766
+
767
+ const field = def.field as string;
768
+ const values = data.map(item => (item as any)[field]).filter(v => v !== null && v !== undefined);
769
+
770
+ if (values.length === 0) return;
771
+
772
+ if (typeof def.aggFunc === 'function') {
773
+ // Custom aggregation function
774
+ aggregations[field] = def.aggFunc({ values, data });
775
+ } else {
776
+ // Built-in aggregation functions
777
+ switch (def.aggFunc) {
778
+ case 'sum':
779
+ aggregations[field] = values.reduce((sum, v) => sum + (Number(v) || 0), 0);
780
+ break;
781
+ case 'avg':
782
+ aggregations[field] = values.reduce((sum, v) => sum + (Number(v) || 0), 0) / values.length;
783
+ break;
784
+ case 'min':
785
+ aggregations[field] = Math.min(...values.map(v => Number(v) || 0));
786
+ break;
787
+ case 'max':
788
+ aggregations[field] = Math.max(...values.map(v => Number(v) || 0));
789
+ break;
790
+ case 'count':
791
+ aggregations[field] = values.length;
792
+ break;
793
+ default:
794
+ aggregations[field] = values[0];
795
+ }
796
+ }
797
+ });
798
+
799
+ return aggregations;
800
+ }
801
+
802
+ private initializeRowNodesFromGroupedData(): void {
803
+ // DO NOT CLEAR this.rowNodes - reuse existing nodes to preserve state
804
+ this.displayedRowNodes = [];
805
+ const flattened = this.flattenGroupedDataWithLevel(this.cachedGroupedData || []);
806
+
807
+ flattened.forEach((entry, index) => {
808
+ const { item, level } = entry;
809
+ let id: string;
810
+ let data: TData;
811
+ let isGroup = false;
812
+ let expanded = false;
813
+
814
+ if (this.isGroupRowNode(item)) {
815
+ // Group node
816
+ id = item.id;
817
+ // Re-use aggregation data from the group node
818
+ data = {
819
+ ...item.aggregation,
820
+ pivotData: item.pivotData,
821
+ [item.groupField]: item.groupKey,
822
+ 'ag-Grid-AutoColumn': item.groupKey
823
+ } as TData;
824
+ isGroup = true;
825
+ expanded = item.expanded;
826
+ } else {
827
+ // Regular data node - IMPORTANT: DO NOT CLONE DATA
828
+ id = this.getRowId(item, index);
829
+ data = item;
830
+ }
831
+
832
+ // Check if we already have this node
833
+ let node = this.rowNodes.get(id);
834
+ if (node) {
835
+ // Update existing node properties
836
+ node.data = data;
837
+ node.expanded = expanded;
838
+ node.group = isGroup;
839
+ node.level = level;
840
+ node.rowIndex = index;
841
+ node.displayedRowIndex = index;
842
+ node.firstChild = index === 0;
843
+ node.lastChild = index === flattened.length - 1;
844
+ } else {
845
+ // Create new node only if it doesn't exist
846
+ node = {
847
+ id,
848
+ data,
849
+ rowPinned: false,
850
+ rowHeight: null,
851
+ displayed: true,
852
+ selected: this.selectedRows.has(id),
853
+ expanded,
854
+ group: isGroup,
855
+ level,
856
+ firstChild: index === 0,
857
+ lastChild: index === flattened.length - 1,
858
+ rowIndex: index,
859
+ displayedRowIndex: index
860
+ };
861
+ this.rowNodes.set(id, node);
862
+ }
863
+
864
+ this.displayedRowNodes.push(node);
865
+ });
866
+
867
+ this.updateRowHeightCache();
868
+ }
869
+
870
+ private isGroupRowNode(item: any): item is GroupRowNode<TData> {
871
+ return item && 'groupKey' in item;
872
+ }
873
+
874
+ private flattenGroupedDataWithLevel(
875
+ groupedData: (TData | GroupRowNode<TData>)[],
876
+ level: number = 0,
877
+ result: { item: TData | GroupRowNode<TData>, level: number }[] = []
878
+ ): { item: TData | GroupRowNode<TData>, level: number }[] {
879
+ for (const item of groupedData) {
880
+ result.push({ item, level });
881
+
882
+ if (this.isGroupRowNode(item)) {
883
+ if (item.expanded) {
884
+ this.flattenGroupedDataWithLevel(item.children, level + 1, result);
885
+ }
886
+ }
887
+ }
888
+ return result;
889
+ }
890
+
891
+ private matchesFilter(value: any, filterItem: FilterModelItem): boolean {
892
+ if (value === null || value === undefined) {
893
+ return false;
894
+ }
895
+
896
+ const { filterType, type, filter, filterTo } = filterItem;
897
+
898
+ switch (filterType) {
899
+ case 'text':
900
+ return this.matchesTextFilter(String(value), type, filter);
901
+ case 'number':
902
+ return this.matchesNumberFilter(Number(value), type, filter, filterTo);
903
+ case 'date':
904
+ return this.matchesDateFilter(String(value), type, filter, filterTo);
905
+ case 'boolean':
906
+ return this.matchesBooleanFilter(value, filter);
907
+ default:
908
+ return true;
909
+ }
910
+ }
911
+
912
+ private matchesTextFilter(value: string, type: string | undefined, filter: any): boolean {
913
+ if (!type || filter === null || filter === undefined) {
914
+ return true;
915
+ }
916
+
917
+ const lowerValue = String(value).toLowerCase();
918
+ const lowerFilter = String(filter).toLowerCase();
919
+
920
+ switch (type) {
921
+ case 'contains':
922
+ return lowerValue.includes(lowerFilter);
923
+ case 'notContains':
924
+ return !lowerValue.includes(lowerFilter);
925
+ case 'startsWith':
926
+ return lowerValue.startsWith(lowerFilter);
927
+ case 'endsWith':
928
+ return lowerValue.endsWith(lowerFilter);
929
+ case 'equals':
930
+ return lowerValue === lowerFilter;
931
+ case 'notEqual':
932
+ return lowerValue !== lowerFilter;
933
+ default:
934
+ return true;
935
+ }
936
+ }
937
+
938
+ private matchesNumberFilter(value: number, type: string | undefined, filter: any, filterTo?: any): boolean {
939
+ if (type === undefined || filter === null || filter === undefined || isNaN(value)) {
940
+ return true;
941
+ }
942
+
943
+ const filterNum = Number(filter);
944
+
945
+ switch (type) {
946
+ case 'equals':
947
+ return value === filterNum;
948
+ case 'notEqual':
949
+ return value !== filterNum;
950
+ case 'greaterThan':
951
+ return value > filterNum;
952
+ case 'greaterThanOrEqual':
953
+ return value >= filterNum;
954
+ case 'lessThan':
955
+ return value < filterNum;
956
+ case 'lessThanOrEqual':
957
+ return value <= filterNum;
958
+ case 'inRange':
959
+ const filterToNum = Number(filterTo);
960
+ return value >= filterNum && value <= filterToNum;
961
+ default:
962
+ return true;
963
+ }
964
+ }
965
+
966
+ private matchesDateFilter(value: string, type: string | undefined, filter: any, filterTo?: any): boolean {
967
+ if (!type || !filter) {
968
+ return true;
969
+ }
970
+
971
+ const valueDate = new Date(value).getTime();
972
+ const filterDate = new Date(filter).getTime();
973
+
974
+ if (type === 'inRange' && filterTo) {
975
+ const filterToDate = new Date(filterTo).getTime();
976
+ return valueDate >= filterDate && valueDate <= filterToDate;
977
+ }
978
+
979
+ switch (type) {
980
+ case 'equals':
981
+ return valueDate === filterDate;
982
+ case 'notEqual':
983
+ return valueDate !== filterDate;
984
+ case 'greaterThan':
985
+ return valueDate > filterDate;
986
+ case 'greaterThanOrEqual':
987
+ return valueDate >= filterDate;
988
+ case 'lessThan':
989
+ return valueDate < filterDate;
990
+ case 'lessThanOrEqual':
991
+ return valueDate <= filterDate;
992
+ default:
993
+ return true;
994
+ }
995
+ }
996
+
997
+ private matchesBooleanFilter(value: any, filter: any): boolean {
998
+ if (filter === null || filter === undefined) {
999
+ return true;
1000
+ }
1001
+ return Boolean(value) === Boolean(filter);
1002
+ }
1003
+
1004
+ private initializeRowNodesFromFilteredData(): void {
1005
+ this.groupingDirty = true;
1006
+ // DO NOT CLEAR this.rowNodes - reuse existing nodes
1007
+ this.displayedRowNodes = [];
1008
+
1009
+ // Separate rows by pinned state
1010
+ const pinnedTopRows: TData[] = [];
1011
+ const pinnedBottomRows: TData[] = [];
1012
+ const normalRows: TData[] = [];
1013
+
1014
+ this.filteredRowData.forEach(data => {
1015
+ const anyData = data as any;
1016
+ const pinned = anyData?.pinned;
1017
+
1018
+ if (pinned === 'top') {
1019
+ pinnedTopRows.push(data);
1020
+ } else if (pinned === 'bottom') {
1021
+ pinnedBottomRows.push(data);
1022
+ } else {
1023
+ normalRows.push(data);
1024
+ }
1025
+ });
1026
+
1027
+ const orderedRows = [...pinnedTopRows, ...normalRows, ...pinnedBottomRows];
1028
+
1029
+ orderedRows.forEach((data, index) => {
1030
+ const id = this.getRowId(data, index);
1031
+ const anyData = data as any;
1032
+ const rowPinned = anyData?.pinned || false;
1033
+ const isMaster = this.gridOptions?.masterDetail &&
1034
+ (this.gridOptions.isRowMaster ? this.gridOptions.isRowMaster(data) : true);
1035
+
1036
+ let node = this.rowNodes.get(id);
1037
+ if (node) {
1038
+ node.data = data;
1039
+ node.rowPinned = rowPinned;
1040
+ node.master = isMaster;
1041
+ node.expanded = this.expandedGroups.has(id);
1042
+ node.rowIndex = index;
1043
+ node.displayedRowIndex = this.displayedRowNodes.length;
1044
+ node.firstChild = index === 0;
1045
+ node.lastChild = index === orderedRows.length - 1;
1046
+ } else {
1047
+ node = {
1048
+ id,
1049
+ data,
1050
+ rowPinned,
1051
+ rowHeight: null,
1052
+ displayed: true,
1053
+ selected: this.selectedRows.has(id),
1054
+ expanded: this.expandedGroups.has(id!),
1055
+ group: false,
1056
+ master: isMaster,
1057
+ level: 0,
1058
+ firstChild: index === 0,
1059
+ lastChild: index === orderedRows.length - 1,
1060
+ rowIndex: index,
1061
+ displayedRowIndex: this.displayedRowNodes.length
1062
+ };
1063
+ this.rowNodes.set(id!, node);
1064
+ }
1065
+
1066
+ this.displayedRowNodes.push(node);
1067
+
1068
+ // If master row is expanded, insert a detail node
1069
+ if (isMaster && node.expanded) {
1070
+ const detailId = `${id}-detail`;
1071
+ let detailNode = this.rowNodes.get(detailId);
1072
+ if (!detailNode) {
1073
+ detailNode = {
1074
+ id: detailId,
1075
+ data: data, // Detail node shares master data
1076
+ rowPinned: false,
1077
+ rowHeight: this.gridOptions?.detailRowHeight || 200,
1078
+ displayed: true,
1079
+ selected: false,
1080
+ expanded: false,
1081
+ group: false,
1082
+ detail: true,
1083
+ masterRowNode: node,
1084
+ level: 1,
1085
+ firstChild: false,
1086
+ lastChild: false,
1087
+ rowIndex: null,
1088
+ displayedRowIndex: this.displayedRowNodes.length
1089
+ };
1090
+ this.rowNodes.set(detailId, detailNode);
1091
+ } else {
1092
+ detailNode.displayedRowIndex = this.displayedRowNodes.length;
1093
+ }
1094
+ this.displayedRowNodes.push(detailNode);
1095
+ }
1096
+ });
1097
+
1098
+ this.updateRowHeightCache();
1099
+ }
1100
+
1101
+ private compareValues(a: any, b: any): number {
1102
+ if (a === b) return 0;
1103
+ if (a === null || a === undefined) return 1;
1104
+ if (b === null || b === undefined) return -1;
1105
+ if (typeof a === 'number' && typeof b === 'number') {
1106
+ return a - b;
1107
+ }
1108
+ return String(a).localeCompare(String(b));
1109
+ }
1110
+
1111
+ private getGridState(): GridState {
1112
+ const filterState: { [key: string]: FilterModelItem } = {};
1113
+ Object.keys(this.filterModel).forEach(key => {
1114
+ const item = this.filterModel[key];
1115
+ if (item) {
1116
+ filterState[key] = item;
1117
+ }
1118
+ });
1119
+
1120
+ // Get pinned columns
1121
+ const allColumns = Array.from(this.columns.values());
1122
+ const leftPinned = allColumns.filter(c => c.pinned === 'left').map(c => c.colId);
1123
+ const rightPinned = allColumns.filter(c => c.pinned === 'right').map(c => c.colId);
1124
+
1125
+ return {
1126
+ sort: { sortModel: [...this.sortModel] },
1127
+ filter: filterState,
1128
+ columnPinning: { left: leftPinned, right: rightPinned },
1129
+ columnOrder: allColumns.map(col => ({
1130
+ colId: col.colId,
1131
+ width: col.width,
1132
+ hide: !col.visible,
1133
+ pinned: col.pinned,
1134
+ sort: col.sort,
1135
+ sortIndex: col.sortIndex
1136
+ }))
1137
+ };
1138
+ }
1139
+
1140
+ private applyGridState(state: GridState): void {
1141
+ if (state.sort) {
1142
+ this.sortModel = state.sort.sortModel;
1143
+ this.applySorting();
1144
+ }
1145
+ if (state.filter) {
1146
+ this.filterModel = state.filter;
1147
+ }
1148
+ if (state.columnOrder) {
1149
+ state.columnOrder.forEach(colState => {
1150
+ const column = this.columns.get(colState.colId);
1151
+ if (column) {
1152
+ column.width = colState.width;
1153
+ column.visible = !colState.hide;
1154
+ column.pinned = colState.pinned;
1155
+ column.sort = colState.sort;
1156
+ column.sortIndex = colState.sortIndex;
1157
+ }
1158
+ });
1159
+ }
1160
+ }
1161
+
1162
+ private exportAsCsv(params?: CsvExportParams): void {
1163
+ const fileName = params?.fileName || 'export.csv';
1164
+ const delimiter = params?.delimiter || ',';
1165
+ const skipHeader = params?.skipHeader || false;
1166
+ const columnKeys = params?.columnKeys;
1167
+
1168
+ // Get columns to export
1169
+ let columnsToExport = this.getAllColumns().filter(col => col.visible);
1170
+ if (columnKeys && columnKeys.length > 0) {
1171
+ columnsToExport = columnsToExport.filter(col => columnKeys.includes(col.colId));
1172
+ }
1173
+
1174
+ // Build headers
1175
+ const headers = columnsToExport.map(col => {
1176
+ const headerName = col.headerName || col.colId;
1177
+ // Escape quotes and wrap in quotes if contains delimiter
1178
+ if (headerName.includes(delimiter) || headerName.includes('"') || headerName.includes('\n')) {
1179
+ return '"' + headerName.replace(/"/g, '""') + '"';
1180
+ }
1181
+ return headerName;
1182
+ });
1183
+
1184
+ // Build rows
1185
+ const rows = this.rowData.map(data => {
1186
+ return columnsToExport.map(col => {
1187
+ const value = (data as any)[col.field!];
1188
+ let cellValue = '';
1189
+
1190
+ if (value !== null && value !== undefined) {
1191
+ cellValue = String(value);
1192
+ }
1193
+
1194
+ // Escape quotes and wrap in quotes if contains special chars
1195
+ if (cellValue.includes(delimiter) || cellValue.includes('"') || cellValue.includes('\n')) {
1196
+ return '"' + cellValue.replace(/"/g, '""') + '"';
1197
+ }
1198
+ return cellValue;
1199
+ });
1200
+ });
1201
+
1202
+ // Build CSV content
1203
+ let csvContent = '';
1204
+ if (!skipHeader) {
1205
+ csvContent += headers.join(delimiter) + '\n';
1206
+ }
1207
+ csvContent += rows.map(row => row.join(delimiter)).join('\n');
1208
+
1209
+ // Download CSV
1210
+ this.downloadFile(csvContent, fileName, 'text/csv;charset=utf-8;');
1211
+ }
1212
+
1213
+ private exportAsExcel(params?: ExcelExportParams): void {
1214
+ const fileName = params?.fileName || 'export.xlsx';
1215
+ const sheetName = params?.sheetName || 'Sheet1';
1216
+ const skipHeader = params?.skipHeader || false;
1217
+
1218
+ // Get columns to export
1219
+ let columnsToExport = this.getAllColumns().filter(col => col.visible);
1220
+ if (params?.columnKeys && params.columnKeys.length > 0) {
1221
+ columnsToExport = columnsToExport.filter(col => params.columnKeys!.includes(col.colId));
1222
+ }
1223
+
1224
+ const workbook = new Workbook();
1225
+ const worksheet = workbook.addWorksheet(sheetName);
1226
+
1227
+ // Add headers
1228
+ if (!skipHeader) {
1229
+ const headerRow = worksheet.addRow(columnsToExport.map(col => col.headerName || col.colId));
1230
+ headerRow.font = { bold: true };
1231
+ headerRow.fill = {
1232
+ type: 'pattern',
1233
+ pattern: 'solid',
1234
+ fgColor: { argb: 'FFF0F0F0' }
1235
+ };
1236
+ }
1237
+
1238
+ // Add data
1239
+ this.rowData.forEach(data => {
1240
+ const rowValues = columnsToExport.map(col => {
1241
+ const value = (data as any)[col.field!];
1242
+ return value !== null && value !== undefined ? value : '';
1243
+ });
1244
+ worksheet.addRow(rowValues);
1245
+ });
1246
+
1247
+ // Auto-fit columns (basic implementation)
1248
+ worksheet.columns.forEach((column, i) => {
1249
+ let maxLength = 0;
1250
+ column.eachCell!({ includeEmpty: true }, (cell) => {
1251
+ const columnLength = cell.value ? cell.value.toString().length : 10;
1252
+ if (columnLength > maxLength) {
1253
+ maxLength = columnLength;
1254
+ }
1255
+ });
1256
+ column.width = Math.min(50, Math.max(10, maxLength + 2));
1257
+ });
1258
+
1259
+ // Generate buffer and download
1260
+ workbook.xlsx.writeBuffer().then(buffer => {
1261
+ const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
1262
+ const url = URL.createObjectURL(blob);
1263
+ const link = document.createElement('a');
1264
+ link.href = url;
1265
+ link.download = fileName;
1266
+ link.click();
1267
+ URL.revokeObjectURL(url);
1268
+ });
1269
+ }
1270
+
1271
+ private getAllColumns(): Column[] {
1272
+ return Array.from(this.columns.values());
1273
+ }
1274
+
1275
+ private downloadFile(content: string, fileName: string, mimeType: string): void {
1276
+ const blob = new Blob([content], { type: mimeType });
1277
+ const url = URL.createObjectURL(blob);
1278
+ const link = document.createElement('a');
1279
+ link.href = url;
1280
+ link.download = fileName;
1281
+ link.click();
1282
+ URL.revokeObjectURL(url);
1283
+ }
1284
+ }