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.
- package/.github/workflows/pages.yml +68 -0
- package/AGENTS.md +179 -0
- package/README.md +222 -0
- package/demo-app/README.md +70 -0
- package/demo-app/angular.json +78 -0
- package/demo-app/e2e/benchmark.spec.ts +53 -0
- package/demo-app/e2e/demo-page.spec.ts +77 -0
- package/demo-app/e2e/grid-features.spec.ts +269 -0
- package/demo-app/package-lock.json +14023 -0
- package/demo-app/package.json +36 -0
- package/demo-app/playwright-test-menu.js +19 -0
- package/demo-app/playwright.config.ts +23 -0
- package/demo-app/src/app/app.component.ts +10 -0
- package/demo-app/src/app/app.config.ts +13 -0
- package/demo-app/src/app/app.routes.ts +7 -0
- package/demo-app/src/app/demo-page/demo-page.component.css +313 -0
- package/demo-app/src/app/demo-page/demo-page.component.html +124 -0
- package/demo-app/src/app/demo-page/demo-page.component.ts +366 -0
- package/demo-app/src/index.html +19 -0
- package/demo-app/src/main.ts +6 -0
- package/demo-app/tsconfig.json +31 -0
- package/ng-package.json +8 -0
- package/package.json +60 -0
- package/plan.md +131 -0
- package/setup-vitest.ts +18 -0
- package/src/lib/argent-grid.module.ts +21 -0
- package/src/lib/components/argent-grid.component.css +483 -0
- package/src/lib/components/argent-grid.component.html +320 -0
- package/src/lib/components/argent-grid.component.spec.ts +189 -0
- package/src/lib/components/argent-grid.component.ts +1188 -0
- package/src/lib/directives/ag-grid-compatibility.directive.ts +92 -0
- package/src/lib/rendering/canvas-renderer.ts +962 -0
- package/src/lib/rendering/render/blit.spec.ts +453 -0
- package/src/lib/rendering/render/blit.ts +393 -0
- package/src/lib/rendering/render/cells.ts +369 -0
- package/src/lib/rendering/render/index.ts +105 -0
- package/src/lib/rendering/render/lines.ts +363 -0
- package/src/lib/rendering/render/theme.spec.ts +282 -0
- package/src/lib/rendering/render/theme.ts +201 -0
- package/src/lib/rendering/render/types.ts +279 -0
- package/src/lib/rendering/render/walk.spec.ts +360 -0
- package/src/lib/rendering/render/walk.ts +360 -0
- package/src/lib/rendering/utils/damage-tracker.spec.ts +444 -0
- package/src/lib/rendering/utils/damage-tracker.ts +423 -0
- package/src/lib/rendering/utils/index.ts +7 -0
- package/src/lib/services/grid.service.spec.ts +1039 -0
- package/src/lib/services/grid.service.ts +1284 -0
- package/src/lib/types/ag-grid-types.ts +970 -0
- package/src/public-api.ts +22 -0
- package/tsconfig.json +32 -0
- package/tsconfig.lib.json +11 -0
- package/tsconfig.spec.json +8 -0
- 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
|
+
}
|