Skip to content

Commit 6def22e

Browse files
committed
Merge latest main into a11y/contrast-verify-and-correct-badge
2 parents 3eb5c42 + fade45f commit 6def22e

26 files changed

Lines changed: 977 additions & 412 deletions

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ jobs:
5151

5252
- name: Scan PR diff for secrets
5353
env:
54-
BASE_REF: ${{ github.event.pull_request.base.ref || 'origin/main' }}
54+
BASE_REF: origin/${{ github.event.pull_request.base.ref || 'main' }}
5555
run: npm run scan:secrets:ci
5656

5757
build-test:

CONTRIBUTING.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,28 @@ Run coverage locally with:
6565
npm run test:coverage
6666
```
6767

68+
#### Testing components with global side effects
69+
70+
Components that register `window`/`document`-level listeners (keyboard shortcuts,
71+
visibility changes, `matchMedia`) or trigger navigation need a few extra things
72+
covered beyond a normal render test. `src/components/__tests__/CommandPalette.test.tsx`
73+
is the reference example — it mocks `next/navigation`'s `useRouter` and drives the
74+
component entirely through `fireEvent.keyDown(window, ...)`, since the palette's
75+
open/close/navigate behaviour is wired to a global `keydown` listener rather than
76+
props. At minimum, cover:
77+
78+
- The open/dismiss triggers themselves (e.g. `Cmd`/`Ctrl+K`, `Escape`) fired on
79+
`window`, not just on an element inside the component.
80+
- Filtering or derived state is asserted against the actual source data
81+
(e.g. `ROUTES` from `src/lib/routes.ts`), not a hard-coded expected list, so the
82+
test still catches a real regression if that data changes shape.
83+
- Keyboard selection assertions target a *specific* highlighted item (not just
84+
"the first one"), so a test can't pass by accident if the highlight logic is
85+
broken but happens to default to index 0.
86+
- Listener cleanup on unmount (`jest.spyOn(window, 'removeEventListener')`), so a
87+
leaked listener from a later regression fails the suite instead of silently
88+
accumulating in production.
89+
6890
---
6991

7092
## Lighthouse CI — performance budgets

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ Reusable building blocks live under `src/components` and are imported by route p
5858
| [`KeyboardShortcutsHelp`](src/components/KeyboardShortcutsHelp.tsx) | `?` overlay listing keyboard shortcuts |
5959
| [`CommandPalette`](src/components/CommandPalette.tsx) | `Cmd/Ctrl+K` route jump palette |
6060
| [`Tooltip`](src/components/Tooltip.tsx) | Status surface with loading/empty/error/success states (see [tooltips.md](docs/tooltips.md)) |
61-
| [`Slippage`](src/components/Slippage.tsx) | Slippage tolerance control with preset/custom options and state handling (see [slippage.md](docs/slippage.md)) |
61+
| [`QuoteHistory`](src/app/quote/QuoteHistory.tsx) | Recent quote history list with selection callback and memoization (see [history.md](docs/history.md)) |
6262

6363
Data fetching helpers (`apiClient`, `useApi`, `useList`) live in `src/lib`.
6464

docs/STYLEGUIDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,8 @@ function SortHeader({ label, sortKey, activeSortKey, sortDir, onSort }) {
233233
- [`README.md`](../README.md) — overview, routes, scripts.
234234
- [`docs/theme-storage.md`](./theme-storage.md) — light/dark toggle and the
235235
`localStorage` contract that backs it.
236+
- [`docs/history.md`](./history.md) — quote history component contract, props,
237+
and memoization.
236238

237239
## IconButton accessible-label contract
238240

docs/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ incrementing `requestIdRef`.
124124
| 3 | Request | Calls `apiFetch<Quote>(path)` with query params `?source_asset=...&dest_asset=...&amount=...`. Aborts previous in-flight request via `AbortController`. |
125125
| 4 | `src/lib/apiClient.ts` | Sends GET, parses `Quote` response. |
126126
| 5 | Display | Formats amount via `formatQuoteAmountDisplay` / `formatQuoteRateDisplay` (both from `src/lib/format.ts`). |
127-
| 6 | History | Inputs persisted to `localStorage` via `useLocalStorage` (`src/lib/useLocalStorage.ts`). |
127+
| 6 | History | Inputs persisted to `localStorage` via `useLocalStorage` (`src/lib/useLocalStorage.ts`) and rendered via `QuoteHistory` (see [`docs/history.md`](history.md)). |
128128

129129
### Events
130130

docs/history.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Quote History (`QuoteHistory`)
2+
3+
The `QuoteHistory` component renders a list of recently requested currency routing path quotes persisted in `localStorage`. Clicking a history item applies its saved inputs (`source`, `dest`, `amount`) back into the quote form.
4+
5+
The component is memoized (`React.memo`) so stable prop references prevent unnecessary re-renders when parent form inputs, loading state, or error messages update.
6+
7+
## Props
8+
9+
| Prop | Type | Required | Description |
10+
|---|---|---|---|
11+
| `history` | `HistoryEntry[]` | Yes | List of recently saved quote input entries. |
12+
| `onSelect` | `(entry: HistoryEntry) => void` | Yes | Callback invoked when a user clicks a history button to select an entry. |
13+
14+
## Types
15+
16+
```typescript
17+
export type QuoteInputs = {
18+
source: string;
19+
dest: string;
20+
amount: string;
21+
};
22+
23+
export type HistoryEntry = QuoteInputs & {
24+
savedAt: number; // Unix timestamp in milliseconds when saved
25+
};
26+
27+
export interface QuoteHistoryProps {
28+
history: HistoryEntry[];
29+
onSelect: (entry: HistoryEntry) => void;
30+
}
31+
```
32+
33+
## Behavior & Rendering Contract
34+
35+
- **Empty State** — If `history` is empty (`history.length === 0`), `QuoteHistory` renders `null`.
36+
- **Item Display** — Each entry is rendered as a full-width button displaying `{entry.source} → {entry.dest} · {entry.amount}`.
37+
- **Memoization** — Wrapped with `React.memo`. Re-renders only when `history` array reference or `onSelect` function reference changes.
38+
- **Persistence** — Quotes are stored in `localStorage` under `stableroute.quote.history`, limited to a maximum of 5 entries. Duplicate entries are deduplicated on push.
39+
40+
## Usage Example
41+
42+
```tsx
43+
import { QuoteHistory, HistoryEntry } from './QuoteHistory';
44+
import { useCallback, useState } from 'react';
45+
46+
function QuoteContainer() {
47+
const [history, setHistory] = useState<HistoryEntry[]>([]);
48+
49+
const handleSelectHistory = useCallback((entry: HistoryEntry) => {
50+
// Populate form fields with selected history item
51+
setSourceAsset(entry.source);
52+
setDestAsset(entry.dest);
53+
setAmount(entry.amount);
54+
}, []);
55+
56+
return (
57+
<QuoteHistory
58+
history={history}
59+
onSelect={handleSelectHistory}
60+
/>
61+
);
62+
}
63+
```
64+
65+
## Accessibility
66+
67+
- Wrapped in a `<section>` element linked to the section title via `aria-labelledby="recent-quotes-heading"`.
68+
- Uses semantic `<h2>` with ID `recent-quotes-heading`.
69+
- Interactive items are rendered as semantic `<button type="button">` elements with explicit type attributes to avoid accidental form submission.

src/app/api-keys/Client.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
'use client';
22

3-
import { useCallback, useState } from 'react';
3+
import React, { memo, useCallback, useState, useRef } from 'react';
44
import { IconButton } from '@/components/IconButton';
55
import { ResourceList } from '@/components/ResourceList';
66
import { TextField } from '@/components/TextField';
77
import { TimeAgo } from '@/components/TimeAgo';
88
import { Badge } from '@/components/Badge';
99
import { apiDelete, apiGet, apiPost } from '@/lib/apiClient';
10+
import { useFormAnnouncement } from '@/lib/useFormAnnouncement';
1011
import { useList } from '@/lib/useList';
1112
import { writeToClipboard } from '@/lib/clipboard';
1213
import { useToast } from '@/components/ToastProvider';
1314
import type { ApiKey, CreateApiKeyResponse } from '@/lib/types';
1415
import { isApiKeyListResponse, isCreateApiKeyResponse } from '@/lib/validate';
1516

16-
export default function ApiKeysClient() {
17+
function ApiKeysClient() {
18+
const renderCount = useRef(0);
19+
renderCount.current++;
1720
const loadItems = useCallback(
1821
() =>
1922
apiGet<{ items: ApiKey[] }>('/api/v1/api-keys', {
@@ -29,6 +32,7 @@ export default function ApiKeysClient() {
2932
const [recentPrefix, setRecentPrefix] = useState<string | null>(null);
3033
const [submitting, setSubmitting] = useState(false);
3134
const [copyFailed, setCopyFailed] = useState(false);
35+
const { message: formStatus, announce } = useFormAnnouncement();
3236
const { push } = useToast();
3337
const items = itemsResult.status === 'success' ? itemsResult.data : null;
3438
const loading =
@@ -37,6 +41,7 @@ export default function ApiKeysClient() {
3741
const onCreate = async (event: React.FormEvent) => {
3842
event.preventDefault();
3943
setSubmitting(true);
44+
announce('Creating API key…');
4045
try {
4146
const response = await apiPost<CreateApiKeyResponse>(
4247
'/api/v1/api-keys',
@@ -47,8 +52,10 @@ export default function ApiKeysClient() {
4752
setCopyFailed(false);
4853
setRecentPrefix(response.prefix ?? response.key.slice(0, 8));
4954
setLabel('');
55+
announce('API key created. Copy it now.');
5056
await itemsResult.refetch();
5157
} catch (err) {
58+
announce('');
5259
/* surfaced via useList error if refetch fails; keep form local */
5360
} finally {
5461
setSubmitting(false);
@@ -145,6 +152,7 @@ export default function ApiKeysClient() {
145152
loading={loading}
146153
emptyMessage="No API keys yet."
147154
getKey={(key) => key.prefix}
155+
announcement={formStatus || undefined}
148156
rowClassName="flex items-center justify-between py-3"
149157
removeDialogTitle="Revoke API key?"
150158
removeDialogConfirmLabel="Revoke"
@@ -180,3 +188,4 @@ export default function ApiKeysClient() {
180188
</main>
181189
);
182190
}
191+
export default memo(ApiKeysClient);

src/app/api-keys/page.test.tsx

Lines changed: 123 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,93 @@ describe('ApiKeysPage', () => {
227227
expect(screen.getAllByText(/Key/i).length).toBeGreaterThanOrEqual(1);
228228
});
229229

230+
describe('form announcement', () => {
231+
it('announces form submission status inside the polite live region', async () => {
232+
let resolvePost: ((value: Response) => void) | undefined;
233+
const pendingResponse = new Promise<Response>((resolve) => {
234+
resolvePost = resolve;
235+
});
236+
237+
global.fetch = jest
238+
.fn()
239+
.mockResolvedValueOnce({
240+
ok: true,
241+
text: async () => JSON.stringify({ items: [] }),
242+
} as unknown as Response)
243+
.mockImplementationOnce(
244+
() => pendingResponse
245+
) as unknown as typeof global.fetch;
246+
247+
renderPage();
248+
await waitFor(() => screen.getByText(/No API keys yet/i));
249+
250+
fireEvent.change(screen.getByLabelText('Label'), {
251+
target: { value: 'My key' },
252+
});
253+
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
254+
255+
const liveRegion = document.querySelector('[aria-live=polite]');
256+
expect(liveRegion).toHaveTextContent('Creating API key…');
257+
258+
resolvePost?.({
259+
ok: true,
260+
text: async () =>
261+
JSON.stringify({ key: 'sk_test123', prefix: 'sk_test' }),
262+
} as unknown as Response);
263+
264+
// After success, the live region should contain the success announcement
265+
await waitFor(() => {
266+
expect(liveRegion).toHaveTextContent('API key created. Copy it now.');
267+
});
268+
});
269+
270+
it('clears the form announcement on creation failure', async () => {
271+
global.fetch = jest
272+
.fn()
273+
.mockResolvedValueOnce({
274+
ok: true,
275+
text: async () => JSON.stringify({ items: [] }),
276+
} as unknown as Response)
277+
.mockRejectedValueOnce(
278+
new Error('Network error')
279+
) as unknown as typeof global.fetch;
280+
281+
renderPage();
282+
await waitFor(() => screen.getByText(/No API keys yet/i));
283+
284+
fireEvent.change(screen.getByLabelText('Label'), {
285+
target: { value: 'My key' },
286+
});
287+
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
288+
289+
// After the POST fails, the catch block clears the announcement.
290+
// The button re-enables once submitting is false.
291+
await waitFor(() => {
292+
expect(
293+
screen.getByRole('button', { name: 'Create' })
294+
).not.toBeDisabled();
295+
});
296+
297+
const liveRegion = document.querySelector('[aria-live=polite]');
298+
expect(liveRegion).not.toHaveTextContent('Creating API key…');
299+
expect(liveRegion).not.toHaveTextContent('API key created.');
300+
});
301+
302+
it('does not announce form status on initial render', async () => {
303+
global.fetch = jest.fn().mockResolvedValueOnce({
304+
ok: true,
305+
text: async () => JSON.stringify({ items: [] }),
306+
} as unknown as Response);
307+
308+
renderPage();
309+
await waitFor(() => screen.getByText(/No API keys yet/i));
310+
311+
const liveRegion = document.querySelector('[aria-live=polite]');
312+
expect(liveRegion).not.toHaveTextContent('Creating');
313+
expect(liveRegion).not.toHaveTextContent('API key created');
314+
});
315+
});
316+
230317
describe('clipboard guard', () => {
231318
function mockCreateFlow() {
232319
global.fetch = jest
@@ -255,32 +342,43 @@ describe('ApiKeysPage', () => {
255342
} as unknown as Response);
256343
}
257344

258-
it('copies the secret and hides it once the write succeeds', async () => {
259-
mockCreateFlow();
260-
const writeText = jest.fn().mockResolvedValue(undefined);
261-
setClipboard({ writeText });
262-
263-
renderPage();
264-
await waitFor(() => screen.getByText(/No API keys yet/i));
265-
await createKey();
266-
267-
expect(
268-
await screen.findByText('sk_live_supersecret')
269-
).toBeInTheDocument();
270-
271-
fireEvent.click(
272-
screen.getByRole('button', { name: 'Copy API key secret' })
273-
);
345+
it('increments render count on each render', async () => {
346+
// Initial fetch with empty list
347+
global.fetch = jest.fn().mockResolvedValueOnce({
348+
ok: true,
349+
text: async () => JSON.stringify({ items: [] }),
350+
} as unknown as Response);
351+
352+
renderPage();
353+
await waitFor(() => screen.getByText(/No API keys yet/i));
354+
const getRenderCount = () => Number(screen.getByTestId('render-count').textContent);
355+
const initial = getRenderCount();
356+
expect(initial).toBe(1);
357+
358+
// Mock fetches for creating a key and then listing keys
359+
const now = Date.now();
360+
global.fetch = jest
361+
.fn()
362+
.mockResolvedValueOnce({
363+
ok: true,
364+
text: async () => JSON.stringify({ items: [] }),
365+
} as unknown as Response) // initial list
366+
.mockResolvedValueOnce({
367+
ok: true,
368+
text: async () => JSON.stringify({ key: 'sk_test', prefix: 'sk_test' }),
369+
} as unknown as Response) // create response
370+
.mockResolvedValueOnce({
371+
ok: true,
372+
text: async () =>
373+
JSON.stringify({
374+
items: [{ prefix: 'sk_test', label: 'Test', createdAt: now }],
375+
}),
376+
} as unknown as Response); // list after creation
274377

275-
await waitFor(() => {
276-
expect(writeText).toHaveBeenCalledWith('sk_live_supersecret');
277-
});
278-
await waitFor(() => {
279-
expect(
280-
screen.queryByText('sk_live_supersecret')
281-
).not.toBeInTheDocument();
282-
});
283-
});
378+
await createKey();
379+
const after = getRenderCount();
380+
expect(after).toBeGreaterThan(initial);
381+
});
284382

285383
it('shows a toast and a selectable fallback field when the write is rejected', async () => {
286384
mockCreateFlow();

0 commit comments

Comments
 (0)