diff --git a/README.md b/README.md index 379ff03..b4a1219 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,10 @@ Backend endpoints are taken from the companion documentation page `src/app/docs/ The `/api-keys` page lists each key label, prefix, and created-at age with the absolute ISO timestamp available on hover. If the account has no keys, the page renders a clear "No API keys yet" empty state instead of an empty list while preserving the create, reveal-once, copy, and revoke confirmation flows. +### Export page notes + +The `/export` page builds JSON and CSV export requests from the resolved API base URL. Downloads are fetched as blobs so the UI can disable both buttons during an in-flight request, show the shared `Spinner` with a "Preparing export" status, trigger the browser download with the response filename, show a success toast, and surface backend failures in a `role="alert"` message. + ## Shared components See [docs/components.md](docs/components.md) for the shared component catalog, diff --git a/src/app/export/ExportActions.tsx b/src/app/export/ExportActions.tsx new file mode 100644 index 0000000..54affbb --- /dev/null +++ b/src/app/export/ExportActions.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { Spinner } from "@/components/Spinner"; +import { useToast } from "@/components/ToastProvider"; +import { useState } from "react"; + +type ExportFormat = "json" | "csv"; + +type Props = { + apiBase: string; +}; + +const buttonBase = + "rounded-full px-5 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"; + +function filenameFromDisposition(disposition: string | null, fallback: string) { + const match = disposition?.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i); + if (!match) return fallback; + + try { + return decodeURIComponent(match[1]); + } catch { + return fallback; + } +} + +export function ExportActions({ apiBase }: Props) { + const [downloading, setDownloading] = useState(null); + const [error, setError] = useState(null); + const toast = useToast(); + + const startDownload = async (format: ExportFormat) => { + setError(null); + setDownloading(format); + + try { + const response = await fetch(`${apiBase}/api/v1/usage/export.${format}`); + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error(body || `Export failed with status ${response.status}`); + } + + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filenameFromDisposition( + response.headers.get("content-disposition"), + `usage-export.${format}`, + ); + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + toast.push(`${format.toUpperCase()} export downloaded.`, "info"); + } catch (err) { + setError((err as Error).message || "Export failed"); + } finally { + setDownloading(null); + } + }; + + return ( +
+
+ + +
+ {downloading && ( +
+ + Preparing {downloading.toUpperCase()} export... +
+ )} + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/src/app/export/page.test.tsx b/src/app/export/page.test.tsx new file mode 100644 index 0000000..82af6e2 --- /dev/null +++ b/src/app/export/page.test.tsx @@ -0,0 +1,118 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { ToastProvider } from "@/components/ToastProvider"; +import { ExportActions } from "./ExportActions"; + +const originalCreateObjectURL = URL.createObjectURL; +const originalRevokeObjectURL = URL.revokeObjectURL; +let clickSpy: jest.SpyInstance; + +function renderExportActions() { + return render( + + + , + ); +} + +function mockSuccessResponse(filename = "usage.json", content = "{}") { + return { + ok: true, + status: 200, + blob: async () => new Blob([content], { type: "application/json" }), + headers: { + get: (name: string) => + name.toLowerCase() === "content-disposition" + ? `attachment; filename="${filename}"` + : null, + }, + } as Response; +} + +beforeEach(() => { + globalThis.fetch = jest.fn(); + clickSpy = jest.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(); + URL.createObjectURL = jest.fn(() => "blob:usage-export"); + URL.revokeObjectURL = jest.fn(); +}); + +afterEach(() => { + clickSpy.mockRestore(); + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; +}); + +describe("ExportActions", () => { + it("downloads the selected export, uses the response filename, and shows a toast", async () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce( + mockSuccessResponse("usage.csv"), + ); + + renderExportActions(); + + fireEvent.click(screen.getByRole("button", { name: "Download CSV" })); + + await waitFor(() => { + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://api.example.test/api/v1/usage/export.csv", + ); + expect(clickSpy).toHaveBeenCalled(); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:usage-export"); + }); + expect(await screen.findByText("CSV export downloaded.")).toBeInTheDocument(); + }); + + it("disables both download buttons while a request is pending", async () => { + let resolveResponse: (response: Response) => void = () => {}; + (globalThis.fetch as jest.Mock).mockReturnValueOnce( + new Promise((resolve) => { + resolveResponse = resolve; + }), + ); + + renderExportActions(); + + fireEvent.click(screen.getByRole("button", { name: "Download JSON" })); + + expect(screen.getByRole("button", { name: /downloading json/i })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Download CSV" })).toBeDisabled(); + expect(screen.getByText("Preparing JSON export...")).toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveTextContent("Preparing JSON export"); + + resolveResponse(mockSuccessResponse()); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Download JSON" })).toBeEnabled(); + }); + }); + + it("shows backend errors to the operator", async () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 503, + text: async () => "export service unavailable", + } as Response); + + renderExportActions(); + + fireEvent.click(screen.getByRole("button", { name: "Download JSON" })); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "export service unavailable", + ); + }); + + it("still downloads an empty export response", async () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce( + mockSuccessResponse("usage-empty.json", ""), + ); + + renderExportActions(); + + fireEvent.click(screen.getByRole("button", { name: "Download JSON" })); + + await waitFor(() => { + expect(URL.createObjectURL).toHaveBeenCalled(); + expect(clickSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/export/page.tsx b/src/app/export/page.tsx index a311556..ffff311 100644 --- a/src/app/export/page.tsx +++ b/src/app/export/page.tsx @@ -1,4 +1,5 @@ import { resolveApiBase } from "@/lib/resolveApiBase"; +import { ExportActions } from "./ExportActions"; const API_BASE = resolveApiBase(); @@ -17,20 +18,7 @@ export default function ExportPage() { Calls the backend export endpoints directly; the browser downloads the file via Content-Disposition.

-
- - Download JSON - - - Download CSV - -
+ ); }