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: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,9 @@ The `/export` page builds JSON and CSV export requests from the resolved API bas

See [docs/components.md](docs/components.md) for the shared component catalog,
including prop tables, usage examples, and accessibility notes for the
primitives in `src/components`.
primitives in `src/components`. For the pagination control specifically, see
[docs/pagination.md](docs/pagination.md) for its props, render states, and
minimal usage examples.

## Shared hooks

Expand Down
5 changes: 5 additions & 0 deletions docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,11 +236,16 @@ secrets, private keys, seed phrases, or passwords.
| `error` | `string \| null` | no | Shows an `ErrorMessage` in place of the nav controls, regardless of `pageCount`. Takes precedence over `loading`. Defaults to `null`. |
| `onRetry` | `() => void` | no | Retry handler passed through to the error state's "Try again" button. Only rendered when both `error` and `onRetry` are set. |

Render precedence is **error → loading → hidden (`pageCount <= 1`) → nav**.
Announces page changes to assistive tech via a polite, debounced `aria-live`
region (`"Page N of pageCount"`). The announcement is debounced 300ms so
rapid successive page changes collapse into a single announcement for the
page the user settles on, and it stays empty on first mount.

For the full prop table, state matrix, accessibility notes, and usage
examples (including loading/error), see the
[Pagination component contract](./pagination.md).

```tsx
<Pagination page={page} pageCount={pageCount} onChange={setPage} />
```
Expand Down
139 changes: 122 additions & 17 deletions docs/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ contract changes.
| Hook | Source | Status |
| --- | --- | --- |
| `useApi` | `src/lib/useApi.ts` | Exported |
| `useApiMutation` | `src/lib/useApiMutation.ts` | Exported |
| `useClipboard` | `src/lib/useClipboard.ts` | Exported |
| `useDebounce` | `src/lib/useDebounce.ts` | Exported |
| `useLocalState` | `src/lib/useLocalState.ts` | Exported |
Expand All @@ -30,18 +31,21 @@ import { useApi } from "@/lib/useApi";
Return shape:

```ts
type ApiErrorKind = "timeout" | "generic";
type ApiErrorKind = "timeout" | "rate_limited" | "generic";

type State<T> =
| { status: "loading" }
| { status: "loading"; refetch: () => void }
| {
status: "error";
error: string;
errorKind: ApiErrorKind;
isTimeout: boolean;
isRateLimited: boolean;
retryAfterMs: number | null;
retry: () => void;
refetch: () => void;
}
| { status: "ok"; data: T };
| { status: "ok"; data: T; refetch: () => void };
```

Parameters:
Expand All @@ -52,43 +56,143 @@ Parameters:
Behaviour and gotchas:

- This is a client hook and must be used from a client component.
- The first state is `{ status: "loading" }`.
- The first state is `{ status: "loading"; refetch }`.
- When `path` changes, the hook dispatches a fresh loading state and fetches the
new path.
- If the component unmounts or `path` changes before a response settles, the
stale response is ignored through an internal cancellation flag.
stale response is ignored through an internal cancellation flag and the
in-flight request is aborted.
- `path: null` skips fetching and leaves the existing state unchanged.
- Detects `ApiTimeoutError` and sets `errorKind: "timeout"`, `isTimeout: true`, and `"Request timed out. Please try again."`. Generic errors set `errorKind: "generic"`, `isTimeout: false`, and `Error.message` (or `"failed to load"`).
- Provides a `retry()` callback affordance on the error state to trigger a refetch of the path.
Calling `refetch()` while `path` is `null` is a no-op.
- Detects `ApiTimeoutError` and sets `errorKind: "timeout"`, `isTimeout: true`, and `"Request timed out. Please try again."`. Detects `ApiRateLimitedError` and sets `errorKind: "rate_limited"`, `isRateLimited: true`, and `retryAfterMs`. Generic errors set `errorKind: "generic"`, `isTimeout: false`, and `Error.message` (or `"failed to load"`).
- Provides a stable `refetch()` callback on every status. Calling it aborts any
in-flight request and re-runs the fetch for the current `path`.
- Error states also expose `retry()`, which is the same callback as `refetch`,
kept for existing callers.
- When the browser fires an `online` event while the hook is in an error state,
the request is automatically retried.

Minimal real usage, based on `src/app/changelog/page.tsx`:
Minimal real usage, based on `src/app/agents/[agent]/page.tsx`:

```tsx
"use client";

import { ErrorMessage } from "@/components/ErrorMessage";
import { Spinner } from "@/components/Spinner";
import { useApi } from "@/lib/useApi";

type Entry = { version: string; date: string; notes: string[] };
type Usage = { items: { serviceId: string; total: number }[] };

export function ChangelogPreview() {
const state = useApi<{ entries: Entry[] }>("/api/v1/changelog");
export function AgentUsagePreview({ agent }: { agent: string }) {
const state = useApi<Usage>(
`/api/v1/agents/${encodeURIComponent(agent)}/usage`,
);

if (state.status === "loading") {
return <Spinner label="Loading changelog" />;
return <Spinner label="Loading usage" />;
}

if (state.status === "error") {
return <p role="alert">{state.error}</p>;
return (
<ErrorMessage title={state.error} onRetry={state.refetch} />
);
}

return <p>{state.data.entries.length} entries</p>;
return <p>{state.data.items.length} services</p>;
}
```

Use this hook for simple GET-backed client views that can be represented as
loading, error, or successful data. For write actions or request bodies, use the
helpers in `src/lib/apiClient.ts` directly.
loading, error, or successful data. For write actions or request bodies, prefer
`useApiMutation` below.

## `useApiMutation`

```ts
function useApiMutation<TData, TVariables = void>(
mutationFn: (
variables: TVariables,
options: { signal: AbortSignal },
) => Promise<TData>,
): {
mutate: (variables: TVariables) => Promise<TData>;
status: "idle" | "pending" | "success" | "error";
error: string | null;
reset: () => void;
};
```

Import from:

```ts
import { useApiMutation } from "@/lib/useApiMutation";
```

Parameters:

- `mutationFn`: async writer that receives call variables and an AbortSignal.
Forward the signal into `apiPost` / `apiDelete` / `apiPatch` so the shared
client can cancel the underlying fetch.

Return shape:

- `mutate(variables)`: starts the mutation, sets `status` to `"pending"`, and
resolves with `TData` on success. On failure it sets `status` to `"error"`,
mirrors a display-ready message on `error`, and rethrows.
- `status`: `"idle"` | `"pending"` | `"success"` | `"error"`.
- `error`: display-ready message when `status` is `"error"`, otherwise `null`.
- `reset()`: returns to `"idle"`, clears `error`, and aborts any in-flight
mutation.

Behaviour and gotchas:

- This is a client hook and must be used from a client component.
- Calling `mutate` while a previous mutation is still pending aborts the older
request and ignores its late response.
- Unmount aborts the current request and ignores late responses, matching the
`useApi` cancellation contract.
- Aborted / superseded mutations do not flip `status` to `"error"`.
- Non-Error rejections fall back to `"failed to mutate"`.

Minimal real usage, based on `src/app/services/new/page.tsx`:

```tsx
"use client";

import { apiPost } from "@/lib/apiClient";
import { useApiMutation } from "@/lib/useApiMutation";

type CreateServiceBody = {
serviceId: string;
priceStroops: number;
};

export function RegisterServiceButton(props: CreateServiceBody) {
const { mutate, status, error } = useApiMutation(
(body: CreateServiceBody, { signal }) =>
apiPost("/api/v1/services", body, { signal }),
);

return (
<div>
<button
type="button"
disabled={status === "pending"}
onClick={() => {
void mutate(props).catch(() => {});
}}
>
{status === "pending" ? "Saving…" : "Register"}
</button>
{error && <p role="alert">{error}</p>}
</div>
);
}
```

Use this hook for POST / DELETE / PATCH flows that need a shared pending, error,
and success state machine without copying AbortController cleanup into each
page.

## `usePolling`

Expand Down Expand Up @@ -389,4 +493,5 @@ Use this hook in components that need to react to connectivity changes, such as
## Coverage Note

This reference covers every hook exported from `src/lib` at the time of writing:
`useApi`, `useClipboard`, `useOnlineStatus`, `usePolling`, `useDebounce`, and `useLocalState`.
`useApi`, `useApiMutation`, `useClipboard`, `useOnlineStatus`, `usePolling`,
`useDebounce`, and `useLocalState`.
121 changes: 121 additions & 0 deletions docs/pagination.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Pagination component contract

This document is the concise reference for
[`src/components/Pagination.tsx`](../src/components/Pagination.tsx). Use it when
wiring list pages so callers agree on props, render states, and accessibility
behaviour.

Import from:

```ts
import { Pagination } from "@/components/Pagination";
```

`Pagination` is a client component (`"use client"`) and is exported as a
`React.memo` wrapper so unrelated parent re-renders do not force it to update
when its props are unchanged.

## Props

```ts
type Props = {
page: number;
pageCount: number;
onChange: (next: number) => void;
loading?: boolean;
error?: string | null;
onRetry?: () => void;
};
```

| Prop | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `page` | `number` | yes | — | Current **1-based** page. |
| `pageCount` | `number` | yes | — | Total number of pages. |
| `onChange` | `(next: number) => void` | yes | — | Called with the next page. Previous/Next clamp to `[1, pageCount]`. |
| `loading` | `boolean` | no | `false` | Replaces the nav with a `Spinner` labelled `"Loading page"`. |
| `error` | `string \| null` | no | `null` | Replaces the nav with an `ErrorMessage`. Takes precedence over `loading`. |
| `onRetry` | `() => void` | no | — | Passed to the error state's `"Try again"` button. The button renders only when both `error` and `onRetry` are set. |

## Render states

Exactly one of the following is rendered. Precedence is top to bottom:

| State | Condition | What renders |
| --- | --- | --- |
| **Error** | `error` is truthy | `ErrorMessage` with `title="Failed to load page"`, `detail={error}`, and optional `onRetry`. |
| **Loading** | `loading === true` (and no error) | Centered `Spinner` with `label="Loading page"`. |
| **Hidden** | `pageCount <= 1` (and neither error nor loading) | `null` — no DOM. |
| **Nav** | otherwise | `<nav aria-label="Pagination">` with Previous, `"Page {page} of {pageCount}"`, a polite live region, and Next. |

Important details:

- `error` wins over `loading` when both are set.
- Loading and error still render when `pageCount <= 1`. Only the idle nav is
suppressed for a single page.
- Previous is `disabled` when `page <= 1`; Next is `disabled` when
`page >= pageCount`.
- Clicking Previous calls `onChange(Math.max(1, page - 1))`; Next calls
`onChange(Math.min(pageCount, page + 1))`. The parent owns the controlled
`page` value.

Some list pages (for example `src/app/agents/page.tsx`) already gate
pagination with `{!loading && !error && (...)}` and only pass
`page` / `pageCount` / `onChange`. That remains valid. Prefer the component's
own `loading` / `error` / `onRetry` props when the pagination strip itself
should show those states instead of disappearing.

## Accessibility

- The nav landmark is labelled `aria-label="Pagination"`.
- Page changes are announced through a polite, `aria-atomic` live region
(`"Page N of pageCount"`). Announcements are debounced **300ms** so rapid
clicks collapse into one announcement for the settled page.
- The live region stays empty on first mount (no announcement of the initial
page).
- Previous, Next, and the error retry control are ordinary buttons and are
keyboard operable (Tab + Enter/Space).

## Minimal usage

Controlled page state with the idle nav:

```tsx
"use client";

import { useState } from "react";
import { Pagination } from "@/components/Pagination";

export function AgentsListFooter({ pageCount }: { pageCount: number }) {
const [page, setPage] = useState(1);

return (
<Pagination page={page} pageCount={pageCount} onChange={setPage} />
);
}
```

Loading and error states in place of the nav:

```tsx
<Pagination
page={page}
pageCount={pageCount}
onChange={setPage}
loading={isFetching}
/>

<Pagination
page={page}
pageCount={pageCount}
onChange={setPage}
error={loadError}
onRetry={refetch}
/>
```

## Related

- Catalog entry: [Component Catalog — Pagination](./components.md#pagination)
- Tests: `src/components/__tests__/Pagination.test.tsx`
- Coverage notes: [Testing](./testing.md)
39 changes: 39 additions & 0 deletions src/app/agents/[agent]/page.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { render, screen, waitFor } from "@testing-library/react";
import { act } from "react";
import AgentDetailPage from "./page";
import { apiGet } from "@/lib/apiClient";

Expand Down Expand Up @@ -184,6 +185,44 @@ describe("AgentDetailPage", () => {
expect(await screen.findByRole("alert")).toHaveTextContent(
"Backend usage offline",
);
expect(
screen.getByRole("button", { name: /try again/i }),
).toBeInTheDocument();
});

it("refetches usage when Try again is clicked after an error", async () => {
let usageCalls = 0;

apiGetMock.mockImplementation((path: string) => {
if (path.endsWith("/usage")) {
usageCalls += 1;
if (usageCalls === 1) {
return Promise.reject(new Error("Backend usage offline")) as never;
}
return Promise.resolve({
agent: "agent-retry",
items: [{ serviceId: "svc-retry", total: 3 }],
}) as never;
}
if (path.endsWith("/total")) {
return Promise.resolve({ total: 10 }) as never;
}
return Promise.reject(new Error(`unexpected: ${path}`)) as never;
});

renderPage("agent-retry");

expect(await screen.findByRole("alert")).toHaveTextContent(
"Backend usage offline",
);

act(() => {
screen.getByRole("button", { name: /try again/i }).click();
});

expect(await screen.findByText("svc-retry")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(usageCalls).toBe(2);
});

it("hides the spinner after a usage error", async () => {
Expand Down
Loading
Loading