From 51364fed1b592204e04d59f5b3e14b3cf0fcdc8a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Nov 2025 08:35:14 +0000 Subject: [PATCH 1/3] feat: Add CSV export functionality for plots Co-authored-by: andershaf --- src/components/Figure.test.tsx | 102 ++++++++++++- src/components/Figure.tsx | 29 +++- src/utils/exportCsv.test.ts | 253 +++++++++++++++++++++++++++++++++ src/utils/exportCsv.ts | 52 +++++++ 4 files changed, 431 insertions(+), 5 deletions(-) create mode 100644 src/utils/exportCsv.test.ts create mode 100644 src/utils/exportCsv.ts diff --git a/src/components/Figure.test.tsx b/src/components/Figure.test.tsx index 45dbf65c..b4c46b66 100644 --- a/src/components/Figure.test.tsx +++ b/src/components/Figure.test.tsx @@ -1,7 +1,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import Figure from "./Figure"; import { Compute, Fix, Variable, PlotData, LMPModifier, ModifierType } from "../types"; +import { exportPlotDataToCsv } from "../utils/exportCsv"; // Mock useStoreState hook vi.mock("../hooks", () => ({ @@ -19,12 +21,33 @@ vi.mock("dygraphs", () => { }; }); -// Mock antd Modal and Empty components +// Mock antd Modal, Empty, and Button components vi.mock("antd", () => ({ - Modal: ({ children, open, onCancel }: { children: React.ReactNode; open: boolean; onCancel: () => void }) => ( - open ?
{children}
: null + Modal: ({ children, open, onCancel, extra }: { children: React.ReactNode; open: boolean; onCancel: () => void; extra?: React.ReactNode }) => ( + open ? ( +
+ {extra &&
{extra}
} + {children} +
+ ) : null ), Empty: () =>
Empty
, + Button: ({ children, onClick, icon, type }: { children: React.ReactNode; onClick: () => void; icon?: React.ReactNode; type?: string }) => ( + + ), +})); + +// Mock @ant-design/icons +vi.mock("@ant-design/icons", () => ({ + DownloadOutlined: () => Download, +})); + +// Mock exportCsv utility +vi.mock("../utils/exportCsv", () => ({ + exportPlotDataToCsv: vi.fn(), })); describe("Figure", () => { @@ -324,5 +347,78 @@ describe("Figure", () => { expect(screen.getByTestId("empty")).toBeInTheDocument(); }); }); + + describe("CSV export", () => { + it("should render export button when data1D is available", () => { + // Arrange + const plotData = createMockPlotData(); + + // Act + render(
); + + // Assert + expect(screen.getByTestId("button")).toBeInTheDocument(); + expect(screen.getByText("Export CSV")).toBeInTheDocument(); + }); + + it("should not render export button when data1D is not available", () => { + // Arrange + const plotData = createMockPlotData({ + data1D: undefined, + }); + + // Act + render(
); + + // Assert + expect(screen.queryByTestId("button")).not.toBeInTheDocument(); + }); + + it("should call exportPlotDataToCsv when export button is clicked", async () => { + // Arrange + const plotData = createMockPlotData({ + name: "test plot", + data1D: { + data: [[0, 1], [1, 2]], + labels: ["x", "y"], + }, + }); + const user = userEvent.setup(); + + // Act + render(
); + const exportButton = screen.getByTestId("button"); + await user.click(exportButton); + + // Assert + expect(exportPlotDataToCsv).toHaveBeenCalledWith( + plotData.data1D, + "test-plot.csv", + ); + }); + + it("should sanitize filename by replacing spaces with hyphens", async () => { + // Arrange + const plotData = createMockPlotData({ + name: "my test plot with spaces", + data1D: { + data: [[0, 1]], + labels: ["x", "y"], + }, + }); + const user = userEvent.setup(); + + // Act + render(
); + const exportButton = screen.getByTestId("button"); + await user.click(exportButton); + + // Assert + expect(exportPlotDataToCsv).toHaveBeenCalledWith( + plotData.data1D, + "my-test-plot-with-spaces.csv", + ); + }); + }); }); diff --git a/src/components/Figure.tsx b/src/components/Figure.tsx index 01e140f3..bdf93576 100644 --- a/src/components/Figure.tsx +++ b/src/components/Figure.tsx @@ -1,8 +1,10 @@ -import { Modal, Empty } from "antd"; +import { Modal, Empty, Button } from "antd"; +import { DownloadOutlined } from "@ant-design/icons"; import { Compute, Fix, Variable, PlotData } from "../types"; import { useEffect, useState, useId, useMemo, useRef } from "react"; import { useStoreState } from "../hooks"; import Dygraph from "dygraphs"; +import { exportPlotDataToCsv } from "../utils/exportCsv"; type FigureProps = { onClose: () => void; @@ -132,8 +134,31 @@ const Figure = ({ } }, [graph, plotConfig, timesteps]); + const handleExportCsv = () => { + if (plotConfig?.data1D) { + const filename = `${plotConfig.name.replace(/\s+/g, "-")}.csv`; + exportPlotDataToCsv(plotConfig.data1D, filename); + } + }; + return ( - + } + onClick={handleExportCsv} + > + Export CSV + + ) + } + >
{!graph && } diff --git a/src/utils/exportCsv.test.ts b/src/utils/exportCsv.test.ts new file mode 100644 index 00000000..bd633092 --- /dev/null +++ b/src/utils/exportCsv.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { plotDataToCsv, exportPlotDataToCsv } from "./exportCsv"; +import { Data1D } from "../types"; + +describe("plotDataToCsv", () => { + it("should convert simple plot data to CSV format", () => { + // Arrange + const data1D: Data1D = { + data: [ + [0, 1], + [1, 2], + [2, 3], + ], + labels: ["x", "y"], + }; + + // Act + const csv = plotDataToCsv(data1D); + + // Assert + const expected = "x,y\n0,1\n1,2\n2,3"; + expect(csv).toBe(expected); + }); + + it("should handle multiple columns", () => { + // Arrange + const data1D: Data1D = { + data: [ + [0, 1, 2, 3], + [1, 2, 3, 4], + ], + labels: ["time", "temperature", "pressure", "energy"], + }; + + // Act + const csv = plotDataToCsv(data1D); + + // Assert + const expected = "time,temperature,pressure,energy\n0,1,2,3\n1,2,3,4"; + expect(csv).toBe(expected); + }); + + it("should handle empty data array", () => { + // Arrange + const data1D: Data1D = { + data: [], + labels: ["x", "y"], + }; + + // Act + const csv = plotDataToCsv(data1D); + + // Assert + expect(csv).toBe("x,y\n"); + }); + + it("should handle single data row", () => { + // Arrange + const data1D: Data1D = { + data: [[42, 3.14]], + labels: ["answer", "pi"], + }; + + // Act + const csv = plotDataToCsv(data1D); + + // Assert + expect(csv).toBe("answer,pi\n42,3.14"); + }); + + it("should handle decimal numbers", () => { + // Arrange + const data1D: Data1D = { + data: [ + [0.0, 1.234], + [0.5, 2.345], + [1.0, 3.456], + ], + labels: ["time", "value"], + }; + + // Act + const csv = plotDataToCsv(data1D); + + // Assert + const expected = "time,value\n0,1.234\n0.5,2.345\n1,3.456"; + expect(csv).toBe(expected); + }); + + it("should handle negative numbers", () => { + // Arrange + const data1D: Data1D = { + data: [ + [-1, -2], + [0, 0], + [1, 2], + ], + labels: ["x", "y"], + }; + + // Act + const csv = plotDataToCsv(data1D); + + // Assert + const expected = "x,y\n-1,-2\n0,0\n1,2"; + expect(csv).toBe(expected); + }); + + it("should handle scientific notation", () => { + // Arrange + const data1D: Data1D = { + data: [ + [0, 1e-10], + [1, 2e10], + ], + labels: ["x", "y"], + }; + + // Act + const csv = plotDataToCsv(data1D); + + // Assert + const expected = "x,y\n0,1e-10\n1,20000000000"; + expect(csv).toBe(expected); + }); +}); + +describe("exportPlotDataToCsv", () => { + let createElementSpy: ReturnType; + let createObjectURLSpy: ReturnType; + let revokeObjectURLSpy: ReturnType; + let mockLink: { + setAttribute: ReturnType; + click: ReturnType; + style: { visibility: string }; + }; + + beforeEach(() => { + // Create mock link element + mockLink = { + setAttribute: vi.fn(), + click: vi.fn(), + style: { visibility: "" }, + }; + + // Mock document.createElement + createElementSpy = vi.spyOn(document, "createElement").mockReturnValue( + mockLink as unknown as HTMLElement, + ); + + // Mock URL.createObjectURL and URL.revokeObjectURL + createObjectURLSpy = vi.spyOn(URL, "createObjectURL").mockReturnValue("mock-url"); + revokeObjectURLSpy = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + + // Mock document.body methods + vi.spyOn(document.body, "appendChild").mockImplementation(() => mockLink as unknown as Node); + vi.spyOn(document.body, "removeChild").mockImplementation(() => mockLink as unknown as Node); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should create a downloadable CSV file", () => { + // Arrange + const data1D: Data1D = { + data: [ + [0, 1], + [1, 2], + ], + labels: ["x", "y"], + }; + + // Act + exportPlotDataToCsv(data1D); + + // Assert + expect(createElementSpy).toHaveBeenCalledWith("a"); + expect(createObjectURLSpy).toHaveBeenCalled(); + expect(mockLink.setAttribute).toHaveBeenCalledWith("href", "mock-url"); + expect(mockLink.setAttribute).toHaveBeenCalledWith("download", "plot-data.csv"); + expect(mockLink.click).toHaveBeenCalled(); + expect(revokeObjectURLSpy).toHaveBeenCalledWith("mock-url"); + }); + + it("should use custom filename when provided", () => { + // Arrange + const data1D: Data1D = { + data: [[0, 1]], + labels: ["x", "y"], + }; + const customFilename = "my-custom-plot.csv"; + + // Act + exportPlotDataToCsv(data1D, customFilename); + + // Assert + expect(mockLink.setAttribute).toHaveBeenCalledWith("download", customFilename); + }); + + it("should create blob with correct CSV content", () => { + // Arrange + const data1D: Data1D = { + data: [ + [0, 1], + [1, 2], + ], + labels: ["time", "value"], + }; + + const blobSpy = vi.spyOn(global, "Blob"); + + // Act + exportPlotDataToCsv(data1D); + + // Assert + expect(blobSpy).toHaveBeenCalledWith( + ["time,value\n0,1\n1,2"], + { type: "text/csv;charset=utf-8;" }, + ); + }); + + it("should set link visibility to hidden", () => { + // Arrange + const data1D: Data1D = { + data: [[0, 1]], + labels: ["x", "y"], + }; + + // Act + exportPlotDataToCsv(data1D); + + // Assert + expect(mockLink.style.visibility).toBe("hidden"); + }); + + it("should append and remove link from document body", () => { + // Arrange + const data1D: Data1D = { + data: [[0, 1]], + labels: ["x", "y"], + }; + const appendChildSpy = vi.spyOn(document.body, "appendChild"); + const removeChildSpy = vi.spyOn(document.body, "removeChild"); + + // Act + exportPlotDataToCsv(data1D); + + // Assert + expect(appendChildSpy).toHaveBeenCalledWith(mockLink); + expect(removeChildSpy).toHaveBeenCalledWith(mockLink); + }); +}); diff --git a/src/utils/exportCsv.ts b/src/utils/exportCsv.ts new file mode 100644 index 00000000..1a5c20b7 --- /dev/null +++ b/src/utils/exportCsv.ts @@ -0,0 +1,52 @@ +import { Data1D } from "../types"; + +/** + * Converts plot data to CSV format + * @param data1D - The 1D plot data containing data rows and column labels + * @param filename - Optional filename for the downloaded CSV file + * @returns CSV string + */ +export function plotDataToCsv(data1D: Data1D): string { + const { data, labels } = data1D; + + // Handle empty data + if (!data || data.length === 0) { + return labels.join(",") + "\n"; + } + + // Create CSV header from labels + const header = labels.join(","); + + // Create CSV rows from data + const rows = data.map((row) => row.join(",")); + + // Combine header and rows + return [header, ...rows].join("\n"); +} + +/** + * Exports plot data to a CSV file and triggers download + * @param data1D - The 1D plot data containing data rows and column labels + * @param filename - The filename for the downloaded CSV file (default: "plot-data.csv") + */ +export function exportPlotDataToCsv(data1D: Data1D, filename = "plot-data.csv"): void { + const csv = plotDataToCsv(data1D); + + // Create a blob from the CSV string + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + + // Create a temporary link element to trigger download + const link = document.createElement("a"); + const url = URL.createObjectURL(blob); + + link.setAttribute("href", url); + link.setAttribute("download", filename); + link.style.visibility = "hidden"; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // Clean up the URL object + URL.revokeObjectURL(url); +} From 16dfec90c54bffe6ac2ffcb73322bc4b81cf0230 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Nov 2025 08:40:33 +0000 Subject: [PATCH 2/3] Refactor: Move export button inside Modal content Co-authored-by: andershaf --- src/components/Figure.test.tsx | 7 +++---- src/components/Figure.tsx | 21 ++++++++++----------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/components/Figure.test.tsx b/src/components/Figure.test.tsx index b4c46b66..403ec1c8 100644 --- a/src/components/Figure.test.tsx +++ b/src/components/Figure.test.tsx @@ -23,17 +23,16 @@ vi.mock("dygraphs", () => { // Mock antd Modal, Empty, and Button components vi.mock("antd", () => ({ - Modal: ({ children, open, onCancel, extra }: { children: React.ReactNode; open: boolean; onCancel: () => void; extra?: React.ReactNode }) => ( + Modal: ({ children, open, onCancel }: { children: React.ReactNode; open: boolean; onCancel: () => void }) => ( open ? (
- {extra &&
{extra}
} {children}
) : null ), Empty: () =>
Empty
, - Button: ({ children, onClick, icon, type }: { children: React.ReactNode; onClick: () => void; icon?: React.ReactNode; type?: string }) => ( - diff --git a/src/components/Figure.tsx b/src/components/Figure.tsx index bdf93576..86af1a71 100644 --- a/src/components/Figure.tsx +++ b/src/components/Figure.tsx @@ -147,18 +147,17 @@ const Figure = ({ width={width} footer={null} onCancel={onClose} - extra={ - plotConfig?.data1D && ( - - ) - } > + {plotConfig?.data1D && ( + + )}
{!graph && } From 5e58dc4705ce0728af5c445e2398e451100c707c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Nov 2025 10:12:28 +0000 Subject: [PATCH 3/3] Refactor: Sanitize plot names for CSV export filenames Co-authored-by: andershaf --- src/components/Figure.test.tsx | 23 +++++++++++++++++++++++ src/components/Figure.tsx | 2 +- src/utils/exportCsv.ts | 13 +++++++------ 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/components/Figure.test.tsx b/src/components/Figure.test.tsx index 403ec1c8..3941f30a 100644 --- a/src/components/Figure.test.tsx +++ b/src/components/Figure.test.tsx @@ -418,6 +418,29 @@ describe("Figure", () => { "my-test-plot-with-spaces.csv", ); }); + + it("should sanitize filename by replacing invalid characters", async () => { + // Arrange + const plotData = createMockPlotData({ + name: 'plot/with\\invalid:characters*?"|<>', + data1D: { + data: [[0, 1]], + labels: ["x", "y"], + }, + }); + const user = userEvent.setup(); + + // Act + render(
); + const exportButton = screen.getByTestId("button"); + await user.click(exportButton); + + // Assert + expect(exportPlotDataToCsv).toHaveBeenCalledWith( + plotData.data1D, + "plot-with-invalid-characters------.csv", + ); + }); }); }); diff --git a/src/components/Figure.tsx b/src/components/Figure.tsx index 86af1a71..2fc74c0d 100644 --- a/src/components/Figure.tsx +++ b/src/components/Figure.tsx @@ -136,7 +136,7 @@ const Figure = ({ const handleExportCsv = () => { if (plotConfig?.data1D) { - const filename = `${plotConfig.name.replace(/\s+/g, "-")}.csv`; + const filename = `${plotConfig.name.replace(/[\s/\\?%*:"|<>]/g, "-")}.csv`; exportPlotDataToCsv(plotConfig.data1D, filename); } }; diff --git a/src/utils/exportCsv.ts b/src/utils/exportCsv.ts index 1a5c20b7..671b217d 100644 --- a/src/utils/exportCsv.ts +++ b/src/utils/exportCsv.ts @@ -3,7 +3,6 @@ import { Data1D } from "../types"; /** * Converts plot data to CSV format * @param data1D - The 1D plot data containing data rows and column labels - * @param filename - Optional filename for the downloaded CSV file * @returns CSV string */ export function plotDataToCsv(data1D: Data1D): string { @@ -44,9 +43,11 @@ export function exportPlotDataToCsv(data1D: Data1D, filename = "plot-data.csv"): link.style.visibility = "hidden"; document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - // Clean up the URL object - URL.revokeObjectURL(url); + try { + link.click(); + } finally { + document.body.removeChild(link); + // Clean up the URL object + URL.revokeObjectURL(url); + } }