@opencorestack/opengridx 2.1.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +399 -0
- package/README.md +95 -72
- package/dist/index.d.ts +590 -131
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +7891 -5631
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +17 -23
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +348 -166
- package/docs/README.md +6 -1
- package/docs/architecture/datagrid-orchestration.md +81 -0
- package/docs/architecture/grid-row-meta.md +102 -0
- package/docs/architecture/use-grid-columns.md +128 -0
- package/docs/architecture/use-grid-controlled-state.md +157 -0
- package/docs/architecture/use-grid-row-pipeline.md +120 -0
- package/docs/architecture/use-grid-scroll-sync.md +111 -0
- package/docs/architecture/use-grid-state-snapshot.md +90 -0
- package/docs/architecture/use-grid-virtualization.md +115 -0
- package/docs/architecture/use-grid-visible-rows.md +85 -0
- package/docs/assets/banner-alt.png +0 -0
- package/docs/assets/banner.png +0 -0
- package/docs/assets/hero.png +0 -0
- package/docs/assets/logo.png +0 -0
- package/docs/components/aggregation-footer.md +15 -10
- package/docs/components/cell.md +16 -6
- package/docs/components/column-group-header.md +55 -28
- package/docs/components/column-resize.md +17 -13
- package/docs/components/column-visibility.md +6 -7
- package/docs/components/datagrid.md +15 -13
- package/docs/components/empty-state.md +2 -1
- package/docs/components/error-overlay.md +12 -7
- package/docs/components/filter-panel.md +28 -8
- package/docs/components/header.md +28 -9
- package/docs/components/pagination.md +14 -8
- package/docs/components/row.md +21 -5
- package/docs/components/toolbar.md +27 -9
- package/docs/components/tooltip.md +16 -7
- package/docs/customization/slots-api.md +90 -38
- package/docs/customization/theming.md +66 -18
- package/docs/features/aggregation-pivot.md +75 -9
- package/docs/features/cell-spanning.md +80 -125
- package/docs/features/clipboard.md +41 -16
- package/docs/features/custom-pagination.md +38 -18
- package/docs/features/data-source.md +36 -13
- package/docs/features/editing-reordering.md +125 -16
- package/docs/features/export-guide.md +126 -39
- package/docs/features/filtering.md +44 -6
- package/docs/features/infinite-scroll.md +54 -46
- package/docs/features/keyboard-navigation.md +83 -0
- package/docs/features/list-view.md +58 -5
- package/docs/features/loading-states.md +18 -20
- package/docs/features/master-detail.md +18 -5
- package/docs/features/pdf-export.md +72 -19
- package/docs/features/pinning.md +24 -5
- package/docs/features/selection.md +50 -10
- package/docs/features/sorting-pagination.md +64 -14
- package/docs/features/state-persistence.md +78 -10
- package/docs/features/toolbar-customization.md +26 -17
- package/docs/features/tree-data-grouping.md +36 -8
- package/docs/features/virtualization.md +37 -14
- package/docs/migration/v1-to-v2.md +115 -0
- package/docs/migration/v2-to-v3.md +1233 -0
- package/docs/performance.md +124 -0
- package/docs/roadmap.md +5 -5
- package/lib/components/Cell/Cell.css +17 -1
- package/lib/components/Cell/Cell.tsx +129 -57
- package/lib/components/Cell/CellErrorBoundary.tsx +12 -6
- package/lib/components/Cell/GridEditInputCell.tsx +114 -41
- package/lib/components/ColumnGroupHeader/ColumnGroupHeader.css +7 -1
- package/lib/components/ColumnResizeHandle/ColumnResizeHandle.css +15 -2
- package/lib/components/ColumnResizeHandle/ColumnResizeHandle.tsx +104 -38
- package/lib/components/ColumnResizeHandle/clampResizeWidth.ts +13 -0
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +6 -0
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +21 -13
- package/lib/components/DataGrid/DataGrid.css +85 -12
- package/lib/components/DataGrid/DataGrid.tsx +582 -876
- package/lib/components/DataGrid/GridAggregationFooter.tsx +53 -17
- package/lib/components/DataGrid/GridErrorOverlay.tsx +15 -12
- package/lib/components/DataGrid/GridListView.tsx +165 -24
- package/lib/components/DataGrid/GridLiveRegion.tsx +31 -0
- package/lib/components/DataGrid/GridLoadingOverlay.tsx +27 -0
- package/lib/components/DataGrid/GridPaginationArea.tsx +44 -0
- package/lib/components/DataGrid/GridPinnedRows.tsx +62 -11
- package/lib/components/DataGrid/GridStandaloneColumnPanel.tsx +40 -38
- package/lib/components/DataGrid/GridToolbarSlot.tsx +51 -0
- package/lib/components/DataGrid/GridVirtualRows.tsx +40 -24
- package/lib/components/DetailPanel/DetailPanel.css +0 -6
- package/lib/components/DetailPanel/DetailPanel.tsx +34 -5
- package/lib/components/DetailPanel/DetailPanelContent.tsx +17 -0
- package/lib/components/FilterPanel/FilterPanel.css +13 -2
- package/lib/components/FilterPanel/FilterPanel.tsx +272 -99
- package/lib/components/Header/ColumnMenu.tsx +91 -39
- package/lib/components/Header/Header.css +6 -3
- package/lib/components/Header/Header.tsx +181 -160
- package/lib/components/ListView/ListView.css +25 -0
- package/lib/components/ListView/ListViewRow.tsx +76 -10
- package/lib/components/Pagination/Pagination.tsx +15 -7
- package/lib/components/Row/Row.tsx +177 -77
- package/lib/components/Toolbar/GlobalSearch.css +6 -3
- package/lib/components/Toolbar/GlobalSearch.tsx +26 -27
- package/lib/components/Toolbar/GridToolbar.css +12 -11
- package/lib/components/Toolbar/GridToolbar.tsx +133 -89
- package/lib/components/Toolbar/PivotPanel.tsx +89 -49
- package/lib/components/Tooltip/Tooltip.css +20 -0
- package/lib/components/Tooltip/Tooltip.tsx +68 -35
- package/lib/components/ui/Button.tsx +3 -0
- package/lib/components/ui/Checkbox.tsx +2 -1
- package/lib/components/ui/ExpandIcon.css +0 -9
- package/lib/components/ui/ExpandIcon.tsx +4 -1
- package/lib/hooks/core/gridApiPlaceholder.ts +34 -0
- package/lib/hooks/core/gridToolbarHostContext.ts +14 -0
- package/lib/hooks/core/useDataGrid.ts +115 -173
- package/lib/hooks/core/useGridAggregationApi.ts +72 -0
- package/lib/hooks/core/useGridApiMethods.ts +115 -0
- package/lib/hooks/core/useGridApiRef.ts +19 -4
- package/lib/hooks/core/useGridApiRefBinding.ts +18 -0
- package/lib/hooks/core/useGridAriaRows.ts +38 -0
- package/lib/hooks/core/useGridClipboardApi.ts +38 -0
- package/lib/hooks/core/useGridColumnLookup.ts +30 -0
- package/lib/hooks/core/useGridColumns.tsx +279 -76
- package/lib/hooks/core/useGridColumnsPanel.ts +77 -0
- package/lib/hooks/core/useGridControlledState.ts +99 -6
- package/lib/hooks/core/useGridDetailPanel.ts +51 -0
- package/lib/hooks/core/useGridDevWarnings.ts +104 -5
- package/lib/hooks/core/useGridFocusHandlers.ts +50 -0
- package/lib/hooks/core/useGridGroupingColumn.ts +62 -0
- package/lib/hooks/core/useGridHeaderHandlers.ts +92 -0
- package/lib/hooks/core/useGridHierarchy.ts +81 -0
- package/lib/hooks/core/useGridKeyboardNavigation.ts +447 -243
- package/lib/hooks/core/useGridLiveRowSelection.ts +67 -0
- package/lib/hooks/core/useGridPageCorrection.ts +55 -0
- package/lib/hooks/core/useGridRowIdOf.ts +32 -0
- package/lib/hooks/core/useGridRowInteractions.ts +81 -0
- package/lib/hooks/core/useGridRowPipeline.ts +69 -39
- package/lib/hooks/core/useGridRowSelection.ts +91 -0
- package/lib/hooks/core/useGridScrollSync.ts +80 -32
- package/lib/hooks/core/useGridScrollToIndexesApi.ts +45 -0
- package/lib/hooks/core/useGridStateSnapshot.ts +40 -5
- package/lib/hooks/core/useGridThemeDimensions.ts +49 -0
- package/lib/hooks/core/useGridToolbarProps.ts +97 -0
- package/lib/hooks/core/useGridViewport.ts +38 -0
- package/lib/hooks/core/useGridViewportSize.ts +26 -0
- package/lib/hooks/core/useGridVisibleRows.ts +17 -5
- package/lib/hooks/core/useLayout.ts +215 -195
- package/lib/hooks/core/useRowsScrollEndLatch.ts +41 -0
- package/lib/hooks/core/useStableColumns.ts +34 -0
- package/lib/hooks/features/useAggregation.ts +82 -51
- package/lib/hooks/features/useColumnGroupReorderGuard.ts +34 -0
- package/lib/hooks/features/useDetailPanelHeights.ts +29 -0
- package/lib/hooks/features/useGridClipboard.ts +133 -73
- package/lib/hooks/features/useGridDataSource.ts +354 -128
- package/lib/hooks/features/useGridEditing.ts +211 -51
- package/lib/hooks/features/useGridPivot.ts +92 -0
- package/lib/hooks/features/useGridSpanRenderWindow.ts +104 -0
- package/lib/hooks/features/useGridSpanning.ts +410 -306
- package/lib/hooks/features/usePivot.ts +0 -0
- package/lib/hooks/features/useServerTreeChildren.ts +50 -0
- package/lib/hooks/useColumnReorder.ts +38 -44
- package/lib/hooks/useRowGrouping.ts +298 -160
- package/lib/hooks/useRowReorder.ts +62 -31
- package/lib/hooks/useTreeData.ts +287 -220
- package/lib/index.ts +28 -2
- package/lib/state/index.ts +1 -0
- package/lib/state/types.ts +7 -1
- package/lib/state/useGridStateStorage.ts +88 -29
- package/lib/theme/DataGridThemeProvider.tsx +143 -38
- package/lib/theme/gridThemeContext.ts +5 -0
- package/lib/theme/index.ts +5 -0
- package/lib/theme/presets.ts +5 -4
- package/lib/theme/types.ts +40 -2
- package/lib/types/index.ts +313 -116
- package/lib/utils/aggregation/index.ts +119 -24
- package/lib/utils/aria/index.ts +40 -0
- package/lib/utils/columnGroups.ts +110 -0
- package/lib/utils/columnLookup.ts +33 -0
- package/lib/utils/columnWidth.ts +22 -0
- package/lib/utils/dataSource/index.ts +46 -0
- package/lib/utils/detailPanel.ts +66 -0
- package/lib/utils/dragData.ts +44 -0
- package/lib/utils/editing/index.ts +30 -0
- package/lib/utils/export/exportShared.ts +197 -0
- package/lib/utils/export/exportToExcelAdvanced.ts +278 -139
- package/lib/utils/export/exportToPdf.ts +143 -92
- package/lib/utils/export/groupLabel.ts +14 -0
- package/lib/utils/export/index.ts +278 -272
- package/lib/utils/export/pdfText.ts +50 -0
- package/lib/utils/filtering/index.ts +277 -148
- package/lib/utils/focus/index.ts +115 -0
- package/lib/utils/gridApiRows.ts +35 -0
- package/lib/utils/gridModes.ts +77 -0
- package/lib/utils/grouping/groupedExportRows.ts +91 -0
- package/lib/utils/pagination/index.ts +56 -0
- package/lib/utils/pinning/index.ts +77 -33
- package/lib/utils/pivot/index.ts +368 -0
- package/lib/utils/scroll/index.ts +55 -5
- package/lib/utils/scroll/scrollEnd.ts +18 -0
- package/lib/utils/sorting/index.ts +154 -30
- package/lib/utils/syntheticRows.ts +12 -0
- package/lib/utils/values.ts +206 -0
- package/lib/utils/viewport.ts +14 -0
- package/llms.txt +261 -164
- package/package.json +13 -5
- package/lib/components/ColumnGroupHeader/ColumnGroupHeader.tsx +0 -220
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.test.tsx +0 -85
- package/lib/components/DataGrid/DataGrid.defects.test.tsx +0 -103
- package/lib/components/DataGrid/DataGrid.regression.test.tsx +0 -132
- package/lib/components/DataGrid/DataGrid.smoke.test.tsx +0 -58
- package/lib/components/DataGrid/DataGrid.v2.test.tsx +0 -127
- package/lib/components/DataGrid/DataGrid.virtualization.browser.test.tsx +0 -183
- package/lib/components/Header/Header.test.tsx +0 -135
- package/lib/components/Pagination/Pagination.test.tsx +0 -64
- package/lib/hooks/core/useDataGrid.regression.test.ts +0 -66
- package/lib/hooks/core/useGridControlledState.test.ts +0 -86
- package/lib/hooks/core/useGridKeyboardNavigation.test.ts +0 -176
- package/lib/hooks/core/useGridRowPipeline.test.ts +0 -117
- package/lib/hooks/features/useAggregation.test.ts +0 -175
- package/lib/hooks/features/usePivot.test.ts +0 -202
- package/lib/hooks/useRowGrouping.test.ts +0 -298
- package/lib/hooks/useTreeData.test.ts +0 -83
- package/lib/utils/aggregation/aggregation.test.ts +0 -16
- package/lib/utils/export/exportToExcelAdvanced.test.ts +0 -109
- package/lib/utils/export/exportToPdf.test.ts +0 -120
- package/lib/utils/export/groupedExport.test.ts +0 -144
- package/lib/utils/filtering/filtering.test.ts +0 -178
- package/lib/utils/sorting/sorting.test.ts +0 -88
|
@@ -1,22 +1,84 @@
|
|
|
1
1
|
|
|
2
|
-
import type { GridColDef, GridRowModel, GridAggregationModel, GridGroupedExportRow } from '../../types';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
import type { GridColDef, GridRowId, GridRowModel, GridValidRowModel, GridAggregationModel, GridGroupedExportRow } from '../../types';
|
|
3
|
+
import { groupHeaderLabel } from './groupLabel';
|
|
4
|
+
import {
|
|
5
|
+
aggregationForExport,
|
|
6
|
+
escapeHTML,
|
|
7
|
+
formatExportAggregate,
|
|
8
|
+
formatExportValue,
|
|
9
|
+
getExportColumns,
|
|
10
|
+
getRawExportValue,
|
|
11
|
+
hasSelection,
|
|
12
|
+
isNonTextValue,
|
|
13
|
+
neutralizeFormula,
|
|
14
|
+
pickExportedAggregates,
|
|
15
|
+
rowsForExport,
|
|
16
|
+
sanitizeSheetName,
|
|
17
|
+
shouldExportGrouped,
|
|
18
|
+
summaryLabelText,
|
|
19
|
+
} from './exportShared';
|
|
20
|
+
|
|
21
|
+
export interface CsvExportOptions<R extends GridValidRowModel = GridRowModel> {
|
|
6
22
|
fileName?: string;
|
|
7
23
|
includeHeaders?: boolean;
|
|
8
24
|
delimiter?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Export only these row IDs. A non-empty selection takes precedence over `groupedRows` (the
|
|
27
|
+
* selected rows are exported flat), and the totals row is recomputed over the selected rows.
|
|
28
|
+
*/
|
|
9
29
|
selectedRows?: (string | number)[];
|
|
30
|
+
/**
|
|
31
|
+
* The grid's `getRowId`, when it has one: `selectedRows` holds those ids, and the grid does not
|
|
32
|
+
* copy them onto `row.id` (v3.0+). Defaults to `row.id`.
|
|
33
|
+
*/
|
|
34
|
+
getRowId?: (row: R) => GridRowId;
|
|
10
35
|
aggregationResult?: Record<string, unknown> | null;
|
|
11
36
|
aggregationModel?: GridAggregationModel | null;
|
|
12
37
|
/** When provided, emits group headers, leaf rows, subtotals, and a grand total instead of a flat row list. */
|
|
13
38
|
groupedRows?: GridGroupedExportRow[];
|
|
39
|
+
/**
|
|
40
|
+
* Prefix text cells that start with `=`, `+`, `-`, `@`, tab or carriage return with `'`, so
|
|
41
|
+
* spreadsheet apps show them as text instead of running them as formulas (CSV injection).
|
|
42
|
+
* Plain signed numbers are left alone. Default: `true`
|
|
43
|
+
* @since v3.0
|
|
44
|
+
*/
|
|
45
|
+
escapeFormulas?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Start the file with a UTF-8 byte-order mark, so Excel reads non-ASCII text (é, ü, ₹, 中文)
|
|
48
|
+
* correctly. Default: `true`
|
|
49
|
+
* @since v3.0
|
|
50
|
+
*/
|
|
51
|
+
bom?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One exported cell of a data row: its text, and whether that text was formatted from a non-text value. */
|
|
55
|
+
interface ExportCell {
|
|
56
|
+
text: string;
|
|
57
|
+
raw: unknown;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function exportCell<R extends GridRowModel>(row: R, col: GridColDef<R>): ExportCell {
|
|
61
|
+
const raw = getRawExportValue(row, col);
|
|
62
|
+
return { text: formatExportValue(row, col, raw), raw };
|
|
14
63
|
}
|
|
15
64
|
|
|
65
|
+
/** Aggregate text per export column for one subtotal / total record. */
|
|
66
|
+
function aggregateTexts<R extends GridRowModel>(
|
|
67
|
+
exportColumns: GridColDef<R>[],
|
|
68
|
+
aggregates: Record<string, unknown>,
|
|
69
|
+
aggModel: GridAggregationModel,
|
|
70
|
+
): string[] {
|
|
71
|
+
return exportColumns.map(col => formatExportAggregate(col, aggModel[col.field], aggregates[col.field], aggregates));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const UTF8_BOM = String.fromCharCode(0xfeff);
|
|
75
|
+
|
|
76
|
+
export function exportToCsv<R extends GridValidRowModel = GridRowModel>(rows: R[], columns: GridColDef<R>[], options?: CsvExportOptions<R>): void;
|
|
77
|
+
export function exportToCsv<R extends GridValidRowModel = GridRowModel>(rows: R[], columns: GridColDef[], options?: CsvExportOptions<R>): void;
|
|
16
78
|
export function exportToCsv<R extends GridRowModel>(
|
|
17
79
|
rows: R[],
|
|
18
80
|
columns: GridColDef<R>[],
|
|
19
|
-
options: CsvExportOptions = {}
|
|
81
|
+
options: CsvExportOptions<R> = {}
|
|
20
82
|
): void {
|
|
21
83
|
const {
|
|
22
84
|
fileName = 'export.csv',
|
|
@@ -24,147 +86,142 @@ export function exportToCsv<R extends GridRowModel>(
|
|
|
24
86
|
delimiter = ',',
|
|
25
87
|
selectedRows,
|
|
26
88
|
groupedRows,
|
|
89
|
+
escapeFormulas = true,
|
|
90
|
+
bom = true,
|
|
27
91
|
} = options;
|
|
28
92
|
|
|
29
|
-
const exportColumns = columns
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
93
|
+
const exportColumns = getExportColumns(columns);
|
|
94
|
+
const aggModel = options.aggregationModel || {};
|
|
95
|
+
const field = (text: string, fromNonText = false): string =>
|
|
96
|
+
escapeCSV(escapeFormulas ? neutralizeFormula(text, fromNonText) : text, delimiter);
|
|
97
|
+
const headerLine = () => exportColumns.map(col => field(col.headerName || col.field)).join(delimiter) + '\n';
|
|
34
98
|
|
|
35
99
|
let csvContent = '';
|
|
36
100
|
|
|
37
|
-
if (groupedRows
|
|
101
|
+
if (shouldExportGrouped(groupedRows, selectedRows)) {
|
|
38
102
|
// Grouped export path
|
|
39
|
-
if (includeHeaders)
|
|
40
|
-
csvContent += exportColumns.map(col => escapeCSV(col.headerName || col.field)).join(delimiter) + '\n';
|
|
41
|
-
}
|
|
103
|
+
if (includeHeaders) csvContent += headerLine();
|
|
42
104
|
groupedRows.forEach(entry => {
|
|
105
|
+
const indent = ' '.repeat(entry.depth);
|
|
43
106
|
if (entry.type === 'group-header') {
|
|
44
|
-
const
|
|
45
|
-
const label =
|
|
46
|
-
|
|
47
|
-
csvContent += cells.join(delimiter) + '\n';
|
|
107
|
+
const labelText = groupHeaderLabel(entry, columns);
|
|
108
|
+
const label = escapeCSV(indent + (escapeFormulas ? neutralizeFormula(labelText) : labelText), delimiter);
|
|
109
|
+
csvContent += exportColumns.map((_, i) => (i === 0 ? label : '')).join(delimiter) + '\n';
|
|
48
110
|
} else if (entry.type === 'leaf' && entry.row) {
|
|
49
111
|
const row = entry.row as R;
|
|
50
|
-
const indent = ' '.repeat(entry.depth);
|
|
51
112
|
const cells = exportColumns.map((col, i) => {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
value = col.valueFormatter({ value, row, field: col.field });
|
|
56
|
-
}
|
|
57
|
-
return escapeCSV((i === 0 ? indent : '') + String(value ?? ''));
|
|
58
|
-
});
|
|
59
|
-
csvContent += cells.join(delimiter) + '\n';
|
|
60
|
-
} else if (entry.type === 'group-subtotal' && entry.aggregatedValues) {
|
|
61
|
-
const indent = ' '.repeat(entry.depth);
|
|
62
|
-
const aggModel = options.aggregationModel || {};
|
|
63
|
-
const cells = exportColumns.map((col, i) => {
|
|
64
|
-
if (i === 0) return escapeCSV(`${indent}Subtotal`);
|
|
65
|
-
const aggVal = entry.aggregatedValues![col.field];
|
|
66
|
-
if (aggVal !== undefined && aggVal !== null) {
|
|
67
|
-
let formatted: unknown = aggVal;
|
|
68
|
-
if (col.valueFormatter) {
|
|
69
|
-
formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
|
|
70
|
-
} else if (aggModel[col.field]) {
|
|
71
|
-
formatted = formatAggregationValue(aggVal, aggModel[col.field]);
|
|
72
|
-
}
|
|
73
|
-
return escapeCSV(String(formatted));
|
|
74
|
-
}
|
|
75
|
-
return '';
|
|
113
|
+
const cell = exportCell(row, col);
|
|
114
|
+
const text = escapeFormulas ? neutralizeFormula(cell.text, isNonTextValue(cell.raw)) : cell.text;
|
|
115
|
+
return escapeCSV((i === 0 ? indent : '') + text, delimiter);
|
|
76
116
|
});
|
|
77
117
|
csvContent += cells.join(delimiter) + '\n';
|
|
78
|
-
} else if (entry.type === 'grand-total' && entry.aggregatedValues) {
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (col.valueFormatter) {
|
|
86
|
-
formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
|
|
87
|
-
} else if (aggModel[col.field]) {
|
|
88
|
-
formatted = formatAggregationValue(aggVal, aggModel[col.field]);
|
|
89
|
-
}
|
|
90
|
-
return escapeCSV(String(formatted));
|
|
91
|
-
}
|
|
92
|
-
return '';
|
|
93
|
-
});
|
|
118
|
+
} else if ((entry.type === 'group-subtotal' || entry.type === 'grand-total') && entry.aggregatedValues) {
|
|
119
|
+
const values = entry.aggregatedValues;
|
|
120
|
+
const label = entry.type === 'grand-total' ? 'Grand Total' : `${indent}Subtotal`;
|
|
121
|
+
const texts = aggregateTexts(exportColumns, values, aggModel);
|
|
122
|
+
const cells = exportColumns.map((col, i) => (i === 0
|
|
123
|
+
? escapeCSV(summaryLabelText(label, texts[0]), delimiter)
|
|
124
|
+
: field(texts[i], isNonTextValue(values[col.field]))));
|
|
94
125
|
csvContent += cells.join(delimiter) + '\n';
|
|
95
126
|
}
|
|
96
127
|
});
|
|
97
128
|
} else {
|
|
98
|
-
|
|
99
|
-
const rowsToExport = selectedRows && selectedRows.length > 0
|
|
100
|
-
? rows.filter(r => selectedRows.includes(r.id))
|
|
101
|
-
: rows;
|
|
129
|
+
const rowsToExport = rowsForExport(rows, selectedRows, options.getRowId);
|
|
102
130
|
|
|
103
131
|
if (rowsToExport.length === 0) {
|
|
104
132
|
console.warn('No rows to export');
|
|
105
133
|
return;
|
|
106
134
|
}
|
|
107
135
|
|
|
108
|
-
if (includeHeaders)
|
|
109
|
-
csvContent += exportColumns.map(col => escapeCSV(col.headerName || col.field)).join(delimiter) + '\n';
|
|
110
|
-
}
|
|
136
|
+
if (includeHeaders) csvContent += headerLine();
|
|
111
137
|
|
|
112
138
|
rowsToExport.forEach(row => {
|
|
113
139
|
const values = exportColumns.map(col => {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (col.valueFormatter && value !== undefined && value !== null) {
|
|
117
|
-
value = col.valueFormatter({ value, row, field: col.field });
|
|
118
|
-
}
|
|
119
|
-
return escapeCSV(String(value ?? ''));
|
|
140
|
+
const cell = exportCell(row, col);
|
|
141
|
+
return field(cell.text, isNonTextValue(cell.raw));
|
|
120
142
|
});
|
|
121
143
|
csvContent += values.join(delimiter) + '\n';
|
|
122
144
|
});
|
|
123
145
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
const aggLabels = exportColumns.map(col => escapeCSV(aggModel[col.field] ? aggModel[col.field].toUpperCase() : ''));
|
|
146
|
+
const aggResult = aggregationForExport(rowsToExport, hasSelection(selectedRows), columns, options.aggregationResult, options.aggregationModel);
|
|
147
|
+
if (aggResult) {
|
|
148
|
+
const aggLabels = exportColumns.map(col => field(aggModel[col.field] ? aggModel[col.field].toUpperCase() : ''));
|
|
128
149
|
csvContent += aggLabels.join(delimiter) + '\n';
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
if (aggVal !== undefined && aggVal !== null) {
|
|
132
|
-
let formatted: unknown = aggVal;
|
|
133
|
-
if (col.valueFormatter) formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
|
|
134
|
-
else if (aggModel[col.field]) formatted = formatAggregationValue(aggVal, aggModel[col.field]);
|
|
135
|
-
return escapeCSV(String(formatted));
|
|
136
|
-
}
|
|
137
|
-
return '';
|
|
138
|
-
});
|
|
139
|
-
csvContent += aggValues.join(delimiter) + '\n';
|
|
150
|
+
const texts = aggregateTexts(exportColumns, aggResult, aggModel);
|
|
151
|
+
csvContent += exportColumns.map((col, i) => field(texts[i], isNonTextValue(aggResult[col.field]))).join(delimiter) + '\n';
|
|
140
152
|
}
|
|
141
153
|
}
|
|
142
154
|
|
|
143
|
-
downloadFile(csvContent, fileName, 'text/csv;charset=utf-8;');
|
|
155
|
+
downloadFile((bom ? UTF8_BOM : '') + csvContent, fileName, 'text/csv;charset=utf-8;');
|
|
144
156
|
}
|
|
145
157
|
|
|
146
|
-
|
|
147
|
-
|
|
158
|
+
/** Quote a CSV field when it contains the delimiter, a quote, or a line break (LF or a bare CR). */
|
|
159
|
+
function escapeCSV(value: string, delimiter: string): string {
|
|
160
|
+
if ((delimiter !== '' && value.includes(delimiter)) || value.includes('"') || value.includes('\n') || value.includes('\r')) {
|
|
148
161
|
return `"${value.replace(/"/g, '""')}"`;
|
|
149
162
|
}
|
|
150
163
|
return value;
|
|
151
164
|
}
|
|
152
165
|
|
|
153
|
-
export interface ExcelExportOptions {
|
|
166
|
+
export interface ExcelExportOptions<R extends GridValidRowModel = GridRowModel> {
|
|
167
|
+
/**
|
|
168
|
+
* Output filename. The file is an HTML table that Excel opens as a legacy `.xls` workbook, so a
|
|
169
|
+
* `.xlsx` extension is replaced with `.xls` (Excel refuses HTML content named `.xlsx`). Use
|
|
170
|
+
* `exportToExcelAdvanced` for a real `.xlsx`. Default: `'export.xls'`
|
|
171
|
+
*/
|
|
154
172
|
fileName?: string;
|
|
173
|
+
/** Sheet tab name. Characters Excel rejects are replaced and the name is cut to 31 characters. Default: `'Sheet1'` */
|
|
155
174
|
sheetName?: string;
|
|
156
175
|
includeHeaders?: boolean;
|
|
176
|
+
/**
|
|
177
|
+
* Export only these row IDs. A non-empty selection takes precedence over `groupedRows` (the
|
|
178
|
+
* selected rows are exported flat), and the totals rows are recomputed over the selected rows.
|
|
179
|
+
*/
|
|
157
180
|
selectedRows?: (string | number)[];
|
|
181
|
+
/**
|
|
182
|
+
* The grid's `getRowId`, when it has one: `selectedRows` holds those ids, and the grid does not
|
|
183
|
+
* copy them onto `row.id` (v3.0+). Defaults to `row.id`.
|
|
184
|
+
*/
|
|
185
|
+
getRowId?: (row: R) => GridRowId;
|
|
158
186
|
aggregationResult?: Record<string, unknown> | null;
|
|
159
187
|
aggregationModel?: GridAggregationModel | null;
|
|
160
188
|
/** When provided, emits group headers, leaf rows, subtotals, and a grand total instead of a flat row list. */
|
|
161
189
|
groupedRows?: GridGroupedExportRow[];
|
|
190
|
+
/**
|
|
191
|
+
* Prefix text cells that start with `=`, `+`, `-`, `@`, tab or carriage return with `'`, so
|
|
192
|
+
* Excel does not run them as formulas. Default: `true`
|
|
193
|
+
* @since v3.0
|
|
194
|
+
*/
|
|
195
|
+
escapeFormulas?: boolean;
|
|
162
196
|
}
|
|
163
197
|
|
|
198
|
+
/** Excel's text number format: the cell keeps its text as typed (leading zeros, long ids, "1-2"). */
|
|
199
|
+
const XLS_TEXT_CLASS = 'ogx-xls-text';
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Whether an HTML-Excel data cell should be marked as text. Number, date and boolean columns, and
|
|
203
|
+
* values that are numbers, dates or booleans, are left for Excel to type.
|
|
204
|
+
*/
|
|
205
|
+
function isXlsTextCell<R extends GridRowModel>(col: GridColDef<R>, raw: unknown): boolean {
|
|
206
|
+
return col.type !== 'number' && col.type !== 'date' && col.type !== 'boolean' && !isNonTextValue(raw);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function xlsFileName(fileName: string): string {
|
|
210
|
+
if (!/\.xlsx$/i.test(fileName)) return fileName;
|
|
211
|
+
const corrected = fileName.replace(/\.xlsx$/i, '.xls');
|
|
212
|
+
console.warn(
|
|
213
|
+
`[exportToExcel] "${fileName}" renamed to "${corrected}": exportToExcel writes an HTML table that Excel ` +
|
|
214
|
+
'refuses to open as .xlsx. Use exportToExcelAdvanced for a real .xlsx file.'
|
|
215
|
+
);
|
|
216
|
+
return corrected;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function exportToExcel<R extends GridValidRowModel = GridRowModel>(rows: R[], columns: GridColDef<R>[], options?: ExcelExportOptions<R>): void;
|
|
220
|
+
export function exportToExcel<R extends GridValidRowModel = GridRowModel>(rows: R[], columns: GridColDef[], options?: ExcelExportOptions<R>): void;
|
|
164
221
|
export function exportToExcel<R extends GridRowModel>(
|
|
165
222
|
rows: R[],
|
|
166
223
|
columns: GridColDef<R>[],
|
|
167
|
-
options: ExcelExportOptions = {}
|
|
224
|
+
options: ExcelExportOptions<R> = {}
|
|
168
225
|
): void {
|
|
169
226
|
const {
|
|
170
227
|
fileName = 'export.xls',
|
|
@@ -172,76 +229,62 @@ export function exportToExcel<R extends GridRowModel>(
|
|
|
172
229
|
includeHeaders = true,
|
|
173
230
|
selectedRows,
|
|
174
231
|
groupedRows,
|
|
232
|
+
escapeFormulas = true,
|
|
175
233
|
} = options;
|
|
176
234
|
|
|
177
|
-
const exportColumns = columns
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
235
|
+
const exportColumns = getExportColumns(columns);
|
|
236
|
+
const aggModel = options.aggregationModel || {};
|
|
237
|
+
const cellText = (text: string, fromNonText = false): string =>
|
|
238
|
+
escapeHTML(escapeFormulas ? neutralizeFormula(text, fromNonText) : text);
|
|
239
|
+
const textTd = (html: string): string => `<td class="${XLS_TEXT_CLASS}">${html}</td>`;
|
|
182
240
|
|
|
183
241
|
const header = '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40">'
|
|
184
242
|
+ '<head><!--[if gte mso 9]><xml><x:ExcelWorkbook><x:ExcelWorksheets><x:ExcelWorksheet>'
|
|
185
|
-
+ `<x:Name>${sheetName}</x:Name>`
|
|
243
|
+
+ `<x:Name>${escapeHTML(sanitizeSheetName(sheetName))}</x:Name>`
|
|
186
244
|
+ '<x:WorksheetOptions><x:DisplayGridlines/></x:WorksheetOptions></x:ExcelWorksheet></x:ExcelWorksheets></x:ExcelWorkbook></xml><![endif]-->'
|
|
187
|
-
+
|
|
245
|
+
+ `<meta charset="UTF-8"><style>.${XLS_TEXT_CLASS}{mso-number-format:"\\@";}</style></head><body><table border="1">`;
|
|
188
246
|
|
|
189
247
|
let html = header;
|
|
190
248
|
|
|
191
249
|
if (includeHeaders) {
|
|
192
250
|
html += '<thead><tr>';
|
|
193
|
-
exportColumns.forEach(col => { html += `<th>${
|
|
251
|
+
exportColumns.forEach(col => { html += `<th class="${XLS_TEXT_CLASS}">${cellText(col.headerName || col.field)}</th>`; });
|
|
194
252
|
html += '</tr></thead>';
|
|
195
253
|
}
|
|
196
254
|
|
|
197
255
|
html += '<tbody>';
|
|
198
256
|
|
|
199
|
-
|
|
200
|
-
const
|
|
257
|
+
const dataCell = (cell: ExportCell, col: GridColDef<R>, prefix = ''): string => {
|
|
258
|
+
const content = prefix + cellText(cell.text, isNonTextValue(cell.raw));
|
|
259
|
+
return isXlsTextCell(col, cell.raw) ? textTd(content) : `<td>${content}</td>`;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
if (shouldExportGrouped(groupedRows, selectedRows)) {
|
|
201
263
|
groupedRows.forEach(entry => {
|
|
264
|
+
const indent = ' '.repeat(entry.depth * 2);
|
|
202
265
|
if (entry.type === 'group-header') {
|
|
203
|
-
const indent = ' '.repeat(entry.depth * 2);
|
|
204
|
-
const label = `${indent}${entry.groupField ?? ''}: ${String(entry.groupValue ?? '')}`;
|
|
205
266
|
html += `<tr style="font-weight:bold;background:#e8eaf6;">`;
|
|
206
|
-
html += `<td colspan="${exportColumns.length}">${escapeHTML(
|
|
267
|
+
html += `<td colspan="${exportColumns.length}" class="${XLS_TEXT_CLASS}">${escapeHTML(indent)}${cellText(groupHeaderLabel(entry, columns))}</td>`;
|
|
207
268
|
html += '</tr>';
|
|
208
269
|
} else if (entry.type === 'leaf' && entry.row) {
|
|
209
270
|
const row = entry.row as R;
|
|
210
|
-
const indent = ' '.repeat(entry.depth * 2);
|
|
211
271
|
html += '<tr>';
|
|
212
|
-
exportColumns.forEach((col, i) => {
|
|
213
|
-
let value: unknown = (row as GridRowModel)[col.field];
|
|
214
|
-
if (col.valueGetter) value = col.valueGetter({ row, field: col.field, value });
|
|
215
|
-
if (col.valueFormatter && value !== undefined && value !== null) {
|
|
216
|
-
value = col.valueFormatter({ value, row, field: col.field });
|
|
217
|
-
}
|
|
218
|
-
html += `<td>${escapeHTML((i === 0 ? indent : '') + String(value ?? ''))}</td>`;
|
|
219
|
-
});
|
|
272
|
+
exportColumns.forEach((col, i) => { html += dataCell(exportCell(row, col), col, i === 0 ? escapeHTML(indent) : ''); });
|
|
220
273
|
html += '</tr>';
|
|
221
274
|
} else if ((entry.type === 'group-subtotal' || entry.type === 'grand-total') && entry.aggregatedValues) {
|
|
222
|
-
const
|
|
223
|
-
const
|
|
224
|
-
const
|
|
275
|
+
const values = entry.aggregatedValues;
|
|
276
|
+
const label = entry.type === 'grand-total' ? 'Grand Total' : `${indent}Subtotal`;
|
|
277
|
+
const texts = aggregateTexts(exportColumns, values, aggModel);
|
|
225
278
|
html += `<tr style="font-weight:bold;background:#f5f5f5;">`;
|
|
226
279
|
exportColumns.forEach((col, i) => {
|
|
227
|
-
if (i === 0) { html +=
|
|
228
|
-
|
|
229
|
-
if (aggVal !== undefined && aggVal !== null) {
|
|
230
|
-
let formatted: unknown = aggVal;
|
|
231
|
-
if (col.valueFormatter) formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
|
|
232
|
-
else if (aggModel[col.field]) formatted = formatAggregationValue(aggVal, aggModel[col.field]);
|
|
233
|
-
html += `<td style="text-align:right;">${escapeHTML(String(formatted))}</td>`;
|
|
234
|
-
} else {
|
|
235
|
-
html += '<td></td>';
|
|
236
|
-
}
|
|
280
|
+
if (i === 0) { html += textTd(escapeHTML(summaryLabelText(label, texts[0]))); return; }
|
|
281
|
+
html += texts[i] ? `<td style="text-align:right;">${cellText(texts[i], isNonTextValue(values[col.field]))}</td>` : '<td></td>';
|
|
237
282
|
});
|
|
238
283
|
html += '</tr>';
|
|
239
284
|
}
|
|
240
285
|
});
|
|
241
286
|
} else {
|
|
242
|
-
const rowsToExport = selectedRows
|
|
243
|
-
? rows.filter(r => selectedRows.includes(r.id))
|
|
244
|
-
: rows;
|
|
287
|
+
const rowsToExport = rowsForExport(rows, selectedRows, options.getRowId);
|
|
245
288
|
|
|
246
289
|
if (rowsToExport.length === 0) {
|
|
247
290
|
console.warn('No rows to export');
|
|
@@ -250,20 +293,12 @@ export function exportToExcel<R extends GridRowModel>(
|
|
|
250
293
|
|
|
251
294
|
rowsToExport.forEach(row => {
|
|
252
295
|
html += '<tr>';
|
|
253
|
-
exportColumns.forEach(col => {
|
|
254
|
-
let value: unknown = (row as GridRowModel)[col.field];
|
|
255
|
-
if (col.valueGetter) value = col.valueGetter({ row, field: col.field, value });
|
|
256
|
-
if (col.valueFormatter && value !== undefined && value !== null) {
|
|
257
|
-
value = col.valueFormatter({ value, row, field: col.field });
|
|
258
|
-
}
|
|
259
|
-
html += `<td>${escapeHTML(String(value ?? ''))}</td>`;
|
|
260
|
-
});
|
|
296
|
+
exportColumns.forEach(col => { html += dataCell(exportCell(row, col), col); });
|
|
261
297
|
html += '</tr>';
|
|
262
298
|
});
|
|
263
299
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const aggModel = options.aggregationModel || {};
|
|
300
|
+
const aggResult = aggregationForExport(rowsToExport, hasSelection(selectedRows), columns, options.aggregationResult, options.aggregationModel);
|
|
301
|
+
if (aggResult) {
|
|
267
302
|
html += '<tr style="font-size: 11px; color: #666; background-color: #f5f5f5;">';
|
|
268
303
|
exportColumns.forEach(col => {
|
|
269
304
|
html += aggModel[col.field]
|
|
@@ -271,42 +306,35 @@ export function exportToExcel<R extends GridRowModel>(
|
|
|
271
306
|
: '<td></td>';
|
|
272
307
|
});
|
|
273
308
|
html += '</tr><tr style="font-weight:bold;background-color:#f5f5f5;">';
|
|
274
|
-
exportColumns
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
let formatted: unknown = aggVal;
|
|
278
|
-
if (col.valueFormatter) formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
|
|
279
|
-
else if (aggModel[col.field]) formatted = formatAggregationValue(aggVal, aggModel[col.field]);
|
|
280
|
-
html += `<td style="text-align:right;">${escapeHTML(String(formatted))}</td>`;
|
|
281
|
-
} else {
|
|
282
|
-
html += '<td></td>';
|
|
283
|
-
}
|
|
309
|
+
const texts = aggregateTexts(exportColumns, aggResult, aggModel);
|
|
310
|
+
exportColumns.forEach((col, i) => {
|
|
311
|
+
html += texts[i] ? `<td style="text-align:right;">${cellText(texts[i], isNonTextValue(aggResult[col.field]))}</td>` : '<td></td>';
|
|
284
312
|
});
|
|
285
313
|
html += '</tr>';
|
|
286
314
|
}
|
|
287
315
|
}
|
|
288
316
|
|
|
289
317
|
html += '</tbody></table></body></html>';
|
|
290
|
-
downloadFile(html, fileName, 'application/vnd.ms-excel');
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function escapeHTML(value: string): string {
|
|
294
|
-
return value
|
|
295
|
-
.replace(/&/g, '&')
|
|
296
|
-
.replace(/</g, '<')
|
|
297
|
-
.replace(/>/g, '>')
|
|
298
|
-
.replace(/"/g, '"')
|
|
299
|
-
.replace(/'/g, ''');
|
|
318
|
+
downloadFile(html, xlsFileName(fileName), 'application/vnd.ms-excel');
|
|
300
319
|
}
|
|
301
320
|
|
|
302
321
|
// ============================================================================
|
|
303
322
|
// JSON Export
|
|
304
323
|
// ============================================================================
|
|
305
324
|
|
|
306
|
-
export interface JsonExportOptions {
|
|
325
|
+
export interface JsonExportOptions<R extends GridValidRowModel = GridRowModel> {
|
|
307
326
|
fileName?: string;
|
|
308
327
|
pretty?: boolean;
|
|
328
|
+
/**
|
|
329
|
+
* Export only these row IDs. A non-empty selection takes precedence over `groupedRows` (the
|
|
330
|
+
* selected rows are exported flat), and the aggregation values are recomputed over them.
|
|
331
|
+
*/
|
|
309
332
|
selectedRows?: (string | number)[];
|
|
333
|
+
/**
|
|
334
|
+
* The grid's `getRowId`, when it has one: `selectedRows` holds those ids, and the grid does not
|
|
335
|
+
* copy them onto `row.id` (v3.0+). Defaults to `row.id`.
|
|
336
|
+
*/
|
|
337
|
+
getRowId?: (row: R) => GridRowId;
|
|
310
338
|
aggregationResult?: Record<string, unknown> | null;
|
|
311
339
|
aggregationModel?: GridAggregationModel | null;
|
|
312
340
|
/** When provided, emits a nested grouping tree instead of a flat array. */
|
|
@@ -314,12 +342,15 @@ export interface JsonExportOptions {
|
|
|
314
342
|
}
|
|
315
343
|
|
|
316
344
|
/**
|
|
317
|
-
* Export data to JSON format
|
|
345
|
+
* Export data to JSON format. Values are written raw (after `valueGetter`, without
|
|
346
|
+
* `valueFormatter`), including aggregation values, so the output can be parsed as data.
|
|
318
347
|
*/
|
|
348
|
+
export function exportToJson<R extends GridValidRowModel = GridRowModel>(rows: R[], columns: GridColDef<R>[], options?: JsonExportOptions<R>): void;
|
|
349
|
+
export function exportToJson<R extends GridValidRowModel = GridRowModel>(rows: R[], columns: GridColDef[], options?: JsonExportOptions<R>): void;
|
|
319
350
|
export function exportToJson<R extends GridRowModel>(
|
|
320
351
|
rows: R[],
|
|
321
352
|
columns: GridColDef<R>[],
|
|
322
|
-
options: JsonExportOptions = {}
|
|
353
|
+
options: JsonExportOptions<R> = {}
|
|
323
354
|
): void {
|
|
324
355
|
const {
|
|
325
356
|
fileName = 'export.json',
|
|
@@ -328,11 +359,7 @@ export function exportToJson<R extends GridRowModel>(
|
|
|
328
359
|
groupedRows,
|
|
329
360
|
} = options;
|
|
330
361
|
|
|
331
|
-
const exportColumns = columns
|
|
332
|
-
if (col.exportable === false) return false;
|
|
333
|
-
if (col.field === '__check__' || col.field === '__actions__') return false;
|
|
334
|
-
return true;
|
|
335
|
-
});
|
|
362
|
+
const exportColumns = getExportColumns(columns);
|
|
336
363
|
|
|
337
364
|
type GroupNode = {
|
|
338
365
|
group: string;
|
|
@@ -348,9 +375,15 @@ export function exportToJson<R extends GridRowModel>(
|
|
|
348
375
|
| { data: Record<string, unknown>[]; aggregation: { labels: Record<string, unknown>; values: Record<string, unknown> } }
|
|
349
376
|
| { groups: GroupNode[]; grandTotal?: Record<string, unknown> };
|
|
350
377
|
|
|
378
|
+
const rowObject = (row: R): Record<string, unknown> => {
|
|
379
|
+
const obj: Record<string, unknown> = {};
|
|
380
|
+
exportColumns.forEach(col => { obj[col.field] = getRawExportValue(row, col); });
|
|
381
|
+
return obj;
|
|
382
|
+
};
|
|
383
|
+
|
|
351
384
|
let outData: JsonPayload;
|
|
352
385
|
|
|
353
|
-
if (groupedRows
|
|
386
|
+
if (shouldExportGrouped(groupedRows, selectedRows)) {
|
|
354
387
|
// Build nested tree from the flat ordered list
|
|
355
388
|
const stack: GroupNode[] = [];
|
|
356
389
|
const roots: GroupNode[] = [];
|
|
@@ -359,7 +392,7 @@ export function exportToJson<R extends GridRowModel>(
|
|
|
359
392
|
groupedRows.forEach(entry => {
|
|
360
393
|
if (entry.type === 'group-header') {
|
|
361
394
|
const node: GroupNode = {
|
|
362
|
-
group:
|
|
395
|
+
group: groupHeaderLabel(entry, columns),
|
|
363
396
|
field: entry.groupField ?? '',
|
|
364
397
|
value: entry.groupValue,
|
|
365
398
|
rows: [],
|
|
@@ -373,56 +406,34 @@ export function exportToJson<R extends GridRowModel>(
|
|
|
373
406
|
}
|
|
374
407
|
stack.push(node);
|
|
375
408
|
} else if (entry.type === 'leaf' && entry.row) {
|
|
376
|
-
|
|
377
|
-
const obj: Record<string, unknown> = {};
|
|
378
|
-
exportColumns.forEach(col => {
|
|
379
|
-
let value: unknown = (row as GridRowModel)[col.field];
|
|
380
|
-
if (col.valueGetter) value = col.valueGetter({ row, field: col.field, value });
|
|
381
|
-
obj[col.field] = value;
|
|
382
|
-
});
|
|
383
|
-
if (stack.length > 0) stack[stack.length - 1].rows.push(obj);
|
|
409
|
+
if (stack.length > 0) stack[stack.length - 1].rows.push(rowObject(entry.row as R));
|
|
384
410
|
} else if (entry.type === 'group-subtotal' && entry.aggregatedValues && stack.length > 0) {
|
|
385
|
-
stack[stack.length - 1].subtotals =
|
|
411
|
+
stack[stack.length - 1].subtotals = pickExportedAggregates(entry.aggregatedValues, exportColumns);
|
|
386
412
|
stack.pop();
|
|
387
413
|
} else if (entry.type === 'grand-total' && entry.aggregatedValues) {
|
|
388
|
-
grandTotal =
|
|
414
|
+
grandTotal = pickExportedAggregates(entry.aggregatedValues, exportColumns);
|
|
389
415
|
}
|
|
390
416
|
});
|
|
391
417
|
|
|
392
418
|
outData = grandTotal ? { groups: roots, grandTotal } : { groups: roots };
|
|
393
419
|
} else {
|
|
394
|
-
const rowsToExport = selectedRows
|
|
395
|
-
? rows.filter(r => selectedRows.includes(r.id))
|
|
396
|
-
: rows;
|
|
420
|
+
const rowsToExport = rowsForExport(rows, selectedRows, options.getRowId);
|
|
397
421
|
|
|
398
422
|
if (rowsToExport.length === 0) {
|
|
399
423
|
console.warn('No rows to export');
|
|
400
424
|
return;
|
|
401
425
|
}
|
|
402
426
|
|
|
403
|
-
const data = rowsToExport.map(
|
|
404
|
-
const obj: Record<string, unknown> = {};
|
|
405
|
-
exportColumns.forEach(col => {
|
|
406
|
-
let value: unknown = (row as GridRowModel)[col.field];
|
|
407
|
-
if (col.valueGetter) value = col.valueGetter({ row, field: col.field, value });
|
|
408
|
-
obj[col.field] = value;
|
|
409
|
-
});
|
|
410
|
-
return obj;
|
|
411
|
-
});
|
|
427
|
+
const data = rowsToExport.map(rowObject);
|
|
412
428
|
|
|
413
|
-
|
|
414
|
-
|
|
429
|
+
const aggResult = aggregationForExport(rowsToExport, hasSelection(selectedRows), columns, options.aggregationResult, options.aggregationModel);
|
|
430
|
+
if (aggResult) {
|
|
415
431
|
const aggModel = options.aggregationModel || {};
|
|
416
432
|
const summaryLabelObj: Record<string, unknown> = {};
|
|
417
433
|
exportColumns.forEach(col => { if (aggModel[col.field]) summaryLabelObj[col.field] = aggModel[col.field].toUpperCase(); });
|
|
418
434
|
const summaryDataObj: Record<string, unknown> = {};
|
|
419
435
|
exportColumns.forEach(col => {
|
|
420
|
-
if (aggResult[col.field] !== undefined && aggResult[col.field] !== null)
|
|
421
|
-
let aggVal: unknown = aggResult[col.field];
|
|
422
|
-
if (col.valueFormatter) aggVal = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
|
|
423
|
-
else if (aggModel[col.field]) aggVal = formatAggregationValue(aggVal, aggModel[col.field]);
|
|
424
|
-
summaryDataObj[col.field] = aggVal;
|
|
425
|
-
}
|
|
436
|
+
if (aggResult[col.field] !== undefined && aggResult[col.field] !== null) summaryDataObj[col.field] = aggResult[col.field];
|
|
426
437
|
});
|
|
427
438
|
outData = { data, aggregation: { labels: summaryLabelObj, values: summaryDataObj } };
|
|
428
439
|
} else {
|
|
@@ -437,22 +448,48 @@ export function exportToJson<R extends GridRowModel>(
|
|
|
437
448
|
// Print
|
|
438
449
|
// ============================================================================
|
|
439
450
|
|
|
440
|
-
export interface PrintOptions {
|
|
451
|
+
export interface PrintOptions<R extends GridValidRowModel = GridRowModel> {
|
|
441
452
|
title?: string;
|
|
453
|
+
/**
|
|
454
|
+
* Print only these row IDs. A non-empty selection takes precedence over `groupedRows` (the
|
|
455
|
+
* selected rows are printed flat), and the totals are recomputed over the selected rows.
|
|
456
|
+
*/
|
|
442
457
|
selectedRows?: (string | number)[];
|
|
458
|
+
/**
|
|
459
|
+
* The grid's `getRowId`, when it has one: `selectedRows` holds those ids, and the grid does not
|
|
460
|
+
* copy them onto `row.id` (v3.0+). Defaults to `row.id`.
|
|
461
|
+
*/
|
|
462
|
+
getRowId?: (row: R) => GridRowId;
|
|
443
463
|
aggregationResult?: Record<string, unknown> | null;
|
|
444
464
|
aggregationModel?: GridAggregationModel | null;
|
|
445
465
|
/** When provided, emits group headers, leaf rows, subtotals, and a grand total instead of a flat row list. */
|
|
446
466
|
groupedRows?: GridGroupedExportRow[];
|
|
447
467
|
}
|
|
448
468
|
|
|
469
|
+
const SAFE_IMAGE_URL = /^(?:https?:|data:image\/|blob:)/i;
|
|
470
|
+
const URL_SCHEME = /^[a-z][a-z\d+.-]*:/i;
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* The URL to use as an `<img src>` in the print window, or null when it is not an image URL:
|
|
474
|
+
* http(s), `data:image/…`, `blob:` and relative URLs are allowed; `javascript:`, `data:text/html`,
|
|
475
|
+
* `file:` and other schemes are not.
|
|
476
|
+
*/
|
|
477
|
+
function printableImageUrl(value: string): string | null {
|
|
478
|
+
const url = value.trim();
|
|
479
|
+
if (!url) return null;
|
|
480
|
+
if (SAFE_IMAGE_URL.test(url)) return url;
|
|
481
|
+
return URL_SCHEME.test(url) ? null : url;
|
|
482
|
+
}
|
|
483
|
+
|
|
449
484
|
/**
|
|
450
485
|
* Open print dialog with formatted table
|
|
451
486
|
*/
|
|
487
|
+
export function printGrid<R extends GridValidRowModel = GridRowModel>(rows: R[], columns: GridColDef<R>[], titleOrOptions?: string | PrintOptions<R>): Promise<void>;
|
|
488
|
+
export function printGrid<R extends GridValidRowModel = GridRowModel>(rows: R[], columns: GridColDef[], titleOrOptions?: string | PrintOptions<R>): Promise<void>;
|
|
452
489
|
export async function printGrid<R extends GridRowModel>(
|
|
453
490
|
rows: R[],
|
|
454
491
|
columns: GridColDef<R>[],
|
|
455
|
-
titleOrOptions?: string | PrintOptions
|
|
492
|
+
titleOrOptions?: string | PrintOptions<R>
|
|
456
493
|
): Promise<void> {
|
|
457
494
|
// 1. Open window immediately to resolve user activation constraints and provide feedback
|
|
458
495
|
const printWindow = window.open('', '_blank');
|
|
@@ -492,6 +529,7 @@ export async function printGrid<R extends GridRowModel>(
|
|
|
492
529
|
try {
|
|
493
530
|
let title = 'Print';
|
|
494
531
|
let selectedRows: (string | number)[] | undefined;
|
|
532
|
+
let getRowId: ((row: R) => GridRowId) | undefined;
|
|
495
533
|
let aggregationResult: Record<string, unknown> | null = null;
|
|
496
534
|
let aggregationModel: GridAggregationModel | null = null;
|
|
497
535
|
|
|
@@ -502,27 +540,27 @@ export async function printGrid<R extends GridRowModel>(
|
|
|
502
540
|
} else if (typeof titleOrOptions === 'object') {
|
|
503
541
|
title = titleOrOptions.title || 'Print';
|
|
504
542
|
selectedRows = titleOrOptions.selectedRows;
|
|
543
|
+
getRowId = titleOrOptions.getRowId;
|
|
505
544
|
aggregationResult = titleOrOptions.aggregationResult || null;
|
|
506
545
|
aggregationModel = titleOrOptions.aggregationModel || null;
|
|
507
546
|
groupedRows = titleOrOptions.groupedRows;
|
|
508
547
|
}
|
|
509
548
|
|
|
510
|
-
const exportColumns = columns
|
|
511
|
-
|
|
512
|
-
if (col.field === '__check__' || col.field === '__actions__') return false;
|
|
513
|
-
return true;
|
|
514
|
-
});
|
|
549
|
+
const exportColumns = getExportColumns(columns);
|
|
550
|
+
const aggModel = aggregationModel || {};
|
|
515
551
|
|
|
516
|
-
|
|
552
|
+
// Group-row rules are qualified with `tr` so they outrank the stripe rule, which has the same
|
|
553
|
+
// specificity, by coming later.
|
|
554
|
+
let html = `<!DOCTYPE html><html><head><title>${escapeHTML(title || 'Print')}</title><style>
|
|
517
555
|
body{font-family:Arial,sans-serif;margin:20px;}
|
|
518
556
|
h1{font-size:24px;margin-bottom:20px;}
|
|
519
557
|
table{border-collapse:collapse;width:100%;}
|
|
520
558
|
th,td{border:1px solid #ddd;padding:8px;text-align:left;}
|
|
521
559
|
th{background-color:#f5f5f5;font-weight:bold;}
|
|
522
560
|
tr:nth-child(even){background-color:#f9f9f9;}
|
|
523
|
-
.group-header{background:#e8eaf6;font-weight:bold;}
|
|
524
|
-
.group-subtotal{background:#f0f4ff;font-style:italic;}
|
|
525
|
-
.grand-total{background:#f5f5f5;font-weight:bold;}
|
|
561
|
+
tr.group-header{background:#e8eaf6;font-weight:bold;}
|
|
562
|
+
tr.group-subtotal{background:#f0f4ff;font-style:italic;}
|
|
563
|
+
tr.grand-total{background:#f5f5f5;font-weight:bold;}
|
|
526
564
|
@media print{body{margin:0;}@page{margin:1cm;}}
|
|
527
565
|
</style></head><body>`;
|
|
528
566
|
|
|
@@ -531,55 +569,42 @@ export async function printGrid<R extends GridRowModel>(
|
|
|
531
569
|
exportColumns.forEach(col => { html += `<th>${escapeHTML(col.headerName || col.field)}</th>`; });
|
|
532
570
|
html += '</tr></thead><tbody>';
|
|
533
571
|
|
|
534
|
-
|
|
535
|
-
const
|
|
572
|
+
const dataCell = (row: R, col: GridColDef<R>, prefixHtml = '', centerImage = false): string => {
|
|
573
|
+
const { text } = exportCell(row, col);
|
|
574
|
+
const src = col.type === 'image' ? printableImageUrl(text) : null;
|
|
575
|
+
if (src !== null) {
|
|
576
|
+
const img = `<img src="${escapeHTML(src)}" alt="${escapeHTML(col.headerName || col.field)}" style="max-height:40px;border-radius:4px;">`;
|
|
577
|
+
return centerImage ? `<td><div style="display:flex;justify-content:center;">${img}</div></td>` : `<td>${img}</td>`;
|
|
578
|
+
}
|
|
579
|
+
return `<td>${prefixHtml}${escapeHTML(text)}</td>`;
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
if (shouldExportGrouped(groupedRows, selectedRows)) {
|
|
536
583
|
groupedRows.forEach(entry => {
|
|
584
|
+
const indent = ' '.repeat(entry.depth * 4);
|
|
537
585
|
if (entry.type === 'group-header') {
|
|
538
|
-
|
|
539
|
-
const label = `${indent}${escapeHTML(entry.groupField ?? '')}: ${escapeHTML(String(entry.groupValue ?? ''))}`;
|
|
540
|
-
html += `<tr class="group-header"><td colspan="${exportColumns.length}">${label}</td></tr>`;
|
|
586
|
+
html += `<tr class="group-header"><td colspan="${exportColumns.length}">${indent}${escapeHTML(groupHeaderLabel(entry, columns))}</td></tr>`;
|
|
541
587
|
} else if (entry.type === 'leaf' && entry.row) {
|
|
542
588
|
const row = entry.row as R;
|
|
543
|
-
const indent = ' '.repeat(entry.depth * 4);
|
|
544
589
|
html += '<tr>';
|
|
545
|
-
exportColumns.forEach((col, i) => {
|
|
546
|
-
let value: unknown = (row as GridRowModel)[col.field];
|
|
547
|
-
if (col.valueGetter) value = col.valueGetter({ row, field: col.field, value });
|
|
548
|
-
if (col.valueFormatter && value !== undefined && value !== null) {
|
|
549
|
-
value = col.valueFormatter({ value, row, field: col.field });
|
|
550
|
-
}
|
|
551
|
-
if (col.type === 'image' && value) {
|
|
552
|
-
html += `<td><img src="${value}" style="max-height:40px;border-radius:4px;"></td>`;
|
|
553
|
-
} else {
|
|
554
|
-
html += `<td>${(i === 0 ? indent : '') + escapeHTML(String(value ?? ''))}</td>`;
|
|
555
|
-
}
|
|
556
|
-
});
|
|
590
|
+
exportColumns.forEach((col, i) => { html += dataCell(row, col, i === 0 ? indent : ''); });
|
|
557
591
|
html += '</tr>';
|
|
558
592
|
} else if ((entry.type === 'group-subtotal' || entry.type === 'grand-total') && entry.aggregatedValues) {
|
|
559
593
|
const isGrand = entry.type === 'grand-total';
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
const aggVal = entry.aggregatedValues![col.field];
|
|
567
|
-
if (aggVal !== undefined && aggVal !== null) {
|
|
568
|
-
let formatted: unknown = aggVal;
|
|
569
|
-
if (col.valueFormatter) formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
|
|
570
|
-
else if (aggModel[col.field]) formatted = formatAggregationValue(aggVal, aggModel[col.field]);
|
|
571
|
-
html += `<td style="text-align:right;">${escapeHTML(String(formatted))}</td>`;
|
|
572
|
-
} else {
|
|
573
|
-
html += '<td></td>';
|
|
594
|
+
const texts = aggregateTexts(exportColumns, entry.aggregatedValues, aggModel);
|
|
595
|
+
html += `<tr class="${isGrand ? 'grand-total' : 'group-subtotal'}">`;
|
|
596
|
+
exportColumns.forEach((_, i) => {
|
|
597
|
+
if (i === 0) {
|
|
598
|
+
html += `<td>${isGrand ? '' : indent}${escapeHTML(summaryLabelText(isGrand ? 'Grand Total' : 'Subtotal', texts[0]))}</td>`;
|
|
599
|
+
return;
|
|
574
600
|
}
|
|
601
|
+
html += texts[i] ? `<td style="text-align:right;">${escapeHTML(texts[i])}</td>` : '<td></td>';
|
|
575
602
|
});
|
|
576
603
|
html += '</tr>';
|
|
577
604
|
}
|
|
578
605
|
});
|
|
579
606
|
} else {
|
|
580
|
-
const rowsToExport =
|
|
581
|
-
? rows.filter(r => selectedRows.includes(r.id))
|
|
582
|
-
: rows;
|
|
607
|
+
const rowsToExport = rowsForExport(rows, selectedRows, getRowId);
|
|
583
608
|
|
|
584
609
|
if (rowsToExport.length === 0) {
|
|
585
610
|
printWindow.document.body.innerHTML = '<h3>No rows to export</h3>';
|
|
@@ -588,23 +613,12 @@ export async function printGrid<R extends GridRowModel>(
|
|
|
588
613
|
|
|
589
614
|
rowsToExport.forEach(row => {
|
|
590
615
|
html += '<tr>';
|
|
591
|
-
exportColumns.forEach(col => {
|
|
592
|
-
let value: unknown = (row as GridRowModel)[col.field];
|
|
593
|
-
if (col.valueGetter) value = col.valueGetter({ row, field: col.field, value });
|
|
594
|
-
if (col.valueFormatter && value !== undefined && value !== null) {
|
|
595
|
-
value = col.valueFormatter({ value, row, field: col.field });
|
|
596
|
-
}
|
|
597
|
-
if (col.type === 'image' && value) {
|
|
598
|
-
html += `<td><div style="display:flex;justify-content:center;"><img src="${value}" alt="${col.headerName}" style="max-height:40px;border-radius:4px;"></div></td>`;
|
|
599
|
-
} else {
|
|
600
|
-
html += `<td>${escapeHTML(String(value ?? ''))}</td>`;
|
|
601
|
-
}
|
|
602
|
-
});
|
|
616
|
+
exportColumns.forEach(col => { html += dataCell(row, col, '', true); });
|
|
603
617
|
html += '</tr>';
|
|
604
618
|
});
|
|
605
619
|
|
|
606
|
-
|
|
607
|
-
|
|
620
|
+
const aggResult = aggregationForExport(rowsToExport, hasSelection(selectedRows), columns, aggregationResult, aggregationModel);
|
|
621
|
+
if (aggResult) {
|
|
608
622
|
html += '<tfoot><tr style="font-size:11px;color:#666;background-color:#f5f5f5;">';
|
|
609
623
|
exportColumns.forEach(col => {
|
|
610
624
|
html += aggModel[col.field]
|
|
@@ -612,16 +626,8 @@ export async function printGrid<R extends GridRowModel>(
|
|
|
612
626
|
: '<td></td>';
|
|
613
627
|
});
|
|
614
628
|
html += '</tr><tr style="font-weight:bold;background-color:#f5f5f5;">';
|
|
615
|
-
exportColumns.forEach(
|
|
616
|
-
|
|
617
|
-
if (aggVal !== undefined && aggVal !== null) {
|
|
618
|
-
let formatted: unknown = aggVal;
|
|
619
|
-
if (col.valueFormatter) formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
|
|
620
|
-
else if (aggModel[col.field]) formatted = formatAggregationValue(aggVal, aggModel[col.field]);
|
|
621
|
-
html += `<td style="text-align:right;">${escapeHTML(String(formatted))}</td>`;
|
|
622
|
-
} else {
|
|
623
|
-
html += '<td></td>';
|
|
624
|
-
}
|
|
629
|
+
aggregateTexts(exportColumns, aggResult, aggModel).forEach(text => {
|
|
630
|
+
html += text ? `<td style="text-align:right;">${escapeHTML(text)}</td>` : '<td></td>';
|
|
625
631
|
});
|
|
626
632
|
html += '</tr></tfoot>';
|
|
627
633
|
}
|
|
@@ -666,7 +672,7 @@ export async function printGrid<R extends GridRowModel>(
|
|
|
666
672
|
console.error('Print generation failed:', e);
|
|
667
673
|
if (printWindow && !printWindow.closed) {
|
|
668
674
|
const message = e instanceof Error ? e.message : String(e);
|
|
669
|
-
printWindow.document.body.innerHTML = `<div style="color: red; padding: 20px;"><h3>Error Generating Print Preview</h3><p>${message}</p></div>`;
|
|
675
|
+
printWindow.document.body.innerHTML = `<div style="color: red; padding: 20px;"><h3>Error Generating Print Preview</h3><p>${escapeHTML(message)}</p></div>`;
|
|
670
676
|
}
|
|
671
677
|
}
|
|
672
678
|
}
|