@marimo-team/islands 0.25.1-dev44 → 0.25.1-dev45
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/{ConnectedDataExplorerComponent-CwMXfDQR.js → ConnectedDataExplorerComponent-C2d9ysUI.js} +5 -6
- package/dist/{ErrorBoundary-VUuyxCWc.js → ErrorBoundary-pBYdeBnS.js} +1 -1
- package/dist/{chat-ui-DLSF8a04.js → chat-ui-fVyVOhYN.js} +4 -4
- package/dist/{code-visibility-A4d1wosh.js → code-visibility-BtQ8kX30.js} +1 -1
- package/dist/{common-p1O4ctAw.js → common-CT8H5t1V.js} +6722 -6426
- package/dist/{data-grid-overlay-editor-CasvLZPZ.js → data-grid-overlay-editor-BLIYSMps.js} +1 -1
- package/dist/{dropdown-menu-CZgjYSAh.js → dropdown-menu-CZy06LAq.js} +12 -12
- package/dist/{feedback-button-DrJQ0USU.js → feedback-button-Oixw7nP7.js} +2 -2
- package/dist/{glide-data-editor-C55qOCCK.js → glide-data-editor-u2Z6Gofm.js} +5 -5
- package/dist/{globals-CUD2Xo__.js → globals-JfO03feH.js} +1 -1
- package/dist/{html-to-image-CGVgroef.js → html-to-image-CmkwfNkf.js} +1 -1
- package/dist/{useNumberFormatter-BfDpa21j.js → label-BslhXqL6.js} +108 -81
- package/dist/main.js +60 -54
- package/dist/{number-overlay-editor-CZb24BVz.js → number-overlay-editor-Da3rqdsN.js} +1 -1
- package/dist/{process-output-CGfg-JmZ.js → process-output-CYN5E-iO.js} +1 -1
- package/dist/{reveal-component-_Fc3Tdml.js → reveal-component-DvSplL6e.js} +3 -3
- package/dist/{spec-CAYwFeV6.js → spec-DQBRQSba.js} +123 -102
- package/dist/style.css +1 -1
- package/dist/{useDateFormatter-BRTPsUK7.js → useDateFormatter-BXQGu86d.js} +28 -49
- package/package.json +1 -1
- package/src/components/data-table/TableTopBar.tsx +2 -2
- package/src/components/data-table/__tests__/export-actions.test.tsx +662 -0
- package/src/components/data-table/data-table.tsx +1 -1
- package/src/components/data-table/export-actions.tsx +642 -176
- package/src/components/data-table/schemas.ts +25 -3
- package/dist/label-BJ49YTvk.js +0 -30
|
@@ -2,14 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
import { useAtomValue } from "jotai";
|
|
4
4
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
AlertCircleIcon,
|
|
6
|
+
ChevronRightIcon,
|
|
7
|
+
ClipboardCopyIcon,
|
|
7
8
|
DownloadIcon,
|
|
8
|
-
|
|
9
|
-
TableIcon,
|
|
9
|
+
InfoIcon,
|
|
10
10
|
} from "lucide-react";
|
|
11
11
|
import React from "react";
|
|
12
12
|
import { downloadSizeLimitAtom } from "./download-policy/atoms";
|
|
13
|
+
import type {
|
|
14
|
+
DownloadAsArgs,
|
|
15
|
+
DownloadAsOptions,
|
|
16
|
+
DownloadFormat,
|
|
17
|
+
} from "./schemas";
|
|
13
18
|
import { logNever } from "@/utils/assertNever";
|
|
14
19
|
import { cn } from "@/utils/cn";
|
|
15
20
|
import { copyToClipboard } from "@/utils/copy";
|
|
@@ -21,66 +26,81 @@ import {
|
|
|
21
26
|
jsonToMarkdown,
|
|
22
27
|
} from "@/utils/json/json-parser";
|
|
23
28
|
import { MissingPackagePrompt } from "../datasources/missing-package-prompt";
|
|
29
|
+
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
|
|
24
30
|
import { Button } from "../ui/button";
|
|
25
31
|
import {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
} from "../ui/
|
|
32
|
+
Dialog,
|
|
33
|
+
DialogContent,
|
|
34
|
+
DialogDescription,
|
|
35
|
+
DialogHeader,
|
|
36
|
+
DialogTitle,
|
|
37
|
+
DialogTrigger,
|
|
38
|
+
} from "../ui/dialog";
|
|
39
|
+
import { Label } from "../ui/label";
|
|
40
|
+
import { NativeSelect } from "../ui/native-select";
|
|
41
|
+
import { Switch } from "../ui/switch";
|
|
33
42
|
import { Tooltip } from "../ui/tooltip";
|
|
34
43
|
import { toast } from "../ui/use-toast";
|
|
35
44
|
|
|
36
|
-
const
|
|
37
|
-
|
|
45
|
+
const EXPORT_OPTIONS = [
|
|
46
|
+
{
|
|
38
47
|
label: "CSV",
|
|
39
48
|
format: "csv",
|
|
40
49
|
description: "Comma-separated values",
|
|
41
|
-
|
|
50
|
+
canDownload: true,
|
|
51
|
+
canCopy: true,
|
|
42
52
|
},
|
|
43
|
-
|
|
53
|
+
{
|
|
54
|
+
label: "TSV",
|
|
55
|
+
format: "tsv",
|
|
56
|
+
description: "Best for Excel and Google Sheets",
|
|
57
|
+
canDownload: true,
|
|
58
|
+
canCopy: true,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
44
61
|
label: "JSON",
|
|
45
62
|
format: "json",
|
|
46
63
|
description: "Raw JSON data",
|
|
47
|
-
|
|
64
|
+
canDownload: true,
|
|
65
|
+
canCopy: true,
|
|
48
66
|
},
|
|
49
|
-
|
|
67
|
+
{
|
|
50
68
|
label: "Parquet",
|
|
51
69
|
format: "parquet",
|
|
52
70
|
description: "Columnar binary format",
|
|
53
|
-
|
|
71
|
+
canDownload: true,
|
|
72
|
+
canCopy: false,
|
|
54
73
|
},
|
|
55
|
-
|
|
56
|
-
label: "TSV",
|
|
57
|
-
format: "tsv",
|
|
58
|
-
description: "Best for Excel and Google Sheets",
|
|
59
|
-
icon: TableIcon,
|
|
60
|
-
},
|
|
61
|
-
MARKDOWN: {
|
|
74
|
+
{
|
|
62
75
|
label: "Markdown",
|
|
63
76
|
format: "markdown",
|
|
64
77
|
description: "Preserves hyperlinks and formatting",
|
|
65
|
-
|
|
78
|
+
canDownload: false,
|
|
79
|
+
canCopy: true,
|
|
66
80
|
},
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
] as const;
|
|
82
|
+
|
|
83
|
+
type ExportFormat = (typeof EXPORT_OPTIONS)[number]["format"];
|
|
84
|
+
type CopyFormat = Exclude<ExportFormat, "parquet">;
|
|
85
|
+
type ExportAction =
|
|
86
|
+
| { destination: "download"; format: DownloadFormat }
|
|
87
|
+
| { destination: "copy"; format: CopyFormat };
|
|
88
|
+
|
|
89
|
+
type ExportFailure =
|
|
90
|
+
| {
|
|
91
|
+
kind: "error";
|
|
92
|
+
title: string;
|
|
93
|
+
description: string;
|
|
94
|
+
action: ExportAction;
|
|
95
|
+
}
|
|
96
|
+
| {
|
|
97
|
+
kind: "missing-packages";
|
|
98
|
+
title: string;
|
|
99
|
+
description?: string | null;
|
|
100
|
+
packages: string[];
|
|
101
|
+
featureName: string;
|
|
102
|
+
action: ExportAction;
|
|
103
|
+
};
|
|
84
104
|
|
|
85
105
|
// Each clipboard-copy format fetches from a backend download format, then
|
|
86
106
|
// transforms the payload client-side as needed.
|
|
@@ -91,13 +111,117 @@ const COPY_SOURCE_FORMAT: Record<CopyFormat, DownloadFormat> = {
|
|
|
91
111
|
markdown: "json",
|
|
92
112
|
};
|
|
93
113
|
|
|
114
|
+
const SEPARATORS = [
|
|
115
|
+
{ value: ",", label: "Comma" },
|
|
116
|
+
{ value: ";", label: "Semicolon" },
|
|
117
|
+
{ value: "|", label: "Pipe" },
|
|
118
|
+
] as const;
|
|
119
|
+
|
|
120
|
+
const ENCODINGS = [
|
|
121
|
+
{ value: "utf-8", label: "UTF-8" },
|
|
122
|
+
{ value: "utf-8-sig", label: "UTF-8 with BOM" },
|
|
123
|
+
{ value: "latin-1", label: "Latin-1" },
|
|
124
|
+
] as const;
|
|
125
|
+
|
|
126
|
+
type Separator = (typeof SEPARATORS)[number]["value"];
|
|
127
|
+
type Encoding = (typeof ENCODINGS)[number]["value"];
|
|
128
|
+
|
|
129
|
+
interface ExportSettings {
|
|
130
|
+
csv: { separator: Separator; encoding: Encoding };
|
|
131
|
+
tsv: { encoding: Encoding };
|
|
132
|
+
json: { ensureAscii: boolean };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const DEFAULT_SETTINGS: ExportSettings = {
|
|
136
|
+
csv: { separator: ",", encoding: "utf-8" },
|
|
137
|
+
tsv: { encoding: "utf-8" },
|
|
138
|
+
json: { ensureAscii: true },
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
type ConfigurableFormat = keyof ExportSettings;
|
|
142
|
+
|
|
143
|
+
const isConfigurable = (format: ExportFormat): format is ConfigurableFormat =>
|
|
144
|
+
format in DEFAULT_SETTINGS;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Build the request options for one action. Only settings that differ from
|
|
148
|
+
* the defaults are sent, so the backend keeps its own defaults otherwise.
|
|
149
|
+
*
|
|
150
|
+
* Clipboard copies never send an encoding. The browser decodes the fetched
|
|
151
|
+
* payload as UTF-8, so any other encoding would corrupt the copied text.
|
|
152
|
+
*/
|
|
153
|
+
function requestOptions(
|
|
154
|
+
settings: ExportSettings,
|
|
155
|
+
format: DownloadFormat,
|
|
156
|
+
destination: ExportAction["destination"],
|
|
157
|
+
): DownloadAsOptions | undefined {
|
|
158
|
+
const options: DownloadAsOptions = {};
|
|
159
|
+
switch (format) {
|
|
160
|
+
case "csv":
|
|
161
|
+
if (settings.csv.separator !== DEFAULT_SETTINGS.csv.separator) {
|
|
162
|
+
options.separator = settings.csv.separator;
|
|
163
|
+
}
|
|
164
|
+
if (
|
|
165
|
+
destination === "download" &&
|
|
166
|
+
settings.csv.encoding !== DEFAULT_SETTINGS.csv.encoding
|
|
167
|
+
) {
|
|
168
|
+
options.encoding = settings.csv.encoding;
|
|
169
|
+
}
|
|
170
|
+
break;
|
|
171
|
+
case "tsv":
|
|
172
|
+
if (
|
|
173
|
+
destination === "download" &&
|
|
174
|
+
settings.tsv.encoding !== DEFAULT_SETTINGS.tsv.encoding
|
|
175
|
+
) {
|
|
176
|
+
options.encoding = settings.tsv.encoding;
|
|
177
|
+
}
|
|
178
|
+
break;
|
|
179
|
+
case "json":
|
|
180
|
+
if (settings.json.ensureAscii !== DEFAULT_SETTINGS.json.ensureAscii) {
|
|
181
|
+
options.ensure_ascii = settings.json.ensureAscii;
|
|
182
|
+
}
|
|
183
|
+
break;
|
|
184
|
+
case "parquet":
|
|
185
|
+
break;
|
|
186
|
+
default:
|
|
187
|
+
logNever(format);
|
|
188
|
+
}
|
|
189
|
+
return Object.keys(options).length > 0 ? options : undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const labelForValue = (
|
|
193
|
+
items: ReadonlyArray<{ value: string; label: string }>,
|
|
194
|
+
value: string,
|
|
195
|
+
): string => items.find((item) => item.value === value)?.label ?? value;
|
|
196
|
+
|
|
197
|
+
/** Short summary of the current settings, shown next to the format label. */
|
|
198
|
+
function settingsSummary(
|
|
199
|
+
settings: ExportSettings,
|
|
200
|
+
format: ExportFormat,
|
|
201
|
+
): string | null {
|
|
202
|
+
switch (format) {
|
|
203
|
+
case "csv":
|
|
204
|
+
return [
|
|
205
|
+
labelForValue(SEPARATORS, settings.csv.separator),
|
|
206
|
+
labelForValue(ENCODINGS, settings.csv.encoding),
|
|
207
|
+
].join(" · ");
|
|
208
|
+
case "tsv":
|
|
209
|
+
return labelForValue(ENCODINGS, settings.tsv.encoding);
|
|
210
|
+
case "json":
|
|
211
|
+
return settings.json.ensureAscii
|
|
212
|
+
? "Escapes non-ASCII"
|
|
213
|
+
: "Keeps non-ASCII";
|
|
214
|
+
case "parquet":
|
|
215
|
+
case "markdown":
|
|
216
|
+
return null;
|
|
217
|
+
default:
|
|
218
|
+
logNever(format);
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
94
223
|
export interface ExportActionProps {
|
|
95
|
-
downloadAs:
|
|
96
|
-
url: string;
|
|
97
|
-
filename: string;
|
|
98
|
-
error?: string | null;
|
|
99
|
-
missing_packages?: string[] | null;
|
|
100
|
-
}>;
|
|
224
|
+
downloadAs: DownloadAsArgs;
|
|
101
225
|
// JSON-serialized size of the currently-rendered data. Used together with
|
|
102
226
|
// downloadSizeLimitAtom to disable the Export button when a host (e.g.,
|
|
103
227
|
// marimo-lsp inside VS Code) declares a download size cap. Null/undefined
|
|
@@ -106,13 +230,27 @@ export interface ExportActionProps {
|
|
|
106
230
|
sizeBytesIsLoading?: boolean;
|
|
107
231
|
}
|
|
108
232
|
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
233
|
+
const labelForFormat = (format: ExportFormat): string =>
|
|
234
|
+
EXPORT_OPTIONS.find((option) => option.format === format)?.label ?? format;
|
|
235
|
+
|
|
236
|
+
const failureTitleForAction = (action: ExportAction): string =>
|
|
237
|
+
action.destination === "download"
|
|
238
|
+
? "Failed to download"
|
|
239
|
+
: "Failed to copy to clipboard";
|
|
240
|
+
|
|
241
|
+
const failureDescription = (error: unknown): string =>
|
|
242
|
+
typeof error === "string" ? error : prettyError(error);
|
|
113
243
|
|
|
114
|
-
export const
|
|
115
|
-
const [
|
|
244
|
+
export const ExportActions: React.FC<ExportActionProps> = (props) => {
|
|
245
|
+
const [exportDialogOpen, setExportDialogOpen] = React.useState(false);
|
|
246
|
+
const [failure, setFailure] = React.useState<ExportFailure | null>(null);
|
|
247
|
+
const [settings, setSettings] =
|
|
248
|
+
React.useState<ExportSettings>(DEFAULT_SETTINGS);
|
|
249
|
+
const [expandedFormat, setExpandedFormat] =
|
|
250
|
+
React.useState<ConfigurableFormat | null>(null);
|
|
251
|
+
const triggerRef = React.useRef<HTMLButtonElement>(null);
|
|
252
|
+
const dialogRef = React.useRef<HTMLDivElement>(null);
|
|
253
|
+
const latestActionId = React.useRef(0);
|
|
116
254
|
const policy = useAtomValue(downloadSizeLimitAtom);
|
|
117
255
|
const overLimit = !!(
|
|
118
256
|
policy &&
|
|
@@ -128,13 +266,14 @@ export const ExportMenu: React.FC<ExportActionProps> = (props) => {
|
|
|
128
266
|
|
|
129
267
|
const button = (
|
|
130
268
|
<Button
|
|
269
|
+
ref={triggerRef}
|
|
131
270
|
data-testid="export-button"
|
|
132
271
|
size="xs"
|
|
133
272
|
variant="text"
|
|
134
273
|
disabled={disabled}
|
|
135
274
|
className={cn(
|
|
136
275
|
"print:hidden text-xs gap-1",
|
|
137
|
-
|
|
276
|
+
exportDialogOpen ? "text-primary" : "text-muted-foreground",
|
|
138
277
|
)}
|
|
139
278
|
>
|
|
140
279
|
<DownloadIcon className="w-3.5 h-3.5" />
|
|
@@ -142,38 +281,60 @@ export const ExportMenu: React.FC<ExportActionProps> = (props) => {
|
|
|
142
281
|
</Button>
|
|
143
282
|
);
|
|
144
283
|
|
|
284
|
+
const handleDialogOpenChange = (open: boolean) => {
|
|
285
|
+
setExportDialogOpen(open);
|
|
286
|
+
if (!open) {
|
|
287
|
+
latestActionId.current += 1;
|
|
288
|
+
setFailure(null);
|
|
289
|
+
setExpandedFormat(null);
|
|
290
|
+
setSettings(DEFAULT_SETTINGS);
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const beginAction = () => {
|
|
295
|
+
const actionId = latestActionId.current + 1;
|
|
296
|
+
latestActionId.current = actionId;
|
|
297
|
+
setFailure(null);
|
|
298
|
+
return actionId;
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const setActionFailure = (actionId: number, nextFailure: ExportFailure) => {
|
|
302
|
+
if (actionId === latestActionId.current) {
|
|
303
|
+
setFailure(nextFailure);
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
145
307
|
const resolveDownloadUrl = async (
|
|
146
308
|
format: DownloadFormat,
|
|
147
|
-
|
|
309
|
+
action: ExportAction,
|
|
310
|
+
actionId: number,
|
|
148
311
|
): Promise<{
|
|
149
312
|
url: string;
|
|
150
313
|
filename: string;
|
|
151
314
|
} | null> => {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
315
|
+
const options = requestOptions(settings, format, action.destination);
|
|
316
|
+
const response = await props.downloadAs(
|
|
317
|
+
options ? { format, options } : { format },
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
if (response.missing_packages && response.missing_packages.length > 0) {
|
|
321
|
+
setActionFailure(actionId, {
|
|
322
|
+
kind: "missing-packages",
|
|
323
|
+
title: "Export failed",
|
|
324
|
+
packages: response.missing_packages,
|
|
325
|
+
featureName: `${labelForFormat(action.format)} export`,
|
|
326
|
+
description: response.error,
|
|
327
|
+
action,
|
|
162
328
|
});
|
|
163
329
|
return null;
|
|
164
330
|
}
|
|
165
331
|
|
|
166
|
-
if (response.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
featureName={`${labelForDownloadFormat(format)} export`}
|
|
173
|
-
description={response.error}
|
|
174
|
-
onInstall={onRetry}
|
|
175
|
-
/>
|
|
176
|
-
),
|
|
332
|
+
if (response.error) {
|
|
333
|
+
setActionFailure(actionId, {
|
|
334
|
+
kind: "error",
|
|
335
|
+
title: failureTitleForAction(action),
|
|
336
|
+
description: response.error,
|
|
337
|
+
action,
|
|
177
338
|
});
|
|
178
339
|
return null;
|
|
179
340
|
}
|
|
@@ -185,49 +346,60 @@ export const ExportMenu: React.FC<ExportActionProps> = (props) => {
|
|
|
185
346
|
};
|
|
186
347
|
|
|
187
348
|
const handleDownload = async (format: DownloadFormat) => {
|
|
188
|
-
const
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
349
|
+
const action: ExportAction = { destination: "download", format };
|
|
350
|
+
const actionId = beginAction();
|
|
351
|
+
const label = labelForFormat(format);
|
|
352
|
+
try {
|
|
353
|
+
const ok = await withLoadingToast(
|
|
354
|
+
`Preparing ${label} export...`,
|
|
355
|
+
async () => {
|
|
356
|
+
const result = await resolveDownloadUrl(format, action, actionId);
|
|
357
|
+
if (!result) {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
const rawName = (result.filename ?? "").trim();
|
|
361
|
+
const baseName = Filenames.withoutExtension(rawName) || "download";
|
|
362
|
+
const downloadName = `${baseName}.${format}`;
|
|
363
|
+
// Append ?download=1 so the server returns Content-Disposition: attachment.
|
|
364
|
+
// This forces a save even when <a download> is ignored — e.g., inside
|
|
365
|
+
// sandboxed iframes that lack `allow-downloads`. Skip for data: URLs
|
|
366
|
+
// (used in pyodide/wasm) since query params would corrupt the payload.
|
|
367
|
+
let downloadUrl = result.url;
|
|
368
|
+
if (!downloadUrl.startsWith("data:")) {
|
|
369
|
+
const separator = downloadUrl.includes("?") ? "&" : "?";
|
|
370
|
+
const params = new URLSearchParams({
|
|
371
|
+
download: "1",
|
|
372
|
+
filename: downloadName,
|
|
373
|
+
});
|
|
374
|
+
downloadUrl = `${downloadUrl}${separator}${params.toString()}`;
|
|
375
|
+
}
|
|
376
|
+
downloadByURL(downloadUrl, downloadName);
|
|
377
|
+
return true;
|
|
378
|
+
},
|
|
379
|
+
);
|
|
380
|
+
if (ok) {
|
|
381
|
+
toast({ title: `${label} download started` });
|
|
382
|
+
}
|
|
383
|
+
} catch (error) {
|
|
384
|
+
setActionFailure(actionId, {
|
|
385
|
+
kind: "error",
|
|
386
|
+
title: failureTitleForAction(action),
|
|
387
|
+
description: failureDescription(error),
|
|
388
|
+
action,
|
|
389
|
+
});
|
|
220
390
|
}
|
|
221
391
|
};
|
|
222
392
|
|
|
223
|
-
const handleClipboardCopy = async (
|
|
393
|
+
const handleClipboardCopy = async (
|
|
394
|
+
format: CopyFormat,
|
|
395
|
+
action: ExportAction,
|
|
396
|
+
actionId: number,
|
|
397
|
+
) => {
|
|
224
398
|
await withLoadingToast(
|
|
225
|
-
`Preparing ${
|
|
399
|
+
`Preparing ${labelForFormat(format)} for clipboard...`,
|
|
226
400
|
async () => {
|
|
227
401
|
const sourceFormat = COPY_SOURCE_FORMAT[format];
|
|
228
|
-
const result = await resolveDownloadUrl(sourceFormat,
|
|
229
|
-
void handleClipboardCopy(format);
|
|
230
|
-
});
|
|
402
|
+
const result = await resolveDownloadUrl(sourceFormat, action, actionId);
|
|
231
403
|
if (!result) {
|
|
232
404
|
return;
|
|
233
405
|
}
|
|
@@ -257,79 +429,373 @@ export const ExportMenu: React.FC<ExportActionProps> = (props) => {
|
|
|
257
429
|
toast({
|
|
258
430
|
title: "Copied to clipboard",
|
|
259
431
|
});
|
|
432
|
+
handleDialogOpenChange(false);
|
|
260
433
|
},
|
|
261
434
|
);
|
|
262
435
|
};
|
|
263
436
|
|
|
437
|
+
const handleCopyAction = async (format: CopyFormat) => {
|
|
438
|
+
const action: ExportAction = { destination: "copy", format };
|
|
439
|
+
const actionId = beginAction();
|
|
440
|
+
try {
|
|
441
|
+
await handleClipboardCopy(format, action, actionId);
|
|
442
|
+
} catch (error) {
|
|
443
|
+
setActionFailure(actionId, {
|
|
444
|
+
kind: "error",
|
|
445
|
+
title: failureTitleForAction(action),
|
|
446
|
+
description: failureDescription(error),
|
|
447
|
+
action,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
const retryAction = (action: ExportAction) => {
|
|
453
|
+
if (action.destination === "download") {
|
|
454
|
+
void handleDownload(action.format);
|
|
455
|
+
} else {
|
|
456
|
+
void handleCopyAction(action.format);
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
const toggleExpanded = (format: ExportFormat) => {
|
|
461
|
+
if (!isConfigurable(format)) {
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
setExpandedFormat((current) => (current === format ? null : format));
|
|
465
|
+
};
|
|
466
|
+
|
|
264
467
|
return (
|
|
265
|
-
<
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
content={tooltipContent}
|
|
272
|
-
open={downloadMenuOpen ? false : undefined}
|
|
273
|
-
>
|
|
274
|
-
<DropdownMenuTrigger asChild={true} disabled={disabled}>
|
|
275
|
-
<span tabIndex={disabled ? 0 : -1} className="inline-flex">
|
|
468
|
+
<Dialog open={exportDialogOpen} onOpenChange={handleDialogOpenChange}>
|
|
469
|
+
<Tooltip content={tooltipContent}>
|
|
470
|
+
{disabled ? (
|
|
471
|
+
// Keep the host-limit reason reachable when the nested button is disabled.
|
|
472
|
+
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
|
473
|
+
<span tabIndex={0} className="inline-flex">
|
|
276
474
|
{button}
|
|
277
475
|
</span>
|
|
278
|
-
|
|
476
|
+
) : (
|
|
477
|
+
<DialogTrigger asChild={true}>{button}</DialogTrigger>
|
|
478
|
+
)}
|
|
279
479
|
</Tooltip>
|
|
280
|
-
<
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
</
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
480
|
+
<DialogContent
|
|
481
|
+
ref={dialogRef}
|
|
482
|
+
className="print:hidden gap-4 sm:max-w-[540px]"
|
|
483
|
+
onOpenAutoFocus={(event) => {
|
|
484
|
+
event.preventDefault();
|
|
485
|
+
dialogRef.current?.focus();
|
|
486
|
+
}}
|
|
487
|
+
onCloseAutoFocus={(event) => {
|
|
488
|
+
event.preventDefault();
|
|
489
|
+
triggerRef.current?.focus();
|
|
490
|
+
}}
|
|
491
|
+
onKeyDown={(event) => {
|
|
492
|
+
if (event.key === "Escape") {
|
|
493
|
+
handleDialogOpenChange(false);
|
|
494
|
+
}
|
|
495
|
+
}}
|
|
496
|
+
>
|
|
497
|
+
<DialogHeader>
|
|
498
|
+
<DialogTitle>Export table</DialogTitle>
|
|
499
|
+
<DialogDescription>
|
|
500
|
+
Download a file or copy to the clipboard.
|
|
501
|
+
</DialogDescription>
|
|
502
|
+
</DialogHeader>
|
|
503
|
+
<ul
|
|
504
|
+
aria-label="Export formats"
|
|
505
|
+
className="list-none overflow-hidden rounded-md border divide-y"
|
|
506
|
+
>
|
|
507
|
+
{EXPORT_OPTIONS.map((option) => {
|
|
508
|
+
const configurableFormat = isConfigurable(option.format)
|
|
509
|
+
? option.format
|
|
510
|
+
: null;
|
|
511
|
+
const expanded =
|
|
512
|
+
configurableFormat !== null &&
|
|
513
|
+
expandedFormat === configurableFormat;
|
|
514
|
+
const panelId = `export-options-${option.format}`;
|
|
515
|
+
const rowFailure =
|
|
516
|
+
failure?.action.format === option.format ? failure : null;
|
|
517
|
+
const summary = settingsSummary(settings, option.format);
|
|
518
|
+
return (
|
|
519
|
+
<li
|
|
520
|
+
key={option.format}
|
|
521
|
+
data-testid={`export-row-${option.format}`}
|
|
522
|
+
className={cn(expanded && "bg-muted/40")}
|
|
523
|
+
>
|
|
524
|
+
<div className="grid min-h-[60px] grid-cols-[28px_1fr_auto] items-center gap-x-1 px-2.5 py-2 hover:bg-accent/50">
|
|
525
|
+
{configurableFormat ? (
|
|
526
|
+
<Button
|
|
527
|
+
type="button"
|
|
528
|
+
variant="ghost"
|
|
529
|
+
size="icon"
|
|
530
|
+
aria-label={`${option.label} options`}
|
|
531
|
+
aria-expanded={expanded}
|
|
532
|
+
aria-controls={panelId}
|
|
533
|
+
onClick={() => toggleExpanded(option.format)}
|
|
534
|
+
>
|
|
535
|
+
<ChevronRightIcon
|
|
536
|
+
className={cn(
|
|
537
|
+
"h-4 w-4 text-muted-foreground transition-transform",
|
|
538
|
+
expanded && "rotate-90",
|
|
539
|
+
)}
|
|
540
|
+
/>
|
|
541
|
+
</Button>
|
|
542
|
+
) : (
|
|
543
|
+
<span aria-hidden={true} className="h-6 w-6" />
|
|
544
|
+
)}
|
|
545
|
+
<div className="grid gap-1 pl-1">
|
|
546
|
+
<span className="flex items-baseline gap-x-2">
|
|
547
|
+
<span className="text-sm font-medium leading-tight">
|
|
548
|
+
{option.label}
|
|
549
|
+
</span>
|
|
550
|
+
{summary && (
|
|
551
|
+
<span
|
|
552
|
+
data-testid={`export-summary-${option.format}`}
|
|
553
|
+
className="text-xs text-muted-foreground"
|
|
554
|
+
>
|
|
555
|
+
{summary}
|
|
556
|
+
</span>
|
|
557
|
+
)}
|
|
558
|
+
</span>
|
|
559
|
+
<span className="text-xs text-muted-foreground leading-tight">
|
|
560
|
+
{option.description}
|
|
561
|
+
</span>
|
|
562
|
+
</div>
|
|
563
|
+
<div className="flex items-center gap-0.5">
|
|
564
|
+
<Tooltip
|
|
565
|
+
content={
|
|
566
|
+
option.canDownload
|
|
567
|
+
? `Download ${option.label}`
|
|
568
|
+
: `${option.label} is available for copy only.`
|
|
569
|
+
}
|
|
570
|
+
>
|
|
571
|
+
<span className="inline-flex">
|
|
572
|
+
<Button
|
|
573
|
+
type="button"
|
|
574
|
+
variant="ghost"
|
|
575
|
+
size="icon"
|
|
576
|
+
aria-label={`Download ${option.label}`}
|
|
577
|
+
disabled={!option.canDownload}
|
|
578
|
+
onClick={
|
|
579
|
+
option.canDownload
|
|
580
|
+
? () => {
|
|
581
|
+
void handleDownload(option.format);
|
|
582
|
+
}
|
|
583
|
+
: undefined
|
|
584
|
+
}
|
|
585
|
+
>
|
|
586
|
+
<DownloadIcon className="h-4 w-4" />
|
|
587
|
+
</Button>
|
|
588
|
+
</span>
|
|
589
|
+
</Tooltip>
|
|
590
|
+
<Tooltip
|
|
591
|
+
content={
|
|
592
|
+
option.canCopy
|
|
593
|
+
? `Copy ${option.label}`
|
|
594
|
+
: `${option.label} is available for download only.`
|
|
595
|
+
}
|
|
596
|
+
>
|
|
597
|
+
<span className="inline-flex">
|
|
598
|
+
<Button
|
|
599
|
+
type="button"
|
|
600
|
+
variant="ghost"
|
|
601
|
+
size="icon"
|
|
602
|
+
aria-label={`Copy ${option.label}`}
|
|
603
|
+
disabled={!option.canCopy}
|
|
604
|
+
onClick={
|
|
605
|
+
option.canCopy
|
|
606
|
+
? () => {
|
|
607
|
+
void handleCopyAction(option.format);
|
|
608
|
+
}
|
|
609
|
+
: undefined
|
|
610
|
+
}
|
|
611
|
+
>
|
|
612
|
+
<ClipboardCopyIcon className="h-4 w-4" />
|
|
613
|
+
</Button>
|
|
614
|
+
</span>
|
|
615
|
+
</Tooltip>
|
|
616
|
+
</div>
|
|
617
|
+
</div>
|
|
618
|
+
{expanded && configurableFormat && (
|
|
619
|
+
<fieldset
|
|
620
|
+
id={panelId}
|
|
621
|
+
className="grid grid-cols-2 gap-3 border-t bg-muted/50 px-3.5 py-3.5 pl-[52px]"
|
|
622
|
+
>
|
|
623
|
+
<legend className="sr-only">{option.label} options</legend>
|
|
624
|
+
<ExportSettingsFields
|
|
625
|
+
format={configurableFormat}
|
|
626
|
+
settings={settings}
|
|
627
|
+
onChange={setSettings}
|
|
628
|
+
/>
|
|
629
|
+
</fieldset>
|
|
630
|
+
)}
|
|
631
|
+
{rowFailure && (
|
|
632
|
+
<div className="border-t px-3 py-2 pl-[42px]">
|
|
633
|
+
<Alert
|
|
634
|
+
variant="destructive"
|
|
635
|
+
className="p-3 has-[svg]:pl-9 [&>svg]:left-3 [&>svg]:top-3"
|
|
636
|
+
>
|
|
637
|
+
<AlertCircleIcon className="h-4 w-4" />
|
|
638
|
+
<div>
|
|
639
|
+
<AlertTitle className="text-sm">
|
|
640
|
+
{rowFailure.title}
|
|
641
|
+
</AlertTitle>
|
|
642
|
+
<AlertDescription className="text-xs">
|
|
643
|
+
{rowFailure.kind === "missing-packages" ? (
|
|
644
|
+
<MissingPackagePrompt
|
|
645
|
+
packages={rowFailure.packages}
|
|
646
|
+
featureName={rowFailure.featureName}
|
|
647
|
+
description={rowFailure.description}
|
|
648
|
+
onInstall={() => retryAction(rowFailure.action)}
|
|
649
|
+
className="items-start"
|
|
650
|
+
/>
|
|
651
|
+
) : (
|
|
652
|
+
rowFailure.description
|
|
653
|
+
)}
|
|
654
|
+
</AlertDescription>
|
|
655
|
+
</div>
|
|
656
|
+
</Alert>
|
|
657
|
+
</div>
|
|
658
|
+
)}
|
|
659
|
+
</li>
|
|
660
|
+
);
|
|
661
|
+
})}
|
|
662
|
+
</ul>
|
|
663
|
+
</DialogContent>
|
|
664
|
+
</Dialog>
|
|
665
|
+
);
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
interface ExportSettingsFieldsProps {
|
|
669
|
+
format: ConfigurableFormat;
|
|
670
|
+
settings: ExportSettings;
|
|
671
|
+
onChange: React.Dispatch<React.SetStateAction<ExportSettings>>;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const ExportSettingsFields: React.FC<ExportSettingsFieldsProps> = ({
|
|
675
|
+
format,
|
|
676
|
+
settings,
|
|
677
|
+
onChange,
|
|
678
|
+
}) => {
|
|
679
|
+
const id = React.useId();
|
|
680
|
+
|
|
681
|
+
const encodingField = (encoding: Encoding, key: "csv" | "tsv") => (
|
|
682
|
+
<SettingField
|
|
683
|
+
id={`${id}-encoding`}
|
|
684
|
+
label="Encoding"
|
|
685
|
+
help="Text encoding of the saved file. Pick UTF-8 with BOM when Excel shows garbled accents. Copies always use UTF-8."
|
|
686
|
+
>
|
|
687
|
+
<NativeSelect
|
|
688
|
+
id={`${id}-encoding`}
|
|
689
|
+
className="mb-0 w-full"
|
|
690
|
+
value={encoding}
|
|
691
|
+
onChange={(event) => {
|
|
692
|
+
const next = event.target.value as Encoding;
|
|
693
|
+
onChange((current) => ({
|
|
694
|
+
...current,
|
|
695
|
+
[key]: { ...current[key], encoding: next },
|
|
696
|
+
}));
|
|
697
|
+
}}
|
|
698
|
+
>
|
|
699
|
+
{ENCODINGS.map((item) => (
|
|
700
|
+
<option key={item.value} value={item.value}>
|
|
701
|
+
{item.label}
|
|
702
|
+
</option>
|
|
327
703
|
))}
|
|
328
|
-
</
|
|
329
|
-
</
|
|
704
|
+
</NativeSelect>
|
|
705
|
+
</SettingField>
|
|
330
706
|
);
|
|
707
|
+
|
|
708
|
+
switch (format) {
|
|
709
|
+
case "csv":
|
|
710
|
+
return (
|
|
711
|
+
<>
|
|
712
|
+
<SettingField
|
|
713
|
+
id={`${id}-separator`}
|
|
714
|
+
label="Delimiter"
|
|
715
|
+
help="Character between fields. Semicolon suits spreadsheets in locales that use a comma as the decimal separator."
|
|
716
|
+
>
|
|
717
|
+
<NativeSelect
|
|
718
|
+
id={`${id}-separator`}
|
|
719
|
+
className="mb-0 w-full"
|
|
720
|
+
value={settings.csv.separator}
|
|
721
|
+
onChange={(event) => {
|
|
722
|
+
const next = event.target.value as Separator;
|
|
723
|
+
onChange((current) => ({
|
|
724
|
+
...current,
|
|
725
|
+
csv: { ...current.csv, separator: next },
|
|
726
|
+
}));
|
|
727
|
+
}}
|
|
728
|
+
>
|
|
729
|
+
{SEPARATORS.map((item) => (
|
|
730
|
+
<option key={item.value} value={item.value}>
|
|
731
|
+
{item.label}
|
|
732
|
+
</option>
|
|
733
|
+
))}
|
|
734
|
+
</NativeSelect>
|
|
735
|
+
</SettingField>
|
|
736
|
+
{encodingField(settings.csv.encoding, "csv")}
|
|
737
|
+
</>
|
|
738
|
+
);
|
|
739
|
+
case "tsv":
|
|
740
|
+
return encodingField(settings.tsv.encoding, "tsv");
|
|
741
|
+
case "json":
|
|
742
|
+
return (
|
|
743
|
+
<SettingField
|
|
744
|
+
id={`${id}-ensure-ascii`}
|
|
745
|
+
label="Escape non-ASCII"
|
|
746
|
+
help="Write characters outside ASCII as \\u escapes. Turn off to keep accents and symbols readable in the file."
|
|
747
|
+
>
|
|
748
|
+
<Switch
|
|
749
|
+
id={`${id}-ensure-ascii`}
|
|
750
|
+
size="xs"
|
|
751
|
+
checked={settings.json.ensureAscii}
|
|
752
|
+
onCheckedChange={(checked) => {
|
|
753
|
+
onChange((current) => ({
|
|
754
|
+
...current,
|
|
755
|
+
json: { ensureAscii: checked },
|
|
756
|
+
}));
|
|
757
|
+
}}
|
|
758
|
+
/>
|
|
759
|
+
</SettingField>
|
|
760
|
+
);
|
|
761
|
+
default:
|
|
762
|
+
logNever(format);
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
331
765
|
};
|
|
332
766
|
|
|
767
|
+
interface SettingFieldProps {
|
|
768
|
+
id: string;
|
|
769
|
+
label: string;
|
|
770
|
+
help: string;
|
|
771
|
+
children: React.ReactNode;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const SettingField: React.FC<SettingFieldProps> = ({
|
|
775
|
+
id,
|
|
776
|
+
label,
|
|
777
|
+
help,
|
|
778
|
+
children,
|
|
779
|
+
}) => (
|
|
780
|
+
<div className="grid gap-1.5">
|
|
781
|
+
<span className="flex items-center gap-1">
|
|
782
|
+
<Label htmlFor={id} className="text-xs font-medium">
|
|
783
|
+
{label}
|
|
784
|
+
</Label>
|
|
785
|
+
<Tooltip content={help}>
|
|
786
|
+
<button
|
|
787
|
+
type="button"
|
|
788
|
+
className="inline-flex text-muted-foreground hover:text-foreground"
|
|
789
|
+
aria-label={`${label} help`}
|
|
790
|
+
>
|
|
791
|
+
<InfoIcon className="h-3 w-3" />
|
|
792
|
+
</button>
|
|
793
|
+
</Tooltip>
|
|
794
|
+
</span>
|
|
795
|
+
<span className="flex items-center">{children}</span>
|
|
796
|
+
</div>
|
|
797
|
+
);
|
|
798
|
+
|
|
333
799
|
function fetchJson(url: string): Promise<Record<string, unknown>[]> {
|
|
334
800
|
return fetchText(url).then(
|
|
335
801
|
jsonParseWithSpecialChar<Record<string, unknown>[]>,
|