Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
98 changes: 98 additions & 0 deletions src/app/export/ExportActions.tsx
Original file line number Diff line number Diff line change
@@ -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<ExportFormat | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap gap-3">
<button
type="button"
disabled={downloading !== null}
aria-busy={downloading === "json" || undefined}
onClick={() => startDownload("json")}
className={`${buttonBase} bg-black text-white`}
>
{downloading === "json" ? "Downloading JSON..." : "Download JSON"}
</button>
<button
type="button"
disabled={downloading !== null}
aria-busy={downloading === "csv" || undefined}
onClick={() => startDownload("csv")}
className={`${buttonBase} border border-zinc-300 dark:border-zinc-700`}
>
{downloading === "csv" ? "Downloading CSV..." : "Download CSV"}
</button>
</div>
{downloading && (
<div className="flex items-center gap-2 text-sm text-zinc-600 dark:text-zinc-400">
<Spinner label={`Preparing ${downloading.toUpperCase()} export`} />
<span>Preparing {downloading.toUpperCase()} export...</span>
</div>
)}
{error && (
<p role="alert" className="text-sm text-rose-600">
{error}
</p>
)}
</div>
);
}
118 changes: 118 additions & 0 deletions src/app/export/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ToastProvider>
<ExportActions apiBase="https://api.example.test" />
</ToastProvider>,
);
}

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();
});
});
});
16 changes: 2 additions & 14 deletions src/app/export/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { resolveApiBase } from "@/lib/resolveApiBase";
import { ExportActions } from "./ExportActions";

const API_BASE = resolveApiBase();

Expand All @@ -17,20 +18,7 @@ export default function ExportPage() {
Calls the backend export endpoints directly; the browser downloads the
file via Content-Disposition.
</p>
<div className="flex flex-wrap gap-3">
<a
href={`${API_BASE}/api/v1/usage/export.json`}
className="rounded-full bg-black px-5 py-2 text-sm font-medium text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
>
Download JSON
</a>
<a
href={`${API_BASE}/api/v1/usage/export.csv`}
className="rounded-full border border-zinc-300 px-5 py-2 text-sm font-medium focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:border-zinc-700"
>
Download CSV
</a>
</div>
<ExportActions apiBase={API_BASE} />
</main>
);
}