Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions frontend/plugins.openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3140,6 +3140,16 @@ components:
- json
- parquet
- tsv
locale:
type: object
properties:
tag:
type: string
decimal_separator:
type: string
required:
- tag
- decimal_separator
required:
- format
marimo-dataframe.download_as.output:
Expand Down Expand Up @@ -4577,6 +4587,16 @@ components:
- json
- parquet
- tsv
locale:
type: object
properties:
tag:
type: string
decimal_separator:
type: string
required:
- tag
- decimal_separator
required:
- format
marimo-table.download_as.output:
Expand Down
121 changes: 121 additions & 0 deletions frontend/src/components/data-table/__tests__/export-actions.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/* Copyright 2026 Marimo. All rights reserved. */

import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import { toast } from "@/components/ui/use-toast";
import { downloadByURL } from "@/utils/download";
import {
buildDownloadAsRequest,
ExportMenu,
sourceFormatForCopy,
} from "../export-actions";

vi.mock("@/components/ui/use-toast", () => ({
toast: vi.fn(() => ({ dismiss: vi.fn(), update: vi.fn() })),
}));

vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => children,
TooltipProvider: ({ children }: { children: React.ReactNode }) => children,
}));

vi.mock("@/utils/download", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/utils/download")>();
return { ...actual, downloadByURL: vi.fn() };
});

const PT_BR_LOCALE = { tag: "pt-BR", decimal_separator: "," };

beforeEach(() => {
vi.clearAllMocks();
});

describe("ExportMenu", () => {
it("stops a download and shows a standalone export error", async () => {
const downloadAs = vi.fn().mockResolvedValue({
url: "",
filename: "",
error: "The export locale is invalid.",
});
render(
<TooltipProvider>
<ExportMenu downloadAs={downloadAs} />
</TooltipProvider>,
);

fireEvent.keyDown(screen.getByTestId("export-button"), {
key: "ArrowDown",
});
fireEvent.click(
await screen.findByRole("menuitem", {
name: "Download CSV: Comma-separated values",
}),
);

await waitFor(() => {
expect(toast).toHaveBeenCalledWith({
title: "Export failed",
description: "The export locale is invalid.",
variant: "danger",
});
});
expect(downloadByURL).not.toHaveBeenCalled();
});
});

describe("buildDownloadAsRequest", () => {
it("includes locale for CSV and TSV", () => {
expect(buildDownloadAsRequest("csv", "pt-BR")).toEqual({
format: "csv",
locale: PT_BR_LOCALE,
});
expect(buildDownloadAsRequest("tsv", "pt-BR")).toEqual({
format: "tsv",
locale: PT_BR_LOCALE,
});
});

it("omits locale for JSON and Parquet", () => {
expect(buildDownloadAsRequest("json", "pt-BR")).toEqual({ format: "json" });
expect(buildDownloadAsRequest("parquet", "pt-BR")).toEqual({
format: "parquet",
});
});
});

describe("file and clipboard request parity", () => {
it("sends the same locale for CSV download and clipboard copy", () => {
const download = buildDownloadAsRequest("csv", "pt-BR");
const clipboard = buildDownloadAsRequest(
sourceFormatForCopy("csv"),
"pt-BR",
);
expect(download).toEqual({ format: "csv", locale: PT_BR_LOCALE });
expect(clipboard).toEqual(download);
});

it("sends the same locale for TSV download and clipboard copy", () => {
const download = buildDownloadAsRequest("tsv", "pt-BR");
const clipboard = buildDownloadAsRequest(
sourceFormatForCopy("tsv"),
"pt-BR",
);
expect(download).toEqual({ format: "tsv", locale: PT_BR_LOCALE });
expect(clipboard).toEqual(download);
});

it("omits locale for JSON, Parquet, and Markdown source requests", () => {
expect(buildDownloadAsRequest("json", "pt-BR")).toEqual({ format: "json" });
expect(buildDownloadAsRequest("parquet", "pt-BR")).toEqual({
format: "parquet",
});
expect(
buildDownloadAsRequest(sourceFormatForCopy("json"), "pt-BR"),
).toEqual({ format: "json" });
expect(
buildDownloadAsRequest(sourceFormatForCopy("markdown"), "pt-BR"),
).toEqual({ format: "json" });
});
});
46 changes: 46 additions & 0 deletions frontend/src/components/data-table/__tests__/export-locale.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/* Copyright 2026 Marimo. All rights reserved. */

import { describe, expect, it } from "vitest";
import { resolveExportLocale } from "../export-locale";
import { DownloadAsSchema } from "../schemas";

describe("resolveExportLocale", () => {
it("resolves a decimal point for en-US", () => {
expect(resolveExportLocale("en-US")).toEqual({
tag: "en-US",
decimal_separator: ".",
});
});

it("resolves a decimal comma for pt-BR", () => {
expect(resolveExportLocale("pt-BR").decimal_separator).toBe(",");
});

it("preserves a non-comma Unicode decimal separator", () => {
expect(resolveExportLocale("ar-EG").decimal_separator).toBe("٫");
});

it("rejects an invalid locale", () => {
expect(() => resolveExportLocale("not_a_locale")).toThrow();
});

it("rejects an empty locale tag", () => {
expect(() => resolveExportLocale("")).toThrow();
});
});

describe("DownloadAsSchema", () => {
it("accepts a request with only format", () => {
expect(DownloadAsSchema.input.parse({ format: "csv" })).toEqual({
format: "csv",
});
});

it("accepts an optional locale value", () => {
const locale = { tag: "pt-BR", decimal_separator: "," };
expect(DownloadAsSchema.input.parse({ format: "csv", locale })).toEqual({
format: "csv",
locale,
});
});
});
41 changes: 33 additions & 8 deletions frontend/src/components/data-table/export-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
TableIcon,
} from "lucide-react";
import React from "react";
import { useLocale } from "react-aria";
import { downloadSizeLimitAtom } from "./download-policy/atoms";
import { logNever } from "@/utils/assertNever";
import { cn } from "@/utils/cn";
Expand All @@ -32,6 +33,8 @@ import {
} from "../ui/dropdown-menu";
import { Tooltip } from "../ui/tooltip";
import { toast } from "../ui/use-toast";
import { resolveExportLocale } from "./export-locale";
import type { DownloadAsArgs } from "./schemas";

const FILE_TYPES = {
CSV: {
Expand Down Expand Up @@ -91,13 +94,22 @@ const COPY_SOURCE_FORMAT: Record<CopyFormat, DownloadFormat> = {
markdown: "json",
};

export function sourceFormatForCopy(format: CopyFormat): DownloadFormat {
return COPY_SOURCE_FORMAT[format];
}

export function buildDownloadAsRequest(
format: DownloadFormat,
localeTag: string,
): Parameters<DownloadAsArgs>[0] {
if (format === "csv" || format === "tsv") {
return { format, locale: resolveExportLocale(localeTag) };
}
return { format };
}

export interface ExportActionProps {
downloadAs: (req: { format: DownloadFormat }) => Promise<{
url: string;
filename: string;
error?: string | null;
missing_packages?: string[] | null;
}>;
downloadAs: DownloadAsArgs;
// JSON-serialized size of the currently-rendered data. Used together with
// downloadSizeLimitAtom to disable the Export button when a host (e.g.,
// marimo-lsp inside VS Code) declares a download size cap. Null/undefined
Expand All @@ -113,6 +125,7 @@ const labelForCopyFormat = (format: CopyFormat): string =>

export const ExportMenu: React.FC<ExportActionProps> = (props) => {
const [downloadMenuOpen, setDownloadMenuOpen] = React.useState(false);
const { locale } = useLocale();
const policy = useAtomValue(downloadSizeLimitAtom);
const overLimit = !!(
policy &&
Expand Down Expand Up @@ -151,7 +164,8 @@ export const ExportMenu: React.FC<ExportActionProps> = (props) => {
} | null> => {
let response: Awaited<ReturnType<typeof props.downloadAs>>;
try {
response = await props.downloadAs({ format });
const request = buildDownloadAsRequest(format, locale);
response = await props.downloadAs(request);
} catch (error) {
toast({
title: "Failed to download",
Expand All @@ -178,6 +192,15 @@ export const ExportMenu: React.FC<ExportActionProps> = (props) => {
return null;
}

if (response.error) {
toast({
title: "Export failed",
description: response.error,
variant: "danger",
});
return null;
}

return {
url: response.url,
filename: response.filename,
Expand Down Expand Up @@ -224,7 +247,7 @@ export const ExportMenu: React.FC<ExportActionProps> = (props) => {
await withLoadingToast(
`Preparing ${labelForCopyFormat(format)} for clipboard...`,
async () => {
const sourceFormat = COPY_SOURCE_FORMAT[format];
const sourceFormat = sourceFormatForCopy(format);
const result = await resolveDownloadUrl(sourceFormat, () => {
void handleClipboardCopy(format);
});
Expand Down Expand Up @@ -284,6 +307,7 @@ export const ExportMenu: React.FC<ExportActionProps> = (props) => {
{downloadOptions.map((option) => (
<DropdownMenuItem
key={option.label}
aria-label={`Download ${option.label}: ${option.description}`}
onSelect={() => {
void handleDownload(option.format);
}}
Expand All @@ -304,6 +328,7 @@ export const ExportMenu: React.FC<ExportActionProps> = (props) => {
{copyOptions.map((option) => (
<DropdownMenuItem
key={option.label}
aria-label={`Copy ${option.label} to clipboard: ${option.description}`}
onSelect={async () => {
try {
await handleClipboardCopy(option.format);
Expand Down
33 changes: 33 additions & 0 deletions frontend/src/components/data-table/export-locale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/* Copyright 2026 Marimo. All rights reserved. */

export interface ResolvedExportLocale {
tag: string;
decimal_separator: string;
}

const FORBIDDEN_DECIMAL_SEPARATORS = new Set(["\r", "\n", "\0"]);

export function resolveExportLocale(locale: string): ResolvedExportLocale {
if (locale === "") {
throw new Error("Locale tag must be a non-empty string.");
}

const decimalSeparator = new Intl.NumberFormat(locale)
.formatToParts(1.1)
.find((part) => part.type === "decimal")?.value;

if (
decimalSeparator === undefined ||
[...decimalSeparator].length !== 1 ||
FORBIDDEN_DECIMAL_SEPARATORS.has(decimalSeparator)
) {
throw new Error(
`Locale "${locale}" did not resolve a valid decimal separator.`,
);
}

return {
tag: locale,
decimal_separator: decimalSeparator,
};
}
8 changes: 8 additions & 0 deletions frontend/src/components/data-table/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

import z from "zod";
import { rpc } from "@/plugins/core/rpc";
import type { ResolvedExportLocale } from "./export-locale";

export type DownloadAsArgs = (req: {
format: "csv" | "json" | "parquet" | "tsv";
locale?: ResolvedExportLocale;
}) => Promise<{
url: string;
filename: string;
Expand All @@ -16,6 +18,12 @@ export const DownloadAsSchema = rpc
.input(
z.object({
format: z.enum(["csv", "json", "parquet", "tsv"]),
locale: z
.object({
tag: z.string(),
decimal_separator: z.string(),
})
.optional(),
}),
)
.output(
Expand Down
Loading
Loading