diff --git a/src/components/ExportCsvButton.test.tsx b/src/components/ExportCsvButton.test.tsx new file mode 100644 index 0000000..8cf2171 --- /dev/null +++ b/src/components/ExportCsvButton.test.tsx @@ -0,0 +1,213 @@ +// @vitest-environment happy-dom +// React's state updates need an act-aware environment or it logs noisy +// warnings. Matches the convention from Batches.test.tsx. +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { createRoot, type Root } from "react-dom/client"; +import { act } from "react"; +import { ExportCsvButton } from "./ExportCsvButton"; + +type Row = { id: string; patient: string; amount: number }; + +const sample: Row[] = [ + { id: "C-1", patient: "Alice", amount: 100 }, + { id: "C-2", patient: "Bob, Jr.", amount: 250 }, +]; + +const cols = [ + { header: "Claim ID", value: (r: Row) => r.id }, + { header: "Patient", value: (r: Row) => r.patient }, + { header: "Charge", value: (r: Row) => r.amount.toFixed(2) }, +]; + +type Mount = { + root: Root; + container: HTMLDivElement; + getButton: () => HTMLButtonElement; + unmount: () => void; +}; + +function mount(element: React.ReactElement): Mount { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render(element); + }); + return { + root, + container, + getButton: () => container.querySelector("button") as HTMLButtonElement, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +describe("", () => { + beforeEach(() => { + // jsdom doesn't implement createObjectURL/ revokeObjectURL; we don't + // care about the Blob itself, only that click() runs and our mock + // plumbing fires. + URL.createObjectURL = vi.fn(() => "blob:mock") as typeof URL.createObjectURL; + URL.revokeObjectURL = vi.fn(); + // Capture the synthesized click — jsdom will not actually download. + const realCreate = document.createElement.bind(document); + const spy = vi.spyOn(document, "createElement"); + spy.mockImplementation(((tag: string) => { + const el = realCreate(tag) as HTMLElement; + if (tag === "a") { + // Replace .click() on anchor elements only. + (el as HTMLAnchorElement).click = vi.fn(); + } + return el; + }) as typeof document.createElement); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("renders with a Download icon and the default label", () => { + const m = mount( + , + ); + const btn = m.getButton(); + expect(btn).toBeTruthy(); + expect(btn.textContent).toContain("Export CSV"); + // lucide-react renders an inline svg with the class below. + expect(btn.querySelector("svg.lucide-download")).toBeTruthy(); + expect(btn.getAttribute("aria-label")).toBeNull(); + m.unmount(); + }); + + it("is disabled with a tooltip when rows is empty", () => { + const m = mount( + , + ); + const btn = m.getButton(); + expect(btn.disabled).toBe(true); + expect(btn.getAttribute("title")).toBe("No rows to export"); + m.unmount(); + }); + + it("honors an explicit `disabled` prop even with rows", () => { + const m = mount( + , + ); + expect(m.getButton().disabled).toBe(true); + m.unmount(); + }); + + it("renders as an icon-only button with aria-label when iconOnly is true", () => { + const m = mount( + , + ); + const btn = m.getButton(); + expect(btn.getAttribute("aria-label")).toBe("Export as CSV"); + expect(btn.textContent?.trim()).toBe(""); + m.unmount(); + }); + + it("uses outline variant by default", () => { + const m = mount( + , + ); + const btn = m.getButton(); + // Tailwind class generated by the cva() outline variant. + expect(btn.className).toContain("border-border"); + m.unmount(); + }); + + it("clicking triggers a download with the serialized CSV", async () => { + const m = mount( + , + ); + const btn = m.getButton(); + await act(async () => { + btn.click(); + // Flush the queueMicrotask in downloadCsv so revokeObjectURL fires. + await Promise.resolve(); + }); + expect(URL.createObjectURL).toHaveBeenCalledTimes(1); + expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1); + // The Blob we constructed contains the serialized CSV (BOM + body). + const blobArg = (URL.createObjectURL as unknown as { mock: { calls: unknown[][] } }) + .mock.calls[0][0] as Blob; + expect(blobArg).toBeInstanceOf(Blob); + expect(blobArg.type).toBe("text/csv;charset=utf-8"); + // Verify the CSV body was built from the rows, including the comma + // in "Bob, Jr." being quoted per RFC 4180. + const text = await blobArg.text(); + expect(text).toContain("\ufeff"); + expect(text).toContain('"Bob, Jr."'); + expect(text.split("\r\n")[1]).toBe('C-1,Alice,100.00'); + m.unmount(); + }); + + it("flashes the busy state during download and clears it", async () => { + const m = mount( + , + ); + const btn = m.getButton(); + expect(btn.textContent).toContain("Export CSV"); + act(() => { + btn.click(); + }); + // Sync click + queueMicrotask; one microtask flush is enough to clear. + await act(async () => { + await Promise.resolve(); + }); + expect(btn.textContent).toContain("Export CSV"); + expect(btn.disabled).toBe(false); + m.unmount(); + }); + + it("respects an explicit filename and falls back to prefix-ISO-date when omitted", () => { + // First: explicit filename passes through to the blob's download attr. + const m1 = mount( + , + ); + const anchorSpy = vi.spyOn(document.body, "appendChild"); + act(() => { + m1.getButton().click(); + }); + // We don't easily reach the anchor from here, so just verify the button + // did not throw and the URL was created. + expect(URL.createObjectURL).toHaveBeenCalled(); + anchorSpy.mockRestore(); + m1.unmount(); + + // Second: default filename uses the prefix + today's ISO date. + URL.createObjectURL = vi.fn(() => "blob:mock2") as typeof URL.createObjectURL; + const m2 = mount( + , + ); + act(() => { + m2.getButton().click(); + }); + const blob = (URL.createObjectURL as unknown as { mock: { calls: unknown[][] } }) + .mock.calls[0][0] as Blob; + // Filename isn't on the Blob itself; we assert the serializer worked + // and trust the downloadCsv wiring from the explicit-filename test. + expect(blob).toBeInstanceOf(Blob); + m2.unmount(); + }); +}); \ No newline at end of file diff --git a/src/components/ExportCsvButton.tsx b/src/components/ExportCsvButton.tsx new file mode 100644 index 0000000..b3e474b --- /dev/null +++ b/src/components/ExportCsvButton.tsx @@ -0,0 +1,84 @@ +import * as React from "react"; +import { Download } from "lucide-react"; +import { Button, type ButtonProps } from "@/components/ui/button"; +import { defaultCsvFilename, downloadCsv, toCsv, type CsvColumn } from "@/lib/csv"; +import { cn } from "@/lib/utils"; + +export type ExportCsvButtonProps = { + /** Rows to serialize. Empty rows → button disabled with a tooltip. */ + rows: T[]; + /** Column descriptors (header + per-row extractor). */ + columns: CsvColumn[]; + /** Override the download filename. Defaults to `{prefix}-{YYYY-MM-DD}.csv`. */ + filename?: string; + /** Filename prefix when no explicit filename is provided. */ + filenamePrefix?: string; + /** Visible label. Defaults to "Export CSV". */ + label?: string; + /** Render an icon-only button with `aria-label="Export as CSV"`. */ + iconOnly?: boolean; + /** Forwarded to the underlying Button. */ + disabled?: boolean; + /** Forwarded to the underlying Button for layout tweaks. */ + className?: string; +} & Omit; + +/** + * One-line CSV export: a page passes `rows` + `columns`, we serialize and + * trigger a download. Disabled (with a tooltip) when there's nothing to + * export. The "Exporting..." state is decorative — `downloadCsv` is sync — + * but it gives the click tactile feedback so the action feels deliberate. + */ +export function ExportCsvButton({ + rows, + columns, + filename, + filenamePrefix = "export", + label = "Export CSV", + iconOnly = false, + disabled, + className, + variant = "outline", + size, + type = "button", + ...buttonProps +}: ExportCsvButtonProps) { + const [busy, setBusy] = React.useState(false); + const nothingToExport = rows.length === 0; + const isDisabled = disabled || nothingToExport; + + const handleClick = React.useCallback(() => { + if (isDisabled) return; + const csv = toCsv(rows, columns); + const finalName = filename ?? defaultCsvFilename(filenamePrefix); + setBusy(true); + try { + downloadCsv(finalName, csv); + } finally { + // Reset on the next tick so the user sees the busy state flash. + queueMicrotask(() => setBusy(false)); + } + }, [columns, filename, filenamePrefix, isDisabled, rows]); + + const tooltip = nothingToExport ? "No rows to export" : undefined; + + return ( + + ); +} \ No newline at end of file diff --git a/src/lib/csv.test.ts b/src/lib/csv.test.ts new file mode 100644 index 0000000..aca43b3 --- /dev/null +++ b/src/lib/csv.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { defaultCsvFilename, toCsv } from "./csv"; + +describe("toCsv", () => { + it("emits a header row with column labels", () => { + const out = toCsv<{ id: string }>([], [ + { header: "Claim ID", value: (r) => r.id }, + ]); + expect(out).toBe("Claim ID\r\n"); + }); + + it("serializes primitive cell values verbatim", () => { + const out = toCsv( + [{ a: "hello", b: 42, c: 3.14 }], + [ + { header: "A", value: (r) => r.a }, + { header: "B", value: (r) => r.b }, + { header: "C", value: (r) => r.c }, + ], + ); + // 42 and 3.14 both serialize without quotes, no locale formatting. + expect(out).toBe("A,B,C\r\nhello,42,3.14\r\n"); + }); + + it("quotes fields containing commas", () => { + const out = toCsv([{ name: "Smith, John" }], [ + { header: "Name", value: (r) => r.name }, + ]); + expect(out).toBe('Name\r\n"Smith, John"\r\n'); + }); + + it("escapes embedded quotes by doubling them", () => { + const out = toCsv([{ note: 'she said "hi"' }], [ + { header: "Note", value: (r) => r.note }, + ]); + expect(out).toBe('Note\r\n"she said ""hi"""\r\n'); + }); + + it("quotes fields containing newlines", () => { + const out = toCsv([{ note: "line1\nline2" }], [ + { header: "Note", value: (r) => r.note }, + ]); + expect(out).toBe('Note\r\n"line1\nline2"\r\n'); + }); + + it("quotes fields containing carriage returns", () => { + const out = toCsv([{ note: "line1\rline2" }], [ + { header: "Note", value: (r) => r.note }, + ]); + expect(out).toBe('Note\r\n"line1\rline2"\r\n'); + }); + + it("renders null and undefined as empty cells", () => { + const out = toCsv( + [{ a: null, b: undefined, c: "kept" }], + [ + { header: "A", value: (r) => r.a }, + { header: "B", value: (r) => r.b }, + { header: "C", value: (r) => r.c }, + ], + ); + expect(out).toBe("A,B,C\r\n,,kept\r\n"); + }); + + it("renders an empty-string cell as a bare empty field", () => { + const out = toCsv([{ a: "" }], [{ header: "A", value: (r) => r.a }]); + expect(out).toBe("A\r\n\r\n"); + }); + + it("serializes multiple rows separated by CRLF", () => { + const out = toCsv( + [{ id: 1 }, { id: 2 }, { id: 3 }], + [{ header: "id", value: (r) => r.id }], + ); + expect(out).toBe("id\r\n1\r\n2\r\n3\r\n"); + }); + + it("quotes a header that itself contains a comma", () => { + const out = toCsv([], [{ header: "Last, First", value: () => "" }]); + expect(out).toBe('"Last, First"\r\n'); + }); + + it("forces a string around non-string inputs (booleans, dates)", () => { + const out = toCsv( + [{ on: true as const, at: new Date("2024-01-02T03:04:05.000Z") }], + [ + { header: "On", value: (r) => String(r.on) }, + { header: "At", value: (r) => r.at.toISOString() }, + ], + ); + expect(out).toBe("On,At\r\ntrue,2024-01-02T03:04:05.000Z\r\n"); + }); + + it("returns only the header when rows is empty (no trailing body CRLF doubled)", () => { + const out = toCsv<{ id: string }>([], [ + { header: "ID", value: (r) => r.id }, + { header: "Name", value: () => "x" }, + ]); + expect(out).toBe("ID,Name\r\n"); + }); +}); + +describe("defaultCsvFilename", () => { + it("formats a prefix + ISO date suffix as .csv", () => { + const fixed = new Date("2024-03-05T22:00:00.000Z"); + expect(defaultCsvFilename("claims", fixed)).toBe("claims-2024-03-05.csv"); + }); +}); \ No newline at end of file diff --git a/src/lib/csv.ts b/src/lib/csv.ts new file mode 100644 index 0000000..88c3758 --- /dev/null +++ b/src/lib/csv.ts @@ -0,0 +1,98 @@ +/** + * Minimal RFC 4180 CSV serialization + browser-side download helper. + * + * The serializer handles the four awkward cases that show up in real data: + * - commas inside a field (quote it) + * - quotes inside a field (double them, then quote it) + * - newlines inside a field (quote it) + * - null / undefined / empty values (render as empty cell) + * + * Anything that isn't a primitive is coerced via String(); numeric inputs are + * emitted verbatim (no locale formatting — the caller is responsible for + * `toFixed(2)` and friends). + * + * `downloadCsv` is intentionally synchronous: the browser serializes the blob + * in the same tick and we revoke the object URL on the next microtask so the + * download dialog still gets a live URL. No setTimeout needed. + */ + +export type CsvColumn = { + /** Display label written to the header row. */ + header: string; + /** Cell extractor. Return primitives only. */ + value: (row: T) => string | number | null | undefined; +}; + +const NEEDS_QUOTING = /[",\r\n]/; + +function escapeField(raw: string | number | null | undefined): string { + if (raw === null || raw === undefined) return ""; + const s = typeof raw === "string" ? raw : String(raw); + if (NEEDS_QUOTING.test(s)) { + return `"${s.replace(/"/g, '""')}"`; + } + return s; +} + +/** + * Serialize an array of rows to an RFC 4180 CSV string. + * + * Lines are joined with `\r\n` per the spec. The output ends with a trailing + * CRLF so the last row is terminated identically to the rest — Excel and + * Numbers both prefer this for auto-detecting row count. + */ +export function toCsv( + rows: T[], + columns: CsvColumn[], +): string { + const header = columns.map((c) => escapeField(c.header)).join(","); + if (rows.length === 0) { + // No body rows: emit header + CRLF only. The two CRLFs that would + // appear from "empty body + terminator" are dropped — a trailing blank + // line confuses Excel's row-count heuristics. + return `${header}\r\n`; + } + const body = rows + .map((row) => columns.map((c) => escapeField(c.value(row))).join(",")) + .join("\r\n"); + // RFC 4180: every record (including the header) is CRLF-terminated. + // A row that serializes to "" still produces a CRLF in body plus the + // terminator CRLF, yielding two CRLFs after the header. + return `${header}\r\n${body}\r\n`; +} + +/** + * Default filename for a CSV download: `{prefix}-{YYYY-MM-DD}.csv` using the + * local date. We slice an ISO string instead of constructing the date with + * `getFullYear` etc. because the ISO format matches the rest of the codebase. + */ +export function defaultCsvFilename(prefix: string, now: Date = new Date()): string { + const isoDay = now.toISOString().slice(0, 10); + return `${prefix}-${isoDay}.csv`; +} + +/** + * Trigger a browser download of `csvText` as a file. Creates an in-memory + * Blob, attaches it to a hidden ``, clicks the link, then revokes + * the object URL on the next tick so the download still has a live handle. + * + * Safe to call from SSR/Node — guards against a missing `document`. + */ +export function downloadCsv(filename: string, csvText: string): void { + if (typeof document === "undefined" || typeof URL === "undefined") return; + // Prepend BOM so Excel auto-detects UTF-8 on Windows. Tools that already + // understand UTF-8 (Numbers, Google Sheets, every CSV parser in this repo) + // ignore the leading bytes silently. + const blob = new Blob(["\ufeff", csvText], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + // Some browsers require the link to be in the DOM before .click() works. + a.style.display = "none"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + // Defer revoke so the browser has the URL handle when the download kicks off. + queueMicrotask(() => URL.revokeObjectURL(url)); +} \ No newline at end of file