@marimo-team/frontend 0.25.2-dev56 → 0.25.2-dev61

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 (36) hide show
  1. package/dist/assets/ConnectedDataExplorerComponent-C5UW0p9I.js +1 -0
  2. package/dist/assets/JsonOutput-nqqzL-n0.js +11 -0
  3. package/dist/assets/{agent-panel-9FeJ9hAg.js → agent-panel-Bkcf1sED.js} +1 -1
  4. package/dist/assets/{cell-editor-BNIogYsY.js → cell-editor-Cmqe3QmM.js} +1 -1
  5. package/dist/assets/{column-preview-D2Um0Qe3.js → column-preview-DtrgJzVI.js} +1 -1
  6. package/dist/assets/{command-palette-ChCapZF9.js → command-palette-DUPyVGsq.js} +1 -1
  7. package/dist/assets/{edit-page-DIBiWEli.js → edit-page-DMsRAPsr.js} +4 -4
  8. package/dist/assets/{file-explorer-panel-CxBxvZDk.js → file-explorer-panel-BQi88S1o.js} +1 -1
  9. package/dist/assets/{hooks-5owWOOnf.js → hooks-BHoInJWN.js} +1 -1
  10. package/dist/assets/{index-D9fmFCuc.js → index-C968xQuz.js} +7 -7
  11. package/dist/assets/index-CF8Gfz1R.css +2 -0
  12. package/dist/assets/{layout-giNu7tsO.js → layout-CzNq5YtR.js} +3 -3
  13. package/dist/assets/{panels-DftjTXK1.js → panels-Dsf6yEsA.js} +1 -1
  14. package/dist/assets/{reveal-component-C72HbbmJ.js → reveal-component-CJxdWpbj.js} +1 -1
  15. package/dist/assets/{run-page-BOSkpSu_.js → run-page-DCu4YGwW.js} +1 -1
  16. package/dist/assets/{scratchpad-panel-C3gu3_1_.js → scratchpad-panel-BSlnM0TK.js} +1 -1
  17. package/dist/assets/{session-panel-CNK8FDu3.js → session-panel-BdN43Lzi.js} +1 -1
  18. package/dist/assets/spec-D9d9nT0z.js +1 -0
  19. package/dist/assets/{list-filter-CTg19675.js → table-B7KYyTxw.js} +1 -1
  20. package/dist/assets/{useNotebookActions-ByyEgzc-.js → useNotebookActions-BDD321Hs.js} +1 -1
  21. package/dist/index.html +6 -6
  22. package/package.json +1 -1
  23. package/src/components/data-table/TableTopBar.tsx +4 -0
  24. package/src/components/data-table/__tests__/export-actions.test.tsx +1111 -14
  25. package/src/components/data-table/__tests__/schemas.test.ts +57 -0
  26. package/src/components/data-table/data-table.tsx +3 -0
  27. package/src/components/data-table/export-actions.tsx +365 -81
  28. package/src/components/data-table/schemas.ts +90 -14
  29. package/src/plugins/impl/DataTablePlugin.tsx +23 -2
  30. package/src/plugins/impl/__tests__/DataTablePlugin.test.tsx +65 -8
  31. package/src/plugins/impl/data-frames/DataFramePlugin.tsx +15 -3
  32. package/src/stories/dataframe.stories.tsx +7 -0
  33. package/dist/assets/ConnectedDataExplorerComponent-Jv_dljLQ.js +0 -1
  34. package/dist/assets/JsonOutput-Dmq_45PS.js +0 -11
  35. package/dist/assets/index-BGdpHNhZ.css +0 -2
  36. package/dist/assets/spec-SOFn9xEK.js +0 -1
@@ -18,6 +18,7 @@ import { downloadByURL } from "@/utils/download";
18
18
  import { jsonToMarkdown } from "@/utils/json/json-parser";
19
19
  import { downloadSizeLimitAtom } from "../download-policy/atoms";
20
20
  import { ExportActions, type ExportActionProps } from "../export-actions";
21
+ import type { ExportMetadata } from "../schemas";
21
22
 
22
23
  const mocks = vi.hoisted(() => ({
23
24
  handleInstallPackages: vi.fn(),
@@ -58,13 +59,19 @@ function renderExportActions(
58
59
  store.set(downloadSizeLimitAtom, sizeLimit);
59
60
  store.set(viewStateAtom, { mode, cellAnchor: null });
60
61
 
61
- return render(
62
+ const view = (nextProps: Partial<ExportActionProps>) => (
62
63
  <Provider store={store}>
63
64
  <TooltipProvider delayDuration={0}>
64
- <ExportActions downloadAs={downloadAs} {...props} />
65
+ <ExportActions downloadAs={downloadAs} {...nextProps} />
65
66
  </TooltipProvider>
66
- </Provider>,
67
+ </Provider>
67
68
  );
69
+ const result = render(view(props));
70
+ return {
71
+ ...result,
72
+ rerenderExportActions: (nextProps: Partial<ExportActionProps>) =>
73
+ result.rerender(view(nextProps)),
74
+ };
68
75
  }
69
76
 
70
77
  async function openDialog() {
@@ -72,6 +79,27 @@ async function openDialog() {
72
79
  return screen.findByRole("dialog", { name: "Export table" });
73
80
  }
74
81
 
82
+ function selectGeometry(name: string) {
83
+ const select = screen.getByRole("combobox", { name: "Primary geometry" });
84
+ const option = within(select).getByRole("option", {
85
+ name,
86
+ }) as HTMLOptionElement;
87
+ fireEvent.change(select, { target: { value: option.value } });
88
+ }
89
+
90
+ const geometryMetadata: ExportMetadata = {
91
+ geometry_columns: [
92
+ { name: "location", encoding: "objects", crs: "EPSG:4326" },
93
+ { name: "boundary", encoding: "objects", crs: null },
94
+ ],
95
+ primary_geometry_column: "location",
96
+ default_geometry_column: "location",
97
+ formats: {
98
+ parquet: { available: true, reason: null, missing_packages: [] },
99
+ geojson: { available: true, reason: null, missing_packages: [] },
100
+ },
101
+ };
102
+
75
103
  describe("ExportActions dialog", () => {
76
104
  beforeEach(() => {
77
105
  vi.clearAllMocks();
@@ -85,6 +113,423 @@ describe("ExportActions dialog", () => {
85
113
  vi.unstubAllGlobals();
86
114
  });
87
115
 
116
+ it("selects a primary geometry for GeoParquet without changing other formats", async () => {
117
+ const getExportMetadata = vi.fn().mockResolvedValue(geometryMetadata);
118
+ renderExportActions({ getExportMetadata });
119
+ await openDialog();
120
+ await waitFor(() => {
121
+ expect(
122
+ screen.getByRole("button", { name: "GeoParquet options" }),
123
+ ).toBeEnabled();
124
+ });
125
+ expect(screen.getByTestId("export-summary-parquet")).toHaveTextContent(
126
+ "location",
127
+ );
128
+ fireEvent.click(screen.getByRole("button", { name: "GeoParquet options" }));
129
+ selectGeometry("boundary");
130
+ fireEvent.click(
131
+ screen.getByRole("button", { name: "Download GeoParquet" }),
132
+ );
133
+ await waitFor(() => {
134
+ expect(downloadByURL).toHaveBeenCalled();
135
+ });
136
+ expect(downloadAs).toHaveBeenCalledWith({
137
+ format: "parquet",
138
+ geometry_column: "boundary",
139
+ });
140
+ expect(getExportMetadata).toHaveBeenCalledWith({});
141
+ expect(screen.getByText("GeoParquet")).toBeInTheDocument();
142
+ expect(
143
+ screen.getByText("Parquet with geometry and CRS metadata"),
144
+ ).toBeInTheDocument();
145
+ expect(screen.getByTestId("export-summary-parquet")).toHaveTextContent(
146
+ "boundary",
147
+ );
148
+
149
+ fireEvent.click(screen.getByRole("button", { name: "Download CSV" }));
150
+ await waitFor(() => {
151
+ expect(downloadAs).toHaveBeenCalledWith({ format: "csv" });
152
+ });
153
+ });
154
+
155
+ it("requires a choice when several geometries have no primary", async () => {
156
+ renderExportActions({
157
+ getExportMetadata: vi.fn().mockResolvedValue({
158
+ ...geometryMetadata,
159
+ primary_geometry_column: null,
160
+ default_geometry_column: null,
161
+ }),
162
+ });
163
+ await openDialog();
164
+ await screen.findByRole("button", { name: "GeoParquet options" });
165
+ expect(
166
+ screen.getByRole("button", { name: "Download GeoParquet" }),
167
+ ).toBeDisabled();
168
+ expect(
169
+ screen.getByText(
170
+ "Choose a primary geometry column to export GeoParquet.",
171
+ ),
172
+ ).toBeInTheDocument();
173
+ fireEvent.click(screen.getByRole("button", { name: "GeoParquet options" }));
174
+ selectGeometry("boundary");
175
+ expect(
176
+ screen.getByRole("button", { name: "Download GeoParquet" }),
177
+ ).toBeEnabled();
178
+ });
179
+
180
+ it("shows ineligible geometry sources and keeps ordinary exports available", async () => {
181
+ renderExportActions({
182
+ getExportMetadata: vi.fn().mockResolvedValue({
183
+ ...geometryMetadata,
184
+ formats: {
185
+ parquet: {
186
+ available: false,
187
+ reason: "GeoParquet export from Arrow tables is not supported yet.",
188
+ missing_packages: [],
189
+ },
190
+ },
191
+ }),
192
+ });
193
+ await openDialog();
194
+ expect(
195
+ await screen.findByText(
196
+ "GeoParquet export from Arrow tables is not supported yet.",
197
+ ),
198
+ ).toBeInTheDocument();
199
+ expect(
200
+ screen.getByRole("button", { name: "Download GeoParquet" }),
201
+ ).toBeDisabled();
202
+ fireEvent.click(screen.getByRole("button", { name: "Download JSON" }));
203
+ await waitFor(() => {
204
+ expect(downloadAs).toHaveBeenCalledWith({ format: "json" });
205
+ });
206
+ });
207
+
208
+ it("keeps the geometry choice through a package install retry", async () => {
209
+ const getExportMetadata = vi.fn().mockResolvedValue(geometryMetadata);
210
+ downloadAs
211
+ .mockResolvedValueOnce({
212
+ url: "",
213
+ filename: "",
214
+ error: "GeoParquet export requires pyarrow.",
215
+ code: "missing_packages",
216
+ missing_packages: ["pyarrow"],
217
+ })
218
+ .mockResolvedValueOnce({
219
+ url: "https://example.test/export",
220
+ filename: "table",
221
+ });
222
+ renderExportActions({ getExportMetadata });
223
+ await openDialog();
224
+ await screen.findByRole("button", { name: "GeoParquet options" });
225
+ fireEvent.click(screen.getByRole("button", { name: "GeoParquet options" }));
226
+ selectGeometry("boundary");
227
+ fireEvent.click(
228
+ screen.getByRole("button", { name: "Download GeoParquet" }),
229
+ );
230
+ expect(
231
+ await screen.findByText("GeoParquet export requires pyarrow."),
232
+ ).toBeInTheDocument();
233
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
234
+ expect(
235
+ screen.getByRole("button", { name: "Download GeoParquet" }),
236
+ ).toBeDisabled();
237
+ expect(downloadByURL).not.toHaveBeenCalled();
238
+ fireEvent.click(screen.getByRole("button", { name: "Install pyarrow" }));
239
+ expect(mocks.handleInstallPackages).toHaveBeenCalledWith(
240
+ ["pyarrow"],
241
+ expect.any(Function),
242
+ );
243
+ await act(async () => {
244
+ mocks.handleInstallPackages.mock.calls[0][1]();
245
+ });
246
+ await waitFor(() => {
247
+ expect(downloadByURL).toHaveBeenCalled();
248
+ });
249
+ expect(downloadAs).toHaveBeenLastCalledWith({
250
+ format: "parquet",
251
+ geometry_column: "boundary",
252
+ });
253
+ });
254
+
255
+ it("refreshes eligibility after package installation", async () => {
256
+ const getExportMetadata = vi.fn().mockResolvedValue({
257
+ ...geometryMetadata,
258
+ formats: {
259
+ parquet: {
260
+ available: false,
261
+ reason: "GeoParquet export requires pyarrow.",
262
+ missing_packages: ["pyarrow"],
263
+ },
264
+ },
265
+ });
266
+ renderExportActions({ getExportMetadata });
267
+ await openDialog();
268
+ await screen.findByRole("button", { name: "GeoParquet options" });
269
+ expect(
270
+ screen.getByRole("button", { name: "Download GeoParquet" }),
271
+ ).toBeDisabled();
272
+ expect(
273
+ screen.getAllByText("GeoParquet export requires pyarrow."),
274
+ ).toHaveLength(1);
275
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
276
+ expect(downloadAs).not.toHaveBeenCalled();
277
+ fireEvent.click(
278
+ await screen.findByRole("button", { name: "Install pyarrow" }),
279
+ );
280
+ getExportMetadata.mockResolvedValue(geometryMetadata);
281
+ await act(async () => {
282
+ mocks.handleInstallPackages.mock.calls[0][1]();
283
+ });
284
+ await waitFor(() => {
285
+ expect(downloadByURL).toHaveBeenCalled();
286
+ });
287
+ expect(getExportMetadata).toHaveBeenCalledTimes(2);
288
+ expect(
289
+ screen.queryByText("GeoParquet export requires pyarrow."),
290
+ ).not.toBeInTheDocument();
291
+ });
292
+
293
+ it("keeps the install prompt when the package is still missing", async () => {
294
+ const getExportMetadata = vi.fn().mockResolvedValue({
295
+ ...geometryMetadata,
296
+ formats: {
297
+ parquet: {
298
+ available: false,
299
+ reason: "GeoParquet export requires pyarrow.",
300
+ missing_packages: ["pyarrow"],
301
+ },
302
+ },
303
+ });
304
+ renderExportActions({ getExportMetadata });
305
+ await openDialog();
306
+ fireEvent.click(
307
+ await screen.findByRole("button", { name: "Install pyarrow" }),
308
+ );
309
+ await act(async () => {
310
+ mocks.handleInstallPackages.mock.calls[0][1]();
311
+ });
312
+ await waitFor(() => {
313
+ expect(getExportMetadata).toHaveBeenCalledTimes(2);
314
+ });
315
+ expect(
316
+ screen.getByRole("button", { name: "Install pyarrow" }),
317
+ ).toBeInTheDocument();
318
+ expect(
319
+ screen.getByRole("button", { name: "Download GeoParquet" }),
320
+ ).toBeDisabled();
321
+ expect(downloadAs).not.toHaveBeenCalled();
322
+ });
323
+
324
+ it("can select a geometry column with an empty name", async () => {
325
+ renderExportActions({
326
+ getExportMetadata: vi.fn().mockResolvedValue({
327
+ ...geometryMetadata,
328
+ geometry_columns: [
329
+ geometryMetadata.geometry_columns[0],
330
+ { name: "", encoding: "objects", crs: null },
331
+ ],
332
+ }),
333
+ });
334
+ await openDialog();
335
+ fireEvent.click(
336
+ await screen.findByRole("button", { name: "GeoParquet options" }),
337
+ );
338
+ selectGeometry("(unnamed geometry)");
339
+ fireEvent.click(
340
+ screen.getByRole("button", { name: "Download GeoParquet" }),
341
+ );
342
+ await waitFor(() => {
343
+ expect(downloadAs).toHaveBeenCalledWith({
344
+ format: "parquet",
345
+ geometry_column: "",
346
+ });
347
+ });
348
+ });
349
+
350
+ it("refreshes geometry metadata when the source changes", async () => {
351
+ const first = vi.fn().mockResolvedValue(geometryMetadata);
352
+ const second = vi.fn().mockResolvedValue({
353
+ ...geometryMetadata,
354
+ geometry_columns: [
355
+ { name: "region", encoding: "objects", crs: "EPSG:3857" },
356
+ ],
357
+ primary_geometry_column: "region",
358
+ default_geometry_column: "region",
359
+ });
360
+ const { rerenderExportActions } = renderExportActions({
361
+ getExportMetadata: first,
362
+ metadataSource: "first",
363
+ });
364
+ await openDialog();
365
+ await screen.findByRole("button", { name: "GeoParquet options" });
366
+ fireEvent.click(screen.getByRole("button", { name: "GeoParquet options" }));
367
+ selectGeometry("boundary");
368
+
369
+ rerenderExportActions({
370
+ getExportMetadata: second,
371
+ metadataSource: "second",
372
+ });
373
+ await waitFor(() => {
374
+ expect(screen.getByTestId("export-summary-parquet")).toHaveTextContent(
375
+ "region",
376
+ );
377
+ });
378
+ expect(
379
+ screen.getByRole("combobox", { name: "Primary geometry" }),
380
+ ).toHaveDisplayValue("region");
381
+ fireEvent.click(
382
+ screen.getByRole("button", { name: "Download GeoParquet" }),
383
+ );
384
+ await waitFor(() => {
385
+ expect(downloadAs).toHaveBeenCalledWith({
386
+ format: "parquet",
387
+ geometry_column: "region",
388
+ });
389
+ });
390
+ expect(first).toHaveBeenCalledTimes(1);
391
+ expect(second).toHaveBeenCalledTimes(1);
392
+ });
393
+
394
+ it("keeps a still-valid geometry override when metadata refreshes", async () => {
395
+ const getExportMetadata = vi.fn().mockResolvedValue(geometryMetadata);
396
+ const { rerenderExportActions } = renderExportActions({
397
+ getExportMetadata,
398
+ metadataSource: "first",
399
+ });
400
+ await openDialog();
401
+ fireEvent.click(
402
+ await screen.findByRole("button", { name: "GeoParquet options" }),
403
+ );
404
+ selectGeometry("boundary");
405
+ rerenderExportActions({ getExportMetadata, metadataSource: "second" });
406
+ await waitFor(() => {
407
+ expect(getExportMetadata).toHaveBeenCalledTimes(2);
408
+ });
409
+ expect(
410
+ screen.getByRole("combobox", { name: "Primary geometry" }),
411
+ ).toHaveDisplayValue("boundary");
412
+ });
413
+
414
+ it("does not publish an artifact for a structured failure without an error message", async () => {
415
+ downloadAs.mockResolvedValueOnce({
416
+ url: "",
417
+ filename: "",
418
+ code: "invalid_geometry",
419
+ column: "location",
420
+ });
421
+ renderExportActions({
422
+ getExportMetadata: vi.fn().mockResolvedValue(geometryMetadata),
423
+ });
424
+ await openDialog();
425
+ await screen.findByRole("button", { name: "GeoParquet options" });
426
+ fireEvent.click(
427
+ screen.getByRole("button", { name: "Download GeoParquet" }),
428
+ );
429
+ expect(
430
+ await screen.findByText("The export did not produce a file."),
431
+ ).toBeInTheDocument();
432
+ expect(downloadByURL).not.toHaveBeenCalled();
433
+ });
434
+
435
+ it("retries a failed metadata lookup without blocking ordinary formats", async () => {
436
+ const getExportMetadata = vi
437
+ .fn()
438
+ .mockRejectedValueOnce(new Error("Connection lost"))
439
+ .mockResolvedValueOnce(geometryMetadata);
440
+ renderExportActions({ getExportMetadata });
441
+ await openDialog();
442
+ expect(
443
+ await screen.findByText(
444
+ "Could not load geometry options: Connection lost",
445
+ ),
446
+ ).toBeInTheDocument();
447
+ expect(
448
+ screen.getByRole("button", { name: "Download Parquet" }),
449
+ ).toBeDisabled();
450
+ fireEvent.click(screen.getByRole("button", { name: "Download CSV" }));
451
+ await waitFor(() => {
452
+ expect(downloadAs).toHaveBeenCalledWith({ format: "csv" });
453
+ });
454
+ fireEvent.click(screen.getByRole("button", { name: "Retry" }));
455
+ expect(
456
+ await screen.findByRole("button", { name: "GeoParquet options" }),
457
+ ).toBeInTheDocument();
458
+ expect(
459
+ screen.getByRole("button", { name: "Download GeoParquet" }),
460
+ ).toBeEnabled();
461
+ expect(getExportMetadata).toHaveBeenCalledTimes(2);
462
+ });
463
+
464
+ it("does not cancel a pending CSV download when geometry metadata is retried", async () => {
465
+ let resolveDownload: (value: {
466
+ url: string;
467
+ filename: string;
468
+ }) => void = () => undefined;
469
+ downloadAs.mockImplementationOnce(
470
+ () =>
471
+ new Promise((resolve) => {
472
+ resolveDownload = resolve;
473
+ }),
474
+ );
475
+ const getExportMetadata = vi
476
+ .fn()
477
+ .mockRejectedValueOnce(new Error("Connection lost"))
478
+ .mockResolvedValueOnce(geometryMetadata);
479
+ renderExportActions({ getExportMetadata });
480
+ await openDialog();
481
+ await screen.findByText("Could not load geometry options: Connection lost");
482
+
483
+ fireEvent.click(screen.getByRole("button", { name: "Download CSV" }));
484
+ fireEvent.click(screen.getByRole("button", { name: "Retry" }));
485
+ await screen.findByRole("button", { name: "GeoParquet options" });
486
+ await act(async () => {
487
+ resolveDownload({
488
+ url: "https://example.test/export",
489
+ filename: "table",
490
+ });
491
+ });
492
+
493
+ expect(downloadByURL).toHaveBeenCalledWith(
494
+ expect.stringContaining("https://example.test/export"),
495
+ "table.csv",
496
+ );
497
+ });
498
+
499
+ it("cancels a pending download when the table source changes", async () => {
500
+ let resolveDownload: (value: {
501
+ url: string;
502
+ filename: string;
503
+ }) => void = () => undefined;
504
+ downloadAs.mockImplementationOnce(
505
+ () =>
506
+ new Promise((resolve) => {
507
+ resolveDownload = resolve;
508
+ }),
509
+ );
510
+ const getExportMetadata = vi.fn().mockResolvedValue(geometryMetadata);
511
+ const { rerenderExportActions } = renderExportActions({
512
+ getExportMetadata,
513
+ metadataSource: "first",
514
+ });
515
+ await openDialog();
516
+ await screen.findByRole("button", { name: "GeoParquet options" });
517
+
518
+ fireEvent.click(screen.getByRole("button", { name: "Download CSV" }));
519
+ rerenderExportActions({ getExportMetadata, metadataSource: "second" });
520
+ await waitFor(() => {
521
+ expect(getExportMetadata).toHaveBeenCalledTimes(2);
522
+ });
523
+ await act(async () => {
524
+ resolveDownload({
525
+ url: "https://example.test/export",
526
+ filename: "table",
527
+ });
528
+ });
529
+
530
+ expect(downloadByURL).not.toHaveBeenCalled();
531
+ });
532
+
88
533
  it("shows formats in the approved order with an options toggle where supported", async () => {
89
534
  renderExportActions();
90
535
  const dialog = await openDialog();
@@ -98,6 +543,11 @@ describe("ExportActions dialog", () => {
98
543
  "export-row-parquet",
99
544
  "export-row-markdown",
100
545
  ]);
546
+ for (const format of ["csv", "tsv", "json", "parquet", "markdown"]) {
547
+ expect(
548
+ screen.getByTestId(`export-format-icon-${format}`),
549
+ ).toBeInTheDocument();
550
+ }
101
551
  for (const label of ["CSV", "TSV", "JSON"]) {
102
552
  expect(
103
553
  screen.getByRole("button", { name: `${label} options` }),
@@ -484,11 +934,14 @@ describe("ExportActions dialog", () => {
484
934
 
485
935
  fireEvent.click(screen.getByRole("button", { name: "Download Parquet" }));
486
936
 
487
- const alert = await screen.findByRole("alert");
488
- expect(alert).toHaveTextContent("Parquet export requires pyarrow.");
489
- fireEvent.click(
490
- within(alert).getByRole("button", { name: "Install pyarrow" }),
491
- );
937
+ expect(
938
+ await screen.findByText("Parquet export requires pyarrow."),
939
+ ).toBeInTheDocument();
940
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
941
+ expect(
942
+ screen.getByRole("button", { name: "Download Parquet" }),
943
+ ).toBeDisabled();
944
+ fireEvent.click(screen.getByRole("button", { name: "Install pyarrow" }));
492
945
  expect(mocks.handleInstallPackages).toHaveBeenCalledWith(
493
946
  ["pyarrow"],
494
947
  expect.any(Function),
@@ -520,12 +973,16 @@ describe("ExportActions dialog", () => {
520
973
 
521
974
  fireEvent.click(screen.getByRole("button", { name: "Download Parquet" }));
522
975
 
523
- const alert = await screen.findByRole("alert");
524
- expect(alert).toHaveTextContent(
525
- "Parquet export isn't available in this notebook",
526
- );
527
- expect(alert).not.toHaveTextContent("pyarrow");
528
- expect(within(alert).queryByRole("button")).not.toBeInTheDocument();
976
+ expect(
977
+ await screen.findByText(
978
+ "Parquet export isn't available in this notebook",
979
+ ),
980
+ ).toBeInTheDocument();
981
+ expect(screen.queryByText("pyarrow")).not.toBeInTheDocument();
982
+ expect(
983
+ screen.queryByRole("button", { name: /Install/ }),
984
+ ).not.toBeInTheDocument();
985
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
529
986
  });
530
987
 
531
988
  it("clears stale failures before a new action and after close", async () => {
@@ -660,3 +1117,643 @@ describe("ExportActions dialog", () => {
660
1117
  expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
661
1118
  });
662
1119
  });
1120
+
1121
+ const LONG_GEOMETRY_WKT = `LINESTRING (${Array.from(
1122
+ { length: 160 },
1123
+ (_, index) => `${index} ${index % 17}`,
1124
+ ).join(", ")})`;
1125
+ const jsonRows = [{ row_label: "long", geometry: LONG_GEOMETRY_WKT }];
1126
+
1127
+ describe("ExportActions clipboard geometry", () => {
1128
+ beforeEach(() => {
1129
+ vi.clearAllMocks();
1130
+ downloadAs.mockResolvedValue({
1131
+ url: "https://example.test/export",
1132
+ filename: "geometry",
1133
+ });
1134
+ });
1135
+
1136
+ afterEach(() => {
1137
+ vi.unstubAllGlobals();
1138
+ });
1139
+
1140
+ it.each([
1141
+ {
1142
+ label: "CSV",
1143
+ sourceFormat: "csv" as const,
1144
+ sourceText: `row_label,geometry\nlong,"${LONG_GEOMETRY_WKT}"\n`,
1145
+ expectedText: `row_label,geometry\nlong,"${LONG_GEOMETRY_WKT}"\n`,
1146
+ },
1147
+ {
1148
+ label: "TSV",
1149
+ sourceFormat: "tsv" as const,
1150
+ sourceText: `row_label\tgeometry\nlong\t${LONG_GEOMETRY_WKT}\n`,
1151
+ expectedText: `row_label\tgeometry\nlong\t${LONG_GEOMETRY_WKT}\n`,
1152
+ },
1153
+ {
1154
+ label: "JSON",
1155
+ sourceFormat: "json" as const,
1156
+ sourceText: JSON.stringify(jsonRows),
1157
+ expectedText: JSON.stringify(jsonRows, null, 2),
1158
+ },
1159
+ {
1160
+ label: "Markdown",
1161
+ sourceFormat: "json" as const,
1162
+ sourceText: JSON.stringify(jsonRows),
1163
+ expectedText: jsonToMarkdown(jsonRows),
1164
+ },
1165
+ ])(
1166
+ "copies complete geometry text as $label",
1167
+ async ({ label, sourceFormat, sourceText, expectedText }) => {
1168
+ vi.stubGlobal(
1169
+ "fetch",
1170
+ vi.fn().mockResolvedValue({
1171
+ ok: true,
1172
+ text: vi.fn().mockResolvedValue(sourceText),
1173
+ }),
1174
+ );
1175
+ renderExportActions();
1176
+ await openDialog();
1177
+
1178
+ fireEvent.click(screen.getByRole("button", { name: `Copy ${label}` }));
1179
+
1180
+ await waitFor(() => {
1181
+ expect(copyToClipboard).toHaveBeenCalledWith(expectedText);
1182
+ });
1183
+ expect(downloadAs).toHaveBeenCalledWith({ format: sourceFormat });
1184
+ },
1185
+ );
1186
+ });
1187
+
1188
+ describe("ExportActions GeoJSON", () => {
1189
+ beforeEach(() => {
1190
+ vi.clearAllMocks();
1191
+ downloadAs.mockResolvedValue({
1192
+ url: "https://example.test/export",
1193
+ filename: "table.source",
1194
+ });
1195
+ });
1196
+
1197
+ const metadataWithCrs: ExportMetadata = {
1198
+ ...geometryMetadata,
1199
+ geometry_columns: [
1200
+ geometryMetadata.geometry_columns[0],
1201
+ { name: "boundary", encoding: "objects", crs: "EPSG:3857" },
1202
+ ],
1203
+ };
1204
+
1205
+ it("hides GeoJSON for ordinary sources", async () => {
1206
+ renderExportActions({
1207
+ getExportMetadata: vi.fn().mockResolvedValue({
1208
+ geometry_columns: [],
1209
+ primary_geometry_column: null,
1210
+ default_geometry_column: null,
1211
+ formats: {},
1212
+ }),
1213
+ });
1214
+ await openDialog();
1215
+ expect(screen.queryByTestId("export-row-geojson")).not.toBeInTheDocument();
1216
+ });
1217
+
1218
+ it("downloads GeoJSON without PyArrow", async () => {
1219
+ renderExportActions({
1220
+ getExportMetadata: vi.fn().mockResolvedValue({
1221
+ ...geometryMetadata,
1222
+ formats: {
1223
+ ...geometryMetadata.formats,
1224
+ parquet: {
1225
+ available: false,
1226
+ reason: "GeoParquet export requires pyarrow.",
1227
+ missing_packages: ["pyarrow"],
1228
+ },
1229
+ },
1230
+ }),
1231
+ });
1232
+ await openDialog();
1233
+ fireEvent.click(
1234
+ await screen.findByRole("button", { name: "Download GeoJSON" }),
1235
+ );
1236
+ expect(screen.getByRole("button", { name: "Copy GeoJSON" })).toBeEnabled();
1237
+ await waitFor(() => {
1238
+ expect(downloadByURL).toHaveBeenCalledWith(
1239
+ "https://example.test/export?download=1&filename=table.geojson",
1240
+ "table.geojson",
1241
+ );
1242
+ });
1243
+ expect(downloadAs).toHaveBeenCalledWith({
1244
+ format: "geojson",
1245
+ geometry_column: "location",
1246
+ });
1247
+ expect(copyToClipboard).not.toHaveBeenCalled();
1248
+ });
1249
+
1250
+ it("shares the geometry choice and disables only GeoJSON for a missing CRS", async () => {
1251
+ renderExportActions({
1252
+ getExportMetadata: vi.fn().mockResolvedValue(geometryMetadata),
1253
+ });
1254
+ await openDialog();
1255
+ fireEvent.click(
1256
+ await screen.findByRole("button", { name: "GeoJSON options" }),
1257
+ );
1258
+ expect(
1259
+ screen.getByRole("group", { name: "GeoJSON options" }),
1260
+ ).toHaveTextContent(/longitude and latitude/);
1261
+ expect(
1262
+ screen.getByRole("group", { name: "GeoJSON options" }),
1263
+ ).toHaveTextContent(/complete WKT/);
1264
+ selectGeometry("boundary");
1265
+ expect(
1266
+ screen.getByRole("button", { name: "Download GeoJSON" }),
1267
+ ).toBeDisabled();
1268
+ expect(
1269
+ screen.getByRole("button", { name: "Download GeoParquet" }),
1270
+ ).toBeEnabled();
1271
+ expect(screen.getByRole("button", { name: "Download JSON" })).toBeEnabled();
1272
+ expect(screen.getByRole("button", { name: "Download CSV" })).toBeEnabled();
1273
+ expect(screen.getByTestId("export-row-geojson")).toHaveTextContent(
1274
+ "Declare its source CRS in Python",
1275
+ );
1276
+ fireEvent.click(screen.getByRole("button", { name: "GeoParquet options" }));
1277
+ expect(
1278
+ screen.getByRole("combobox", { name: "Primary geometry" }),
1279
+ ).toHaveDisplayValue("boundary");
1280
+ selectGeometry("location");
1281
+ expect(
1282
+ screen.getByRole("button", { name: "Download GeoJSON" }),
1283
+ ).toBeEnabled();
1284
+ expect(screen.getByTestId("export-summary-geojson")).toHaveTextContent(
1285
+ "location",
1286
+ );
1287
+ });
1288
+
1289
+ it("allows a known-CRS alternative when the default has no CRS", async () => {
1290
+ renderExportActions({
1291
+ getExportMetadata: vi.fn().mockResolvedValue({
1292
+ ...geometryMetadata,
1293
+ default_geometry_column: "boundary",
1294
+ primary_geometry_column: "boundary",
1295
+ }),
1296
+ });
1297
+ await openDialog();
1298
+ fireEvent.click(
1299
+ await screen.findByRole("button", { name: "GeoJSON options" }),
1300
+ );
1301
+ expect(
1302
+ screen.getByRole("button", { name: "Download GeoJSON" }),
1303
+ ).toBeDisabled();
1304
+ selectGeometry("location");
1305
+ fireEvent.click(screen.getByRole("button", { name: "Download GeoJSON" }));
1306
+ await waitFor(() =>
1307
+ expect(downloadAs).toHaveBeenCalledWith({
1308
+ format: "geojson",
1309
+ geometry_column: "location",
1310
+ }),
1311
+ );
1312
+ });
1313
+
1314
+ it("requires a choice for ambiguous sources", async () => {
1315
+ renderExportActions({
1316
+ getExportMetadata: vi.fn().mockResolvedValue({
1317
+ ...metadataWithCrs,
1318
+ primary_geometry_column: null,
1319
+ default_geometry_column: null,
1320
+ }),
1321
+ });
1322
+ await openDialog();
1323
+ fireEvent.click(
1324
+ await screen.findByRole("button", { name: "GeoJSON options" }),
1325
+ );
1326
+ expect(
1327
+ screen.getByRole("button", { name: "Download GeoJSON" }),
1328
+ ).toBeDisabled();
1329
+ selectGeometry("boundary");
1330
+ fireEvent.click(screen.getByRole("button", { name: "Download GeoJSON" }));
1331
+ await waitFor(() =>
1332
+ expect(downloadAs).toHaveBeenCalledWith({
1333
+ format: "geojson",
1334
+ geometry_column: "boundary",
1335
+ }),
1336
+ );
1337
+ });
1338
+
1339
+ it("preserves an empty geometry name", async () => {
1340
+ renderExportActions({
1341
+ getExportMetadata: vi.fn().mockResolvedValue({
1342
+ ...geometryMetadata,
1343
+ geometry_columns: [{ name: "", encoding: "objects", crs: "EPSG:4326" }],
1344
+ primary_geometry_column: "",
1345
+ default_geometry_column: "",
1346
+ }),
1347
+ });
1348
+ await openDialog();
1349
+ fireEvent.click(
1350
+ await screen.findByRole("button", { name: "Download GeoJSON" }),
1351
+ );
1352
+ await waitFor(() =>
1353
+ expect(downloadAs).toHaveBeenCalledWith({
1354
+ format: "geojson",
1355
+ geometry_column: "",
1356
+ }),
1357
+ );
1358
+ });
1359
+
1360
+ it.each(["missing_crs", "conversion_failed"] as const)(
1361
+ "keeps choices after %s and retries",
1362
+ async (code) => {
1363
+ downloadAs.mockResolvedValueOnce({
1364
+ url: "",
1365
+ filename: "",
1366
+ code,
1367
+ error: "Geometry export failed.",
1368
+ });
1369
+ renderExportActions({
1370
+ getExportMetadata: vi.fn().mockResolvedValue(metadataWithCrs),
1371
+ });
1372
+ await openDialog();
1373
+ fireEvent.click(
1374
+ await screen.findByRole("button", { name: "GeoJSON options" }),
1375
+ );
1376
+ selectGeometry("boundary");
1377
+ fireEvent.click(screen.getByRole("button", { name: "Download GeoJSON" }));
1378
+ expect(await screen.findByRole("alert")).toHaveTextContent(
1379
+ "Geometry export failed.",
1380
+ );
1381
+ expect(downloadByURL).not.toHaveBeenCalled();
1382
+ expect(
1383
+ screen.getByRole("combobox", { name: "Primary geometry" }),
1384
+ ).toHaveDisplayValue("boundary");
1385
+ fireEvent.click(screen.getByRole("button", { name: "Download GeoJSON" }));
1386
+ await waitFor(() => expect(downloadByURL).toHaveBeenCalled());
1387
+ expect(downloadAs).toHaveBeenLastCalledWith({
1388
+ format: "geojson",
1389
+ geometry_column: "boundary",
1390
+ });
1391
+ },
1392
+ );
1393
+
1394
+ it("refreshes metadata and keeps the geometry choice after installation", async () => {
1395
+ const getExportMetadata = vi.fn().mockResolvedValue(metadataWithCrs);
1396
+ downloadAs.mockResolvedValueOnce({
1397
+ url: "",
1398
+ filename: "",
1399
+ code: "missing_packages",
1400
+ error: "GeoJSON requires geopandas.",
1401
+ missing_packages: ["geopandas"],
1402
+ });
1403
+ renderExportActions({ getExportMetadata });
1404
+ await openDialog();
1405
+ fireEvent.click(
1406
+ await screen.findByRole("button", { name: "GeoJSON options" }),
1407
+ );
1408
+ selectGeometry("boundary");
1409
+ fireEvent.click(screen.getByRole("button", { name: "Download GeoJSON" }));
1410
+ fireEvent.click(
1411
+ await screen.findByRole("button", { name: "Install geopandas" }),
1412
+ );
1413
+ await act(async () => mocks.handleInstallPackages.mock.calls[0][1]());
1414
+ await waitFor(() => expect(downloadByURL).toHaveBeenCalled());
1415
+ expect(getExportMetadata).toHaveBeenCalledTimes(2);
1416
+ expect(downloadAs).toHaveBeenLastCalledWith({
1417
+ format: "geojson",
1418
+ geometry_column: "boundary",
1419
+ });
1420
+ });
1421
+
1422
+ it.each(["missing-package", "missing-crs", "obsolete-choice"] as const)(
1423
+ "does not retry after installation with %s",
1424
+ async (prerequisite) => {
1425
+ const getExportMetadata = vi.fn().mockResolvedValue(metadataWithCrs);
1426
+ downloadAs.mockResolvedValueOnce({
1427
+ url: "",
1428
+ filename: "",
1429
+ code: "missing_packages",
1430
+ missing_packages: ["geopandas"],
1431
+ });
1432
+ renderExportActions({ getExportMetadata });
1433
+ await openDialog();
1434
+ fireEvent.click(
1435
+ await screen.findByRole("button", { name: "GeoJSON options" }),
1436
+ );
1437
+ selectGeometry("boundary");
1438
+ fireEvent.click(screen.getByRole("button", { name: "Download GeoJSON" }));
1439
+ fireEvent.click(
1440
+ await screen.findByRole("button", { name: "Install geopandas" }),
1441
+ );
1442
+ getExportMetadata.mockResolvedValue(
1443
+ prerequisite === "missing-package"
1444
+ ? {
1445
+ ...metadataWithCrs,
1446
+ formats: {
1447
+ geojson: {
1448
+ available: false,
1449
+ reason: "GeoJSON requires geopandas.",
1450
+ missing_packages: ["geopandas"],
1451
+ },
1452
+ },
1453
+ }
1454
+ : prerequisite === "missing-crs"
1455
+ ? geometryMetadata
1456
+ : {
1457
+ ...geometryMetadata,
1458
+ geometry_columns: [geometryMetadata.geometry_columns[0]],
1459
+ },
1460
+ );
1461
+ await act(async () => mocks.handleInstallPackages.mock.calls[0][1]());
1462
+ expect(downloadAs).toHaveBeenCalledTimes(1);
1463
+ expect(downloadByURL).not.toHaveBeenCalled();
1464
+ expect(screen.queryByRole("alert") !== null).toBe(
1465
+ prerequisite === "obsolete-choice",
1466
+ );
1467
+ expect(
1468
+ screen.getByRole("combobox", { name: "Primary geometry" }),
1469
+ ).toHaveDisplayValue(
1470
+ prerequisite === "obsolete-choice" ? "location" : "boundary",
1471
+ );
1472
+ expect(
1473
+ screen.getByRole("button", { name: "Download GeoJSON" }),
1474
+ ).toHaveProperty("disabled", prerequisite !== "obsolete-choice");
1475
+ },
1476
+ );
1477
+
1478
+ it("rejects an obsolete choice after a source change", async () => {
1479
+ const getExportMetadata = vi.fn().mockResolvedValue(metadataWithCrs);
1480
+ const { rerenderExportActions } = renderExportActions({
1481
+ getExportMetadata,
1482
+ metadataSource: "first",
1483
+ });
1484
+ await openDialog();
1485
+ fireEvent.click(
1486
+ await screen.findByRole("button", { name: "GeoJSON options" }),
1487
+ );
1488
+ selectGeometry("boundary");
1489
+ getExportMetadata.mockResolvedValue({
1490
+ ...geometryMetadata,
1491
+ geometry_columns: [geometryMetadata.geometry_columns[0]],
1492
+ });
1493
+ rerenderExportActions({ getExportMetadata, metadataSource: "second" });
1494
+ await waitFor(() =>
1495
+ expect(screen.getByTestId("export-summary-geojson")).toHaveTextContent(
1496
+ "location",
1497
+ ),
1498
+ );
1499
+ fireEvent.click(screen.getByRole("button", { name: "Download GeoJSON" }));
1500
+ await waitFor(() =>
1501
+ expect(downloadAs).toHaveBeenCalledWith({
1502
+ format: "geojson",
1503
+ geometry_column: "location",
1504
+ }),
1505
+ );
1506
+ });
1507
+
1508
+ it("keeps a WASM data URL unchanged", async () => {
1509
+ downloadAs.mockResolvedValue({
1510
+ url: "data:application/geo+json;base64,e30=",
1511
+ filename: "table",
1512
+ });
1513
+ renderExportActions({
1514
+ getExportMetadata: vi.fn().mockResolvedValue(geometryMetadata),
1515
+ });
1516
+ await openDialog();
1517
+ fireEvent.click(
1518
+ await screen.findByRole("button", { name: "Download GeoJSON" }),
1519
+ );
1520
+ await waitFor(() =>
1521
+ expect(downloadByURL).toHaveBeenCalledWith(
1522
+ "data:application/geo+json;base64,e30=",
1523
+ "table.geojson",
1524
+ ),
1525
+ );
1526
+ });
1527
+
1528
+ it("hides package installation in read mode", async () => {
1529
+ renderExportActions(
1530
+ {
1531
+ getExportMetadata: vi.fn().mockResolvedValue({
1532
+ ...geometryMetadata,
1533
+ formats: {
1534
+ geojson: {
1535
+ available: false,
1536
+ reason: "GeoJSON requires geopandas.",
1537
+ missing_packages: ["geopandas"],
1538
+ },
1539
+ },
1540
+ }),
1541
+ },
1542
+ null,
1543
+ "read",
1544
+ );
1545
+ await openDialog();
1546
+ expect(
1547
+ await screen.findByText(
1548
+ "GeoJSON export isn't available in this notebook",
1549
+ ),
1550
+ ).toBeInTheDocument();
1551
+ expect(
1552
+ screen.queryByRole("button", { name: /Install/ }),
1553
+ ).not.toBeInTheDocument();
1554
+ expect(
1555
+ screen.getByRole("button", { name: "Download GeoJSON" }),
1556
+ ).toBeDisabled();
1557
+ });
1558
+
1559
+ it("respects the host size limit for geometry sources", () => {
1560
+ renderExportActions(
1561
+ {
1562
+ getExportMetadata: vi.fn().mockResolvedValue(geometryMetadata),
1563
+ sizeBytes: 101,
1564
+ },
1565
+ { limitBytes: 100, unavailableMessage: "Export is too large." },
1566
+ );
1567
+ expect(screen.getByTestId("export-button")).toBeDisabled();
1568
+ fireEvent.click(screen.getByTestId("export-button"));
1569
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
1570
+ expect(downloadAs).not.toHaveBeenCalled();
1571
+ });
1572
+ });
1573
+
1574
+ describe("ExportActions GeoJSON clipboard", () => {
1575
+ const metadata: ExportMetadata = {
1576
+ ...geometryMetadata,
1577
+ geometry_columns: [
1578
+ geometryMetadata.geometry_columns[0],
1579
+ { name: "boundary", encoding: "objects", crs: "EPSG:3857" },
1580
+ ],
1581
+ };
1582
+ const payload = `{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[20,5]},"properties":{"large":9007199254740993,"secondary":${JSON.stringify(LONG_GEOMETRY_WKT)}}}]}`;
1583
+
1584
+ beforeEach(() => {
1585
+ vi.clearAllMocks();
1586
+ downloadAs.mockResolvedValue({
1587
+ url: "https://example.test/export",
1588
+ filename: "table.geojson",
1589
+ });
1590
+ vi.stubGlobal(
1591
+ "fetch",
1592
+ vi.fn().mockResolvedValue({
1593
+ ok: true,
1594
+ text: vi.fn().mockResolvedValue(payload),
1595
+ }),
1596
+ );
1597
+ });
1598
+
1599
+ afterEach(() => vi.unstubAllGlobals());
1600
+
1601
+ it("copies the exact FeatureCollection with the selected geometry", async () => {
1602
+ renderExportActions({
1603
+ getExportMetadata: vi.fn().mockResolvedValue(metadata),
1604
+ });
1605
+ await openDialog();
1606
+ fireEvent.click(
1607
+ await screen.findByRole("button", { name: "GeoJSON options" }),
1608
+ );
1609
+ selectGeometry("boundary");
1610
+ fireEvent.click(screen.getByRole("button", { name: "Copy GeoJSON" }));
1611
+ await waitFor(() => expect(copyToClipboard).toHaveBeenCalledWith(payload));
1612
+ expect(downloadAs).toHaveBeenCalledWith({
1613
+ format: "geojson",
1614
+ geometry_column: "boundary",
1615
+ });
1616
+ expect(downloadByURL).not.toHaveBeenCalled();
1617
+ expect(toast).toHaveBeenCalledWith({ title: "Copied to clipboard" });
1618
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
1619
+ });
1620
+
1621
+ it.each([
1622
+ { name: "a missing CRS", metadata: geometryMetadata, choice: "boundary" },
1623
+ {
1624
+ name: "an ambiguous geometry",
1625
+ metadata: {
1626
+ ...metadata,
1627
+ primary_geometry_column: null,
1628
+ default_geometry_column: null,
1629
+ },
1630
+ choice: null,
1631
+ },
1632
+ {
1633
+ name: "missing packages",
1634
+ metadata: {
1635
+ ...metadata,
1636
+ formats: {
1637
+ geojson: {
1638
+ available: false,
1639
+ reason: "Install geopandas.",
1640
+ missing_packages: ["geopandas"],
1641
+ },
1642
+ },
1643
+ },
1644
+ choice: "location",
1645
+ },
1646
+ ])("disables copy for $name", async ({ metadata, choice }) => {
1647
+ renderExportActions({
1648
+ getExportMetadata: vi.fn().mockResolvedValue(metadata),
1649
+ });
1650
+ await openDialog();
1651
+ fireEvent.click(
1652
+ await screen.findByRole("button", { name: "GeoJSON options" }),
1653
+ );
1654
+ if (choice !== null) {
1655
+ selectGeometry(choice);
1656
+ }
1657
+ expect(screen.getByRole("button", { name: "Copy GeoJSON" })).toBeDisabled();
1658
+ fireEvent.click(screen.getByRole("button", { name: "Copy GeoJSON" }));
1659
+ expect(downloadAs).not.toHaveBeenCalled();
1660
+ expect(copyToClipboard).not.toHaveBeenCalled();
1661
+ });
1662
+
1663
+ it("preserves the copy destination and geometry after installation", async () => {
1664
+ downloadAs.mockResolvedValueOnce({
1665
+ url: "",
1666
+ filename: "",
1667
+ code: "missing_packages",
1668
+ missing_packages: ["geopandas"],
1669
+ });
1670
+ const getExportMetadata = vi.fn().mockResolvedValue(metadata);
1671
+ renderExportActions({ getExportMetadata });
1672
+ await openDialog();
1673
+ fireEvent.click(
1674
+ await screen.findByRole("button", { name: "GeoJSON options" }),
1675
+ );
1676
+ selectGeometry("boundary");
1677
+ fireEvent.click(screen.getByRole("button", { name: "Copy GeoJSON" }));
1678
+ fireEvent.click(
1679
+ await screen.findByRole("button", { name: "Install geopandas" }),
1680
+ );
1681
+ expect(copyToClipboard).not.toHaveBeenCalled();
1682
+ await act(async () => mocks.handleInstallPackages.mock.calls[0][1]());
1683
+ await waitFor(() => expect(copyToClipboard).toHaveBeenCalledWith(payload));
1684
+ expect(downloadAs).toHaveBeenLastCalledWith({
1685
+ format: "geojson",
1686
+ geometry_column: "boundary",
1687
+ });
1688
+ expect(getExportMetadata).toHaveBeenCalledTimes(2);
1689
+ expect(downloadByURL).not.toHaveBeenCalled();
1690
+ });
1691
+
1692
+ it("preserves the choice after a backend failure", async () => {
1693
+ downloadAs.mockResolvedValueOnce({
1694
+ url: "",
1695
+ filename: "",
1696
+ code: "conversion_failed",
1697
+ error: "Invalid geometry.",
1698
+ });
1699
+ renderExportActions({
1700
+ getExportMetadata: vi.fn().mockResolvedValue(metadata),
1701
+ });
1702
+ await openDialog();
1703
+ fireEvent.click(
1704
+ await screen.findByRole("button", { name: "GeoJSON options" }),
1705
+ );
1706
+ selectGeometry("boundary");
1707
+ fireEvent.click(screen.getByRole("button", { name: "Copy GeoJSON" }));
1708
+ expect(await screen.findByRole("alert")).toHaveTextContent(
1709
+ "Failed to copy to clipboard",
1710
+ );
1711
+ expect(copyToClipboard).not.toHaveBeenCalled();
1712
+ expect(
1713
+ screen.getByRole("combobox", { name: "Primary geometry" }),
1714
+ ).toHaveDisplayValue("boundary");
1715
+ fireEvent.click(screen.getByRole("button", { name: "Copy GeoJSON" }));
1716
+ await waitFor(() => expect(copyToClipboard).toHaveBeenCalledWith(payload));
1717
+ });
1718
+
1719
+ it("reports clipboard rejection in the GeoJSON row", async () => {
1720
+ vi.mocked(copyToClipboard).mockRejectedValueOnce(
1721
+ new Error("Clipboard blocked"),
1722
+ );
1723
+ renderExportActions({
1724
+ getExportMetadata: vi.fn().mockResolvedValue(metadata),
1725
+ });
1726
+ await openDialog();
1727
+ fireEvent.click(
1728
+ await screen.findByRole("button", { name: "Copy GeoJSON" }),
1729
+ );
1730
+ const alert = await screen.findByRole("alert");
1731
+ expect(screen.getByTestId("export-row-geojson")).toContainElement(alert);
1732
+ expect(alert).toHaveTextContent("Clipboard blocked");
1733
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
1734
+ });
1735
+
1736
+ it("does not copy a late response after the source changes", async () => {
1737
+ let resolveText: (value: string) => void = () => undefined;
1738
+ const text = vi.fn().mockReturnValue(
1739
+ new Promise<string>((resolve) => {
1740
+ resolveText = resolve;
1741
+ }),
1742
+ );
1743
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, text }));
1744
+ const getExportMetadata = vi.fn().mockResolvedValue(metadata);
1745
+ const { rerenderExportActions } = renderExportActions({
1746
+ getExportMetadata,
1747
+ metadataSource: "first",
1748
+ });
1749
+ await openDialog();
1750
+ fireEvent.click(
1751
+ await screen.findByRole("button", { name: "Copy GeoJSON" }),
1752
+ );
1753
+ await waitFor(() => expect(text).toHaveBeenCalled());
1754
+ rerenderExportActions({ getExportMetadata, metadataSource: "second" });
1755
+ await act(async () => resolveText(payload));
1756
+ expect(copyToClipboard).not.toHaveBeenCalled();
1757
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
1758
+ });
1759
+ });