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

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
@@ -3,10 +3,15 @@
3
3
  import { useAtomValue } from "jotai";
4
4
  import {
5
5
  AlertCircleIcon,
6
+ BracesIcon,
7
+ BrickWallIcon,
6
8
  ChevronRightIcon,
7
9
  ClipboardCopyIcon,
8
10
  DownloadIcon,
11
+ FileTextIcon,
9
12
  InfoIcon,
13
+ MapIcon,
14
+ TableIcon,
10
15
  } from "lucide-react";
11
16
  import React from "react";
12
17
  import { downloadSizeLimitAtom } from "./download-policy/atoms";
@@ -14,6 +19,8 @@ import type {
14
19
  DownloadAsArgs,
15
20
  DownloadAsOptions,
16
21
  DownloadFormat,
22
+ ExportMetadata,
23
+ GetExportMetadata,
17
24
  } from "./schemas";
18
25
  import { logNever } from "@/utils/assertNever";
19
26
  import { cn } from "@/utils/cn";
@@ -47,6 +54,7 @@ const EXPORT_OPTIONS = [
47
54
  label: "CSV",
48
55
  format: "csv",
49
56
  description: "Comma-separated values",
57
+ icon: TableIcon,
50
58
  canDownload: true,
51
59
  canCopy: true,
52
60
  },
@@ -54,6 +62,7 @@ const EXPORT_OPTIONS = [
54
62
  label: "TSV",
55
63
  format: "tsv",
56
64
  description: "Best for Excel and Google Sheets",
65
+ icon: TableIcon,
57
66
  canDownload: true,
58
67
  canCopy: true,
59
68
  },
@@ -61,6 +70,7 @@ const EXPORT_OPTIONS = [
61
70
  label: "JSON",
62
71
  format: "json",
63
72
  description: "Raw JSON data",
73
+ icon: BracesIcon,
64
74
  canDownload: true,
65
75
  canCopy: true,
66
76
  },
@@ -68,13 +78,23 @@ const EXPORT_OPTIONS = [
68
78
  label: "Parquet",
69
79
  format: "parquet",
70
80
  description: "Columnar binary format",
81
+ icon: BrickWallIcon,
71
82
  canDownload: true,
72
83
  canCopy: false,
73
84
  },
85
+ {
86
+ label: "GeoJSON",
87
+ format: "geojson",
88
+ description: "Geometry in longitude and latitude",
89
+ icon: MapIcon,
90
+ canDownload: true,
91
+ canCopy: true,
92
+ },
74
93
  {
75
94
  label: "Markdown",
76
95
  format: "markdown",
77
96
  description: "Preserves hyperlinks and formatting",
97
+ icon: FileTextIcon,
78
98
  canDownload: false,
79
99
  canCopy: true,
80
100
  },
@@ -82,6 +102,10 @@ const EXPORT_OPTIONS = [
82
102
 
83
103
  type ExportFormat = (typeof EXPORT_OPTIONS)[number]["format"];
84
104
  type CopyFormat = Exclude<ExportFormat, "parquet">;
105
+ type GeographicFormat = "parquet" | "geojson";
106
+
107
+ const isGeographic = (format: ExportFormat): format is GeographicFormat =>
108
+ format === "parquet" || format === "geojson";
85
109
  type ExportAction =
86
110
  | { destination: "download"; format: DownloadFormat }
87
111
  | { destination: "copy"; format: CopyFormat };
@@ -95,10 +119,8 @@ type ExportFailure =
95
119
  }
96
120
  | {
97
121
  kind: "missing-packages";
98
- title: string;
99
122
  description?: string | null;
100
123
  packages: string[];
101
- featureName: string;
102
124
  action: ExportAction;
103
125
  };
104
126
 
@@ -108,6 +130,7 @@ const COPY_SOURCE_FORMAT: Record<CopyFormat, DownloadFormat> = {
108
130
  csv: "csv",
109
131
  tsv: "tsv",
110
132
  json: "json",
133
+ geojson: "geojson",
111
134
  markdown: "json",
112
135
  };
113
136
 
@@ -140,7 +163,7 @@ const DEFAULT_SETTINGS: ExportSettings = {
140
163
 
141
164
  type ConfigurableFormat = keyof ExportSettings;
142
165
 
143
- const isConfigurable = (format: ExportFormat): format is ConfigurableFormat =>
166
+ const isConfigurable = (format: ExportFormat): format is keyof ExportSettings =>
144
167
  format in DEFAULT_SETTINGS;
145
168
 
146
169
  /**
@@ -182,6 +205,7 @@ function requestOptions(
182
205
  }
183
206
  break;
184
207
  case "parquet":
208
+ case "geojson":
185
209
  break;
186
210
  default:
187
211
  logNever(format);
@@ -212,6 +236,7 @@ function settingsSummary(
212
236
  ? "Escapes non-ASCII"
213
237
  : "Keeps non-ASCII";
214
238
  case "parquet":
239
+ case "geojson":
215
240
  case "markdown":
216
241
  return null;
217
242
  default:
@@ -222,6 +247,8 @@ function settingsSummary(
222
247
 
223
248
  export interface ExportActionProps {
224
249
  downloadAs: DownloadAsArgs;
250
+ getExportMetadata?: GetExportMetadata;
251
+ metadataSource?: unknown;
225
252
  // JSON-serialized size of the currently-rendered data. Used together with
226
253
  // downloadSizeLimitAtom to disable the Export button when a host (e.g.,
227
254
  // marimo-lsp inside VS Code) declares a download size cap. Null/undefined
@@ -230,8 +257,14 @@ export interface ExportActionProps {
230
257
  sizeBytesIsLoading?: boolean;
231
258
  }
232
259
 
233
- const labelForFormat = (format: ExportFormat): string =>
234
- EXPORT_OPTIONS.find((option) => option.format === format)?.label ?? format;
260
+ const labelForFormat = (
261
+ format: ExportFormat | DownloadFormat,
262
+ hasGeometry = false,
263
+ ): string =>
264
+ format === "parquet" && hasGeometry
265
+ ? "GeoParquet"
266
+ : (EXPORT_OPTIONS.find((option) => option.format === format)?.label ??
267
+ format);
235
268
 
236
269
  const failureTitleForAction = (action: ExportAction): string =>
237
270
  action.destination === "download"
@@ -242,15 +275,62 @@ const failureDescription = (error: unknown): string =>
242
275
  typeof error === "string" ? error : prettyError(error);
243
276
 
244
277
  export const ExportActions: React.FC<ExportActionProps> = (props) => {
278
+ const { getExportMetadata, metadataSource } = props;
245
279
  const [exportDialogOpen, setExportDialogOpen] = React.useState(false);
246
280
  const [failure, setFailure] = React.useState<ExportFailure | null>(null);
247
281
  const [settings, setSettings] =
248
282
  React.useState<ExportSettings>(DEFAULT_SETTINGS);
249
- const [expandedFormat, setExpandedFormat] =
250
- React.useState<ConfigurableFormat | null>(null);
283
+ const [metadata, setMetadata] = React.useState<ExportMetadata | null>(null);
284
+ const [metadataLoading, setMetadataLoading] = React.useState(false);
285
+ const [metadataError, setMetadataError] = React.useState<string | null>(null);
286
+ const [metadataRefresh, setMetadataRefresh] = React.useState(0);
287
+ const [geometryColumn, setGeometryColumn] = React.useState<string | null>(
288
+ null,
289
+ );
290
+ const hasGeometry = !!metadata?.geometry_columns.length;
291
+ const [expandedFormat, setExpandedFormat] = React.useState<
292
+ ConfigurableFormat | GeographicFormat | null
293
+ >(null);
251
294
  const triggerRef = React.useRef<HTMLButtonElement>(null);
252
295
  const dialogRef = React.useRef<HTMLDivElement>(null);
253
296
  const latestActionId = React.useRef(0);
297
+ React.useEffect(() => {
298
+ latestActionId.current += 1;
299
+ }, [metadataSource]);
300
+ React.useEffect(() => {
301
+ if (!exportDialogOpen || !getExportMetadata) {
302
+ return;
303
+ }
304
+ let active = true;
305
+ setMetadata(null);
306
+ setMetadataLoading(true);
307
+ setMetadataError(null);
308
+ void getExportMetadata({}).then(
309
+ (result) => {
310
+ if (!active) {
311
+ return;
312
+ }
313
+ setMetadata(result);
314
+ setGeometryColumn((current) =>
315
+ current !== null &&
316
+ result.geometry_columns.some((column) => column.name === current)
317
+ ? current
318
+ : result.default_geometry_column,
319
+ );
320
+ setMetadataLoading(false);
321
+ },
322
+ (error: unknown) => {
323
+ if (!active) {
324
+ return;
325
+ }
326
+ setMetadataError(failureDescription(error));
327
+ setMetadataLoading(false);
328
+ },
329
+ );
330
+ return () => {
331
+ active = false;
332
+ };
333
+ }, [exportDialogOpen, getExportMetadata, metadataSource, metadataRefresh]);
254
334
  const policy = useAtomValue(downloadSizeLimitAtom);
255
335
  const overLimit = !!(
256
336
  policy &&
@@ -288,6 +368,9 @@ export const ExportActions: React.FC<ExportActionProps> = (props) => {
288
368
  setFailure(null);
289
369
  setExpandedFormat(null);
290
370
  setSettings(DEFAULT_SETTINGS);
371
+ setMetadata(null);
372
+ setMetadataError(null);
373
+ setGeometryColumn(null);
291
374
  }
292
375
  };
293
376
 
@@ -313,27 +396,33 @@ export const ExportActions: React.FC<ExportActionProps> = (props) => {
313
396
  filename: string;
314
397
  } | null> => {
315
398
  const options = requestOptions(settings, format, action.destination);
316
- const response = await props.downloadAs(
317
- options ? { format, options } : { format },
318
- );
399
+ const request = {
400
+ format,
401
+ ...(options ? { options } : {}),
402
+ ...(isGeographic(format) && geometryColumn !== null
403
+ ? { geometry_column: geometryColumn }
404
+ : {}),
405
+ };
406
+ const response = await props.downloadAs(request);
407
+ if (actionId !== latestActionId.current) {
408
+ return null;
409
+ }
319
410
 
320
411
  if (response.missing_packages && response.missing_packages.length > 0) {
321
412
  setActionFailure(actionId, {
322
413
  kind: "missing-packages",
323
- title: "Export failed",
324
414
  packages: response.missing_packages,
325
- featureName: `${labelForFormat(action.format)} export`,
326
415
  description: response.error,
327
416
  action,
328
417
  });
329
418
  return null;
330
419
  }
331
420
 
332
- if (response.error) {
421
+ if (response.error || response.code || !response.url) {
333
422
  setActionFailure(actionId, {
334
423
  kind: "error",
335
424
  title: failureTitleForAction(action),
336
- description: response.error,
425
+ description: response.error ?? "The export did not produce a file.",
337
426
  action,
338
427
  });
339
428
  return null;
@@ -348,7 +437,7 @@ export const ExportActions: React.FC<ExportActionProps> = (props) => {
348
437
  const handleDownload = async (format: DownloadFormat) => {
349
438
  const action: ExportAction = { destination: "download", format };
350
439
  const actionId = beginAction();
351
- const label = labelForFormat(format);
440
+ const label = labelForFormat(format, hasGeometry);
352
441
  try {
353
442
  const ok = await withLoadingToast(
354
443
  `Preparing ${label} export...`,
@@ -357,6 +446,9 @@ export const ExportActions: React.FC<ExportActionProps> = (props) => {
357
446
  if (!result) {
358
447
  return false;
359
448
  }
449
+ if (actionId !== latestActionId.current) {
450
+ return false;
451
+ }
360
452
  const rawName = (result.filename ?? "").trim();
361
453
  const baseName = Filenames.withoutExtension(rawName) || "download";
362
454
  const downloadName = `${baseName}.${format}`;
@@ -396,7 +488,7 @@ export const ExportActions: React.FC<ExportActionProps> = (props) => {
396
488
  actionId: number,
397
489
  ) => {
398
490
  await withLoadingToast(
399
- `Preparing ${labelForFormat(format)} for clipboard...`,
491
+ `Preparing ${labelForFormat(format, hasGeometry)} for clipboard...`,
400
492
  async () => {
401
493
  const sourceFormat = COPY_SOURCE_FORMAT[format];
402
494
  const result = await resolveDownloadUrl(sourceFormat, action, actionId);
@@ -408,6 +500,7 @@ export const ExportActions: React.FC<ExportActionProps> = (props) => {
408
500
  switch (format) {
409
501
  case "tsv":
410
502
  case "csv":
503
+ case "geojson":
411
504
  text = await fetchText(result.url);
412
505
  break;
413
506
  case "json": {
@@ -425,6 +518,9 @@ export const ExportActions: React.FC<ExportActionProps> = (props) => {
425
518
  return;
426
519
  }
427
520
 
521
+ if (actionId !== latestActionId.current) {
522
+ return;
523
+ }
428
524
  await copyToClipboard(text);
429
525
  toast({
430
526
  title: "Copied to clipboard",
@@ -457,8 +553,61 @@ export const ExportActions: React.FC<ExportActionProps> = (props) => {
457
553
  }
458
554
  };
459
555
 
556
+ const retryAfterInstall = async (action: ExportAction) => {
557
+ if (isGeographic(action.format) && getExportMetadata) {
558
+ const actionId = latestActionId.current;
559
+ let result: ExportMetadata;
560
+ try {
561
+ result = await getExportMetadata({});
562
+ } catch (error) {
563
+ if (actionId === latestActionId.current) {
564
+ setMetadataError(failureDescription(error));
565
+ }
566
+ return;
567
+ }
568
+ if (actionId !== latestActionId.current) {
569
+ return;
570
+ }
571
+ setMetadata(result);
572
+ setMetadataError(null);
573
+ if (result.formats[action.format]?.available === false) {
574
+ setFailure(null);
575
+ return;
576
+ }
577
+ if (
578
+ geometryColumn !== null &&
579
+ !result.geometry_columns.some(
580
+ (column) => column.name === geometryColumn,
581
+ )
582
+ ) {
583
+ setGeometryColumn(result.default_geometry_column);
584
+ setActionFailure(actionId, {
585
+ kind: "error",
586
+ title: failureTitleForAction(action),
587
+ description:
588
+ "The selected geometry is no longer available. Choose another geometry to export.",
589
+ action,
590
+ });
591
+ return;
592
+ }
593
+ if (result.geometry_columns.length > 0 && geometryColumn === null) {
594
+ return;
595
+ }
596
+ if (
597
+ action.format === "geojson" &&
598
+ !result.geometry_columns.find(
599
+ (column) => column.name === geometryColumn,
600
+ )?.crs
601
+ ) {
602
+ setFailure(null);
603
+ return;
604
+ }
605
+ }
606
+ retryAction(action);
607
+ };
608
+
460
609
  const toggleExpanded = (format: ExportFormat) => {
461
- if (!isConfigurable(format)) {
610
+ if (!isConfigurable(format) && !(isGeographic(format) && hasGeometry)) {
462
611
  return;
463
612
  }
464
613
  setExpandedFormat((current) => (current === format ? null : format));
@@ -504,30 +653,80 @@ export const ExportActions: React.FC<ExportActionProps> = (props) => {
504
653
  aria-label="Export formats"
505
654
  className="list-none overflow-hidden rounded-md border divide-y"
506
655
  >
507
- {EXPORT_OPTIONS.map((option) => {
508
- const configurableFormat = isConfigurable(option.format)
509
- ? option.format
510
- : null;
656
+ {EXPORT_OPTIONS.filter(
657
+ (option) => option.format !== "geojson" || hasGeometry,
658
+ ).map((option) => {
659
+ const optionLabel = labelForFormat(option.format, hasGeometry);
660
+ const configurableFormat =
661
+ isConfigurable(option.format) ||
662
+ (isGeographic(option.format) && hasGeometry)
663
+ ? option.format
664
+ : null;
511
665
  const expanded =
512
666
  configurableFormat !== null &&
513
667
  expandedFormat === configurableFormat;
514
668
  const panelId = `export-options-${option.format}`;
515
669
  const rowFailure =
516
670
  failure?.action.format === option.format ? failure : null;
517
- const summary = settingsSummary(settings, option.format);
671
+ const summary =
672
+ isGeographic(option.format) && hasGeometry
673
+ ? geometryColumn === ""
674
+ ? "(unnamed geometry)"
675
+ : (geometryColumn ?? "Choose geometry")
676
+ : settingsSummary(settings, option.format);
677
+ const selectedGeometryIndex =
678
+ metadata?.geometry_columns.findIndex(
679
+ (column) => column.name === geometryColumn,
680
+ ) ?? -1;
681
+ const eligibility = isGeographic(option.format)
682
+ ? metadata?.formats[option.format]
683
+ : undefined;
684
+ const selectedGeometry =
685
+ metadata?.geometry_columns[selectedGeometryIndex];
686
+ const missingCrs =
687
+ option.format === "geojson" &&
688
+ selectedGeometry !== undefined &&
689
+ !selectedGeometry.crs;
690
+ const geometryReason = metadataError
691
+ ? `Could not load geometry options: ${metadataError}`
692
+ : (eligibility?.reason ??
693
+ (hasGeometry && geometryColumn === null
694
+ ? `Choose a primary geometry column to export ${optionLabel}.`
695
+ : missingCrs
696
+ ? `Geometry column ${geometryColumn === "" ? "(unnamed geometry)" : geometryColumn} needs a CRS for GeoJSON export. Declare its source CRS in Python before exporting.`
697
+ : null));
698
+ const missingPackageFailure =
699
+ rowFailure?.kind === "missing-packages" ? rowFailure : null;
700
+ const missingPackages =
701
+ missingPackageFailure?.packages ??
702
+ (eligibility?.available === false
703
+ ? eligibility.missing_packages
704
+ : []);
705
+ const geographicUnavailable =
706
+ isGeographic(option.format) &&
707
+ ((!!getExportMetadata && !metadata) ||
708
+ metadataLoading ||
709
+ !!metadataError ||
710
+ eligibility?.available === false ||
711
+ missingCrs ||
712
+ (!!metadata?.geometry_columns.length &&
713
+ geometryColumn === null));
714
+ const copyUnavailable =
715
+ isGeographic(option.format) &&
716
+ (geographicUnavailable || missingPackages.length > 0);
518
717
  return (
519
718
  <li
520
719
  key={option.format}
521
720
  data-testid={`export-row-${option.format}`}
522
721
  className={cn(expanded && "bg-muted/40")}
523
722
  >
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">
723
+ <div className="grid min-h-[60px] grid-cols-[28px_minmax(0,1fr)_auto] items-center gap-x-1 px-2.5 py-2 hover:bg-accent/50">
525
724
  {configurableFormat ? (
526
725
  <Button
527
726
  type="button"
528
727
  variant="ghost"
529
728
  size="icon"
530
- aria-label={`${option.label} options`}
729
+ aria-label={`${optionLabel} options`}
531
730
  aria-expanded={expanded}
532
731
  aria-controls={panelId}
533
732
  onClick={() => toggleExpanded(option.format)}
@@ -542,30 +741,39 @@ export const ExportActions: React.FC<ExportActionProps> = (props) => {
542
741
  ) : (
543
742
  <span aria-hidden={true} className="h-6 w-6" />
544
743
  )}
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}
744
+ <div className="flex min-w-0 items-center gap-3 pl-1">
745
+ <option.icon
746
+ aria-hidden={true}
747
+ data-testid={`export-format-icon-${option.format}`}
748
+ className="h-4 w-4 shrink-0 text-muted-foreground"
749
+ />
750
+ <div className="grid min-w-0 gap-1">
751
+ <span className="flex min-w-0 items-baseline gap-x-2">
752
+ <span className="text-sm font-medium leading-tight">
753
+ {optionLabel}
556
754
  </span>
557
- )}
558
- </span>
559
- <span className="text-xs text-muted-foreground leading-tight">
560
- {option.description}
561
- </span>
755
+ {summary && (
756
+ <span
757
+ data-testid={`export-summary-${option.format}`}
758
+ className="min-w-0 truncate text-xs text-muted-foreground"
759
+ >
760
+ {summary}
761
+ </span>
762
+ )}
763
+ </span>
764
+ <span className="text-xs text-muted-foreground leading-tight">
765
+ {option.format === "parquet" && hasGeometry
766
+ ? "Parquet with geometry and CRS metadata"
767
+ : option.description}
768
+ </span>
769
+ </div>
562
770
  </div>
563
771
  <div className="flex items-center gap-0.5">
564
772
  <Tooltip
565
773
  content={
566
774
  option.canDownload
567
- ? `Download ${option.label}`
568
- : `${option.label} is available for copy only.`
775
+ ? `Download ${optionLabel}`
776
+ : `${optionLabel} is available for copy only.`
569
777
  }
570
778
  >
571
779
  <span className="inline-flex">
@@ -573,10 +781,16 @@ export const ExportActions: React.FC<ExportActionProps> = (props) => {
573
781
  type="button"
574
782
  variant="ghost"
575
783
  size="icon"
576
- aria-label={`Download ${option.label}`}
577
- disabled={!option.canDownload}
784
+ aria-label={`Download ${optionLabel}`}
785
+ disabled={
786
+ !option.canDownload ||
787
+ geographicUnavailable ||
788
+ missingPackages.length > 0
789
+ }
578
790
  onClick={
579
- option.canDownload
791
+ option.canDownload &&
792
+ !geographicUnavailable &&
793
+ missingPackages.length === 0
580
794
  ? () => {
581
795
  void handleDownload(option.format);
582
796
  }
@@ -590,8 +804,8 @@ export const ExportActions: React.FC<ExportActionProps> = (props) => {
590
804
  <Tooltip
591
805
  content={
592
806
  option.canCopy
593
- ? `Copy ${option.label}`
594
- : `${option.label} is available for download only.`
807
+ ? `Copy ${optionLabel}`
808
+ : `${optionLabel} is available for download only.`
595
809
  }
596
810
  >
597
811
  <span className="inline-flex">
@@ -599,10 +813,10 @@ export const ExportActions: React.FC<ExportActionProps> = (props) => {
599
813
  type="button"
600
814
  variant="ghost"
601
815
  size="icon"
602
- aria-label={`Copy ${option.label}`}
603
- disabled={!option.canCopy}
816
+ aria-label={`Copy ${optionLabel}`}
817
+ disabled={!option.canCopy || copyUnavailable}
604
818
  onClick={
605
- option.canCopy
819
+ option.canCopy && !copyUnavailable
606
820
  ? () => {
607
821
  void handleCopyAction(option.format);
608
822
  }
@@ -620,42 +834,112 @@ export const ExportActions: React.FC<ExportActionProps> = (props) => {
620
834
  id={panelId}
621
835
  className="grid grid-cols-2 gap-3 border-t bg-muted/50 px-3.5 py-3.5 pl-[52px]"
622
836
  >
623
- <legend className="sr-only">{option.label} options</legend>
624
- <ExportSettingsFields
625
- format={configurableFormat}
626
- settings={settings}
627
- onChange={setSettings}
628
- />
837
+ <legend className="sr-only">{optionLabel} options</legend>
838
+ {isGeographic(configurableFormat) && (
839
+ <p className="col-span-2 text-xs text-muted-foreground">
840
+ {configurableFormat === "geojson"
841
+ ? "Reproject the selected geometry to longitude and latitude (WGS 84). Keep other geometry columns as complete WKT in their source coordinates."
842
+ : "Use the selected column as the primary geometry. Preserve all geometry columns and their coordinate reference systems (CRS)."}
843
+ </p>
844
+ )}
845
+ {isGeographic(configurableFormat) ? (
846
+ <SettingField
847
+ id="export-primary-geometry"
848
+ label="Primary geometry"
849
+ help={`Geometry column to use as the primary geometry in the ${optionLabel} file.`}
850
+ >
851
+ <NativeSelect
852
+ id="export-primary-geometry"
853
+ className="mb-0 w-full"
854
+ value={String(selectedGeometryIndex)}
855
+ onChange={(event) => {
856
+ const index = Number(event.target.value);
857
+ setGeometryColumn(
858
+ metadata?.geometry_columns[index]?.name ?? null,
859
+ );
860
+ }}
861
+ >
862
+ <option value="-1" disabled={true}>
863
+ Choose geometry
864
+ </option>
865
+ {metadata?.geometry_columns.map((column, index) => (
866
+ <option key={column.name} value={String(index)}>
867
+ {column.name || "(unnamed geometry)"}
868
+ </option>
869
+ ))}
870
+ </NativeSelect>
871
+ </SettingField>
872
+ ) : (
873
+ <ExportSettingsFields
874
+ format={configurableFormat}
875
+ settings={settings}
876
+ onChange={setSettings}
877
+ />
878
+ )}
629
879
  </fieldset>
630
880
  )}
631
- {rowFailure && (
881
+ {isGeographic(option.format) &&
882
+ missingPackages.length === 0 &&
883
+ geometryReason && (
884
+ <div className="flex items-center justify-between gap-2 border-t px-3 py-2 pl-[42px] text-xs text-muted-foreground">
885
+ <span>{geometryReason}</span>
886
+ {metadataError && (
887
+ <Button
888
+ type="button"
889
+ size="xs"
890
+ variant="outline"
891
+ onClick={() =>
892
+ setMetadataRefresh((current) => current + 1)
893
+ }
894
+ >
895
+ Retry
896
+ </Button>
897
+ )}
898
+ </div>
899
+ )}
900
+ {missingPackages.length > 0 && (
632
901
  <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>
902
+ <MissingPackagePrompt
903
+ packages={missingPackages}
904
+ featureName={`${optionLabel} export`}
905
+ description={
906
+ missingPackageFailure?.description ??
907
+ eligibility?.reason
908
+ }
909
+ onInstall={() => {
910
+ void retryAfterInstall(
911
+ missingPackageFailure?.action ?? {
912
+ destination: "download",
913
+ format:
914
+ option.format === "geojson"
915
+ ? "geojson"
916
+ : "parquet",
917
+ },
918
+ );
919
+ }}
920
+ className="items-start"
921
+ />
657
922
  </div>
658
923
  )}
924
+ {rowFailure?.kind === "error" &&
925
+ missingPackages.length === 0 && (
926
+ <div className="border-t px-3 py-2 pl-[42px]">
927
+ <Alert
928
+ variant="destructive"
929
+ className="p-3 has-[svg]:pl-9 [&>svg]:left-3 [&>svg]:top-3"
930
+ >
931
+ <AlertCircleIcon className="h-4 w-4" />
932
+ <div>
933
+ <AlertTitle className="text-sm">
934
+ {rowFailure.title}
935
+ </AlertTitle>
936
+ <AlertDescription className="text-xs">
937
+ {rowFailure.description}
938
+ </AlertDescription>
939
+ </div>
940
+ </Alert>
941
+ </div>
942
+ )}
659
943
  </li>
660
944
  );
661
945
  })}