From 28cf0a342c9f4a039bc5eeaa320b15abe99d4d93 Mon Sep 17 00:00:00 2001 From: Tudor Date: Wed, 26 Aug 2026 20:38:52 +0100 Subject: [PATCH] feat(suggest): wire autosuggest into the search box behind a flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Off means off — no combobox role, no listener, no fetch. A test asserts the absence of the request, not just the absence of the dropdown, because a hidden-but-fetching control would still be spending the rate limit on a feature nobody can see. Enter with no active option falls through to the form's submit handler and searches the typed text exactly as before. The existing behaviour is preserved, not replaced, and that has its own test. Suppressed once the value parses as a postcode: the box takes a name OR a postcode, and suggesting schools during postcode entry fights the user. .omniBoxContainer gains position: relative — the dropdown is absolutely positioned and without it would have anchored to the page instead. Four render sites, all wired: page.tsx renders HomeView in the success path AND the catch fallback, and HomeView renders FilterBar as hero AND sticky. Missing any one would make the flag silently do nothing somewhere. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj --- backend/flags.py | 7 ++ .../components/FilterBarSuggest.test.tsx | 76 +++++++++++++++++++ nextjs-app/app/page.tsx | 8 ++ nextjs-app/components/FilterBar.module.css | 2 + nextjs-app/components/FilterBar.tsx | 67 +++++++++++++++- nextjs-app/components/HomeView.tsx | 6 +- 6 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 nextjs-app/__tests__/components/FilterBarSuggest.test.tsx diff --git a/backend/flags.py b/backend/flags.py index f843828..a06e93e 100644 --- a/backend/flags.py +++ b/backend/flags.py @@ -50,6 +50,13 @@ REGISTRY: dict[str, Flag] = { ), added=date(2026, 8, 23), ), + Flag( + name="school_autosuggest", + description=( + "School name suggestions as you type in the main search box." + ), + added=date(2026, 8, 26), + ), ) } diff --git a/nextjs-app/__tests__/components/FilterBarSuggest.test.tsx b/nextjs-app/__tests__/components/FilterBarSuggest.test.tsx new file mode 100644 index 0000000..70ec8ac --- /dev/null +++ b/nextjs-app/__tests__/components/FilterBarSuggest.test.tsx @@ -0,0 +1,76 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FilterBar } from '@/components/FilterBar'; + +const push = jest.fn(); +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push, replace: jest.fn(), prefetch: jest.fn() }), + usePathname: () => '/', + useSearchParams: () => new URLSearchParams(), +})); + +const FILTERS = { + local_authorities: [], school_types: [], years: [], phases: [], + genders: [], admissions_policies: [], +}; + +const realFetch = global.fetch; +beforeEach(() => { + global.fetch = jest.fn(async () => ({ + ok: true, + json: async () => ({ suggestions: [{ + urn: 100010, school_name: 'Brecknock Primary School', + local_authority: 'Camden', postcode: 'NW1 1AA', + phase: 'Primary', school_type: 'Community school' }] }), + })) as unknown as typeof fetch; + push.mockClear(); +}); +afterEach(() => { global.fetch = realFetch; }); + +describe('FilterBar autosuggest', () => { + it('is a combobox only when the flag is on', () => { + const { rerender } = render(); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + rerender(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('makes no request while the flag is off', async () => { + // Off means off: no listener, no fetch, no markup. + render(); + await userEvent.type(screen.getByPlaceholderText(/School name or postcode/i), + 'brecknock'); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('shows suggestions and navigates when one is chosen', async () => { + render(); + await userEvent.type(screen.getByRole('combobox'), 'brecknock'); + const option = await screen.findByRole('option', { name: /Brecknock/ }); + await userEvent.click(option); + expect(push).toHaveBeenCalledWith( + expect.stringContaining('/school/100010')); + }); + + it('suppresses suggestions once the value is a postcode', async () => { + // The box takes a name OR a postcode; suggestions must get out of the way. + // + // fireEvent.change, not userEvent.type: typing sets "N", "NW", "NW1"... and + // "NW1" is not a postcode, so a request for it is correct behaviour. Only + // the settled value is the assertion, so set it in one go. + render(); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'NW1 1AA' } }); + await new Promise((r) => setTimeout(r, 300)); // past the 200ms debounce + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('Enter with no active option still submits the free-text search', async () => { + // The existing behaviour is preserved, not replaced. + render(); + const input = screen.getByRole('combobox'); + await userEvent.type(input, 'brecknock{Enter}'); + // updateURL pushes inside startTransition, so the call is not synchronous. + await waitFor(() => expect(push).toHaveBeenCalledWith( + expect.stringContaining('search=brecknock'))); + }); +}); diff --git a/nextjs-app/app/page.tsx b/nextjs-app/app/page.tsx index dfcdeda..f791b95 100644 --- a/nextjs-app/app/page.tsx +++ b/nextjs-app/app/page.tsx @@ -8,6 +8,7 @@ import type { Metadata } from 'next'; import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api'; import { formatAcademicYear } from '@/lib/utils'; import { HomeView } from '@/components/HomeView'; +import { getFlags } from '@/lib/flags'; import { HowItWorksSection } from '@/components/HowItWorksSection'; import { EditorialSection } from '@/components/EditorialSection'; @@ -63,6 +64,11 @@ export default async function HomePage({ searchParams }: HomePageProps) { // Await search params (Next.js 15 requirement) const params = await searchParams; + // Server-read: no flag value reaches the browser bundle. Threaded down to + // both FilterBar instances via HomeView. + const flags = await getFlags(); + const autosuggest = flags.school_autosuggest === true; + // Parse search params const page = parseInt(params.page || '1'); const radius = params.radius ? parseFloat(params.radius) : undefined; @@ -111,6 +117,7 @@ export default async function HomePage({ searchParams }: HomePageProps) { const years = dataInfo?.years_available ?? []; return ( void; geoState?: "idle" | "requesting" | "error"; geoError?: string | null; + /** Server-read feature flag. Off means no listener, no fetch, no markup. */ + autosuggest?: boolean; } /** @@ -48,6 +53,7 @@ export function FilterBar({ onNearMe, geoState = "idle", geoError, + autosuggest = false, }: FilterBarProps) { const router = useRouter(); const pathname = usePathname(); @@ -62,6 +68,45 @@ export function FilterBar({ const [omniValue, setOmniValue] = useState(initialOmniValue); + const suggestId = `school-suggest-${isHero ? "hero" : "bar"}`; + // Suppressed once the value parses as a postcode: the box takes a school + // name OR a postcode, and suggesting schools during postcode entry fights + // the user rather than helping them. + const suggestEnabled = autosuggest && !isValidPostcode(omniValue); + const { suggestions, open, activeIndex, setActiveIndex, close } = + useSchoolSuggest(omniValue, suggestEnabled); + + const pickSuggestion = (s: Suggestion) => { + close(); + track('search_submitted', { + query: s.school_name.toLowerCase(), + via: 'suggestion', + urn: s.urn, + has_postcode: false, + filters_active: '', + filters_count: 0, + }); + router.push(schoolUrl(s.urn, s.school_name)); + }; + + const handleOmniKeyDown = (e: React.KeyboardEvent) => { + if (!open) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIndex(activeIndex + 1 >= suggestions.length ? 0 : activeIndex + 1); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIndex(activeIndex <= 0 ? suggestions.length - 1 : activeIndex - 1); + } else if (e.key === "Escape") { + close(); + } else if (e.key === "Enter" && activeIndex >= 0) { + // Only when an option is active. With none, the event falls through to + // the form's submit handler and searches the typed text, as it does now. + e.preventDefault(); + pickSuggestion(suggestions[activeIndex]); + } + }; + const currentLA = searchParams.get("local_authority") || ""; const currentType = searchParams.get("school_type") || ""; const currentPhase = searchParams.get("phase") || ""; @@ -227,8 +272,19 @@ export function FilterBar({ type="search" value={omniValue} onChange={(e) => setOmniValue(e.target.value)} + onKeyDown={handleOmniKeyDown} + onBlur={close} placeholder="School name or postcode" className={styles.omniInput} + {...(autosuggest ? { + role: "combobox", + "aria-expanded": open, + "aria-controls": suggestId, + "aria-autocomplete": "list" as const, + "aria-activedescendant": + activeIndex >= 0 ? suggestOptionId(suggestId, activeIndex) : undefined, + autoComplete: "off", + } : {})} /> + {autosuggest && open && ( + + )} {isHero && ( <> diff --git a/nextjs-app/components/HomeView.tsx b/nextjs-app/components/HomeView.tsx index 205c626..fd90e77 100644 --- a/nextjs-app/components/HomeView.tsx +++ b/nextjs-app/components/HomeView.tsx @@ -29,6 +29,8 @@ interface HomeViewProps { // show (e.g. an active search). howItWorks?: React.ReactNode; editorial?: React.ReactNode; + /** Server-read feature flag, threaded to both FilterBar instances. */ + autosuggest?: boolean; } function daysUntil(month: number, day: number): number { @@ -193,7 +195,7 @@ const VALUE_PROPS: ValueProp[] = [ }, ]; -export function HomeView({ initialSchools, filters, totalSchools, howItWorks, editorial }: HomeViewProps) { +export function HomeView({ initialSchools, filters, totalSchools, howItWorks, editorial, autosuggest = false }: HomeViewProps) { const searchParams = useSearchParams(); const router = useRouter(); const pathname = usePathname(); @@ -462,6 +464,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed onNearMe={handleNearMe} geoState={geoState} geoError={geoError} + autosuggest={autosuggest} /> @@ -500,6 +503,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed onNearMe={handleNearMe} geoState={geoState} geoError={geoError} + autosuggest={autosuggest} /> )}