Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
99 changes: 97 additions & 2 deletions src/components/Figure.test.tsx
Original file line number Diff line number Diff line change
@@ -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", () => ({
Expand All @@ -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 ? <div data-testid="modal" onClick={onCancel}>{children}</div> : null
open ? (
<div data-testid="modal" onClick={onCancel}>
{children}
</div>
) : null
),
Empty: () => <div data-testid="empty">Empty</div>,
Button: ({ children, onClick, icon, type, style }: { children: React.ReactNode; onClick: () => void; icon?: React.ReactNode; type?: string; style?: React.CSSProperties }) => (
<button data-testid="button" onClick={onClick} data-type={type} style={style}>
{icon}
{children}
</button>
),
}));

// Mock @ant-design/icons
vi.mock("@ant-design/icons", () => ({
DownloadOutlined: () => <span data-testid="download-icon">Download</span>,
}));

// Mock exportCsv utility
vi.mock("../utils/exportCsv", () => ({
exportPlotDataToCsv: vi.fn(),
}));

describe("Figure", () => {
Expand Down Expand Up @@ -324,5 +346,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(<Figure plotData={plotData} onClose={mockOnClose} />);

// 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(<Figure plotData={plotData} onClose={mockOnClose} />);

// 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(<Figure plotData={plotData} onClose={mockOnClose} />);
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(<Figure plotData={plotData} onClose={mockOnClose} />);
const exportButton = screen.getByTestId("button");
await user.click(exportButton);

// Assert
expect(exportPlotDataToCsv).toHaveBeenCalledWith(
plotData.data1D,
"my-test-plot-with-spaces.csv",
);
});
});
});

28 changes: 26 additions & 2 deletions src/components/Figure.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -132,8 +134,30 @@ const Figure = ({
}
}, [graph, plotConfig, timesteps]);

const handleExportCsv = () => {
if (plotConfig?.data1D) {
const filename = `${plotConfig.name.replace(/\s+/g, "-")}.csv`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current filename sanitization only replaces whitespace. If plotConfig.name contains other characters that are invalid in filenames (e.g., /, \, :, *, ?, ", <, >, |), it could cause issues when the user tries to save the file. It would be more robust to sanitize a wider range of special characters.

Suggested change
const filename = `${plotConfig.name.replace(/\s+/g, "-")}.csv`;
const filename = `${plotConfig.name.replace(/[\s/\\?%*:\"|<>]/g, "-")}.csv`;

exportPlotDataToCsv(plotConfig.data1D, filename);
}
};

return (
<Modal open width={width} footer={null} onCancel={onClose}>
<Modal
open
width={width}
footer={null}
onCancel={onClose}
>
{plotConfig?.data1D && (
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={handleExportCsv}
style={{ marginBottom: 16 }}
>
Export CSV
</Button>
)}
<div id={graphId} />
{!graph && <Empty />}
</Modal>
Expand Down
Loading