From 9074286f0c7283056b7aca110c4bf29f583b97b4 Mon Sep 17 00:00:00 2001 From: Oluwafemi Ajibade Date: Sun, 30 Aug 2026 13:02:12 +0000 Subject: [PATCH 1/2] fix: reuse shared csvExport utility in ActivityHistory export (#1266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ActivityHistory built CSV rows with unescaped .join(",") and duplicated the blob/anchor-click download mechanics already implemented in frontend/src/utils/csvExport.ts. Replace it with downloadCSV so fields containing commas, quotes, or newlines are properly escaped, preventing data corruption on export. Add component test coverage verifying the export delegates to the shared utility and produces a spreadsheet-safe CSV. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- .../dashboard/ActivityHistory.test.tsx | 97 +++++++++++++++++++ .../components/dashboard/ActivityHistory.tsx | 36 ++----- 2 files changed, 106 insertions(+), 27 deletions(-) create mode 100644 frontend/src/components/dashboard/ActivityHistory.test.tsx diff --git a/frontend/src/components/dashboard/ActivityHistory.test.tsx b/frontend/src/components/dashboard/ActivityHistory.test.tsx new file mode 100644 index 00000000..351e0813 --- /dev/null +++ b/frontend/src/components/dashboard/ActivityHistory.test.tsx @@ -0,0 +1,97 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { BackendStreamEvent } from "@/lib/api-types"; + +vi.mock("@/utils/csvExport", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + downloadCSV: vi.fn(), + }; +}); + +import { ActivityHistory } from "./ActivityHistory"; +import { convertArrayToCSV, downloadCSV } from "@/utils/csvExport"; + +const makeEvent = ( + overrides: Partial = {} +): BackendStreamEvent => ({ + id: "evt-1", + streamId: 12345, + eventType: "CREATED", + amount: "10000000000", + transactionHash: "", + ledgerSequence: 1000, + timestamp: 1700000000, + metadata: null, + createdAt: "2026-01-01T00:00:00Z", + ...overrides, +}); + +describe("ActivityHistory CSV export", () => { + beforeEach(() => { + vi.mocked(downloadCSV).mockClear(); + }); + + it("exports via the shared downloadCSV utility with a timestamped filename", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: /export csv/i })); + + expect(downloadCSV).toHaveBeenCalledTimes(1); + const [data, filename] = vi.mocked(downloadCSV).mock.calls[0]!; + expect(filename).toMatch(/^flowfi_activity_\d+\.csv$/); + expect(data).toEqual([ + { + "Stream ID": 12345, + "Event Type": "CREATED", + "Amount": "1000", + "Timestamp": "2023-11-14T22:13:20.000Z", + "Tx Hash": "", + }, + ]); + }); + + it("produces a correctly escaped, spreadsheet-safe CSV for fields containing commas and quotes", () => { + const events = [ + makeEvent({ + id: "evt-1", + streamId: 1, + eventType: "CREATED", + transactionHash: "", + }), + makeEvent({ + id: "evt-2", + streamId: 2, + eventType: "TOPPED_UP", + transactionHash: 'abc,def"ghi', + }), + ]; + + render(); + + fireEvent.click(screen.getByRole("button", { name: /export csv/i })); + + const [data] = vi.mocked(downloadCSV).mock.calls[0]!; + const csv = convertArrayToCSV(data); + + const lines = csv.split("\n"); + expect(lines[0]).toBe( + "Stream ID,Event Type,Amount,Timestamp,Tx Hash" + ); + // Second row's Tx Hash contains a comma and double-quote and must be quoted + escaped. + expect(lines[2]).toContain('"abc,def""ghi"'); + expect(csv).not.toContain( + 'abc,def"ghi"' + ); + }); + + it("disables the export button when there are no events", () => { + render(); + + expect( + screen.getByRole("button", { name: /export csv/i }) + ).toBeDisabled(); + expect(downloadCSV).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/dashboard/ActivityHistory.tsx b/frontend/src/components/dashboard/ActivityHistory.tsx index 017309fb..f45761b3 100644 --- a/frontend/src/components/dashboard/ActivityHistory.tsx +++ b/frontend/src/components/dashboard/ActivityHistory.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useVirtualizer } from "@tanstack/react-virtual"; import { BackendStreamEvent } from "@/lib/api-types"; import { formatAmount } from "@/utils/amount"; +import { downloadCSV } from "@/utils/csvExport"; import TransactionTracker from "@/components/TransactionTracker"; import { Download, ExternalLink, Clock } from "lucide-react"; import { Button } from "../ui/Button"; @@ -32,34 +33,15 @@ export const ActivityHistory: React.FC = ({ }); const exportToCSV = () => { - const headers = [ - "Stream ID", - "Event Type", - "Amount", - "Timestamp", - "Tx Hash", - ]; - const rows = events.map((event) => [ - event.streamId, - event.eventType, - event.amount ? formatAmount(BigInt(event.amount), 7) : "0", - new Date(event.timestamp * 1000).toISOString(), - event.transactionHash || "", - ]); + const rows = events.map((event) => ({ + "Stream ID": event.streamId, + "Event Type": event.eventType, + "Amount": event.amount ? formatAmount(BigInt(event.amount), 7) : "0", + "Timestamp": new Date(event.timestamp * 1000).toISOString(), + "Tx Hash": event.transactionHash || "", + })); - const csvContent = [headers, ...rows].map((e) => e.join(",")).join("\n"); - const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); - const link = document.createElement("a"); - const url = URL.createObjectURL(blob); - link.setAttribute("href", url); - link.setAttribute( - "download", - `flowfi_activity_${new Date().getTime()}.csv`, - ); - link.style.visibility = "hidden"; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + downloadCSV(rows, `flowfi_activity_${new Date().getTime()}.csv`); }; const renderEventMessage = (event: BackendStreamEvent): React.ReactNode => { From 4e0029a390b44f146fa9079af1b4ca48e6d9f5c9 Mon Sep 17 00:00:00 2001 From: Oluwafemi Ajibade Date: Sun, 30 Aug 2026 15:01:18 +0000 Subject: [PATCH 2/2] fix: restore shared downloadCSV usage in ActivityHistory export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging main into this branch kept main's inline CSV serialization and dropped the downloadCSV call from the export handler, breaking the branch's own component tests. Restore delegation to the shared csvExport utility so fields containing commas/quotes/newlines are escaped correctly. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- .../components/dashboard/ActivityHistory.tsx | 35 +++++-------------- 1 file changed, 8 insertions(+), 27 deletions(-) diff --git a/frontend/src/components/dashboard/ActivityHistory.tsx b/frontend/src/components/dashboard/ActivityHistory.tsx index a539f53a..72e32c46 100644 --- a/frontend/src/components/dashboard/ActivityHistory.tsx +++ b/frontend/src/components/dashboard/ActivityHistory.tsx @@ -36,34 +36,15 @@ export const ActivityHistory: React.FC = ({ const exportToCSV = useCallback(() => { setExportStatus("exporting"); - const headers = [ - "Stream ID", - "Event Type", - "Amount", - "Timestamp", - "Tx Hash", - ]; - const rows = events.map((event) => [ - event.streamId, - event.eventType, - event.amount ? formatAmount(BigInt(event.amount), 7) : "0", - new Date(event.timestamp * 1000).toISOString(), - event.transactionHash || "", - ]); + const rows = events.map((event) => ({ + "Stream ID": event.streamId, + "Event Type": event.eventType, + "Amount": event.amount ? formatAmount(BigInt(event.amount), 7) : "0", + "Timestamp": new Date(event.timestamp * 1000).toISOString(), + "Tx Hash": event.transactionHash || "", + })); - const csvContent = [headers, ...rows].map((e) => e.join(",")).join("\n"); - const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); - const link = document.createElement("a"); - const url = URL.createObjectURL(blob); - link.setAttribute("href", url); - link.setAttribute( - "download", - `flowfi_activity_${new Date().getTime()}.csv`, - ); - link.style.visibility = "hidden"; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + downloadCSV(rows, `flowfi_activity_${new Date().getTime()}.csv`); setExportStatus("complete"); // Reset after screen readers have had time to announce setTimeout(() => setExportStatus("idle"), 3000);