@ackplus/react-tanstack-data-table 1.1.12 → 1.1.15
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/README.md +143 -11
- package/dist/lib/components/droupdown/menu-dropdown.d.ts.map +1 -1
- package/dist/lib/components/droupdown/menu-dropdown.js +8 -1
- package/dist/lib/components/filters/filter-value-input.js +2 -2
- package/dist/lib/components/pagination/data-table-pagination.d.ts.map +1 -1
- package/dist/lib/components/pagination/data-table-pagination.js +10 -1
- package/dist/lib/components/toolbar/table-export-control.d.ts.map +1 -1
- package/dist/lib/components/toolbar/table-export-control.js +46 -12
- package/dist/lib/contexts/data-table-context.d.ts +7 -10
- package/dist/lib/contexts/data-table-context.d.ts.map +1 -1
- package/dist/lib/contexts/data-table-context.js +5 -1
- package/dist/lib/data-table.d.ts.map +1 -1
- package/dist/lib/data-table.js +517 -237
- package/dist/lib/features/column-filter.feature.js +38 -21
- package/dist/lib/features/selection.feature.d.ts.map +1 -1
- package/dist/lib/features/selection.feature.js +11 -3
- package/dist/lib/types/column.types.d.ts +19 -0
- package/dist/lib/types/column.types.d.ts.map +1 -1
- package/dist/lib/types/data-table-api.d.ts +24 -18
- package/dist/lib/types/data-table-api.d.ts.map +1 -1
- package/dist/lib/types/data-table.types.d.ts +37 -10
- package/dist/lib/types/data-table.types.d.ts.map +1 -1
- package/dist/lib/types/export.types.d.ts +57 -13
- package/dist/lib/types/export.types.d.ts.map +1 -1
- package/dist/lib/types/slots.types.d.ts +3 -1
- package/dist/lib/types/slots.types.d.ts.map +1 -1
- package/dist/lib/types/table.types.d.ts +1 -3
- package/dist/lib/types/table.types.d.ts.map +1 -1
- package/dist/lib/utils/debounced-fetch.utils.d.ts +8 -4
- package/dist/lib/utils/debounced-fetch.utils.d.ts.map +1 -1
- package/dist/lib/utils/debounced-fetch.utils.js +63 -14
- package/dist/lib/utils/export-utils.d.ts +14 -4
- package/dist/lib/utils/export-utils.d.ts.map +1 -1
- package/dist/lib/utils/export-utils.js +362 -66
- package/package.json +4 -2
- package/src/lib/components/droupdown/menu-dropdown.tsx +9 -3
- package/src/lib/components/filters/filter-value-input.tsx +2 -2
- package/src/lib/components/pagination/data-table-pagination.tsx +14 -2
- package/src/lib/components/toolbar/table-export-control.tsx +65 -9
- package/src/lib/contexts/data-table-context.tsx +16 -2
- package/src/lib/data-table.tsx +647 -231
- package/src/lib/features/column-filter.feature.ts +40 -19
- package/src/lib/features/selection.feature.ts +11 -5
- package/src/lib/types/column.types.ts +20 -1
- package/src/lib/types/data-table-api.ts +33 -15
- package/src/lib/types/data-table.types.ts +59 -3
- package/src/lib/types/export.types.ts +79 -10
- package/src/lib/types/slots.types.ts +3 -1
- package/src/lib/types/table.types.ts +1 -3
- package/src/lib/utils/debounced-fetch.utils.ts +90 -18
- package/src/lib/utils/export-utils.ts +496 -69
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Table } from '@tanstack/react-table';
|
|
2
2
|
import * as XLSX from 'xlsx';
|
|
3
3
|
import { SelectionState } from '../features';
|
|
4
|
+
import { ExportPhase, ServerExportResult } from '../types/export.types';
|
|
4
5
|
|
|
5
6
|
// Local types for the utility functions (keep simpler for actual implementation)
|
|
6
7
|
export interface ExportOptions {
|
|
@@ -9,13 +10,210 @@ export interface ExportOptions {
|
|
|
9
10
|
onProgress?: (progress: { processedRows?: number; totalRows?: number; percentage?: number }) => void;
|
|
10
11
|
onComplete?: (result: { success: boolean; filename: string; totalRows: number }) => void;
|
|
11
12
|
onError?: (error: { message: string; code: string }) => void;
|
|
13
|
+
onStateChange?: (state: {
|
|
14
|
+
phase: ExportPhase;
|
|
15
|
+
processedRows?: number;
|
|
16
|
+
totalRows?: number;
|
|
17
|
+
percentage?: number;
|
|
18
|
+
message?: string;
|
|
19
|
+
code?: string;
|
|
20
|
+
}) => void;
|
|
21
|
+
signal?: AbortSignal;
|
|
22
|
+
sanitizeCSV?: boolean;
|
|
12
23
|
}
|
|
13
24
|
|
|
14
25
|
|
|
15
26
|
export interface ServerExportOptions extends ExportOptions {
|
|
16
|
-
fetchData: (
|
|
27
|
+
fetchData: (
|
|
28
|
+
filters?: any,
|
|
29
|
+
selection?: SelectionState,
|
|
30
|
+
signal?: AbortSignal
|
|
31
|
+
) => Promise<ServerExportResult<any>>;
|
|
17
32
|
currentFilters?: any;
|
|
18
33
|
selection?: SelectionState;
|
|
34
|
+
chunkSize?: number;
|
|
35
|
+
strictTotalCheck?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const EXPORT_CANCELLED_CODE = 'CANCELLED';
|
|
39
|
+
const DEFAULT_CHUNK_SIZE = 1000;
|
|
40
|
+
const MAX_SERVER_EXPORT_PAGES = 10000;
|
|
41
|
+
|
|
42
|
+
function createCancelledExportError(): Error & { code: string } {
|
|
43
|
+
const error = new Error('Export cancelled') as Error & { code: string };
|
|
44
|
+
error.name = 'AbortError';
|
|
45
|
+
error.code = EXPORT_CANCELLED_CODE;
|
|
46
|
+
return error;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isCancelledError(error: unknown): boolean {
|
|
50
|
+
if (!(error instanceof Error)) return false;
|
|
51
|
+
return error.name === 'AbortError' || (error as any).code === EXPORT_CANCELLED_CODE;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function throwIfExportCancelled(signal?: AbortSignal): void {
|
|
55
|
+
if (signal?.aborted) {
|
|
56
|
+
throw createCancelledExportError();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function waitWithAbort(ms: number, signal?: AbortSignal): Promise<void> {
|
|
61
|
+
if (!signal) {
|
|
62
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const timer = setTimeout(() => {
|
|
67
|
+
signal.removeEventListener('abort', onAbort);
|
|
68
|
+
resolve();
|
|
69
|
+
}, ms);
|
|
70
|
+
|
|
71
|
+
const onAbort = () => {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
signal.removeEventListener('abort', onAbort);
|
|
74
|
+
reject(createCancelledExportError());
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function notifyState(
|
|
82
|
+
onStateChange: ExportOptions['onStateChange'],
|
|
83
|
+
state: {
|
|
84
|
+
phase: ExportPhase;
|
|
85
|
+
processedRows?: number;
|
|
86
|
+
totalRows?: number;
|
|
87
|
+
percentage?: number;
|
|
88
|
+
message?: string;
|
|
89
|
+
code?: string;
|
|
90
|
+
},
|
|
91
|
+
): void {
|
|
92
|
+
onStateChange?.(state);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isServerExportDataResult(result: unknown): result is { data: any[]; total: number } {
|
|
96
|
+
return (
|
|
97
|
+
!!result
|
|
98
|
+
&& typeof result === 'object'
|
|
99
|
+
&& 'data' in (result as any)
|
|
100
|
+
&& Array.isArray((result as any).data)
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isServerExportBlobResult(result: unknown): result is { blob: Blob; filename?: string; mimeType?: string; total?: number } {
|
|
105
|
+
return (
|
|
106
|
+
!!result
|
|
107
|
+
&& typeof result === 'object'
|
|
108
|
+
&& 'blob' in (result as any)
|
|
109
|
+
&& (result as any).blob instanceof Blob
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isServerExportFileUrlResult(result: unknown): result is { fileUrl: string; filename?: string; mimeType?: string; total?: number } {
|
|
114
|
+
return (
|
|
115
|
+
!!result
|
|
116
|
+
&& typeof result === 'object'
|
|
117
|
+
&& typeof (result as any).fileUrl === 'string'
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function resolveExportHeader(columnDef: any, columnId: string): string {
|
|
122
|
+
const defaultHeader = typeof columnDef?.header === 'string' ? columnDef.header : columnId;
|
|
123
|
+
|
|
124
|
+
if (columnDef?.exportHeader === undefined || columnDef?.exportHeader === null) {
|
|
125
|
+
return defaultHeader;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (typeof columnDef.exportHeader === 'function') {
|
|
129
|
+
return String(
|
|
130
|
+
columnDef.exportHeader({
|
|
131
|
+
columnId,
|
|
132
|
+
defaultHeader,
|
|
133
|
+
columnDef,
|
|
134
|
+
}) ?? defaultHeader
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return String(columnDef.exportHeader);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function applyExportValueTransform(
|
|
142
|
+
columnDef: any,
|
|
143
|
+
value: any,
|
|
144
|
+
row: any,
|
|
145
|
+
rowIndex: number,
|
|
146
|
+
columnId: string,
|
|
147
|
+
) {
|
|
148
|
+
if (typeof columnDef?.exportValue === 'function') {
|
|
149
|
+
return columnDef.exportValue({
|
|
150
|
+
value,
|
|
151
|
+
row,
|
|
152
|
+
rowIndex,
|
|
153
|
+
columnId,
|
|
154
|
+
columnDef,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return value;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function applyExportFormatTransform(
|
|
161
|
+
columnDef: any,
|
|
162
|
+
value: any,
|
|
163
|
+
row: any,
|
|
164
|
+
rowIndex: number,
|
|
165
|
+
columnId: string,
|
|
166
|
+
) {
|
|
167
|
+
const format = columnDef?.exportFormat;
|
|
168
|
+
if (!format || format === 'auto') {
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (typeof format === 'function') {
|
|
173
|
+
return format({
|
|
174
|
+
value,
|
|
175
|
+
row,
|
|
176
|
+
rowIndex,
|
|
177
|
+
columnId,
|
|
178
|
+
columnDef,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (value === null || value === undefined) {
|
|
183
|
+
return '';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
switch (format) {
|
|
187
|
+
case 'string':
|
|
188
|
+
return String(value);
|
|
189
|
+
case 'number':
|
|
190
|
+
return Number(value);
|
|
191
|
+
case 'boolean':
|
|
192
|
+
return Boolean(value);
|
|
193
|
+
case 'json':
|
|
194
|
+
return JSON.stringify(value);
|
|
195
|
+
case 'date':
|
|
196
|
+
if (value instanceof Date) return value.toISOString();
|
|
197
|
+
return String(value);
|
|
198
|
+
default:
|
|
199
|
+
return value;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function normalizeExportValue(value: any): any {
|
|
204
|
+
if (value === null || value === undefined) {
|
|
205
|
+
return '';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (value instanceof Date) {
|
|
209
|
+
return value.toISOString();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (typeof value === 'object') {
|
|
213
|
+
return JSON.stringify(value);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return value;
|
|
19
217
|
}
|
|
20
218
|
|
|
21
219
|
/**
|
|
@@ -28,9 +226,12 @@ export async function exportClientData<TData>(
|
|
|
28
226
|
table: Table<TData>,
|
|
29
227
|
options: ExportOptions,
|
|
30
228
|
): Promise<void> {
|
|
31
|
-
const { format, filename, onProgress, onComplete, onError } = options;
|
|
229
|
+
const { format, filename, onProgress, onComplete, onError, onStateChange, signal, sanitizeCSV = true } = options;
|
|
32
230
|
|
|
33
231
|
try {
|
|
232
|
+
throwIfExportCancelled(signal);
|
|
233
|
+
notifyState(onStateChange, { phase: 'starting' });
|
|
234
|
+
|
|
34
235
|
// Get selected rows if any are selected
|
|
35
236
|
// const selectedRowIds = Object.keys(table.getState().rowSelection).filter(
|
|
36
237
|
// key => table.getState().rowSelection[key]
|
|
@@ -44,45 +245,79 @@ export async function exportClientData<TData>(
|
|
|
44
245
|
const selectedRows = table.getSelectedRows ? table.getSelectedRows() : [];
|
|
45
246
|
const hasSelectedRows = selectedRows.length > 0;
|
|
46
247
|
const rowsToExport = hasSelectedRows ? selectedRows : table.getFilteredRowModel().rows;
|
|
248
|
+
|
|
47
249
|
// Prepare data for export - get all visible columns and their values, excluding hideInExport columns
|
|
48
|
-
const exportData
|
|
250
|
+
const exportData: Record<string, any>[] = [];
|
|
251
|
+
const visibleColumns = table.getVisibleLeafColumns().filter((col) => col.columnDef.hideInExport !== true);
|
|
252
|
+
|
|
253
|
+
for (let index = 0; index < rowsToExport.length; index++) {
|
|
254
|
+
throwIfExportCancelled(signal);
|
|
255
|
+
const row = rowsToExport[index];
|
|
256
|
+
|
|
49
257
|
onProgress?.({
|
|
50
258
|
processedRows: index + 1,
|
|
51
259
|
totalRows: rowsToExport.length,
|
|
52
260
|
percentage: Math.round(((index + 1) / rowsToExport.length) * 100),
|
|
53
261
|
});
|
|
262
|
+
notifyState(onStateChange, {
|
|
263
|
+
phase: 'processing',
|
|
264
|
+
processedRows: index + 1,
|
|
265
|
+
totalRows: rowsToExport.length,
|
|
266
|
+
percentage: Math.round(((index + 1) / rowsToExport.length) * 100),
|
|
267
|
+
});
|
|
54
268
|
|
|
55
269
|
const rowData: Record<string, any> = {};
|
|
56
270
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
rowData[header] =
|
|
70
|
-
}
|
|
271
|
+
for (const column of visibleColumns) {
|
|
272
|
+
const columnDef = column.columnDef;
|
|
273
|
+
const header = resolveExportHeader(columnDef, column.id);
|
|
274
|
+
const cell = row.getVisibleCells().find((visibleCell) => visibleCell.column.id === column.id);
|
|
275
|
+
const baseValue = cell ? cell.getValue() : (row as any)?.original?.[column.id];
|
|
276
|
+
const transformedValue = applyExportFormatTransform(
|
|
277
|
+
columnDef,
|
|
278
|
+
applyExportValueTransform(columnDef, baseValue, row.original, index, column.id),
|
|
279
|
+
row.original,
|
|
280
|
+
index,
|
|
281
|
+
column.id,
|
|
282
|
+
);
|
|
283
|
+
rowData[header] = normalizeExportValue(transformedValue);
|
|
284
|
+
}
|
|
71
285
|
|
|
72
|
-
|
|
73
|
-
}
|
|
286
|
+
exportData.push(rowData);
|
|
287
|
+
}
|
|
74
288
|
|
|
75
289
|
// Export the data
|
|
76
|
-
|
|
290
|
+
notifyState(onStateChange, {
|
|
291
|
+
phase: 'downloading',
|
|
292
|
+
processedRows: exportData.length,
|
|
293
|
+
totalRows: exportData.length,
|
|
294
|
+
percentage: 100,
|
|
295
|
+
});
|
|
296
|
+
await exportToFile(exportData, format, filename, signal, sanitizeCSV);
|
|
77
297
|
|
|
78
298
|
onComplete?.({
|
|
79
299
|
success: true,
|
|
80
300
|
filename: `${filename}.${format === 'excel' ? 'xlsx' : 'csv'}`,
|
|
81
301
|
totalRows: exportData.length,
|
|
82
302
|
});
|
|
303
|
+
notifyState(onStateChange, {
|
|
304
|
+
phase: 'completed',
|
|
305
|
+
processedRows: exportData.length,
|
|
306
|
+
totalRows: exportData.length,
|
|
307
|
+
percentage: 100,
|
|
308
|
+
});
|
|
83
309
|
|
|
84
310
|
} catch (error) {
|
|
311
|
+
if (isCancelledError(error)) {
|
|
312
|
+
notifyState(onStateChange, { phase: 'cancelled', code: EXPORT_CANCELLED_CODE });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
85
315
|
console.error('Client export failed:', error);
|
|
316
|
+
notifyState(onStateChange, {
|
|
317
|
+
phase: 'error',
|
|
318
|
+
message: error instanceof Error ? error.message : 'Export failed',
|
|
319
|
+
code: 'CLIENT_EXPORT_ERROR',
|
|
320
|
+
});
|
|
86
321
|
onError?.({
|
|
87
322
|
message: error instanceof Error ? error.message : 'Export failed',
|
|
88
323
|
code: 'CLIENT_EXPORT_ERROR',
|
|
@@ -100,60 +335,178 @@ export async function exportServerData<TData>(
|
|
|
100
335
|
table: Table<TData>,
|
|
101
336
|
options: ServerExportOptions,
|
|
102
337
|
): Promise<void> {
|
|
103
|
-
const {
|
|
338
|
+
const {
|
|
339
|
+
format,
|
|
340
|
+
filename,
|
|
341
|
+
fetchData,
|
|
342
|
+
currentFilters,
|
|
343
|
+
selection,
|
|
344
|
+
onProgress,
|
|
345
|
+
onComplete,
|
|
346
|
+
onError,
|
|
347
|
+
onStateChange,
|
|
348
|
+
signal,
|
|
349
|
+
chunkSize = DEFAULT_CHUNK_SIZE,
|
|
350
|
+
strictTotalCheck = false,
|
|
351
|
+
sanitizeCSV = true,
|
|
352
|
+
} = options;
|
|
104
353
|
|
|
105
354
|
try {
|
|
355
|
+
throwIfExportCancelled(signal);
|
|
356
|
+
notifyState(onStateChange, { phase: 'starting' });
|
|
357
|
+
|
|
106
358
|
// Initial progress
|
|
107
359
|
onProgress?.({ });
|
|
360
|
+
notifyState(onStateChange, { phase: 'fetching' });
|
|
108
361
|
|
|
109
362
|
// First, get total count to determine if we need chunking
|
|
110
363
|
const initialResponse = await fetchData({
|
|
111
364
|
...currentFilters,
|
|
112
365
|
pagination: { pageIndex: 0, pageSize: 1 }
|
|
113
|
-
}, selection);
|
|
366
|
+
}, selection, signal);
|
|
367
|
+
|
|
368
|
+
if (isServerExportBlobResult(initialResponse)) {
|
|
369
|
+
throwIfExportCancelled(signal);
|
|
370
|
+
const resolvedName = initialResponse.filename || `${filename}.${format === 'excel' ? 'xlsx' : 'csv'}`;
|
|
371
|
+
notifyState(onStateChange, { phase: 'downloading' });
|
|
372
|
+
downloadFile(
|
|
373
|
+
initialResponse.blob,
|
|
374
|
+
resolvedName,
|
|
375
|
+
initialResponse.mimeType || initialResponse.blob.type || 'application/octet-stream'
|
|
376
|
+
);
|
|
377
|
+
onComplete?.({
|
|
378
|
+
success: true,
|
|
379
|
+
filename: resolvedName,
|
|
380
|
+
totalRows: initialResponse.total ?? 0,
|
|
381
|
+
});
|
|
382
|
+
notifyState(onStateChange, { phase: 'completed' });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
114
385
|
|
|
115
|
-
if (
|
|
116
|
-
|
|
386
|
+
if (isServerExportFileUrlResult(initialResponse)) {
|
|
387
|
+
throwIfExportCancelled(signal);
|
|
388
|
+
const resolvedName = initialResponse.filename || `${filename}.${format === 'excel' ? 'xlsx' : 'csv'}`;
|
|
389
|
+
notifyState(onStateChange, { phase: 'downloading' });
|
|
390
|
+
await downloadFromUrl(
|
|
391
|
+
initialResponse.fileUrl,
|
|
392
|
+
resolvedName,
|
|
393
|
+
initialResponse.mimeType || 'application/octet-stream',
|
|
394
|
+
signal
|
|
395
|
+
);
|
|
396
|
+
onComplete?.({
|
|
397
|
+
success: true,
|
|
398
|
+
filename: resolvedName,
|
|
399
|
+
totalRows: initialResponse.total ?? 0,
|
|
400
|
+
});
|
|
401
|
+
notifyState(onStateChange, { phase: 'completed' });
|
|
402
|
+
return;
|
|
117
403
|
}
|
|
118
404
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
405
|
+
if (!isServerExportDataResult(initialResponse)) {
|
|
406
|
+
throw new Error('Invalid data received from server');
|
|
407
|
+
}
|
|
122
408
|
|
|
123
|
-
|
|
409
|
+
const totalRows = typeof initialResponse.total === 'number' ? initialResponse.total : initialResponse.data.length;
|
|
410
|
+
let allData: any[] = [];
|
|
411
|
+
const hasTotal = typeof totalRows === 'number' && totalRows >= 0;
|
|
412
|
+
|
|
413
|
+
for (let page = 0; page < MAX_SERVER_EXPORT_PAGES; page++) {
|
|
414
|
+
throwIfExportCancelled(signal);
|
|
415
|
+
|
|
416
|
+
const chunkFilters = {
|
|
417
|
+
...currentFilters,
|
|
418
|
+
pagination: {
|
|
419
|
+
pageIndex: page,
|
|
420
|
+
pageSize: chunkSize,
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const chunkResponse = await fetchData(chunkFilters, selection, signal);
|
|
425
|
+
|
|
426
|
+
if (isServerExportBlobResult(chunkResponse)) {
|
|
427
|
+
throwIfExportCancelled(signal);
|
|
428
|
+
const resolvedName = chunkResponse.filename || `${filename}.${format === 'excel' ? 'xlsx' : 'csv'}`;
|
|
429
|
+
notifyState(onStateChange, { phase: 'downloading' });
|
|
430
|
+
downloadFile(
|
|
431
|
+
chunkResponse.blob,
|
|
432
|
+
resolvedName,
|
|
433
|
+
chunkResponse.mimeType || chunkResponse.blob.type || 'application/octet-stream'
|
|
434
|
+
);
|
|
435
|
+
onComplete?.({
|
|
436
|
+
success: true,
|
|
437
|
+
filename: resolvedName,
|
|
438
|
+
totalRows: chunkResponse.total ?? allData.length,
|
|
439
|
+
});
|
|
440
|
+
notifyState(onStateChange, { phase: 'completed' });
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
124
443
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
444
|
+
if (isServerExportFileUrlResult(chunkResponse)) {
|
|
445
|
+
throwIfExportCancelled(signal);
|
|
446
|
+
const resolvedName = chunkResponse.filename || `${filename}.${format === 'excel' ? 'xlsx' : 'csv'}`;
|
|
447
|
+
notifyState(onStateChange, { phase: 'downloading' });
|
|
448
|
+
await downloadFromUrl(
|
|
449
|
+
chunkResponse.fileUrl,
|
|
450
|
+
resolvedName,
|
|
451
|
+
chunkResponse.mimeType || 'application/octet-stream',
|
|
452
|
+
signal
|
|
453
|
+
);
|
|
454
|
+
onComplete?.({
|
|
455
|
+
success: true,
|
|
456
|
+
filename: resolvedName,
|
|
457
|
+
totalRows: chunkResponse.total ?? allData.length,
|
|
458
|
+
});
|
|
459
|
+
notifyState(onStateChange, { phase: 'completed' });
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
128
462
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
...currentFilters,
|
|
133
|
-
pagination: {
|
|
134
|
-
pageIndex: page - 1,
|
|
135
|
-
pageSize: CHUNK_SIZE,
|
|
136
|
-
},
|
|
137
|
-
};
|
|
463
|
+
if (!isServerExportDataResult(chunkResponse)) {
|
|
464
|
+
throw new Error(`Failed to fetch chunk ${page + 1}`);
|
|
465
|
+
}
|
|
138
466
|
|
|
139
|
-
|
|
467
|
+
const chunkData = chunkResponse.data;
|
|
468
|
+
if (chunkData.length === 0) {
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
140
471
|
|
|
141
|
-
|
|
142
|
-
throw new Error(`Failed to fetch chunk ${page}`);
|
|
143
|
-
}
|
|
472
|
+
allData.push(...chunkData);
|
|
144
473
|
|
|
145
|
-
|
|
474
|
+
const percentage = hasTotal && totalRows > 0
|
|
475
|
+
? Math.min(100, Math.round((allData.length / totalRows) * 100))
|
|
476
|
+
: undefined;
|
|
477
|
+
onProgress?.({
|
|
478
|
+
processedRows: allData.length,
|
|
479
|
+
totalRows: hasTotal ? totalRows : undefined,
|
|
480
|
+
percentage,
|
|
481
|
+
});
|
|
482
|
+
notifyState(onStateChange, {
|
|
483
|
+
phase: 'fetching',
|
|
484
|
+
processedRows: allData.length,
|
|
485
|
+
totalRows: hasTotal ? totalRows : undefined,
|
|
486
|
+
percentage,
|
|
487
|
+
});
|
|
146
488
|
|
|
147
|
-
|
|
148
|
-
if (
|
|
149
|
-
|
|
489
|
+
if (hasTotal) {
|
|
490
|
+
if (allData.length >= totalRows) {
|
|
491
|
+
break;
|
|
150
492
|
}
|
|
493
|
+
} else if (chunkData.length < chunkSize) {
|
|
494
|
+
break;
|
|
151
495
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
496
|
+
|
|
497
|
+
await waitWithAbort(100, signal);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (hasTotal && allData.length > totalRows) {
|
|
501
|
+
allData = allData.slice(0, totalRows);
|
|
155
502
|
}
|
|
156
503
|
|
|
504
|
+
if (hasTotal && strictTotalCheck && allData.length < totalRows) {
|
|
505
|
+
throw new Error(`Expected ${totalRows} rows for export but received ${allData.length}`);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
throwIfExportCancelled(signal);
|
|
509
|
+
|
|
157
510
|
// Get visible columns for proper headers and data processing, excluding hideInExport columns
|
|
158
511
|
const visibleColumns = table.getVisibleLeafColumns().filter(col => {
|
|
159
512
|
const columnDef = col.columnDef;
|
|
@@ -164,6 +517,7 @@ export async function exportServerData<TData>(
|
|
|
164
517
|
const exportData: Record<string, any>[] = [];
|
|
165
518
|
|
|
166
519
|
for (let index = 0; index < allData.length; index++) {
|
|
520
|
+
throwIfExportCancelled(signal);
|
|
167
521
|
const rowData = allData[index];
|
|
168
522
|
|
|
169
523
|
const exportRow: Record<string, any> = {};
|
|
@@ -171,43 +525,59 @@ export async function exportServerData<TData>(
|
|
|
171
525
|
visibleColumns.forEach(column => {
|
|
172
526
|
const columnId = column.id;
|
|
173
527
|
const columnDef = column.columnDef;
|
|
174
|
-
const header =
|
|
175
|
-
? columnDef.header
|
|
176
|
-
: columnId;
|
|
528
|
+
const header = resolveExportHeader(columnDef, columnId);
|
|
177
529
|
|
|
178
530
|
// Get value from raw data
|
|
179
531
|
let value = rowData[columnId];
|
|
180
532
|
|
|
181
533
|
// Apply accessorFn if defined
|
|
182
534
|
if (column.accessorFn && typeof column.accessorFn === 'function') {
|
|
183
|
-
value =
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Convert to string for export
|
|
187
|
-
if (value === null || value === undefined) {
|
|
188
|
-
value = '';
|
|
189
|
-
} else if (typeof value === 'object') {
|
|
190
|
-
value = JSON.stringify(value);
|
|
191
|
-
} else {
|
|
192
|
-
value = String(value);
|
|
535
|
+
value = column.accessorFn(rowData, index);
|
|
193
536
|
}
|
|
537
|
+
value = applyExportValueTransform(columnDef, value, rowData, index, columnId);
|
|
538
|
+
value = applyExportFormatTransform(columnDef, value, rowData, index, columnId);
|
|
539
|
+
value = normalizeExportValue(value);
|
|
194
540
|
|
|
195
541
|
exportRow[header] = value;
|
|
196
542
|
});
|
|
197
543
|
|
|
198
544
|
exportData.push(exportRow);
|
|
545
|
+
if (allData.length > 0) {
|
|
546
|
+
notifyState(onStateChange, {
|
|
547
|
+
phase: 'processing',
|
|
548
|
+
processedRows: index + 1,
|
|
549
|
+
totalRows: allData.length,
|
|
550
|
+
percentage: Math.round(((index + 1) / allData.length) * 100),
|
|
551
|
+
});
|
|
552
|
+
}
|
|
199
553
|
}
|
|
200
554
|
|
|
201
|
-
|
|
555
|
+
notifyState(onStateChange, { phase: 'downloading' });
|
|
556
|
+
await exportToFile(exportData, format, filename, signal, sanitizeCSV);
|
|
202
557
|
|
|
203
558
|
onComplete?.({
|
|
204
559
|
success: true,
|
|
205
560
|
filename: `${filename}.${format === 'excel' ? 'xlsx' : 'csv'}`,
|
|
206
561
|
totalRows: exportData.length,
|
|
207
562
|
});
|
|
563
|
+
notifyState(onStateChange, {
|
|
564
|
+
phase: 'completed',
|
|
565
|
+
processedRows: exportData.length,
|
|
566
|
+
totalRows: exportData.length,
|
|
567
|
+
percentage: 100,
|
|
568
|
+
});
|
|
208
569
|
|
|
209
570
|
} catch (error) {
|
|
571
|
+
if (isCancelledError(error)) {
|
|
572
|
+
notifyState(onStateChange, { phase: 'cancelled', code: EXPORT_CANCELLED_CODE });
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
210
575
|
console.error('Server export failed:', error);
|
|
576
|
+
notifyState(onStateChange, {
|
|
577
|
+
phase: 'error',
|
|
578
|
+
message: error instanceof Error ? error.message : 'Export failed',
|
|
579
|
+
code: 'SERVER_EXPORT_ERROR',
|
|
580
|
+
});
|
|
211
581
|
onError?.({
|
|
212
582
|
message: error instanceof Error ? error.message : 'Export failed',
|
|
213
583
|
code: 'SERVER_EXPORT_ERROR',
|
|
@@ -222,13 +592,18 @@ async function exportToFile(
|
|
|
222
592
|
data: Record<string, any>[],
|
|
223
593
|
format: 'csv' | 'excel',
|
|
224
594
|
filename: string,
|
|
595
|
+
signal?: AbortSignal,
|
|
596
|
+
sanitizeCSV: boolean = true,
|
|
225
597
|
): Promise<void> {
|
|
598
|
+
throwIfExportCancelled(signal);
|
|
599
|
+
|
|
226
600
|
if (data.length === 0) {
|
|
227
601
|
throw new Error('No data to export');
|
|
228
602
|
}
|
|
229
603
|
|
|
230
604
|
if (format === 'csv') {
|
|
231
|
-
const csv = convertToCSV(data);
|
|
605
|
+
const csv = convertToCSV(data, sanitizeCSV);
|
|
606
|
+
throwIfExportCancelled(signal);
|
|
232
607
|
downloadFile(csv, `${filename}.csv`, 'text/csv');
|
|
233
608
|
} else {
|
|
234
609
|
const workbook = XLSX.utils.book_new();
|
|
@@ -237,6 +612,7 @@ async function exportToFile(
|
|
|
237
612
|
|
|
238
613
|
const excelBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
|
|
239
614
|
const blob = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
|
615
|
+
throwIfExportCancelled(signal);
|
|
240
616
|
downloadFile(blob, `${filename}.xlsx`, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
|
241
617
|
}
|
|
242
618
|
}
|
|
@@ -244,7 +620,7 @@ async function exportToFile(
|
|
|
244
620
|
/**
|
|
245
621
|
* Convert data to CSV format
|
|
246
622
|
*/
|
|
247
|
-
function convertToCSV(data: Record<string, any>[]): string {
|
|
623
|
+
function convertToCSV(data: Record<string, any>[], sanitizeCSV: boolean): string {
|
|
248
624
|
if (data.length === 0) return '';
|
|
249
625
|
|
|
250
626
|
const headers = Object.keys(data[0]);
|
|
@@ -252,9 +628,13 @@ function convertToCSV(data: Record<string, any>[]): string {
|
|
|
252
628
|
|
|
253
629
|
for (const row of data) {
|
|
254
630
|
const values = headers.map(header => {
|
|
255
|
-
const
|
|
631
|
+
const rawValue = row[header] ?? '';
|
|
632
|
+
const normalizedValue = sanitizeCSV ? sanitizeCSVCellValue(rawValue) : rawValue;
|
|
633
|
+
const value = normalizedValue === null || normalizedValue === undefined
|
|
634
|
+
? ''
|
|
635
|
+
: String(normalizedValue);
|
|
256
636
|
// Escape quotes and wrap in quotes if contains comma or quote
|
|
257
|
-
if (
|
|
637
|
+
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
|
|
258
638
|
return `"${value.replace(/"/g, '""')}"`;
|
|
259
639
|
}
|
|
260
640
|
return value;
|
|
@@ -265,6 +645,53 @@ function convertToCSV(data: Record<string, any>[]): string {
|
|
|
265
645
|
return csvRows.join('\n');
|
|
266
646
|
}
|
|
267
647
|
|
|
648
|
+
function sanitizeCSVCellValue(value: any): any {
|
|
649
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
650
|
+
return value;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const firstCharacter = value[0];
|
|
654
|
+
if (firstCharacter === '=' || firstCharacter === '+' || firstCharacter === '-' || firstCharacter === '@') {
|
|
655
|
+
return `'${value}`;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return value;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async function downloadFromUrl(
|
|
662
|
+
url: string,
|
|
663
|
+
filename: string,
|
|
664
|
+
mimeType: string,
|
|
665
|
+
signal?: AbortSignal,
|
|
666
|
+
): Promise<void> {
|
|
667
|
+
throwIfExportCancelled(signal);
|
|
668
|
+
try {
|
|
669
|
+
const response = await fetch(url, { signal });
|
|
670
|
+
if (!response.ok) {
|
|
671
|
+
throw new Error(`Failed to download export file from URL (${response.status})`);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const blob = await response.blob();
|
|
675
|
+
throwIfExportCancelled(signal);
|
|
676
|
+
downloadFile(blob, filename, mimeType || blob.type || 'application/octet-stream');
|
|
677
|
+
return;
|
|
678
|
+
} catch (error) {
|
|
679
|
+
if (isCancelledError(error)) {
|
|
680
|
+
throw error;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Fallback for URLs that block fetch due to CORS - browser handles URL directly.
|
|
684
|
+
const link = document.createElement('a');
|
|
685
|
+
link.href = url;
|
|
686
|
+
link.download = filename;
|
|
687
|
+
link.style.display = 'none';
|
|
688
|
+
|
|
689
|
+
document.body.appendChild(link);
|
|
690
|
+
link.click();
|
|
691
|
+
document.body.removeChild(link);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
268
695
|
/**
|
|
269
696
|
* Download file to user's device
|
|
270
697
|
*/
|