diff --git a/src/components/Figure.test.tsx b/src/components/Figure.test.tsx index 45dbf65c..3941f30a 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,32 @@ 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 + open ? ( +
+ {children} +
+ ) : null ), Empty: () =>
Empty
, + Button: ({ children, onClick, icon, type, style }: { children: React.ReactNode; onClick: () => void; icon?: React.ReactNode; type?: string; style?: React.CSSProperties }) => ( + + ), +})); + +// 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 +346,101 @@ 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", + ); + }); + + 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 01e140f3..2fc74c0d 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,30 @@ const Figure = ({ } }, [graph, plotConfig, timesteps]); + const handleExportCsv = () => { + if (plotConfig?.data1D) { + const filename = `${plotConfig.name.replace(/[\s/\\?%*:"|<>]/g, "-")}.csv`; + exportPlotDataToCsv(plotConfig.data1D, filename); + } + }; + return ( - + + {plotConfig?.data1D && ( + + )}
{!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..671b217d --- /dev/null +++ b/src/utils/exportCsv.ts @@ -0,0 +1,53 @@ +import { Data1D } from "../types"; + +/** + * Converts plot data to CSV format + * @param data1D - The 1D plot data containing data rows and column labels + * @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); + try { + link.click(); + } finally { + document.body.removeChild(link); + // Clean up the URL object + URL.revokeObjectURL(url); + } +}