Skip to content
Merged
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 @@ -308,6 +308,10 @@ The `/events` page renders server-supplied JSON payloads with performance safegu

The `/changelog` page keeps using `useApi("/api/v1/changelog")` for loading release notes. When the backend returns `{ entries: [] }`, it renders the shared `EmptyState` component with a clear "No changelog entries yet" message instead of an empty list. This branch is constant-time and adds no extra network calls.

## Webhooks empty and loading states

The `/webhooks` page shows the shared `Spinner` component during the initial fetch. Once the list resolves, if it is empty, it renders the `EmptyState` component with "No webhooks registered yet" and helpful guidance. When webhooks are present, they are rendered inside an accessible region for better screen-reader discovery.

## Formatting conventions

The frontend formats currency (Stroops / XLM) consistently using the helper `formatStroops` (located in `src/lib/format.ts`):
Expand Down
5 changes: 4 additions & 1 deletion src/app/agents/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ describe("AgentsPage", () => {
// --- Loading state --------------------------------------------------------

it("renders a spinner while the first page is loading", () => {
mockByUrl({ agents: new Promise(() => undefined) /* never resolves */ });
mockByUrl({
agents: new Promise(() => undefined), // never resolves
stats: new Promise(() => undefined), // never resolves
});

render(<AgentsPage />);

Expand Down
10 changes: 8 additions & 2 deletions src/app/usage/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export default function UsagePage() {
const parsed = parsePositiveInt(requests);
if (!parsed.ok) {
// Surface the validation message through the field error.
// We use a special kind 'validation-error' to distinguish it from API errors
// if we wanted to, but for now just making sure it's handled consistently.
setStatus({ kind: "error", message: parsed.message });
return;
}
Expand Down Expand Up @@ -143,7 +145,11 @@ export default function UsagePage() {
required
value={requests}
onChange={(e) => setRequests(e.target.value)}
error={status.kind === "error" ? status.message : undefined}
error={
status.kind === "error" && !status.requestId
? status.message
: undefined
}
/>

<button
Expand All @@ -161,7 +167,7 @@ export default function UsagePage() {
: "Recorded."}
</p>
)}
{status.kind === "error" && (
{status.kind === "error" && status.requestId && (
<p role="alert" className="text-sm text-rose-700 dark:text-rose-400">
{formatAlert(status.message, status.requestId)}
</p>
Expand Down
80 changes: 78 additions & 2 deletions src/app/webhooks/page.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import WebhooksPage from "./page";

const mockItems = [
Expand Down Expand Up @@ -60,7 +60,8 @@ it("calls DELETE and closes dialog when confirmed", async () => {
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ items: [] }) } as unknown as Response);

fireEvent.click(screen.getByRole("button", { name: /^remove$/i }));
const confirmBtn = screen.getAllByRole("button", { name: /^remove$/i })[0];
const dialog = screen.getByRole("dialog");
const confirmBtn = within(dialog).getByRole("button", { name: /^remove$/i });
fireEvent.click(confirmBtn);

await waitFor(() => {
Expand All @@ -69,3 +70,78 @@ it("calls DELETE and closes dialog when confirmed", async () => {
});
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});

it("shows loading spinner on initial load", async () => {
let resolveFetch: (value: unknown) => void;
const fetchPromise = new Promise((resolve) => {
resolveFetch = resolve;
});
globalThis.fetch = jest.fn().mockReturnValue(fetchPromise);

render(<WebhooksPage />);
expect(screen.getByRole("status")).toBeInTheDocument();
expect(screen.getByText(/loading webhooks/i)).toBeInTheDocument();

resolveFetch!({
ok: true,
status: 200,
json: async () => ({ items: [] }),
});
await screen.findByText(/no webhooks registered yet/i);
});

it("shows empty state when no webhooks are registered", async () => {
globalThis.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ items: [] }),
} as unknown as Response);

render(<WebhooksPage />);
await screen.findByText(/no webhooks registered yet/i);
expect(screen.getByText(/register a webhook url to start receiving real-time event notifications/i)).toBeInTheDocument();
});

it("shows list inside accessible region when webhooks exist", async () => {
mockFetchSuccess();
render(<WebhooksPage />);
await screen.findByText("https://example.com/hook");
expect(screen.getByRole("region", { name: /registered webhooks/i })).toBeInTheDocument();
});

it("handles creation error", async () => {
mockFetchSuccess();
render(<WebhooksPage />);
await screen.findByText("https://example.com/hook");

globalThis.fetch = jest.fn().mockRejectedValue(new Error("Failed to create"));

fireEvent.change(screen.getByPlaceholderText(/https:\/\/example.com\/agentpay-hook/i), {
target: { value: "https://new.com/hook" },
});
fireEvent.click(screen.getByRole("button", { name: /register/i }));

await screen.findByText("Failed to create");
});

it("handles deletion error", async () => {
mockFetchSuccess();
render(<WebhooksPage />);
await screen.findByText("https://example.com/hook");

globalThis.fetch = jest.fn().mockRejectedValue(new Error("Failed to delete"));

fireEvent.click(screen.getByRole("button", { name: /^remove$/i }));
// Target the confirm button inside the dialog specifically
const dialog = screen.getByRole("dialog");
const confirmBtn = within(dialog).getByRole("button", { name: /^remove$/i });
fireEvent.click(confirmBtn);

await screen.findByText("Failed to delete");
});

it("handles fetch error", async () => {
globalThis.fetch = jest.fn().mockRejectedValue(new Error("Failed to fetch"));
render(<WebhooksPage />);
await screen.findByText("Failed to fetch");
});
75 changes: 45 additions & 30 deletions src/app/webhooks/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { useEffect, useState } from "react";
import { apiGet, apiPost, apiDelete } from "@/lib/apiClient";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { safeHref } from "@/lib/url";
import { EmptyState } from "@/components/EmptyState";
import { Spinner } from "@/components/Spinner";


type Webhook = { id: string; url: string; events: string[]; createdAt: number };
Expand Down Expand Up @@ -99,37 +101,50 @@ export default function WebhooksPage() {
</p>
)}
</form>
{items && (
<ul className="divide-y divide-zinc-200 dark:divide-zinc-800">
{items.map((w) => (
<li key={w.id} className="flex items-center justify-between gap-2 py-3">
<div>
<p className="text-sm font-medium break-all">
{(() => {
const validated = safeHref(w.url);
if (validated.ok) {
return (
<a href={validated.href} target="_blank" rel="noopener noreferrer">
{w.url}
</a>
);
}
return w.url;
})()}
{items === null && !error && (
<div className="flex justify-center py-10">
<Spinner label="Loading webhooks" />
</div>
)}
{items?.length === 0 && (
<EmptyState
title="No webhooks registered yet"
description="Register a webhook URL to start receiving real-time event notifications."
/>
)}
{items && items.length > 0 && (
<section aria-label="Registered webhooks">
<ul className="divide-y divide-zinc-200 dark:divide-zinc-800">
{items.map((w) => (
<li key={w.id} className="flex items-center justify-between gap-2 py-3">
<div>
<p className="text-sm font-medium break-all">
{(() => {
const validated = safeHref(w.url);
if (validated.ok) {
return (
<a href={validated.href} target="_blank" rel="noopener noreferrer">
{w.url}
</a>
);
}
return w.url;
})()}

</p>
<p className="text-xs text-zinc-500">{w.events.join(", ")}</p>
</div>
<button
type="button"
onClick={() => setPendingRemove(w)}
className="rounded border border-zinc-300 px-3 py-1 text-xs hover:border-rose-500 hover:text-rose-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:border-zinc-700"
>
Remove
</button>
</li>
))}
</ul>
</p>
<p className="text-xs text-zinc-500">{w.events.join(", ")}</p>
</div>
<button
type="button"
onClick={() => setPendingRemove(w)}
className="rounded border border-zinc-300 px-3 py-1 text-xs hover:border-rose-500 hover:text-rose-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:border-zinc-700"
>
Remove
</button>
</li>
))}
</ul>
</section>
)}
</main>
);
Expand Down
18 changes: 10 additions & 8 deletions src/components/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,17 +140,19 @@ export function ConfirmDialog({
if (!open) return null;
return (
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="confirm-title"
aria-describedby={description ? descriptionId : undefined}
tabIndex={-1}
onKeyDown={handleKeyDown}
onMouseDown={handleBackdropMouseDown}
className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 p-4"
>
<div className="w-full max-w-sm rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-900">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="confirm-title"
aria-describedby={description ? descriptionId : undefined}
tabIndex={-1}
onKeyDown={handleKeyDown}
className="w-full max-w-sm rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-900 focus:outline-none"
>
<h2 id="confirm-title" className="text-lg font-semibold">
{title}
</h2>
Expand Down
8 changes: 5 additions & 3 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,15 @@ function MobileNav({

useEffect(() => {
if (menuOpen) {
const first = panelRef.current?.querySelector<HTMLElement>(
const first = panelRef.current!.querySelector<HTMLElement>(
"a[role='menuitem'], a, [role='menuitem']"
);
first?.focus?.();
if (first) {
first.focus();
}
return;
}
toggleRef.current?.focus?.();
toggleRef.current!.focus();
}, [menuOpen]);

return (
Expand Down
16 changes: 14 additions & 2 deletions src/components/KeyValueGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,20 @@ export function KeyValueGrid({ rows }: { rows: Row[] }) {
<dl className="grid grid-cols-[max-content_1fr] gap-x-6 gap-y-2 text-sm">
{rows.map((r, i) => (
<div key={i} className="contents">
<dt className="text-zinc-500">{r.label}</dt>
<dd className="break-all">{r.value}</dd>
<dt
className="text-zinc-500"
role="term"
aria-label={typeof r.label === "string" ? r.label : undefined}
>
{r.label}
</dt>
<dd
className="break-all"
role="definition"
aria-label={typeof r.value === "string" ? r.value : undefined}
>
{r.value}
</dd>
</div>
))}
</dl>
Expand Down
Loading
Loading