@opencorestack/opengridx 3.9.0 → 3.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/README.md +11 -7
- package/dist/index.d.ts +55 -0
- package/dist/opengridx.es.js +6346 -6279
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +16 -16
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +4 -0
- package/docs/architecture/datagrid-orchestration.md +4 -2
- package/docs/architecture/use-grid-virtualization.md +2 -0
- package/docs/components/pagination.md +1 -1
- package/docs/features/ai-toolkit.md +1 -0
- package/docs/features/rtl.md +1 -1
- package/docs/performance.md +19 -1
- package/docs/roadmap.md +26 -5
- package/lib/components/Cell/Cell.tsx +2 -1
- package/lib/components/DataGrid/DataGrid.tsx +19 -18
- package/lib/components/DataGrid/GridListView.tsx +1 -1
- package/lib/components/DataGrid/GridPinnedRows.tsx +1 -1
- package/lib/components/DataGrid/GridViewport.tsx +2 -1
- package/lib/components/DataGrid/GridVirtualRows.tsx +1 -1
- package/lib/components/Header/Header.tsx +4 -3
- package/lib/components/Row/Row.tsx +6 -2
- package/lib/components/Toolbar/AggregationPanel.tsx +90 -0
- package/lib/components/Toolbar/ColumnsPanelWrapper.tsx +56 -0
- package/lib/components/Toolbar/FilterPanelWrapper.tsx +82 -0
- package/lib/components/Toolbar/GridToolbar.tsx +7 -380
- package/lib/components/Toolbar/ToolbarIcons.tsx +35 -0
- package/lib/components/Toolbar/useToolbarPanel.ts +71 -0
- package/lib/hooks/core/gridKeyDown.ts +272 -0
- package/lib/hooks/core/useDataGrid.ts +13 -5
- package/lib/hooks/core/useGridApiMethods.ts +2 -2
- package/lib/hooks/core/useGridCellRangeDrag.ts +345 -0
- package/lib/hooks/core/useGridCellSelection.ts +17 -297
- package/lib/hooks/core/useGridColumnLookup.ts +2 -1
- package/lib/hooks/core/useGridColumns.tsx +3 -2
- package/lib/hooks/core/useGridControlledState.ts +3 -2
- package/lib/hooks/core/useGridHierarchy.ts +1 -1
- package/lib/hooks/core/useGridKeyboardNavigation.ts +51 -235
- package/lib/hooks/core/useGridLiveRowSelection.ts +2 -2
- package/lib/hooks/core/useGridRowInteractions.ts +1 -1
- package/lib/hooks/core/useGridRowSelection.ts +11 -2
- package/lib/hooks/core/useGridToolbarProps.ts +3 -2
- package/lib/hooks/features/useGridAiAssistant.ts +3 -2
- package/lib/hooks/useColumnReorder.ts +2 -1
- package/lib/hooks/useTreeData.ts +4 -2
- package/lib/types/locale.ts +22 -0
- package/lib/utils/aggregation/index.ts +16 -0
- package/lib/utils/aiAssistant.ts +46 -15
- package/lib/utils/export/excelCells.ts +244 -0
- package/lib/utils/export/exportCsv.ts +179 -0
- package/lib/utils/export/exportExcelHtml.ts +220 -0
- package/lib/utils/export/exportJson.ts +179 -0
- package/lib/utils/export/exportRows.ts +85 -0
- package/lib/utils/export/exportToExcelAdvanced.ts +22 -242
- package/lib/utils/export/index.ts +5 -695
- package/lib/utils/export/printGrid.ts +282 -0
- package/lib/utils/gridLocale.ts +30 -0
- package/lib/utils/selectionSets.ts +36 -0
- package/lib/utils/untypedColumns.ts +16 -0
- package/llms.txt +3 -1
- package/package.json +6 -2
- package/lib/theme/__baseline__/default-theme.darwin.chromium.json +0 -1
- package/lib/theme/__baseline__/default-theme.darwin.firefox.json +0 -1
- package/lib/theme/__baseline__/default-theme.darwin.webkit.json +0 -1
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// Cell values, number formats, widths, images and borders of the .xlsx export (exportToExcelAdvanced).
|
|
2
|
+
|
|
3
|
+
import type { GridColDef, GridRowModel } from '../../types';
|
|
4
|
+
import { formatExportValue, getRawExportValue } from './exportShared';
|
|
5
|
+
import type { ExcelColumnStyle } from './exportToExcelAdvanced';
|
|
6
|
+
|
|
7
|
+
/** The only values handed to ExcelJS. Objects are never passed through, because ExcelJS reads
|
|
8
|
+
* `{ formula }`, `{ hyperlink }`, `{ richText }` and `{ error }` objects as live cell content. */
|
|
9
|
+
type ExcelCellValue = string | number | boolean | Date | null;
|
|
10
|
+
|
|
11
|
+
/** A subtotal / total cell value, with the number format it needs when it differs from the column's. */
|
|
12
|
+
export interface AggregateCell {
|
|
13
|
+
value: ExcelCellValue;
|
|
14
|
+
numFmt?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type CellKind = 'number' | 'boolean' | 'date' | 'string';
|
|
18
|
+
|
|
19
|
+
function cellKind(colDef: GridColDef<GridRowModel>, value: unknown): CellKind {
|
|
20
|
+
if (colDef.type === 'number') return 'number';
|
|
21
|
+
if (colDef.type === 'boolean') return 'boolean';
|
|
22
|
+
if (colDef.type === 'date') return 'date';
|
|
23
|
+
if (typeof value === 'number') return 'number';
|
|
24
|
+
if (typeof value === 'boolean') return 'boolean';
|
|
25
|
+
if (value instanceof Date) return 'date';
|
|
26
|
+
return 'string';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Excel date serials carry no timezone and are read as wall-clock time, while ExcelJS converts a
|
|
31
|
+
* Date from its UTC instant. Re-anchor the local wall-clock fields at UTC so the cell shows the
|
|
32
|
+
* date and time the grid shows. Invalid dates become null.
|
|
33
|
+
*/
|
|
34
|
+
export function toExcelDate(date: Date): Date | null {
|
|
35
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
36
|
+
return new Date(Date.UTC(
|
|
37
|
+
date.getFullYear(), date.getMonth(), date.getDate(),
|
|
38
|
+
date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds(),
|
|
39
|
+
));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ISO_DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
43
|
+
const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/i;
|
|
44
|
+
const NUMERIC_STRING = /^[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?$/i;
|
|
45
|
+
|
|
46
|
+
/** A date-column value as a Date: null when missing or invalid, undefined when not a date at all. */
|
|
47
|
+
function dateCellValue(value: unknown): Date | null | undefined {
|
|
48
|
+
if (value instanceof Date) return toExcelDate(value);
|
|
49
|
+
if (typeof value === 'number') return Number.isFinite(value) ? toExcelDate(new Date(value)) : null;
|
|
50
|
+
if (typeof value !== 'string') return undefined;
|
|
51
|
+
const text = value.trim();
|
|
52
|
+
const dateOnly = ISO_DATE_ONLY.exec(text);
|
|
53
|
+
if (dateOnly) {
|
|
54
|
+
// A calendar date: already wall-clock, so build it at UTC directly.
|
|
55
|
+
const [year, month, day] = [Number(dateOnly[1]), Number(dateOnly[2]), Number(dateOnly[3])];
|
|
56
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
57
|
+
return date.getUTCMonth() === month - 1 && date.getUTCDate() === day ? date : undefined;
|
|
58
|
+
}
|
|
59
|
+
if (ISO_DATE_TIME.test(text)) {
|
|
60
|
+
const date = new Date(text.replace(' ', 'T'));
|
|
61
|
+
return Number.isNaN(date.getTime()) ? undefined : toExcelDate(date);
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A native value for a number / boolean / date cell: null for an empty cell (missing, NaN,
|
|
68
|
+
* Infinity, Invalid Date), undefined when the value is not of that kind (e.g. 'n/a' in a
|
|
69
|
+
* number column).
|
|
70
|
+
*/
|
|
71
|
+
function typedCellValue(kind: Exclude<CellKind, 'string'>, value: unknown): number | boolean | Date | null | undefined {
|
|
72
|
+
if (value == null || value === '') return null;
|
|
73
|
+
if (kind === 'number') {
|
|
74
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
|
75
|
+
if (typeof value === 'bigint') {
|
|
76
|
+
const n = Number(value);
|
|
77
|
+
return Number.isSafeInteger(n) ? n : undefined;
|
|
78
|
+
}
|
|
79
|
+
if (typeof value === 'string' && NUMERIC_STRING.test(value.trim())) {
|
|
80
|
+
const n = Number(value.trim());
|
|
81
|
+
return Number.isFinite(n) ? n : undefined;
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
if (kind === 'boolean') {
|
|
86
|
+
if (typeof value === 'boolean') return value;
|
|
87
|
+
if (typeof value === 'string') {
|
|
88
|
+
const text = value.trim().toLowerCase();
|
|
89
|
+
if (text === 'true') return true;
|
|
90
|
+
if (text === 'false') return false;
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
return dateCellValue(value);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Any value as inert cell content: primitives and Dates as themselves, objects as JSON text. */
|
|
98
|
+
function inertCellValue(value: unknown): ExcelCellValue {
|
|
99
|
+
if (value == null) return null;
|
|
100
|
+
if (typeof value === 'string' || typeof value === 'boolean') return value;
|
|
101
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
|
102
|
+
if (value instanceof Date) return toExcelDate(value);
|
|
103
|
+
if (typeof value === 'object') {
|
|
104
|
+
try {
|
|
105
|
+
return JSON.stringify(value) ?? String(value);
|
|
106
|
+
} catch {
|
|
107
|
+
return String(value);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return String(value);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The value of a data cell. Number, boolean and date cells are native (their `valueFormatter` is
|
|
115
|
+
* not used, so Excel's number format applies); a value that is missing or not of the column's
|
|
116
|
+
* kind falls back to the text the grid shows. Other cells get the `valueFormatter` text.
|
|
117
|
+
*/
|
|
118
|
+
export function dataCellValue(row: GridRowModel, col: GridColDef<GridRowModel>): ExcelCellValue {
|
|
119
|
+
const raw = getRawExportValue(row, col);
|
|
120
|
+
const kind = cellKind(col, raw);
|
|
121
|
+
if (kind !== 'string') {
|
|
122
|
+
const typed = typedCellValue(kind, raw);
|
|
123
|
+
if (typed != null) return typed;
|
|
124
|
+
if (!col.valueFormatter) return typed === null ? null : inertCellValue(raw);
|
|
125
|
+
}
|
|
126
|
+
return col.valueFormatter ? formatExportValue(row, col, raw) : inertCellValue(raw);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─── Number formats ──────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
export const INTEGER_FORMAT = '#,##0';
|
|
132
|
+
const DECIMAL_FORMAT = '#,##0.##';
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Default format for a number-column value. '#,##0.##' alone shows whole numbers with a trailing
|
|
136
|
+
* decimal point in Excel ("5."), so whole numbers get '#,##0'.
|
|
137
|
+
*/
|
|
138
|
+
function defaultNumberFormat(value: number): string {
|
|
139
|
+
return Number.isInteger(value) ? INTEGER_FORMAT : DECIMAL_FORMAT;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function columnNumFmt(colDef: GridColDef<GridRowModel>, style: ExcelColumnStyle | undefined): string | undefined {
|
|
143
|
+
if (style?.numFmt) return style.numFmt;
|
|
144
|
+
if (colDef.type === 'date') return 'yyyy-mm-dd';
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The number format for a native cell value, or undefined to keep the column's format. */
|
|
149
|
+
export function cellNumFmt(colDef: GridColDef<GridRowModel>, style: ExcelColumnStyle | undefined, value: ExcelCellValue): string | undefined {
|
|
150
|
+
if (style?.numFmt || typeof value !== 'number' || colDef.type !== 'number') return undefined;
|
|
151
|
+
return defaultNumberFormat(value);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ─── Column display width (chars) ────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
export function colWidth(colDef: GridColDef<GridRowModel>, override?: number): number {
|
|
157
|
+
if (override != null) return override;
|
|
158
|
+
const px = (colDef.width ?? 120) as number;
|
|
159
|
+
return Math.min(60, Math.max(8, (px - 5) / 7));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─── Image helpers ────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
type SupportedImageExt = 'png' | 'jpeg' | 'gif';
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The image format from the file's magic bytes. Excel can only show PNG, JPEG and GIF here; SVG,
|
|
168
|
+
* WebP, AVIF and anything else return null so the URL text is written instead.
|
|
169
|
+
*/
|
|
170
|
+
function detectImageExtension(bytes: Uint8Array): SupportedImageExt | null {
|
|
171
|
+
if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'png';
|
|
172
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'jpeg';
|
|
173
|
+
if (bytes.length >= 6 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) return 'gif';
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The image URL of an embedImage cell: the cell value after `valueGetter`. */
|
|
178
|
+
export function imageUrlOf(row: GridRowModel, col: GridColDef<GridRowModel>): string | null {
|
|
179
|
+
const value = getRawExportValue(row, col);
|
|
180
|
+
return typeof value === 'string' && value ? value : null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Fetch a list of image URLs in parallel.
|
|
185
|
+
* CORS failures and unsupported formats are logged and skipped — raw URL text is written to the
|
|
186
|
+
* cell instead so data is never silently lost.
|
|
187
|
+
*/
|
|
188
|
+
export async function fetchImages(
|
|
189
|
+
urls: string[]
|
|
190
|
+
): Promise<Map<string, { buffer: ArrayBuffer; extension: SupportedImageExt }>> {
|
|
191
|
+
const settled = await Promise.allSettled(
|
|
192
|
+
urls.map(async (url) => {
|
|
193
|
+
let bytes: Uint8Array;
|
|
194
|
+
if (url.startsWith('data:')) {
|
|
195
|
+
// ── data: URI — decode base64 directly, no network needed ──────────
|
|
196
|
+
const b64 = url.slice(url.indexOf(',') + 1);
|
|
197
|
+
const binary = atob(b64);
|
|
198
|
+
bytes = new Uint8Array(binary.length);
|
|
199
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
200
|
+
} else {
|
|
201
|
+
// ── Remote URL — fetch with CORS ──────────────────────────────────
|
|
202
|
+
const res = await fetch(url, { mode: 'cors' });
|
|
203
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
204
|
+
bytes = new Uint8Array(await res.arrayBuffer());
|
|
205
|
+
}
|
|
206
|
+
const ext = detectImageExtension(bytes);
|
|
207
|
+
if (!ext) throw new Error('unsupported image format (only PNG, JPEG and GIF can be embedded)');
|
|
208
|
+
return { url, buffer: bytes.slice().buffer, ext };
|
|
209
|
+
})
|
|
210
|
+
);
|
|
211
|
+
const map = new Map<string, { buffer: ArrayBuffer; extension: SupportedImageExt }>();
|
|
212
|
+
settled.forEach((result, i) => {
|
|
213
|
+
if (result.status === 'fulfilled') {
|
|
214
|
+
map.set(result.value.url, { buffer: result.value.buffer, extension: result.value.ext });
|
|
215
|
+
} else {
|
|
216
|
+
console.warn(
|
|
217
|
+
`[exportToExcelAdvanced] Cannot embed image "${urls[i]}" ` +
|
|
218
|
+
`(CORS/network error or unsupported format — raw URL written as fallback):`,
|
|
219
|
+
result.reason
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
return map;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Convert '#rrggbb' or '#rgb' to ExcelJS ARGB 'FFrrggbb' */
|
|
227
|
+
export function argb(hex: string): string {
|
|
228
|
+
const clean = hex.replace('#', '');
|
|
229
|
+
if (clean.length === 3) {
|
|
230
|
+
return 'FF' + clean.split('').map(c => c + c).join('');
|
|
231
|
+
}
|
|
232
|
+
if (clean.length === 6) {
|
|
233
|
+
return 'FF' + clean;
|
|
234
|
+
}
|
|
235
|
+
return 'FF' + clean.padEnd(6, '0');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function bottomBorder(): Partial<import('exceljs').Borders> {
|
|
239
|
+
return { bottom: { style: 'medium', color: { argb: 'FFcbd5e1' } } };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function topBorder(): Partial<import('exceljs').Borders> {
|
|
243
|
+
return { top: { style: 'medium', color: { argb: 'FFcbd5e1' } } };
|
|
244
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
|
|
2
|
+
import type {
|
|
3
|
+
GridColDef,
|
|
4
|
+
GridRowId,
|
|
5
|
+
GridRowModel,
|
|
6
|
+
GridValidRowModel,
|
|
7
|
+
GridAggregationModel,
|
|
8
|
+
GridGroupedExportRow,
|
|
9
|
+
} from '../../types';
|
|
10
|
+
import { groupHeaderLabel } from './groupLabel';
|
|
11
|
+
import {
|
|
12
|
+
aggregationForExport,
|
|
13
|
+
getExportColumns,
|
|
14
|
+
hasSelection,
|
|
15
|
+
isNonTextValue,
|
|
16
|
+
neutralizeFormula,
|
|
17
|
+
rowsForExport,
|
|
18
|
+
shouldExportGrouped,
|
|
19
|
+
summaryLabelText,
|
|
20
|
+
} from './exportShared';
|
|
21
|
+
|
|
22
|
+
export interface CsvExportOptions<R extends GridValidRowModel = GridRowModel> {
|
|
23
|
+
fileName?: string;
|
|
24
|
+
includeHeaders?: boolean;
|
|
25
|
+
delimiter?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Export only these row IDs. A non-empty selection takes precedence over `groupedRows` (the
|
|
28
|
+
* selected rows are exported flat), and the totals row is recomputed over the selected rows.
|
|
29
|
+
*/
|
|
30
|
+
selectedRows?: (string | number)[];
|
|
31
|
+
/**
|
|
32
|
+
* The grid's `getRowId`, when it has one: `selectedRows` holds those ids, and the grid does not
|
|
33
|
+
* copy them onto `row.id` (v3.0+). Defaults to `row.id`.
|
|
34
|
+
*/
|
|
35
|
+
getRowId?: (row: R) => GridRowId;
|
|
36
|
+
aggregationResult?: Record<string, unknown> | null;
|
|
37
|
+
aggregationModel?: GridAggregationModel | null;
|
|
38
|
+
/** When provided, emits group headers, leaf rows, subtotals, and a grand total instead of a flat row list. */
|
|
39
|
+
groupedRows?: GridGroupedExportRow[];
|
|
40
|
+
/**
|
|
41
|
+
* Prefix text cells that start with `=`, `+`, `-`, `@`, tab or carriage return with `'`, so
|
|
42
|
+
* spreadsheet apps show them as text instead of running them as formulas (CSV injection).
|
|
43
|
+
* Plain signed numbers are left alone. Default: `true`
|
|
44
|
+
* @since v3.0
|
|
45
|
+
*/
|
|
46
|
+
escapeFormulas?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Start the file with a UTF-8 byte-order mark, so Excel reads non-ASCII text (é, ü, ₹, 中文)
|
|
49
|
+
* correctly. Default: `true`
|
|
50
|
+
* @since v3.0
|
|
51
|
+
*/
|
|
52
|
+
bom?: boolean;
|
|
53
|
+
}
|
|
54
|
+
import { aggregateTexts, downloadFile, exportCell } from './exportRows';
|
|
55
|
+
|
|
56
|
+
export interface CsvExportOptions<R extends GridValidRowModel = GridRowModel> {
|
|
57
|
+
fileName?: string;
|
|
58
|
+
includeHeaders?: boolean;
|
|
59
|
+
delimiter?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Export only these row IDs. A non-empty selection takes precedence over `groupedRows` (the
|
|
62
|
+
* selected rows are exported flat), and the totals row is recomputed over the selected rows.
|
|
63
|
+
*/
|
|
64
|
+
selectedRows?: (string | number)[];
|
|
65
|
+
/**
|
|
66
|
+
* The grid's `getRowId`, when it has one: `selectedRows` holds those ids, and the grid does not
|
|
67
|
+
* copy them onto `row.id` (v3.0+). Defaults to `row.id`.
|
|
68
|
+
*/
|
|
69
|
+
getRowId?: (row: R) => GridRowId;
|
|
70
|
+
aggregationResult?: Record<string, unknown> | null;
|
|
71
|
+
aggregationModel?: GridAggregationModel | null;
|
|
72
|
+
/** When provided, emits group headers, leaf rows, subtotals, and a grand total instead of a flat row list. */
|
|
73
|
+
groupedRows?: GridGroupedExportRow[];
|
|
74
|
+
/**
|
|
75
|
+
* Prefix text cells that start with `=`, `+`, `-`, `@`, tab or carriage return with `'`, so
|
|
76
|
+
* spreadsheet apps show them as text instead of running them as formulas (CSV injection).
|
|
77
|
+
* Plain signed numbers are left alone. Default: `true`
|
|
78
|
+
* @since v3.0
|
|
79
|
+
*/
|
|
80
|
+
escapeFormulas?: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Start the file with a UTF-8 byte-order mark, so Excel reads non-ASCII text (é, ü, ₹, 中文)
|
|
83
|
+
* correctly. Default: `true`
|
|
84
|
+
* @since v3.0
|
|
85
|
+
*/
|
|
86
|
+
bom?: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const UTF8_BOM = String.fromCharCode(0xfeff);
|
|
90
|
+
|
|
91
|
+
export function exportToCsv<R extends GridValidRowModel = GridRowModel>(rows: R[], columns: GridColDef<R>[], options?: CsvExportOptions<R>): void;
|
|
92
|
+
export function exportToCsv<R extends GridValidRowModel = GridRowModel>(rows: R[], columns: GridColDef[], options?: CsvExportOptions<R>): void;
|
|
93
|
+
export function exportToCsv<R extends GridRowModel>(
|
|
94
|
+
rows: R[],
|
|
95
|
+
columns: GridColDef<R>[],
|
|
96
|
+
options: CsvExportOptions<R> = {}
|
|
97
|
+
): void {
|
|
98
|
+
const {
|
|
99
|
+
fileName = 'export.csv',
|
|
100
|
+
includeHeaders = true,
|
|
101
|
+
delimiter = ',',
|
|
102
|
+
selectedRows,
|
|
103
|
+
groupedRows,
|
|
104
|
+
escapeFormulas = true,
|
|
105
|
+
bom = true,
|
|
106
|
+
} = options;
|
|
107
|
+
|
|
108
|
+
const exportColumns = getExportColumns(columns);
|
|
109
|
+
const aggModel = options.aggregationModel || {};
|
|
110
|
+
const field = (text: string, fromNonText = false): string =>
|
|
111
|
+
escapeCSV(escapeFormulas ? neutralizeFormula(text, fromNonText) : text, delimiter);
|
|
112
|
+
const headerLine = () => exportColumns.map(col => field(col.headerName || col.field)).join(delimiter) + '\n';
|
|
113
|
+
|
|
114
|
+
let csvContent = '';
|
|
115
|
+
|
|
116
|
+
if (shouldExportGrouped(groupedRows, selectedRows)) {
|
|
117
|
+
// Grouped export path
|
|
118
|
+
if (includeHeaders) csvContent += headerLine();
|
|
119
|
+
groupedRows.forEach(entry => {
|
|
120
|
+
const indent = ' '.repeat(entry.depth);
|
|
121
|
+
if (entry.type === 'group-header') {
|
|
122
|
+
const labelText = groupHeaderLabel(entry, columns);
|
|
123
|
+
const label = escapeCSV(indent + (escapeFormulas ? neutralizeFormula(labelText) : labelText), delimiter);
|
|
124
|
+
csvContent += exportColumns.map((_, i) => (i === 0 ? label : '')).join(delimiter) + '\n';
|
|
125
|
+
} else if (entry.type === 'leaf' && entry.row) {
|
|
126
|
+
const row = entry.row as R;
|
|
127
|
+
const cells = exportColumns.map((col, i) => {
|
|
128
|
+
const cell = exportCell(row, col);
|
|
129
|
+
const text = escapeFormulas ? neutralizeFormula(cell.text, cell.fromNonText) : cell.text;
|
|
130
|
+
return escapeCSV((i === 0 ? indent : '') + text, delimiter);
|
|
131
|
+
});
|
|
132
|
+
csvContent += cells.join(delimiter) + '\n';
|
|
133
|
+
} else if ((entry.type === 'group-subtotal' || entry.type === 'grand-total') && entry.aggregatedValues) {
|
|
134
|
+
const values = entry.aggregatedValues;
|
|
135
|
+
const label = entry.type === 'grand-total' ? 'Grand Total' : `${indent}Subtotal`;
|
|
136
|
+
const texts = aggregateTexts(exportColumns, values, aggModel);
|
|
137
|
+
const cells = exportColumns.map((col, i) => (i === 0
|
|
138
|
+
? escapeCSV(summaryLabelText(label, texts[0]), delimiter)
|
|
139
|
+
: field(texts[i], isNonTextValue(values[col.field]))));
|
|
140
|
+
csvContent += cells.join(delimiter) + '\n';
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
} else {
|
|
144
|
+
const rowsToExport = rowsForExport(rows, selectedRows, options.getRowId);
|
|
145
|
+
|
|
146
|
+
if (rowsToExport.length === 0) {
|
|
147
|
+
console.warn('No rows to export');
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (includeHeaders) csvContent += headerLine();
|
|
152
|
+
|
|
153
|
+
rowsToExport.forEach(row => {
|
|
154
|
+
const values = exportColumns.map(col => {
|
|
155
|
+
const cell = exportCell(row, col);
|
|
156
|
+
return field(cell.text, cell.fromNonText);
|
|
157
|
+
});
|
|
158
|
+
csvContent += values.join(delimiter) + '\n';
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const aggResult = aggregationForExport(rowsToExport, hasSelection(selectedRows), columns, options.aggregationResult, options.aggregationModel);
|
|
162
|
+
if (aggResult) {
|
|
163
|
+
const aggLabels = exportColumns.map(col => field(aggModel[col.field] ? aggModel[col.field].toUpperCase() : ''));
|
|
164
|
+
csvContent += aggLabels.join(delimiter) + '\n';
|
|
165
|
+
const texts = aggregateTexts(exportColumns, aggResult, aggModel);
|
|
166
|
+
csvContent += exportColumns.map((col, i) => field(texts[i], isNonTextValue(aggResult[col.field]))).join(delimiter) + '\n';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
downloadFile((bom ? UTF8_BOM : '') + csvContent, fileName, 'text/csv;charset=utf-8;');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Quote a CSV field when it contains the delimiter, a quote, or a line break (LF or a bare CR). */
|
|
174
|
+
function escapeCSV(value: string, delimiter: string): string {
|
|
175
|
+
if ((delimiter !== '' && value.includes(delimiter)) || value.includes('"') || value.includes('\n') || value.includes('\r')) {
|
|
176
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
177
|
+
}
|
|
178
|
+
return value;
|
|
179
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
|
|
2
|
+
import type {
|
|
3
|
+
GridColDef,
|
|
4
|
+
GridRowId,
|
|
5
|
+
GridRowModel,
|
|
6
|
+
GridValidRowModel,
|
|
7
|
+
GridAggregationModel,
|
|
8
|
+
GridGroupedExportRow,
|
|
9
|
+
} from '../../types';
|
|
10
|
+
import { groupHeaderLabel } from './groupLabel';
|
|
11
|
+
import {
|
|
12
|
+
aggregationForExport,
|
|
13
|
+
escapeHTML,
|
|
14
|
+
getExportColumns,
|
|
15
|
+
hasSelection,
|
|
16
|
+
isNonTextValue,
|
|
17
|
+
neutralizeFormula,
|
|
18
|
+
rowsForExport,
|
|
19
|
+
sanitizeSheetName,
|
|
20
|
+
shouldExportGrouped,
|
|
21
|
+
summaryLabelText,
|
|
22
|
+
} from './exportShared';
|
|
23
|
+
|
|
24
|
+
export interface CsvExportOptions<R extends GridValidRowModel = GridRowModel> {
|
|
25
|
+
fileName?: string;
|
|
26
|
+
includeHeaders?: boolean;
|
|
27
|
+
delimiter?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Export only these row IDs. A non-empty selection takes precedence over `groupedRows` (the
|
|
30
|
+
* selected rows are exported flat), and the totals row is recomputed over the selected rows.
|
|
31
|
+
*/
|
|
32
|
+
selectedRows?: (string | number)[];
|
|
33
|
+
/**
|
|
34
|
+
* The grid's `getRowId`, when it has one: `selectedRows` holds those ids, and the grid does not
|
|
35
|
+
* copy them onto `row.id` (v3.0+). Defaults to `row.id`.
|
|
36
|
+
*/
|
|
37
|
+
getRowId?: (row: R) => GridRowId;
|
|
38
|
+
aggregationResult?: Record<string, unknown> | null;
|
|
39
|
+
aggregationModel?: GridAggregationModel | null;
|
|
40
|
+
/** When provided, emits group headers, leaf rows, subtotals, and a grand total instead of a flat row list. */
|
|
41
|
+
groupedRows?: GridGroupedExportRow[];
|
|
42
|
+
/**
|
|
43
|
+
* Prefix text cells that start with `=`, `+`, `-`, `@`, tab or carriage return with `'`, so
|
|
44
|
+
* spreadsheet apps show them as text instead of running them as formulas (CSV injection).
|
|
45
|
+
* Plain signed numbers are left alone. Default: `true`
|
|
46
|
+
* @since v3.0
|
|
47
|
+
*/
|
|
48
|
+
escapeFormulas?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Start the file with a UTF-8 byte-order mark, so Excel reads non-ASCII text (é, ü, ₹, 中文)
|
|
51
|
+
* correctly. Default: `true`
|
|
52
|
+
* @since v3.0
|
|
53
|
+
*/
|
|
54
|
+
bom?: boolean;
|
|
55
|
+
}
|
|
56
|
+
import {
|
|
57
|
+
aggregateTexts,
|
|
58
|
+
downloadFile,
|
|
59
|
+
exportCell,
|
|
60
|
+
type ExportCell,
|
|
61
|
+
} from './exportRows';
|
|
62
|
+
|
|
63
|
+
export interface ExcelExportOptions<R extends GridValidRowModel = GridRowModel> {
|
|
64
|
+
/**
|
|
65
|
+
* Output filename. The file is an HTML table that Excel opens as a legacy `.xls` workbook, so a
|
|
66
|
+
* `.xlsx` extension is replaced with `.xls` (Excel refuses HTML content named `.xlsx`). Use
|
|
67
|
+
* `exportToExcelAdvanced` for a real `.xlsx`. Default: `'export.xls'`
|
|
68
|
+
*/
|
|
69
|
+
fileName?: string;
|
|
70
|
+
/** Sheet tab name. Characters Excel rejects are replaced and the name is cut to 31 characters. Default: `'Sheet1'` */
|
|
71
|
+
sheetName?: string;
|
|
72
|
+
includeHeaders?: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Export only these row IDs. A non-empty selection takes precedence over `groupedRows` (the
|
|
75
|
+
* selected rows are exported flat), and the totals rows are recomputed over the selected rows.
|
|
76
|
+
*/
|
|
77
|
+
selectedRows?: (string | number)[];
|
|
78
|
+
/**
|
|
79
|
+
* The grid's `getRowId`, when it has one: `selectedRows` holds those ids, and the grid does not
|
|
80
|
+
* copy them onto `row.id` (v3.0+). Defaults to `row.id`.
|
|
81
|
+
*/
|
|
82
|
+
getRowId?: (row: R) => GridRowId;
|
|
83
|
+
aggregationResult?: Record<string, unknown> | null;
|
|
84
|
+
aggregationModel?: GridAggregationModel | null;
|
|
85
|
+
/** When provided, emits group headers, leaf rows, subtotals, and a grand total instead of a flat row list. */
|
|
86
|
+
groupedRows?: GridGroupedExportRow[];
|
|
87
|
+
/**
|
|
88
|
+
* Prefix text cells that start with `=`, `+`, `-`, `@`, tab or carriage return with `'`, so
|
|
89
|
+
* Excel does not run them as formulas. Default: `true`
|
|
90
|
+
* @since v3.0
|
|
91
|
+
*/
|
|
92
|
+
escapeFormulas?: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Excel's text number format: the cell keeps its text as typed (leading zeros, long ids, "1-2"). */
|
|
96
|
+
const XLS_TEXT_CLASS = 'ogx-xls-text';
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Whether an HTML-Excel data cell should be marked as text. Number, date and boolean columns, and
|
|
100
|
+
* values that are numbers, dates or booleans, are left for Excel to type.
|
|
101
|
+
*/
|
|
102
|
+
function isXlsTextCell<R extends GridRowModel>(col: GridColDef<R>, raw: unknown): boolean {
|
|
103
|
+
return col.type !== 'number' && col.type !== 'date' && col.type !== 'boolean' && !isNonTextValue(raw);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function xlsFileName(fileName: string): string {
|
|
107
|
+
if (!/\.xlsx$/i.test(fileName)) return fileName;
|
|
108
|
+
const corrected = fileName.replace(/\.xlsx$/i, '.xls');
|
|
109
|
+
console.warn(
|
|
110
|
+
`[exportToExcel] "${fileName}" renamed to "${corrected}": exportToExcel writes an HTML table that Excel ` +
|
|
111
|
+
'refuses to open as .xlsx. Use exportToExcelAdvanced for a real .xlsx file.'
|
|
112
|
+
);
|
|
113
|
+
return corrected;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function exportToExcel<R extends GridValidRowModel = GridRowModel>(rows: R[], columns: GridColDef<R>[], options?: ExcelExportOptions<R>): void;
|
|
117
|
+
export function exportToExcel<R extends GridValidRowModel = GridRowModel>(rows: R[], columns: GridColDef[], options?: ExcelExportOptions<R>): void;
|
|
118
|
+
export function exportToExcel<R extends GridRowModel>(
|
|
119
|
+
rows: R[],
|
|
120
|
+
columns: GridColDef<R>[],
|
|
121
|
+
options: ExcelExportOptions<R> = {}
|
|
122
|
+
): void {
|
|
123
|
+
const {
|
|
124
|
+
fileName = 'export.xls',
|
|
125
|
+
sheetName = 'Sheet1',
|
|
126
|
+
includeHeaders = true,
|
|
127
|
+
selectedRows,
|
|
128
|
+
groupedRows,
|
|
129
|
+
escapeFormulas = true,
|
|
130
|
+
} = options;
|
|
131
|
+
|
|
132
|
+
const exportColumns = getExportColumns(columns);
|
|
133
|
+
const aggModel = options.aggregationModel || {};
|
|
134
|
+
const cellText = (text: string, fromNonText = false): string =>
|
|
135
|
+
escapeHTML(escapeFormulas ? neutralizeFormula(text, fromNonText) : text);
|
|
136
|
+
const textTd = (html: string): string => `<td class="${XLS_TEXT_CLASS}">${html}</td>`;
|
|
137
|
+
|
|
138
|
+
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">'
|
|
139
|
+
+ '<head><!--[if gte mso 9]><xml><x:ExcelWorkbook><x:ExcelWorksheets><x:ExcelWorksheet>'
|
|
140
|
+
+ `<x:Name>${escapeHTML(sanitizeSheetName(sheetName))}</x:Name>`
|
|
141
|
+
+ '<x:WorksheetOptions><x:DisplayGridlines/></x:WorksheetOptions></x:ExcelWorksheet></x:ExcelWorksheets></x:ExcelWorkbook></xml><![endif]-->'
|
|
142
|
+
+ `<meta charset="UTF-8"><style>.${XLS_TEXT_CLASS}{mso-number-format:"\\@";}</style></head><body><table border="1">`;
|
|
143
|
+
|
|
144
|
+
let html = header;
|
|
145
|
+
|
|
146
|
+
if (includeHeaders) {
|
|
147
|
+
html += '<thead><tr>';
|
|
148
|
+
exportColumns.forEach(col => { html += `<th class="${XLS_TEXT_CLASS}">${cellText(col.headerName || col.field)}</th>`; });
|
|
149
|
+
html += '</tr></thead>';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
html += '<tbody>';
|
|
153
|
+
|
|
154
|
+
const dataCell = (cell: ExportCell, col: GridColDef<R>, prefix = ''): string => {
|
|
155
|
+
const content = prefix + cellText(cell.text, cell.fromNonText);
|
|
156
|
+
return isXlsTextCell(col, cell.raw) ? textTd(content) : `<td>${content}</td>`;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
if (shouldExportGrouped(groupedRows, selectedRows)) {
|
|
160
|
+
groupedRows.forEach(entry => {
|
|
161
|
+
const indent = ' '.repeat(entry.depth * 2);
|
|
162
|
+
if (entry.type === 'group-header') {
|
|
163
|
+
html += `<tr style="font-weight:bold;background:#e8eaf6;">`;
|
|
164
|
+
html += `<td colspan="${exportColumns.length}" class="${XLS_TEXT_CLASS}">${escapeHTML(indent)}${cellText(groupHeaderLabel(entry, columns))}</td>`;
|
|
165
|
+
html += '</tr>';
|
|
166
|
+
} else if (entry.type === 'leaf' && entry.row) {
|
|
167
|
+
const row = entry.row as R;
|
|
168
|
+
html += '<tr>';
|
|
169
|
+
exportColumns.forEach((col, i) => { html += dataCell(exportCell(row, col), col, i === 0 ? escapeHTML(indent) : ''); });
|
|
170
|
+
html += '</tr>';
|
|
171
|
+
} else if ((entry.type === 'group-subtotal' || entry.type === 'grand-total') && entry.aggregatedValues) {
|
|
172
|
+
const values = entry.aggregatedValues;
|
|
173
|
+
const label = entry.type === 'grand-total' ? 'Grand Total' : `${indent}Subtotal`;
|
|
174
|
+
const texts = aggregateTexts(exportColumns, values, aggModel);
|
|
175
|
+
html += `<tr style="font-weight:bold;background:#f5f5f5;">`;
|
|
176
|
+
exportColumns.forEach((col, i) => {
|
|
177
|
+
if (i === 0) { html += textTd(escapeHTML(summaryLabelText(label, texts[0]))); return; }
|
|
178
|
+
html += texts[i] ? `<td style="text-align:right;">${cellText(texts[i], isNonTextValue(values[col.field]))}</td>` : '<td></td>';
|
|
179
|
+
});
|
|
180
|
+
html += '</tr>';
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
} else {
|
|
184
|
+
const rowsToExport = rowsForExport(rows, selectedRows, options.getRowId);
|
|
185
|
+
|
|
186
|
+
if (rowsToExport.length === 0) {
|
|
187
|
+
console.warn('No rows to export');
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
rowsToExport.forEach(row => {
|
|
192
|
+
html += '<tr>';
|
|
193
|
+
exportColumns.forEach(col => { html += dataCell(exportCell(row, col), col); });
|
|
194
|
+
html += '</tr>';
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const aggResult = aggregationForExport(rowsToExport, hasSelection(selectedRows), columns, options.aggregationResult, options.aggregationModel);
|
|
198
|
+
if (aggResult) {
|
|
199
|
+
html += '<tr style="font-size: 11px; color: #666; background-color: #f5f5f5;">';
|
|
200
|
+
exportColumns.forEach(col => {
|
|
201
|
+
html += aggModel[col.field]
|
|
202
|
+
? `<td style="text-align:right;font-weight:bold;">${escapeHTML(aggModel[col.field].toUpperCase())}</td>`
|
|
203
|
+
: '<td></td>';
|
|
204
|
+
});
|
|
205
|
+
html += '</tr><tr style="font-weight:bold;background-color:#f5f5f5;">';
|
|
206
|
+
const texts = aggregateTexts(exportColumns, aggResult, aggModel);
|
|
207
|
+
exportColumns.forEach((col, i) => {
|
|
208
|
+
html += texts[i] ? `<td style="text-align:right;">${cellText(texts[i], isNonTextValue(aggResult[col.field]))}</td>` : '<td></td>';
|
|
209
|
+
});
|
|
210
|
+
html += '</tr>';
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
html += '</tbody></table></body></html>';
|
|
215
|
+
downloadFile(html, xlsFileName(fileName), 'application/vnd.ms-excel');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ============================================================================
|
|
219
|
+
// JSON Export
|
|
220
|
+
// ============================================================================
|