feat(suggest): school autosuggest, and the rate-limit fix it needed first #127
No files matched your search
@@ -0,0 +1,73 @@
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useSchoolSuggest } from '@/hooks/useSchoolSuggest';
|
||||
|
||||
const realFetch = global.fetch;
|
||||
|
||||
function mockFetch(rows: unknown[], delayMs = 0) {
|
||||
global.fetch = jest.fn(async (_url: unknown, init?: { signal?: AbortSignal }) => {
|
||||
if (delayMs) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const t = setTimeout(resolve, delayMs);
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
clearTimeout(t);
|
||||
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
|
||||
});
|
||||
});
|
||||
}
|
||||
return { ok: true, json: async () => ({ suggestions: rows }) };
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
const ROW = {
|
||||
urn: 1, school_name: 'Brecknock Primary School', local_authority: 'Camden',
|
||||
postcode: 'NW1 1AA', phase: 'Primary', school_type: 'Community school',
|
||||
};
|
||||
|
||||
describe('useSchoolSuggest', () => {
|
||||
beforeEach(() => { jest.useFakeTimers(); });
|
||||
afterEach(() => { jest.useRealTimers(); global.fetch = realFetch; });
|
||||
|
||||
it('does not fetch below the minimum query length', () => {
|
||||
mockFetch([ROW]);
|
||||
renderHook(() => useSchoolSuggest('b', true));
|
||||
act(() => { jest.advanceTimersByTime(500); });
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fetch at all when disabled', () => {
|
||||
// The flag being off must mean no request, not a hidden dropdown.
|
||||
mockFetch([ROW]);
|
||||
renderHook(() => useSchoolSuggest('brecknock', false));
|
||||
act(() => { jest.advanceTimersByTime(500); });
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('debounces rather than firing per keystroke', () => {
|
||||
mockFetch([ROW]);
|
||||
const { rerender } = renderHook(
|
||||
({ q }) => useSchoolSuggest(q, true), { initialProps: { q: 'br' } });
|
||||
rerender({ q: 'bre' });
|
||||
rerender({ q: 'brec' });
|
||||
act(() => { jest.advanceTimersByTime(199); });
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
act(() => { jest.advanceTimersByTime(2); });
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('opens with results once they arrive', async () => {
|
||||
mockFetch([ROW]);
|
||||
const { result } = renderHook(() => useSchoolSuggest('brecknock', true));
|
||||
act(() => { jest.advanceTimersByTime(200); });
|
||||
await waitFor(() => expect(result.current.suggestions).toHaveLength(1));
|
||||
expect(result.current.open).toBe(true);
|
||||
});
|
||||
|
||||
it('close() hides the list without clearing the query', async () => {
|
||||
mockFetch([ROW]);
|
||||
const { result } = renderHook(() => useSchoolSuggest('brecknock', true));
|
||||
act(() => { jest.advanceTimersByTime(200); });
|
||||
await waitFor(() => expect(result.current.open).toBe(true));
|
||||
act(() => { result.current.close(); });
|
||||
expect(result.current.open).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { fetchSuggestions, SUGGEST_MIN_QUERY, type Suggestion } from '@/lib/suggest';
|
||||
|
||||
/*
|
||||
* Long enough that a fast typist does not fire a request per character, short
|
||||
* enough that the list feels attached to the keyboard.
|
||||
*/
|
||||
const DEBOUNCE_MS = 200;
|
||||
|
||||
export function useSchoolSuggest(query: string, enabled: boolean) {
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
// Set when the user dismisses the list, so a re-render does not reopen it.
|
||||
const dismissed = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
const q = query.trim();
|
||||
if (!enabled || q.length < SUGGEST_MIN_QUERY || dismissed.current === q) {
|
||||
setSuggestions([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* Abort the superseded request on every keystroke. This is correctness,
|
||||
* not economy: without it a slow response for "st" can land after the fast
|
||||
* one for "st marys" and replace a correct list with a stale one.
|
||||
*/
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(async () => {
|
||||
const rows = await fetchSuggestions(q, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
setSuggestions(rows);
|
||||
setActiveIndex(-1);
|
||||
setOpen(rows.length > 0);
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [query, enabled]);
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
open,
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
close: () => {
|
||||
dismissed.current = query.trim();
|
||||
setOpen(false);
|
||||
setActiveIndex(-1);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Client for /api/suggest.
|
||||
*
|
||||
* No `cache: "no-store"`. The compare modal's search uses it, and copying that
|
||||
* here would discard both the browser cache and the ETag 304s the backend's
|
||||
* CacheAndETagMiddleware already provides — on the one endpoint where prefix
|
||||
* queries repeat most.
|
||||
*/
|
||||
|
||||
export interface Suggestion {
|
||||
urn: number;
|
||||
school_name: string;
|
||||
local_authority: string;
|
||||
postcode: string;
|
||||
phase: string;
|
||||
school_type: string;
|
||||
}
|
||||
|
||||
/** Below this the response is thousands of schools and worth no round trip. */
|
||||
export const SUGGEST_MIN_QUERY = 2;
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_URL || '/api';
|
||||
|
||||
/** Suggestions for `q`. Never throws: no suggestions is a fine outcome. */
|
||||
export async function fetchSuggestions(
|
||||
q: string, signal?: AbortSignal,
|
||||
): Promise<Suggestion[]> {
|
||||
if (q.trim().length < SUGGEST_MIN_QUERY) return [];
|
||||
try {
|
||||
const res = await fetch(`${API}/suggest?q=${encodeURIComponent(q.trim())}`,
|
||||
{ signal });
|
||||
if (!res.ok) return [];
|
||||
const body = await res.json();
|
||||
return body.suggestions ?? [];
|
||||
} catch {
|
||||
// Includes AbortError, which is the normal path on every keystroke.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user