@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.
Files changed (225) hide show
  1. package/CHANGELOG.md +399 -0
  2. package/README.md +95 -72
  3. package/dist/index.d.ts +590 -131
  4. package/dist/opengridx.css +1 -1
  5. package/dist/opengridx.es.js +7891 -5631
  6. package/dist/opengridx.es.js.map +1 -1
  7. package/dist/opengridx.umd.js +17 -23
  8. package/dist/opengridx.umd.js.map +1 -1
  9. package/docs/API_REFERENCE.md +348 -166
  10. package/docs/README.md +6 -1
  11. package/docs/architecture/datagrid-orchestration.md +81 -0
  12. package/docs/architecture/grid-row-meta.md +102 -0
  13. package/docs/architecture/use-grid-columns.md +128 -0
  14. package/docs/architecture/use-grid-controlled-state.md +157 -0
  15. package/docs/architecture/use-grid-row-pipeline.md +120 -0
  16. package/docs/architecture/use-grid-scroll-sync.md +111 -0
  17. package/docs/architecture/use-grid-state-snapshot.md +90 -0
  18. package/docs/architecture/use-grid-virtualization.md +115 -0
  19. package/docs/architecture/use-grid-visible-rows.md +85 -0
  20. package/docs/assets/banner-alt.png +0 -0
  21. package/docs/assets/banner.png +0 -0
  22. package/docs/assets/hero.png +0 -0
  23. package/docs/assets/logo.png +0 -0
  24. package/docs/components/aggregation-footer.md +15 -10
  25. package/docs/components/cell.md +16 -6
  26. package/docs/components/column-group-header.md +55 -28
  27. package/docs/components/column-resize.md +17 -13
  28. package/docs/components/column-visibility.md +6 -7
  29. package/docs/components/datagrid.md +15 -13
  30. package/docs/components/empty-state.md +2 -1
  31. package/docs/components/error-overlay.md +12 -7
  32. package/docs/components/filter-panel.md +28 -8
  33. package/docs/components/header.md +28 -9
  34. package/docs/components/pagination.md +14 -8
  35. package/docs/components/row.md +21 -5
  36. package/docs/components/toolbar.md +27 -9
  37. package/docs/components/tooltip.md +16 -7
  38. package/docs/customization/slots-api.md +90 -38
  39. package/docs/customization/theming.md +66 -18
  40. package/docs/features/aggregation-pivot.md +75 -9
  41. package/docs/features/cell-spanning.md +80 -125
  42. package/docs/features/clipboard.md +41 -16
  43. package/docs/features/custom-pagination.md +38 -18
  44. package/docs/features/data-source.md +36 -13
  45. package/docs/features/editing-reordering.md +125 -16
  46. package/docs/features/export-guide.md +126 -39
  47. package/docs/features/filtering.md +44 -6
  48. package/docs/features/infinite-scroll.md +54 -46
  49. package/docs/features/keyboard-navigation.md +83 -0
  50. package/docs/features/list-view.md +58 -5
  51. package/docs/features/loading-states.md +18 -20
  52. package/docs/features/master-detail.md +18 -5
  53. package/docs/features/pdf-export.md +72 -19
  54. package/docs/features/pinning.md +24 -5
  55. package/docs/features/selection.md +50 -10
  56. package/docs/features/sorting-pagination.md +64 -14
  57. package/docs/features/state-persistence.md +78 -10
  58. package/docs/features/toolbar-customization.md +26 -17
  59. package/docs/features/tree-data-grouping.md +36 -8
  60. package/docs/features/virtualization.md +37 -14
  61. package/docs/migration/v1-to-v2.md +115 -0
  62. package/docs/migration/v2-to-v3.md +1233 -0
  63. package/docs/performance.md +124 -0
  64. package/docs/roadmap.md +5 -5
  65. package/lib/components/Cell/Cell.css +17 -1
  66. package/lib/components/Cell/Cell.tsx +129 -57
  67. package/lib/components/Cell/CellErrorBoundary.tsx +12 -6
  68. package/lib/components/Cell/GridEditInputCell.tsx +114 -41
  69. package/lib/components/ColumnGroupHeader/ColumnGroupHeader.css +7 -1
  70. package/lib/components/ColumnResizeHandle/ColumnResizeHandle.css +15 -2
  71. package/lib/components/ColumnResizeHandle/ColumnResizeHandle.tsx +104 -38
  72. package/lib/components/ColumnResizeHandle/clampResizeWidth.ts +13 -0
  73. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +6 -0
  74. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +21 -13
  75. package/lib/components/DataGrid/DataGrid.css +85 -12
  76. package/lib/components/DataGrid/DataGrid.tsx +582 -876
  77. package/lib/components/DataGrid/GridAggregationFooter.tsx +53 -17
  78. package/lib/components/DataGrid/GridErrorOverlay.tsx +15 -12
  79. package/lib/components/DataGrid/GridListView.tsx +165 -24
  80. package/lib/components/DataGrid/GridLiveRegion.tsx +31 -0
  81. package/lib/components/DataGrid/GridLoadingOverlay.tsx +27 -0
  82. package/lib/components/DataGrid/GridPaginationArea.tsx +44 -0
  83. package/lib/components/DataGrid/GridPinnedRows.tsx +62 -11
  84. package/lib/components/DataGrid/GridStandaloneColumnPanel.tsx +40 -38
  85. package/lib/components/DataGrid/GridToolbarSlot.tsx +51 -0
  86. package/lib/components/DataGrid/GridVirtualRows.tsx +40 -24
  87. package/lib/components/DetailPanel/DetailPanel.css +0 -6
  88. package/lib/components/DetailPanel/DetailPanel.tsx +34 -5
  89. package/lib/components/DetailPanel/DetailPanelContent.tsx +17 -0
  90. package/lib/components/FilterPanel/FilterPanel.css +13 -2
  91. package/lib/components/FilterPanel/FilterPanel.tsx +272 -99
  92. package/lib/components/Header/ColumnMenu.tsx +91 -39
  93. package/lib/components/Header/Header.css +6 -3
  94. package/lib/components/Header/Header.tsx +181 -160
  95. package/lib/components/ListView/ListView.css +25 -0
  96. package/lib/components/ListView/ListViewRow.tsx +76 -10
  97. package/lib/components/Pagination/Pagination.tsx +15 -7
  98. package/lib/components/Row/Row.tsx +177 -77
  99. package/lib/components/Toolbar/GlobalSearch.css +6 -3
  100. package/lib/components/Toolbar/GlobalSearch.tsx +26 -27
  101. package/lib/components/Toolbar/GridToolbar.css +12 -11
  102. package/lib/components/Toolbar/GridToolbar.tsx +133 -89
  103. package/lib/components/Toolbar/PivotPanel.tsx +89 -49
  104. package/lib/components/Tooltip/Tooltip.css +20 -0
  105. package/lib/components/Tooltip/Tooltip.tsx +68 -35
  106. package/lib/components/ui/Button.tsx +3 -0
  107. package/lib/components/ui/Checkbox.tsx +2 -1
  108. package/lib/components/ui/ExpandIcon.css +0 -9
  109. package/lib/components/ui/ExpandIcon.tsx +4 -1
  110. package/lib/hooks/core/gridApiPlaceholder.ts +34 -0
  111. package/lib/hooks/core/gridToolbarHostContext.ts +14 -0
  112. package/lib/hooks/core/useDataGrid.ts +115 -173
  113. package/lib/hooks/core/useGridAggregationApi.ts +72 -0
  114. package/lib/hooks/core/useGridApiMethods.ts +115 -0
  115. package/lib/hooks/core/useGridApiRef.ts +19 -4
  116. package/lib/hooks/core/useGridApiRefBinding.ts +18 -0
  117. package/lib/hooks/core/useGridAriaRows.ts +38 -0
  118. package/lib/hooks/core/useGridClipboardApi.ts +38 -0
  119. package/lib/hooks/core/useGridColumnLookup.ts +30 -0
  120. package/lib/hooks/core/useGridColumns.tsx +279 -76
  121. package/lib/hooks/core/useGridColumnsPanel.ts +77 -0
  122. package/lib/hooks/core/useGridControlledState.ts +99 -6
  123. package/lib/hooks/core/useGridDetailPanel.ts +51 -0
  124. package/lib/hooks/core/useGridDevWarnings.ts +104 -5
  125. package/lib/hooks/core/useGridFocusHandlers.ts +50 -0
  126. package/lib/hooks/core/useGridGroupingColumn.ts +62 -0
  127. package/lib/hooks/core/useGridHeaderHandlers.ts +92 -0
  128. package/lib/hooks/core/useGridHierarchy.ts +81 -0
  129. package/lib/hooks/core/useGridKeyboardNavigation.ts +447 -243
  130. package/lib/hooks/core/useGridLiveRowSelection.ts +67 -0
  131. package/lib/hooks/core/useGridPageCorrection.ts +55 -0
  132. package/lib/hooks/core/useGridRowIdOf.ts +32 -0
  133. package/lib/hooks/core/useGridRowInteractions.ts +81 -0
  134. package/lib/hooks/core/useGridRowPipeline.ts +69 -39
  135. package/lib/hooks/core/useGridRowSelection.ts +91 -0
  136. package/lib/hooks/core/useGridScrollSync.ts +80 -32
  137. package/lib/hooks/core/useGridScrollToIndexesApi.ts +45 -0
  138. package/lib/hooks/core/useGridStateSnapshot.ts +40 -5
  139. package/lib/hooks/core/useGridThemeDimensions.ts +49 -0
  140. package/lib/hooks/core/useGridToolbarProps.ts +97 -0
  141. package/lib/hooks/core/useGridViewport.ts +38 -0
  142. package/lib/hooks/core/useGridViewportSize.ts +26 -0
  143. package/lib/hooks/core/useGridVisibleRows.ts +17 -5
  144. package/lib/hooks/core/useLayout.ts +215 -195
  145. package/lib/hooks/core/useRowsScrollEndLatch.ts +41 -0
  146. package/lib/hooks/core/useStableColumns.ts +34 -0
  147. package/lib/hooks/features/useAggregation.ts +82 -51
  148. package/lib/hooks/features/useColumnGroupReorderGuard.ts +34 -0
  149. package/lib/hooks/features/useDetailPanelHeights.ts +29 -0
  150. package/lib/hooks/features/useGridClipboard.ts +133 -73
  151. package/lib/hooks/features/useGridDataSource.ts +354 -128
  152. package/lib/hooks/features/useGridEditing.ts +211 -51
  153. package/lib/hooks/features/useGridPivot.ts +92 -0
  154. package/lib/hooks/features/useGridSpanRenderWindow.ts +104 -0
  155. package/lib/hooks/features/useGridSpanning.ts +410 -306
  156. package/lib/hooks/features/usePivot.ts +0 -0
  157. package/lib/hooks/features/useServerTreeChildren.ts +50 -0
  158. package/lib/hooks/useColumnReorder.ts +38 -44
  159. package/lib/hooks/useRowGrouping.ts +298 -160
  160. package/lib/hooks/useRowReorder.ts +62 -31
  161. package/lib/hooks/useTreeData.ts +287 -220
  162. package/lib/index.ts +28 -2
  163. package/lib/state/index.ts +1 -0
  164. package/lib/state/types.ts +7 -1
  165. package/lib/state/useGridStateStorage.ts +88 -29
  166. package/lib/theme/DataGridThemeProvider.tsx +143 -38
  167. package/lib/theme/gridThemeContext.ts +5 -0
  168. package/lib/theme/index.ts +5 -0
  169. package/lib/theme/presets.ts +5 -4
  170. package/lib/theme/types.ts +40 -2
  171. package/lib/types/index.ts +313 -116
  172. package/lib/utils/aggregation/index.ts +119 -24
  173. package/lib/utils/aria/index.ts +40 -0
  174. package/lib/utils/columnGroups.ts +110 -0
  175. package/lib/utils/columnLookup.ts +33 -0
  176. package/lib/utils/columnWidth.ts +22 -0
  177. package/lib/utils/dataSource/index.ts +46 -0
  178. package/lib/utils/detailPanel.ts +66 -0
  179. package/lib/utils/dragData.ts +44 -0
  180. package/lib/utils/editing/index.ts +30 -0
  181. package/lib/utils/export/exportShared.ts +197 -0
  182. package/lib/utils/export/exportToExcelAdvanced.ts +278 -139
  183. package/lib/utils/export/exportToPdf.ts +143 -92
  184. package/lib/utils/export/groupLabel.ts +14 -0
  185. package/lib/utils/export/index.ts +278 -272
  186. package/lib/utils/export/pdfText.ts +50 -0
  187. package/lib/utils/filtering/index.ts +277 -148
  188. package/lib/utils/focus/index.ts +115 -0
  189. package/lib/utils/gridApiRows.ts +35 -0
  190. package/lib/utils/gridModes.ts +77 -0
  191. package/lib/utils/grouping/groupedExportRows.ts +91 -0
  192. package/lib/utils/pagination/index.ts +56 -0
  193. package/lib/utils/pinning/index.ts +77 -33
  194. package/lib/utils/pivot/index.ts +368 -0
  195. package/lib/utils/scroll/index.ts +55 -5
  196. package/lib/utils/scroll/scrollEnd.ts +18 -0
  197. package/lib/utils/sorting/index.ts +154 -30
  198. package/lib/utils/syntheticRows.ts +12 -0
  199. package/lib/utils/values.ts +206 -0
  200. package/lib/utils/viewport.ts +14 -0
  201. package/llms.txt +261 -164
  202. package/package.json +13 -5
  203. package/lib/components/ColumnGroupHeader/ColumnGroupHeader.tsx +0 -220
  204. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.test.tsx +0 -85
  205. package/lib/components/DataGrid/DataGrid.defects.test.tsx +0 -103
  206. package/lib/components/DataGrid/DataGrid.regression.test.tsx +0 -132
  207. package/lib/components/DataGrid/DataGrid.smoke.test.tsx +0 -58
  208. package/lib/components/DataGrid/DataGrid.v2.test.tsx +0 -127
  209. package/lib/components/DataGrid/DataGrid.virtualization.browser.test.tsx +0 -183
  210. package/lib/components/Header/Header.test.tsx +0 -135
  211. package/lib/components/Pagination/Pagination.test.tsx +0 -64
  212. package/lib/hooks/core/useDataGrid.regression.test.ts +0 -66
  213. package/lib/hooks/core/useGridControlledState.test.ts +0 -86
  214. package/lib/hooks/core/useGridKeyboardNavigation.test.ts +0 -176
  215. package/lib/hooks/core/useGridRowPipeline.test.ts +0 -117
  216. package/lib/hooks/features/useAggregation.test.ts +0 -175
  217. package/lib/hooks/features/usePivot.test.ts +0 -202
  218. package/lib/hooks/useRowGrouping.test.ts +0 -298
  219. package/lib/hooks/useTreeData.test.ts +0 -83
  220. package/lib/utils/aggregation/aggregation.test.ts +0 -16
  221. package/lib/utils/export/exportToExcelAdvanced.test.ts +0 -109
  222. package/lib/utils/export/exportToPdf.test.ts +0 -120
  223. package/lib/utils/export/groupedExport.test.ts +0 -144
  224. package/lib/utils/filtering/filtering.test.ts +0 -178
  225. 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 { formatAggregationValue } from '../../hooks/features/useAggregation';
4
-
5
- export interface CsvExportOptions {
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.filter(col => {
30
- if (col.exportable === false) return false;
31
- if (col.field === '__check__' || col.field === '__actions__') return false;
32
- return true;
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 && groupedRows.length > 0) {
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 indent = ' '.repeat(entry.depth);
45
- const label = `${indent}${entry.groupField ?? ''}: ${String(entry.groupValue ?? '')}`;
46
- const cells = exportColumns.map((_, i) => i === 0 ? escapeCSV(label) : '');
47
- csvContent += cells.join(delimiter) + '\n';
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
- let value: unknown = (row as GridRowModel)[col.field];
53
- if (col.valueGetter) value = col.valueGetter({ row, field: col.field, value });
54
- if (col.valueFormatter && value !== undefined && value !== null) {
55
- value = col.valueFormatter({ value, row, field: col.field });
56
- }
57
- return escapeCSV((i === 0 ? indent : '') + String(value ?? ''));
58
- });
59
- csvContent += cells.join(delimiter) + '\n';
60
- } else if (entry.type === 'group-subtotal' && entry.aggregatedValues) {
61
- const indent = ' '.repeat(entry.depth);
62
- const aggModel = options.aggregationModel || {};
63
- const cells = exportColumns.map((col, i) => {
64
- if (i === 0) return escapeCSV(`${indent}Subtotal`);
65
- const aggVal = entry.aggregatedValues![col.field];
66
- if (aggVal !== undefined && aggVal !== null) {
67
- let formatted: unknown = aggVal;
68
- if (col.valueFormatter) {
69
- formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
70
- } else if (aggModel[col.field]) {
71
- formatted = formatAggregationValue(aggVal, aggModel[col.field]);
72
- }
73
- return escapeCSV(String(formatted));
74
- }
75
- return '';
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 aggModel = options.aggregationModel || {};
80
- const cells = exportColumns.map((col, i) => {
81
- if (i === 0) return escapeCSV('Grand Total');
82
- const aggVal = entry.aggregatedValues![col.field];
83
- if (aggVal !== undefined && aggVal !== null) {
84
- let formatted: unknown = aggVal;
85
- if (col.valueFormatter) {
86
- formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
87
- } else if (aggModel[col.field]) {
88
- formatted = formatAggregationValue(aggVal, aggModel[col.field]);
89
- }
90
- return escapeCSV(String(formatted));
91
- }
92
- return '';
93
- });
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
- // Flat export path (existing behavior)
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
- let value: unknown = (row as GridRowModel)[col.field];
115
- if (col.valueGetter) value = col.valueGetter({ row, field: col.field, value });
116
- if (col.valueFormatter && value !== undefined && value !== null) {
117
- value = col.valueFormatter({ value, row, field: col.field });
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
- if (options.aggregationResult) {
125
- const aggResult = options.aggregationResult;
126
- const aggModel = options.aggregationModel || {};
127
- const aggLabels = exportColumns.map(col => escapeCSV(aggModel[col.field] ? aggModel[col.field].toUpperCase() : ''));
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 aggValues = exportColumns.map(col => {
130
- const aggVal = aggResult[col.field];
131
- if (aggVal !== undefined && aggVal !== null) {
132
- let formatted: unknown = aggVal;
133
- if (col.valueFormatter) formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
134
- else if (aggModel[col.field]) formatted = formatAggregationValue(aggVal, aggModel[col.field]);
135
- return escapeCSV(String(formatted));
136
- }
137
- return '';
138
- });
139
- csvContent += aggValues.join(delimiter) + '\n';
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
- function escapeCSV(value: string): string {
147
- if (value.includes(',') || value.includes('"') || value.includes('\n')) {
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.filter(col => {
178
- if (col.exportable === false) return false;
179
- if (col.field === '__check__' || col.field === '__actions__') return false;
180
- return true;
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
- + '<meta charset="UTF-8"></head><body><table border="1">';
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>${escapeHTML(col.headerName || col.field)}</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
- if (groupedRows && groupedRows.length > 0) {
200
- const aggModel = options.aggregationModel || {};
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(label)}</td>`;
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 isGrand = entry.type === 'grand-total';
223
- const indent = isGrand ? '' : '  '.repeat(entry.depth * 2);
224
- const label = isGrand ? 'Grand Total' : `${indent}Subtotal`;
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 += `<td>${escapeHTML(label)}</td>`; return; }
228
- const aggVal = entry.aggregatedValues![col.field];
229
- if (aggVal !== undefined && aggVal !== null) {
230
- let formatted: unknown = aggVal;
231
- if (col.valueFormatter) formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
232
- else if (aggModel[col.field]) formatted = formatAggregationValue(aggVal, aggModel[col.field]);
233
- html += `<td style="text-align:right;">${escapeHTML(String(formatted))}</td>`;
234
- } else {
235
- html += '<td></td>';
236
- }
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 && selectedRows.length > 0
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
- if (options.aggregationResult) {
265
- const aggResult = options.aggregationResult;
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.forEach(col => {
275
- const aggVal = aggResult[col.field];
276
- if (aggVal !== undefined && aggVal !== null) {
277
- let formatted: unknown = aggVal;
278
- if (col.valueFormatter) formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
279
- else if (aggModel[col.field]) formatted = formatAggregationValue(aggVal, aggModel[col.field]);
280
- html += `<td style="text-align:right;">${escapeHTML(String(formatted))}</td>`;
281
- } else {
282
- html += '<td></td>';
283
- }
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, '&amp;')
296
- .replace(/</g, '&lt;')
297
- .replace(/>/g, '&gt;')
298
- .replace(/"/g, '&quot;')
299
- .replace(/'/g, '&#039;');
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.filter(col => {
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 && groupedRows.length > 0) {
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: `${entry.groupField ?? ''}: ${String(entry.groupValue ?? '')}`,
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
- const row = entry.row as R;
377
- const obj: Record<string, unknown> = {};
378
- exportColumns.forEach(col => {
379
- let value: unknown = (row as GridRowModel)[col.field];
380
- if (col.valueGetter) value = col.valueGetter({ row, field: col.field, value });
381
- obj[col.field] = value;
382
- });
383
- if (stack.length > 0) stack[stack.length - 1].rows.push(obj);
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 = { ...entry.aggregatedValues };
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 = { ...entry.aggregatedValues };
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 && selectedRows.length > 0
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(row => {
404
- const obj: Record<string, unknown> = {};
405
- exportColumns.forEach(col => {
406
- let value: unknown = (row as GridRowModel)[col.field];
407
- if (col.valueGetter) value = col.valueGetter({ row, field: col.field, value });
408
- obj[col.field] = value;
409
- });
410
- return obj;
411
- });
427
+ const data = rowsToExport.map(rowObject);
412
428
 
413
- if (options.aggregationResult) {
414
- const aggResult = options.aggregationResult;
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.filter(col => {
511
- if (col.exportable === false) return false;
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
- let html = `<!DOCTYPE html><html><head><title>${title || 'Print'}</title><style>
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
- if (groupedRows && groupedRows.length > 0) {
535
- const aggModel = aggregationModel || {};
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 = '&nbsp;'.repeat(entry.depth * 4);
537
585
  if (entry.type === 'group-header') {
538
- const indent = '&nbsp;'.repeat(entry.depth * 4);
539
- const label = `${indent}${escapeHTML(entry.groupField ?? '')}: ${escapeHTML(String(entry.groupValue ?? ''))}`;
540
- html += `<tr class="group-header"><td colspan="${exportColumns.length}">${label}</td></tr>`;
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 = '&nbsp;'.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 indent = isGrand ? '' : '&nbsp;'.repeat(entry.depth * 4);
561
- const label = isGrand ? 'Grand Total' : `${indent}Subtotal`;
562
- const cls = isGrand ? 'grand-total' : 'group-subtotal';
563
- html += `<tr class="${cls}">`;
564
- exportColumns.forEach((col, i) => {
565
- if (i === 0) { html += `<td>${label}</td>`; return; }
566
- const aggVal = entry.aggregatedValues![col.field];
567
- if (aggVal !== undefined && aggVal !== null) {
568
- let formatted: unknown = aggVal;
569
- if (col.valueFormatter) formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
570
- else if (aggModel[col.field]) formatted = formatAggregationValue(aggVal, aggModel[col.field]);
571
- html += `<td style="text-align:right;">${escapeHTML(String(formatted))}</td>`;
572
- } else {
573
- html += '<td></td>';
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 = selectedRows && selectedRows.length > 0
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
- if (aggregationResult) {
607
- const aggModel = aggregationModel || {};
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(col => {
616
- const aggVal = aggregationResult![col.field];
617
- if (aggVal !== undefined && aggVal !== null) {
618
- let formatted: unknown = aggVal;
619
- if (col.valueFormatter) formatted = col.valueFormatter({ value: aggVal, row: {} as R, field: col.field });
620
- else if (aggModel[col.field]) formatted = formatAggregationValue(aggVal, aggModel[col.field]);
621
- html += `<td style="text-align:right;">${escapeHTML(String(formatted))}</td>`;
622
- } else {
623
- html += '<td></td>';
624
- }
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
  }