@hexclave/dashboard-ui-components 1.0.29 → 1.0.32
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/dist/components/button.d.ts +2 -2
- package/dist/components/chart-legend.d.ts +1 -1
- package/dist/components/data-grid/data-grid-export-dialog.d.ts +23 -0
- package/dist/components/data-grid/data-grid-export-dialog.d.ts.map +1 -0
- package/dist/components/data-grid/data-grid-export-dialog.js +406 -0
- package/dist/components/data-grid/data-grid-export-dialog.js.map +1 -0
- package/dist/components/data-grid/data-grid.d.ts.map +1 -1
- package/dist/components/data-grid/data-grid.js +14 -14
- package/dist/components/data-grid/data-grid.js.map +1 -1
- package/dist/components/data-grid/index.d.ts +2 -2
- package/dist/components/data-grid/types.d.ts +31 -2
- package/dist/components/data-grid/types.d.ts.map +1 -1
- package/dist/dashboard-ui-components.global.js +904 -535
- package/dist/dashboard-ui-components.global.js.map +4 -4
- package/dist/esm/components/button.d.ts +2 -2
- package/dist/esm/components/chart-legend.d.ts +1 -1
- package/dist/esm/components/data-grid/data-grid-export-dialog.d.ts +23 -0
- package/dist/esm/components/data-grid/data-grid-export-dialog.d.ts.map +1 -0
- package/dist/esm/components/data-grid/data-grid-export-dialog.js +403 -0
- package/dist/esm/components/data-grid/data-grid-export-dialog.js.map +1 -0
- package/dist/esm/components/data-grid/data-grid.d.ts.map +1 -1
- package/dist/esm/components/data-grid/data-grid.js +16 -16
- package/dist/esm/components/data-grid/data-grid.js.map +1 -1
- package/dist/esm/components/data-grid/index.d.ts +2 -2
- package/dist/esm/components/data-grid/types.d.ts +31 -2
- package/dist/esm/components/data-grid/types.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/package.json +3 -3
- package/src/components/data-grid/data-grid-export-dialog.tsx +479 -0
- package/src/components/data-grid/data-grid.tsx +192 -193
- package/src/components/data-grid/index.ts +5 -0
- package/src/components/data-grid/types.ts +35 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { DownloadSimpleIcon } from "@phosphor-icons/react";
|
|
4
|
+
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
5
|
+
|
|
6
|
+
import { DesignButton } from "../button";
|
|
7
|
+
import { DesignDialog } from "../dialog";
|
|
8
|
+
import { formatGridDate, resolveColumnValue } from "./state";
|
|
9
|
+
import type {
|
|
10
|
+
DataGridColumnDef,
|
|
11
|
+
DataGridExportField,
|
|
12
|
+
DataGridExportFormat,
|
|
13
|
+
DataGridExportOptions,
|
|
14
|
+
DataGridExportScope,
|
|
15
|
+
} from "./types";
|
|
16
|
+
|
|
17
|
+
type ExportProgress = {
|
|
18
|
+
phase: "idle" | "fetching" | "generating" | "complete";
|
|
19
|
+
fetched: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type ExportCellValue = string | number | boolean | null | undefined;
|
|
23
|
+
type ExportTable = {
|
|
24
|
+
csvHeaders: string[];
|
|
25
|
+
jsonKeys: string[];
|
|
26
|
+
rows: ExportCellValue[][];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type DataGridExportDialogProps<TRow> = {
|
|
30
|
+
open: boolean;
|
|
31
|
+
onOpenChange: (open: boolean) => void;
|
|
32
|
+
rows: readonly TRow[];
|
|
33
|
+
columns: readonly DataGridColumnDef<TRow>[];
|
|
34
|
+
exportFilename: string;
|
|
35
|
+
exportOptions?: DataGridExportOptions<TRow>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const idleExportProgress: ExportProgress = {
|
|
39
|
+
phase: "idle",
|
|
40
|
+
fetched: 0,
|
|
41
|
+
};
|
|
42
|
+
const exportCompletionDisplayMs = 800;
|
|
43
|
+
|
|
44
|
+
export function DataGridExportDialog<TRow>({
|
|
45
|
+
open,
|
|
46
|
+
onOpenChange,
|
|
47
|
+
rows,
|
|
48
|
+
columns,
|
|
49
|
+
exportFilename,
|
|
50
|
+
exportOptions,
|
|
51
|
+
}: DataGridExportDialogProps<TRow>) {
|
|
52
|
+
const hasServerExport = exportOptions?.fetchRows != null;
|
|
53
|
+
const resolvedFields = useMemo(
|
|
54
|
+
() => exportOptions?.fields ?? buildColumnExportFields(columns),
|
|
55
|
+
[exportOptions?.fields, columns],
|
|
56
|
+
);
|
|
57
|
+
const [format, setFormat] = useState<DataGridExportFormat>("csv");
|
|
58
|
+
const [scope, setScope] = useState<DataGridExportScope>("all");
|
|
59
|
+
const [fields, setFields] = useState<readonly DataGridExportField<TRow>[]>(resolvedFields);
|
|
60
|
+
const [isExporting, setIsExporting] = useState(false);
|
|
61
|
+
const [progress, setProgress] = useState<ExportProgress>(idleExportProgress);
|
|
62
|
+
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!isExporting) {
|
|
66
|
+
setFields(resolvedFields);
|
|
67
|
+
}
|
|
68
|
+
}, [isExporting, resolvedFields]);
|
|
69
|
+
|
|
70
|
+
const entityName = exportOptions?.entityName ?? "row";
|
|
71
|
+
const entityNamePlural = exportOptions?.entityNamePlural ?? "rows";
|
|
72
|
+
const filenamePrefix = exportOptions?.filenamePrefix ?? exportFilename;
|
|
73
|
+
const title = exportOptions?.title ?? "Export data";
|
|
74
|
+
const description = exportOptions?.description ?? (
|
|
75
|
+
hasServerExport
|
|
76
|
+
? "Configure and download data from this table"
|
|
77
|
+
: "Configure and download the rows currently loaded in this table"
|
|
78
|
+
);
|
|
79
|
+
const allScopeLabel = exportOptions?.allScopeLabel ?? `Export all ${entityNamePlural} in the project`;
|
|
80
|
+
const filteredScopeLabel = exportOptions?.filteredScopeLabel ?? `Export only filtered/searched ${entityNamePlural}`;
|
|
81
|
+
const progressSubjectLabel = exportOptions?.progressSubjectLabel ?? entityNamePlural;
|
|
82
|
+
const progressTitle = progress.phase === "complete" ? "Export complete" : `Exporting ${progressSubjectLabel}`;
|
|
83
|
+
const fetchExportRows = exportOptions?.fetchRows;
|
|
84
|
+
|
|
85
|
+
const closeDialog = useCallback(() => {
|
|
86
|
+
onOpenChange(false);
|
|
87
|
+
setErrorMessage(null);
|
|
88
|
+
}, [onOpenChange]);
|
|
89
|
+
|
|
90
|
+
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
|
91
|
+
if (isExporting && !nextOpen) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (nextOpen) {
|
|
95
|
+
onOpenChange(true);
|
|
96
|
+
} else {
|
|
97
|
+
closeDialog();
|
|
98
|
+
}
|
|
99
|
+
}, [closeDialog, isExporting, onOpenChange]);
|
|
100
|
+
|
|
101
|
+
const toggleField = useCallback((key: string) => {
|
|
102
|
+
setFields((prev) =>
|
|
103
|
+
prev.map((field) =>
|
|
104
|
+
field.key === key ? { ...field, enabled: !field.enabled } : field
|
|
105
|
+
)
|
|
106
|
+
);
|
|
107
|
+
}, []);
|
|
108
|
+
|
|
109
|
+
const selectAllFields = useCallback(() => {
|
|
110
|
+
setFields((prev) => prev.map((field) => ({ ...field, enabled: true })));
|
|
111
|
+
}, []);
|
|
112
|
+
|
|
113
|
+
const deselectAllFields = useCallback(() => {
|
|
114
|
+
setFields((prev) => prev.map((field) => ({ ...field, enabled: false })));
|
|
115
|
+
}, []);
|
|
116
|
+
|
|
117
|
+
const fetchRows = useCallback(async () => {
|
|
118
|
+
if (fetchExportRows != null) {
|
|
119
|
+
return await fetchExportRows({
|
|
120
|
+
scope,
|
|
121
|
+
onProgress: (fetched) => setProgress({ phase: "fetching", fetched }),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
setProgress({ phase: "fetching", fetched: rows.length });
|
|
126
|
+
return rows;
|
|
127
|
+
}, [fetchExportRows, rows, scope]);
|
|
128
|
+
|
|
129
|
+
const handleExport = async () => {
|
|
130
|
+
const enabledFields = fields.filter((field) => field.enabled);
|
|
131
|
+
if (enabledFields.length === 0) {
|
|
132
|
+
setErrorMessage("Select at least one field to export.");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
setErrorMessage(null);
|
|
137
|
+
setIsExporting(true);
|
|
138
|
+
setProgress({ phase: "fetching", fetched: 0 });
|
|
139
|
+
try {
|
|
140
|
+
const exportRows = await fetchRows();
|
|
141
|
+
|
|
142
|
+
if (exportRows.length === 0) {
|
|
143
|
+
setErrorMessage(
|
|
144
|
+
exportOptions?.emptyExportDescription
|
|
145
|
+
?? `There are no ${entityNamePlural} to export.`,
|
|
146
|
+
);
|
|
147
|
+
setIsExporting(false);
|
|
148
|
+
setProgress(idleExportProgress);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
setProgress({ phase: "generating", fetched: exportRows.length });
|
|
153
|
+
const transformedData = buildExportTable(exportRows, enabledFields);
|
|
154
|
+
|
|
155
|
+
if (format === "csv") {
|
|
156
|
+
exportToCsv(transformedData, filenamePrefix);
|
|
157
|
+
} else {
|
|
158
|
+
exportToJson(transformedData, filenamePrefix);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
setProgress({ phase: "complete", fetched: exportRows.length });
|
|
162
|
+
await new Promise<void>((resolve) => setTimeout(resolve, exportCompletionDisplayMs));
|
|
163
|
+
closeDialog();
|
|
164
|
+
} catch {
|
|
165
|
+
setErrorMessage("Something went wrong while exporting. Please try again.");
|
|
166
|
+
} finally {
|
|
167
|
+
setIsExporting(false);
|
|
168
|
+
setProgress(idleExportProgress);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<DesignDialog
|
|
174
|
+
open={open}
|
|
175
|
+
onOpenChange={handleOpenChange}
|
|
176
|
+
title={isExporting ? progressTitle : title}
|
|
177
|
+
description={isExporting ? `Preparing export for ${progressSubjectLabel}.` : description}
|
|
178
|
+
size="2xl"
|
|
179
|
+
variant="plain"
|
|
180
|
+
headerClassName={isExporting ? "sr-only" : undefined}
|
|
181
|
+
hideTopCloseButton={isExporting}
|
|
182
|
+
>
|
|
183
|
+
{isExporting ? (
|
|
184
|
+
<ExportProgressContent
|
|
185
|
+
progress={progress}
|
|
186
|
+
format={format}
|
|
187
|
+
subjectLabel={progressSubjectLabel}
|
|
188
|
+
/>
|
|
189
|
+
) : (
|
|
190
|
+
<div className="space-y-6">
|
|
191
|
+
<div className="space-y-2">
|
|
192
|
+
<label className="text-sm font-medium" htmlFor={`${filenamePrefix}-export-format`}>
|
|
193
|
+
Export Format
|
|
194
|
+
</label>
|
|
195
|
+
<select
|
|
196
|
+
id={`${filenamePrefix}-export-format`}
|
|
197
|
+
value={format}
|
|
198
|
+
onChange={(event) => setFormat(event.currentTarget.value === "json" ? "json" : "csv")}
|
|
199
|
+
className="h-9 w-full rounded-md border border-input bg-white px-3 text-sm dark:bg-background"
|
|
200
|
+
>
|
|
201
|
+
<option value="csv">CSV (Comma-separated values)</option>
|
|
202
|
+
<option value="json">JSON (JavaScript Object Notation)</option>
|
|
203
|
+
</select>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
{hasServerExport ? (
|
|
207
|
+
<fieldset className="space-y-2">
|
|
208
|
+
<legend className="text-sm font-medium">Export Scope</legend>
|
|
209
|
+
<label className="flex cursor-pointer items-center gap-2 text-sm">
|
|
210
|
+
<input
|
|
211
|
+
type="radio"
|
|
212
|
+
name={`${filenamePrefix}-export-scope`}
|
|
213
|
+
value="all"
|
|
214
|
+
checked={scope === "all"}
|
|
215
|
+
onChange={() => setScope("all")}
|
|
216
|
+
/>
|
|
217
|
+
<span>{allScopeLabel}</span>
|
|
218
|
+
</label>
|
|
219
|
+
<label className="flex cursor-pointer items-center gap-2 text-sm">
|
|
220
|
+
<input
|
|
221
|
+
type="radio"
|
|
222
|
+
name={`${filenamePrefix}-export-scope`}
|
|
223
|
+
value="filtered"
|
|
224
|
+
checked={scope === "filtered"}
|
|
225
|
+
onChange={() => setScope("filtered")}
|
|
226
|
+
/>
|
|
227
|
+
<span>{filteredScopeLabel}</span>
|
|
228
|
+
</label>
|
|
229
|
+
</fieldset>
|
|
230
|
+
) : null}
|
|
231
|
+
|
|
232
|
+
<div className="space-y-3">
|
|
233
|
+
<div className="flex items-center justify-between gap-3">
|
|
234
|
+
<label className="text-sm font-medium">Fields to Export</label>
|
|
235
|
+
<div className="flex gap-2">
|
|
236
|
+
<DesignButton type="button" variant="ghost" size="sm" onClick={selectAllFields} className="h-7 text-xs">
|
|
237
|
+
Select All
|
|
238
|
+
</DesignButton>
|
|
239
|
+
<DesignButton type="button" variant="ghost" size="sm" onClick={deselectAllFields} className="h-7 text-xs">
|
|
240
|
+
Deselect All
|
|
241
|
+
</DesignButton>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
<div className="grid max-h-[300px] grid-cols-1 gap-3 overflow-y-auto rounded-lg border border-border p-4 sm:grid-cols-2">
|
|
245
|
+
{fields.map((field) => (
|
|
246
|
+
<label key={field.key} className="flex cursor-pointer items-center gap-2 text-sm font-normal">
|
|
247
|
+
<input
|
|
248
|
+
type="checkbox"
|
|
249
|
+
checked={field.enabled}
|
|
250
|
+
onChange={() => toggleField(field.key)}
|
|
251
|
+
/>
|
|
252
|
+
<span>{field.label}</span>
|
|
253
|
+
</label>
|
|
254
|
+
))}
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
{errorMessage != null ? (
|
|
259
|
+
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
260
|
+
<div className="font-medium">{exportOptions?.emptyExportTitle ?? "Export unavailable"}</div>
|
|
261
|
+
<div>{errorMessage}</div>
|
|
262
|
+
</div>
|
|
263
|
+
) : null}
|
|
264
|
+
|
|
265
|
+
<div className="flex justify-end gap-3 pt-2">
|
|
266
|
+
<DesignButton variant="outline" onClick={closeDialog}>
|
|
267
|
+
Cancel
|
|
268
|
+
</DesignButton>
|
|
269
|
+
<DesignButton onClick={handleExport}>
|
|
270
|
+
<DownloadSimpleIcon className="mr-2 h-4 w-4" />
|
|
271
|
+
Export {titleCase(entityNamePlural)}
|
|
272
|
+
</DesignButton>
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
)}
|
|
276
|
+
</DesignDialog>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function ExportProgressContent(props: {
|
|
281
|
+
progress: ExportProgress;
|
|
282
|
+
format: DataGridExportFormat;
|
|
283
|
+
subjectLabel: string;
|
|
284
|
+
}) {
|
|
285
|
+
const { progress, format, subjectLabel } = props;
|
|
286
|
+
const fileLabel = format.toUpperCase();
|
|
287
|
+
const isComplete = progress.phase === "complete";
|
|
288
|
+
const title = isComplete ? "Export complete" : `Exporting ${subjectLabel}`;
|
|
289
|
+
const description = isComplete
|
|
290
|
+
? `Your ${fileLabel} is ready and the download should begin automatically.`
|
|
291
|
+
: `Your ${fileLabel} is being prepared from matching ${subjectLabel}.`;
|
|
292
|
+
const statusLabel = progress.phase === "complete"
|
|
293
|
+
? "Download ready"
|
|
294
|
+
: progress.phase === "generating"
|
|
295
|
+
? `Preparing ${fileLabel}`
|
|
296
|
+
: `Fetching ${subjectLabel}`;
|
|
297
|
+
const countLabel = `${progress.fetched.toLocaleString()} ${isComplete ? "exported" : "fetched"}`;
|
|
298
|
+
|
|
299
|
+
return (
|
|
300
|
+
<div className="space-y-4">
|
|
301
|
+
<div className="space-y-1">
|
|
302
|
+
<h2 className="text-base font-semibold leading-snug">{title}</h2>
|
|
303
|
+
<p className="text-sm text-muted-foreground">{description}</p>
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
<div className="rounded-xl border border-border bg-muted/35 p-4">
|
|
307
|
+
<div className="mb-3 flex items-center justify-between gap-4 text-sm">
|
|
308
|
+
<span className="font-medium text-foreground">{statusLabel}</span>
|
|
309
|
+
<span className="shrink-0 tabular-nums text-muted-foreground">
|
|
310
|
+
{countLabel}
|
|
311
|
+
</span>
|
|
312
|
+
</div>
|
|
313
|
+
<div className="relative h-2 overflow-hidden rounded-full bg-foreground/10">
|
|
314
|
+
{isComplete ? (
|
|
315
|
+
<div className="h-full w-full rounded-full bg-emerald-500/80" />
|
|
316
|
+
) : (
|
|
317
|
+
<div className="data-grid-export-progress-shimmer absolute inset-y-0 left-0 w-2/5 rounded-full bg-gradient-to-r from-transparent via-foreground/65 to-transparent" />
|
|
318
|
+
)}
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
|
|
322
|
+
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
|
|
323
|
+
Do not reload this page until the export finishes. The download will start automatically.
|
|
324
|
+
</div>
|
|
325
|
+
|
|
326
|
+
<div className="flex justify-end gap-3 pt-2">
|
|
327
|
+
<DesignButton variant="outline" disabled>
|
|
328
|
+
Cancel
|
|
329
|
+
</DesignButton>
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function buildColumnExportFields<TRow>(
|
|
336
|
+
columns: readonly DataGridColumnDef<TRow>[],
|
|
337
|
+
): readonly DataGridExportField<TRow>[] {
|
|
338
|
+
const fields: DataGridExportField<TRow>[] = [];
|
|
339
|
+
|
|
340
|
+
for (const column of columns) {
|
|
341
|
+
const label = typeof column.header === "string" ? column.header.trim() : column.id;
|
|
342
|
+
if (label.length === 0) {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
fields.push({
|
|
347
|
+
key: column.id,
|
|
348
|
+
label,
|
|
349
|
+
enabled: true,
|
|
350
|
+
getValue: (row) => formatColumnExportValue(column, row),
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return fields;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function formatColumnExportValue<TRow>(
|
|
358
|
+
column: DataGridColumnDef<TRow>,
|
|
359
|
+
row: TRow,
|
|
360
|
+
): unknown {
|
|
361
|
+
const value = resolveColumnValue(column, row);
|
|
362
|
+
if (column.formatValue != null) {
|
|
363
|
+
return column.formatValue(value, row);
|
|
364
|
+
}
|
|
365
|
+
if (column.type === "date" || column.type === "dateTime") {
|
|
366
|
+
return formatGridDate(value, "absolute", {
|
|
367
|
+
parseValue: column.parseValue,
|
|
368
|
+
dateFormat: column.dateFormat,
|
|
369
|
+
}).display ?? "";
|
|
370
|
+
}
|
|
371
|
+
return value;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function buildExportTable<TRow>(
|
|
375
|
+
rows: readonly TRow[],
|
|
376
|
+
enabledFields: readonly DataGridExportField<TRow>[],
|
|
377
|
+
): ExportTable {
|
|
378
|
+
return {
|
|
379
|
+
csvHeaders: enabledFields.map((field) => field.label),
|
|
380
|
+
jsonKeys: buildJsonKeys(enabledFields),
|
|
381
|
+
rows: rows.map((row) => enabledFields.map((field) => toExportCellValue(field.getValue(row)))),
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function buildJsonKeys<TRow>(
|
|
386
|
+
fields: readonly DataGridExportField<TRow>[],
|
|
387
|
+
): string[] {
|
|
388
|
+
const labelCounts = new Map<string, number>();
|
|
389
|
+
for (const field of fields) {
|
|
390
|
+
labelCounts.set(field.label, (labelCounts.get(field.label) ?? 0) + 1);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const usedKeys = new Map<string, true>();
|
|
394
|
+
const keys: string[] = [];
|
|
395
|
+
for (const field of fields) {
|
|
396
|
+
const baseKey = labelCounts.get(field.label) === 1 ? field.label : `${field.label} (${field.key})`;
|
|
397
|
+
let key = baseKey;
|
|
398
|
+
let suffix = 2;
|
|
399
|
+
while (usedKeys.has(key)) {
|
|
400
|
+
key = `${baseKey} ${suffix}`;
|
|
401
|
+
suffix++;
|
|
402
|
+
}
|
|
403
|
+
usedKeys.set(key, true);
|
|
404
|
+
keys.push(key);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return keys;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function toExportCellValue(value: unknown): ExportCellValue {
|
|
411
|
+
if (value == null) {
|
|
412
|
+
return "";
|
|
413
|
+
}
|
|
414
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
415
|
+
return value;
|
|
416
|
+
}
|
|
417
|
+
if (typeof value === "bigint") {
|
|
418
|
+
return value.toString();
|
|
419
|
+
}
|
|
420
|
+
if (value instanceof Date) {
|
|
421
|
+
return value.toISOString();
|
|
422
|
+
}
|
|
423
|
+
if (typeof value === "object") {
|
|
424
|
+
return JSON.stringify(value);
|
|
425
|
+
}
|
|
426
|
+
return String(value);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function exportToCsv(data: ExportTable, filenamePrefix: string) {
|
|
430
|
+
const csvContent = "\uFEFF" + [
|
|
431
|
+
data.csvHeaders.map(escapeCsvCell).join(","),
|
|
432
|
+
...data.rows.map((row) => row.map(escapeCsvCell).join(",")),
|
|
433
|
+
].join("\n");
|
|
434
|
+
downloadFile(csvContent, `${buildExportFilename(filenamePrefix)}.csv`, "text/csv;charset=utf-8;");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function escapeCsvCell(value: ExportCellValue): string {
|
|
438
|
+
const rawText = String(value ?? "");
|
|
439
|
+
const text = typeof value === "string" && /^[=+\-@\t\r]/.test(rawText.trimStart()) ? `'${rawText}` : rawText;
|
|
440
|
+
if (text.includes(",") || text.includes('"') || text.includes("\n") || text.includes("\r")) {
|
|
441
|
+
return `"${text.replace(/"/g, '""')}"`;
|
|
442
|
+
}
|
|
443
|
+
return text;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function exportToJson(data: ExportTable, filenamePrefix: string) {
|
|
447
|
+
const rows = data.rows.map((row) => {
|
|
448
|
+
const jsonRow: Record<string, ExportCellValue> = {};
|
|
449
|
+
for (let i = 0; i < data.jsonKeys.length; i++) {
|
|
450
|
+
jsonRow[data.jsonKeys[i]] = row[i] ?? "";
|
|
451
|
+
}
|
|
452
|
+
return jsonRow;
|
|
453
|
+
});
|
|
454
|
+
const jsonString = JSON.stringify(rows, null, 2);
|
|
455
|
+
downloadFile(jsonString, `${buildExportFilename(filenamePrefix)}.json`, "application/json");
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function downloadFile(content: string, filename: string, type: string) {
|
|
459
|
+
const blob = new Blob([content], { type });
|
|
460
|
+
const url = URL.createObjectURL(blob);
|
|
461
|
+
const link = document.createElement("a");
|
|
462
|
+
link.href = url;
|
|
463
|
+
link.download = filename;
|
|
464
|
+
document.body.appendChild(link);
|
|
465
|
+
try {
|
|
466
|
+
link.click();
|
|
467
|
+
} finally {
|
|
468
|
+
link.remove();
|
|
469
|
+
URL.revokeObjectURL(url);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function buildExportFilename(prefix: string) {
|
|
474
|
+
return `${prefix}-${new Date().toISOString().split("T")[0]}`;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function titleCase(value: string) {
|
|
478
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
479
|
+
}
|