@ackplus/react-tanstack-data-table 1.1.11 → 1.1.13

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 (61) hide show
  1. package/README.md +143 -11
  2. package/dist/lib/components/droupdown/menu-dropdown.d.ts.map +1 -1
  3. package/dist/lib/components/droupdown/menu-dropdown.js +8 -1
  4. package/dist/lib/components/filters/filter-value-input.js +2 -2
  5. package/dist/lib/components/pagination/data-table-pagination.d.ts.map +1 -1
  6. package/dist/lib/components/pagination/data-table-pagination.js +10 -1
  7. package/dist/lib/components/toolbar/data-table-toolbar.d.ts.map +1 -1
  8. package/dist/lib/components/toolbar/data-table-toolbar.js +5 -2
  9. package/dist/lib/components/toolbar/table-export-control.d.ts.map +1 -1
  10. package/dist/lib/components/toolbar/table-export-control.js +46 -12
  11. package/dist/lib/components/toolbar/table-refresh-control.d.ts +15 -0
  12. package/dist/lib/components/toolbar/table-refresh-control.d.ts.map +1 -0
  13. package/dist/lib/components/toolbar/table-refresh-control.js +61 -0
  14. package/dist/lib/contexts/data-table-context.d.ts +7 -10
  15. package/dist/lib/contexts/data-table-context.d.ts.map +1 -1
  16. package/dist/lib/contexts/data-table-context.js +5 -1
  17. package/dist/lib/data-table.d.ts.map +1 -1
  18. package/dist/lib/data-table.js +1110 -946
  19. package/dist/lib/features/column-filter.feature.js +38 -21
  20. package/dist/lib/features/selection.feature.d.ts.map +1 -1
  21. package/dist/lib/features/selection.feature.js +11 -3
  22. package/dist/lib/types/column.types.d.ts +19 -0
  23. package/dist/lib/types/column.types.d.ts.map +1 -1
  24. package/dist/lib/types/data-table-api.d.ts +25 -18
  25. package/dist/lib/types/data-table-api.d.ts.map +1 -1
  26. package/dist/lib/types/data-table.types.d.ts +37 -10
  27. package/dist/lib/types/data-table.types.d.ts.map +1 -1
  28. package/dist/lib/types/export.types.d.ts +57 -13
  29. package/dist/lib/types/export.types.d.ts.map +1 -1
  30. package/dist/lib/types/slots.types.d.ts +12 -1
  31. package/dist/lib/types/slots.types.d.ts.map +1 -1
  32. package/dist/lib/types/table.types.d.ts +1 -3
  33. package/dist/lib/types/table.types.d.ts.map +1 -1
  34. package/dist/lib/utils/debounced-fetch.utils.d.ts +8 -4
  35. package/dist/lib/utils/debounced-fetch.utils.d.ts.map +1 -1
  36. package/dist/lib/utils/debounced-fetch.utils.js +63 -14
  37. package/dist/lib/utils/export-utils.d.ts +14 -4
  38. package/dist/lib/utils/export-utils.d.ts.map +1 -1
  39. package/dist/lib/utils/export-utils.js +362 -66
  40. package/dist/lib/utils/slot-helpers.d.ts +1 -1
  41. package/dist/lib/utils/slot-helpers.d.ts.map +1 -1
  42. package/package.json +4 -2
  43. package/src/lib/components/droupdown/menu-dropdown.tsx +9 -3
  44. package/src/lib/components/filters/filter-value-input.tsx +2 -2
  45. package/src/lib/components/pagination/data-table-pagination.tsx +14 -2
  46. package/src/lib/components/toolbar/data-table-toolbar.tsx +15 -1
  47. package/src/lib/components/toolbar/table-export-control.tsx +65 -9
  48. package/src/lib/components/toolbar/table-refresh-control.tsx +58 -0
  49. package/src/lib/contexts/data-table-context.tsx +16 -2
  50. package/src/lib/data-table.tsx +1282 -932
  51. package/src/lib/features/column-filter.feature.ts +40 -19
  52. package/src/lib/features/selection.feature.ts +11 -5
  53. package/src/lib/types/column.types.ts +20 -1
  54. package/src/lib/types/data-table-api.ts +37 -15
  55. package/src/lib/types/data-table.types.ts +59 -3
  56. package/src/lib/types/export.types.ts +79 -10
  57. package/src/lib/types/slots.types.ts +11 -1
  58. package/src/lib/types/table.types.ts +1 -3
  59. package/src/lib/utils/debounced-fetch.utils.ts +90 -18
  60. package/src/lib/utils/export-utils.ts +496 -69
  61. package/src/lib/utils/slot-helpers.tsx +1 -1
@@ -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: (filters?: any, selection?: SelectionState) => Promise<{ data: any[]; total: number }>;
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 = rowsToExport.map((row, index) => {
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
- // Get all visible cells for this row, excluding columns marked as hideInExport
58
- row.getVisibleCells().forEach(cell => {
59
- const columnDef = cell.column.columnDef
60
-
61
- // Skip columns marked as hideInExport
62
- if (columnDef.hideInExport === true) {
63
- return;
64
- }
65
-
66
- const header = typeof columnDef.header === 'string' ? columnDef.header : cell.column.id;
67
-
68
- // Use getValue() - it already handles all formatting
69
- rowData[header] = cell.getValue() || '';
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
- return rowData;
73
- });
286
+ exportData.push(rowData);
287
+ }
74
288
 
75
289
  // Export the data
76
- await exportToFile(exportData, format, filename);
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 { format, filename, fetchData, currentFilters, selection, onProgress, onComplete, onError } = options;
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 (!initialResponse || !initialResponse.data || !Array.isArray(initialResponse.data)) {
116
- throw new Error('Invalid data received from server');
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
- const totalRows = initialResponse.total || initialResponse.data.length;
120
- const CHUNK_SIZE = 1000; // Fetch 1000 rows per request
121
- const needsChunking = totalRows > CHUNK_SIZE;
405
+ if (!isServerExportDataResult(initialResponse)) {
406
+ throw new Error('Invalid data received from server');
407
+ }
122
408
 
123
- let allData: TData[] = [];
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
- if (needsChunking) {
126
- // Fetch data in chunks (no progress events during fetching)
127
- const totalPages = Math.ceil(totalRows / CHUNK_SIZE);
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
- for (let page = 1; page <= totalPages; page++) {
130
- // Fetch current chunk
131
- const chunkFilters = {
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
- const chunkResponse = await fetchData(chunkFilters, selection);
467
+ const chunkData = chunkResponse.data;
468
+ if (chunkData.length === 0) {
469
+ break;
470
+ }
140
471
 
141
- if (!chunkResponse || !chunkResponse.data || !Array.isArray(chunkResponse.data)) {
142
- throw new Error(`Failed to fetch chunk ${page}`);
143
- }
472
+ allData.push(...chunkData);
144
473
 
145
- allData = [...allData, ...chunkResponse.data];
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
- // Small delay to prevent overwhelming the server
148
- if (page < totalPages) {
149
- await new Promise(resolve => setTimeout(resolve, 100));
489
+ if (hasTotal) {
490
+ if (allData.length >= totalRows) {
491
+ break;
150
492
  }
493
+ } else if (chunkData.length < chunkSize) {
494
+ break;
151
495
  }
152
- } else {
153
- // Small dataset, use single request
154
- allData = initialResponse.data;
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 = typeof columnDef.header === 'string'
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 = (column.accessorFn(rowData, index) || '')?.toString() || '';
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
- await exportToFile(exportData, format, filename);
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 value = row[header] || '';
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 (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) {
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
  */