diff --git a/README.md b/README.md index 07d2590..b581b40 100644 --- a/README.md +++ b/README.md @@ -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`): diff --git a/src/app/agents/page.test.tsx b/src/app/agents/page.test.tsx index b5c99ae..e76143c 100644 --- a/src/app/agents/page.test.tsx +++ b/src/app/agents/page.test.tsx @@ -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(); diff --git a/src/app/usage/page.tsx b/src/app/usage/page.tsx index 87b27dd..5e08df8 100644 --- a/src/app/usage/page.tsx +++ b/src/app/usage/page.tsx @@ -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; } @@ -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 + } /> - - ))} - +

+

{w.events.join(", ")}

+ + + + ))} + + )} ); diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index c845d5c..e1f5e96 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -140,17 +140,19 @@ export function ConfirmDialog({ if (!open) return null; return (
-
+

{title}

diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 6160619..9d32e77 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -72,13 +72,15 @@ function MobileNav({ useEffect(() => { if (menuOpen) { - const first = panelRef.current?.querySelector( + const first = panelRef.current!.querySelector( "a[role='menuitem'], a, [role='menuitem']" ); - first?.focus?.(); + if (first) { + first.focus(); + } return; } - toggleRef.current?.focus?.(); + toggleRef.current!.focus(); }, [menuOpen]); return ( diff --git a/src/components/KeyValueGrid.tsx b/src/components/KeyValueGrid.tsx index c02076d..c7fe971 100644 --- a/src/components/KeyValueGrid.tsx +++ b/src/components/KeyValueGrid.tsx @@ -10,8 +10,20 @@ export function KeyValueGrid({ rows }: { rows: Row[] }) {
{rows.map((r, i) => (
-
{r.label}
-
{r.value}
+
+ {r.label} +
+
+ {r.value} +
))}
diff --git a/src/components/__tests__/Header.test.tsx b/src/components/__tests__/Header.test.tsx index c55fdb3..0812ae4 100644 --- a/src/components/__tests__/Header.test.tsx +++ b/src/components/__tests__/Header.test.tsx @@ -155,5 +155,177 @@ describe("Header", () => { const homeLink = screen.getByRole("link", { name: "Home" }); expect(homeLink.className).toContain("focus-visible:outline"); }); + + it("mobile menu manages focus when opening and closing", () => { + mockPathname.mockReturnValue("/"); + render(
); + + const toggle = getMobileToggle(); + fireEvent.click(toggle); + + // Should focus the first menu item (Home) + expect(screen.getByRole("menuitem", { name: "Home" })).toHaveFocus(); + + // Close it + fireEvent.click(toggle); + expect(toggle).toHaveFocus(); + }); + + it("closes the desktop More menu on blur when focus leaves the menu", () => { + mockPathname.mockReturnValue("/"); + render(
); + + const moreBtn = screen.getByRole("button", { name: /more/i }); + fireEvent.click(moreBtn); + + const menu = screen.getByRole("menu"); + expect(menu).toBeInTheDocument(); + + // Blur from the menu to something else + fireEvent.blur(menu, { relatedTarget: document.body }); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + }); + + it("closes the mobile menu when a secondary link is clicked", () => { + mockPathname.mockReturnValue("/"); + render(
); + + fireEvent.click(getMobileToggle()); + const webhooksLink = screen.getByRole("menuitem", { name: "Webhooks" }); + fireEvent.click(webhooksLink); + + expect(screen.queryByRole("region", { name: /mobile navigation/i })).not.toBeInTheDocument(); + }); + + it("closes the mobile menu when a primary link is clicked", () => { + mockPathname.mockReturnValue("/"); + render(
); + + fireEvent.click(getMobileToggle()); + const servicesLink = screen.getByRole("menuitem", { name: "Services" }); + fireEvent.click(servicesLink); + + expect(screen.queryByRole("region", { name: /mobile navigation/i })).not.toBeInTheDocument(); + }); + + it("closes the desktop More menu when a link inside it is clicked", () => { + mockPathname.mockReturnValue("/"); + render(
); + + const moreBtn = screen.getByRole("button", { name: /more/i }); + fireEvent.click(moreBtn); + + const apiKeysLink = screen.getByRole("menuitem", { name: "API Keys" }); + fireEvent.click(apiKeysLink); + + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + }); + + it("marks the active secondary route with aria-current in mobile menu", () => { + mockPathname.mockReturnValue("/api-keys"); + render(
); + fireEvent.click(getMobileToggle()); + expect(screen.getByRole("menuitem", { name: "API Keys" })).toHaveAttribute( + "aria-current", + "page" + ); + }); + + it("keeps the desktop More menu open when focus moves within it", () => { + mockPathname.mockReturnValue("/"); + render(
); + fireEvent.click(screen.getByRole("button", { name: /more/i })); + + const menu = screen.getByRole("menu"); + const items = screen.getAllByRole("menuitem"); + const secondItem = items[1]; + + fireEvent.blur(menu, { relatedTarget: secondItem }); + expect(menu).toBeInTheDocument(); + }); + + it("isActive returns false for Home link when on another page", () => { + mockPathname.mockReturnValue("/services"); + render(
); + expect(screen.getByRole("link", { name: "Home" })).not.toHaveAttribute("aria-current"); + }); + + it("isActive returns false for similar but different routes", () => { + mockPathname.mockReturnValue("/services-more"); + render(
); + expect(screen.getByRole("link", { name: "Services" })).not.toHaveAttribute("aria-current"); + }); + + it("isActive handles trailing slashes", () => { + mockPathname.mockReturnValue("/services/"); + render(
); + expect(screen.getByRole("link", { name: "Services" })).toHaveAttribute("aria-current", "page"); + }); + + it("marks a deep secondary route with aria-current", () => { + mockPathname.mockReturnValue("/api-keys/new"); + render(
); + fireEvent.click(screen.getByRole("button", { name: /more/i })); + expect(screen.getByRole("menuitem", { name: "API Keys" })).toHaveAttribute( + "aria-current", + "page" + ); + }); + + it("mobile menu does not close on other key presses", () => { + mockPathname.mockReturnValue("/"); + render(
); + + const toggle = getMobileToggle(); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + + fireEvent.keyDown(window, { key: "Enter" }); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + }); + + it("marks active routes in mobile menu", () => { + mockPathname.mockReturnValue("/usage"); + render(
); + fireEvent.click(getMobileToggle()); + + const usageLink = screen.getByRole("menuitem", { name: "Usage" }); + expect(usageLink).toHaveAttribute("aria-current", "page"); + }); + + it("closes the desktop More menu on route change", () => { + mockPathname.mockReturnValue("/"); + const { rerender } = render(
); + + const moreBtn = screen.getByRole("button", { name: /more/i }); + fireEvent.click(moreBtn); + expect(screen.getByRole("menu")).toBeInTheDocument(); + + mockPathname.mockReturnValue("/usage"); + rerender(
); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + }); + + it("closes the desktop More menu when focused out to nothing", () => { + mockPathname.mockReturnValue("/"); + render(
); + fireEvent.click(screen.getByRole("button", { name: /more/i })); + + const menu = screen.getByRole("menu"); + fireEvent.blur(menu, { relatedTarget: null }); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + }); + + it("toggles the desktop More menu when clicked multiple times", () => { + mockPathname.mockReturnValue("/"); + render(
); + + const moreBtn = screen.getByRole("button", { name: /more/i }); + fireEvent.click(moreBtn); + expect(moreBtn).toHaveAttribute("aria-expanded", "true"); + + fireEvent.click(moreBtn); + expect(moreBtn).toHaveAttribute("aria-expanded", "false"); + }); }); diff --git a/src/components/__tests__/Pagination.test.tsx b/src/components/__tests__/Pagination.test.tsx index 931c54d..8033e4f 100644 --- a/src/components/__tests__/Pagination.test.tsx +++ b/src/components/__tests__/Pagination.test.tsx @@ -25,4 +25,11 @@ describe("Pagination", () => { fireEvent.click(screen.getByRole("button", { name: /previous/i })); expect(onChange).toHaveBeenCalledWith(1); }); + + it("calls onChange(1) when Previous is clicked on page 2", () => { + const onChange = jest.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: /previous/i })); + expect(onChange).toHaveBeenCalledWith(1); + }); }); diff --git a/src/lib/validateNumber.ts b/src/lib/validateNumber.ts index a3c2537..8af021a 100644 --- a/src/lib/validateNumber.ts +++ b/src/lib/validateNumber.ts @@ -24,8 +24,11 @@ const DEFAULT_POSITIVE_MESSAGE = "requests must be a positive integer"; * Rejected examples: "", "-1", "-0", "1.5", "1e2" (non-integer), "-0.1" */ export function parseNonNegativeInt(input: string): ParseResult { + if (input.trim() === "") { + return { ok: false, message: DEFAULT_NON_NEGATIVE_MESSAGE }; + } const n = Number(input); - if (!Number.isInteger(n) || n < 0) { + if (!Number.isInteger(n) || n < 0 || Object.is(n, -0)) { return { ok: false, message: DEFAULT_NON_NEGATIVE_MESSAGE }; } return { ok: true, value: n }; @@ -38,6 +41,9 @@ export function parseNonNegativeInt(input: string): ParseResult { * Rejected examples: "", "0", "-1", "1.5", "-0.1" */ export function parsePositiveInt(input: string): ParseResult { + if (input.trim() === "") { + return { ok: false, message: DEFAULT_POSITIVE_MESSAGE }; + } const n = Number(input); if (!Number.isInteger(n) || n <= 0) { return { ok: false, message: DEFAULT_POSITIVE_MESSAGE };