feat(search): school type groups and a faith filter

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
TudorandClaude Opus 5.5 committed 2026-10-02 11:54:04 +01:00
1 parent 15b8923e60
commit 17bfb4a3f7
8 files changed
+190 -18

No files matched your search

@@ -5,7 +5,7 @@ import type { ResultFilters } from '@/lib/types';
/*
* A filter's options must not come from the results it is filtering, or
* choosing one leaves only that one on offer: pick "Girls" and "Boys" is gone
* until the filter is cleared. School type, gender and admissions offer the
* until the filter is cleared. School type groups, gender and admissions offer the
* full lists, as phase already did. Local authority stays scoped to the
* results, so a postcode search offers the councils nearby rather than 153.
*
@@ -29,6 +29,10 @@ const filters = {
phases: ['Middle deemed primary', 'Nursery', 'Primary', 'Secondary', 'All-through'],
genders: ['Boys', 'Girls', 'Mixed'],
admissions_policies: ['Non-selective', 'Selective'],
school_type_groups: [
{ value: 'academy', label: 'State school: academy or free school' },
{ value: 'council', label: 'State school: council-run' },
],
};
// What the results came back with once narrowed by the chosen filters.
@@ -54,11 +58,11 @@ beforeEach(() => {
});
describe('filter options', () => {
it('offer every school type, gender and admissions policy, whatever the results hold', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&school_type=Community+school&gender=girls');
it('offer every school type group, gender and admissions policy, whatever the results hold', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&school_type=council&gender=girls');
render(<FilterBar filters={filters} resultFilters={narrowed} />);
const sheet = openSheet();
expect(optionsOf(sheet, 'School type')).toEqual(['Any school type', 'Academy converter', 'Community school']);
expect(optionsOf(sheet, 'School type')).toEqual(['Any school type', 'State school: academy or free school', 'State school: council-run']);
expect(optionsOf(sheet, 'Gender')).toEqual(['Boys, Girls & Mixed', 'Boys', 'Girls', 'Mixed']);
expect(optionsOf(sheet, 'Admissions')).toEqual(['All admissions types', 'Non-selective', 'Selective']);
});
@@ -107,13 +111,13 @@ describe('the secondary-only filters', () => {
it('are cleared by choosing a primary phase, rather than left applied and hidden', () => {
params = new URLSearchParams(
'postcode=SW196AR&radius=1&phase=secondary&gender=girls&has_sixth_form=yes&admissions_policy=selective&school_type=Community+school');
'postcode=SW196AR&radius=1&phase=secondary&gender=girls&has_sixth_form=yes&admissions_policy=selective&school_type=council');
render(<FilterBar filters={filters} />);
fireEvent.change(within(openSheet()).getByRole('combobox', { name: 'Phase' }), { target: { value: 'primary' } });
const next = pushedParams();
expect(next.get('phase')).toBe('primary');
for (const key of ['gender', 'has_sixth_form', 'admissions_policy']) expect(next.get(key)).toBeNull();
expect(next.get('school_type')).toBe('Community school');
expect(next.get('school_type')).toBe('council');
});
it('are kept when the new phase still has them', () => {
@@ -0,0 +1,114 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { FilterBar } from '@/components/FilterBar';
import { track } from '@/lib/analytics';
/*
* School type offers six groups a parent recognises, not GIAS's 34 types, and
* a Faith filter sits beside it (spec 2026-10-02-school-type-groups-and-faith-
* filter-design.md). Both lists come from /api/filters.
*/
let params = new URLSearchParams('postcode=SW196AR&radius=1');
const push = jest.fn();
jest.mock('next/navigation', () => ({
useSearchParams: () => params,
usePathname: () => '/',
useRouter: () => ({ push, replace: jest.fn(), prefetch: jest.fn() }),
}));
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
const filters = {
local_authorities: ['Wandsworth'], school_types: ['Community school'], years: [],
phases: ['Primary', 'Secondary'], genders: [], admissions_policies: [],
school_type_groups: [
{ value: 'academy', label: 'State school: academy or free school' },
{ value: 'council', label: 'State school: council-run' },
{ value: 'special', label: 'Special school (SEND)' },
],
faiths: [
{ value: 'none', label: 'No religious character' },
{ value: 'roman_catholic', label: 'Roman Catholic' },
],
};
const pushedParams = () => new URLSearchParams(push.mock.calls.at(-1)![0].split('?')[1]);
const openSheet = () => {
fireEvent.click(screen.getByRole('button', { name: /^Filters/ }));
return screen.getByRole('dialog', { name: 'Filters' });
};
const optionsOf = (scope: HTMLElement, name: string) =>
within(within(scope).getByRole('combobox', { name })).getAllByRole('option').map((o) => o.textContent);
beforeEach(() => {
params = new URLSearchParams('postcode=SW196AR&radius=1');
push.mockClear();
jest.mocked(track).mockClear();
});
describe('School type', () => {
it('offers the groups, not the GIAS types, on desktop and in the sheet', () => {
render(<FilterBar filters={filters} />);
const groups = ['Any school type', 'State school: academy or free school',
'State school: council-run', 'Special school (SEND)'];
expect(optionsOf(screen.getByRole('group', { name: 'Filters' }), 'School type')).toEqual(groups);
expect(optionsOf(openSheet(), 'School type')).toEqual(groups);
});
it('puts the group key in the URL and names the chip by its label', () => {
const view = render(<FilterBar filters={filters} />);
fireEvent.change(within(openSheet()).getByRole('combobox', { name: 'School type' }),
{ target: { value: 'special' } });
expect(pushedParams().get('school_type')).toBe('special');
params = new URLSearchParams('postcode=SW196AR&radius=1&school_type=special');
view.rerender(<FilterBar filters={filters} />);
expect(screen.getByRole('button', { name: 'Remove filter: Special school (SEND)' })).toBeInTheDocument();
});
it('names an old raw-label link\'s chip by that label', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&school_type=Community+school');
render(<FilterBar filters={filters} />);
expect(screen.getByRole('button', { name: 'Remove filter: Community school' })).toBeInTheDocument();
});
it('is left out when the API sends no groups', () => {
render(<FilterBar filters={{ ...filters, school_type_groups: undefined }} />);
expect(screen.queryByRole('combobox', { name: 'School type' })).not.toBeInTheDocument();
});
});
describe('Faith', () => {
it('offers its options in the More filters panel and in the sheet', () => {
render(<FilterBar filters={filters} />);
fireEvent.click(screen.getByRole('button', { name: /More filters/ }));
const faiths = ['Any faith or none', 'No religious character', 'Roman Catholic'];
expect(optionsOf(document.body, 'Faith')).toEqual(faiths);
expect(optionsOf(openSheet(), 'Faith')).toEqual(faiths);
});
it('shows as a chip, counts on both buttons, and clears with Clear all', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&faith=roman_catholic');
render(<FilterBar filters={filters} />);
expect(screen.getByRole('button', { name: 'Remove filter: Roman Catholic' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Filters, 1 applied' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /More filters \(1\)/ })).toBeInTheDocument();
fireEvent.click(within(screen.getByRole('group', { name: 'Applied filters' }))
.getByRole('button', { name: 'Clear all' }));
expect(pushedParams().get('faith')).toBeNull();
expect(pushedParams().get('postcode')).toBe('SW196AR');
});
it('goes into the search analytics event', () => {
params = new URLSearchParams('faith=roman_catholic');
render(<FilterBar filters={filters} />);
const input = screen.getByRole('searchbox', { name: 'School name or postcode' });
fireEvent.change(input, { target: { value: 'st marys' } });
fireEvent.submit(input.closest('form')!);
expect(track).toHaveBeenCalledWith('search_submitted',
expect.objectContaining({ filters_active: 'faith=roman_catholic', filters_count: 1 }));
});
it('is left out when the API sends no faiths', () => {
render(<FilterBar filters={{ ...filters, faiths: [] }} />);
expect(within(openSheet()).queryByRole('combobox', { name: 'Faith' })).not.toBeInTheDocument();
});
});
@@ -21,6 +21,7 @@ jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
const filters = {
local_authorities: ['Wandsworth', 'Merton'], school_types: ['Community school'], years: [],
phases: ['Primary', 'Secondary'], genders: ['Girls', 'Mixed'], admissions_policies: [],
school_type_groups: [{ value: 'council', label: 'State school: council-run' }],
};
const pushedParams = () => new URLSearchParams(push.mock.calls.at(-1)![0].split('?')[1]);
@@ -38,7 +39,7 @@ describe('the phone Filters button', () => {
});
it('counts every applied filter, phase and type included', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&phase=primary&school_type=Community+school');
params = new URLSearchParams('postcode=SW196AR&radius=1&phase=primary&school_type=council');
render(<FilterBar filters={filters} />);
expect(screen.getByRole('button', { name: 'Filters, 2 applied' })).toBeInTheDocument();
});
@@ -145,7 +146,7 @@ describe('the applied-filter chips', () => {
});
it('carry a Clear all that keeps the search', () => {
params = new URLSearchParams('search=southmead&school_type=Community+school');
params = new URLSearchParams('search=southmead&school_type=council');
render(<FilterBar filters={filters} />);
fireEvent.click(within(screen.getByRole('group', { name: 'Applied filters' }))
.getByRole('button', { name: 'Clear all' }));
@@ -27,6 +27,7 @@ jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
const filters = {
local_authorities: ['Wandsworth'], school_types: ['Community school'], years: [],
phases: ['Primary', 'Secondary'], genders: [], admissions_policies: [],
school_type_groups: [{ value: 'council', label: 'State school: council-run' }],
};
const pushedParams = () => new URLSearchParams(push.mock.calls.at(-1)![0].split('?')[1]);
@@ -53,10 +54,10 @@ it('builds a second change on the first, not on the URL it has not reached', ()
const sheet = openSheet();
fireEvent.change(within(sheet).getByRole('combobox', { name: 'Phase' }), { target: { value: 'primary' } });
fireEvent.change(within(sheet).getByRole('combobox', { name: 'School type' }),
{ target: { value: 'Community school' } });
{ target: { value: 'council' } });
const next = pushedParams();
expect(next.get('phase')).toBe('primary');
expect(next.get('school_type')).toBe('Community school');
expect(next.get('school_type')).toBe('council');
expect(next.get('postcode')).toBe('SW196AR');
});
@@ -35,6 +35,7 @@ jest.mock('@/components/SchoolMap', () => ({ SchoolMap: () => <div data-testid="
const filters = {
local_authorities: ['Wandsworth'], school_types: ['Community school'], years: [],
phases: ['Primary', 'Secondary'], genders: [], admissions_policies: [],
school_type_groups: [{ value: 'council', label: 'State school: council-run' }],
};
function results(): SchoolsResponse {
@@ -99,7 +100,7 @@ describe('the toolbar filters', () => {
});
it('counts only what More filters hides', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&school_type=Community+school&local_authority=Wandsworth');
params = new URLSearchParams('postcode=SW196AR&radius=1&school_type=council&local_authority=Wandsworth');
render(<FilterBar filters={filters} />);
expect(screen.getByRole('button', { name: /More filters \(1\)/ })).toBeInTheDocument();
});
+4 -1
View File
@@ -25,6 +25,7 @@ interface HomePageProps {
gender?: string;
admissions_policy?: string;
has_sixth_form?: string;
faith?: string;
}>;
}
@@ -84,7 +85,8 @@ export default async function HomePage({ searchParams }: HomePageProps) {
params.postcode ||
params.gender ||
params.admissions_policy ||
params.has_sixth_form
params.has_sixth_form ||
params.faith
);
// Failures propagate to the retryable error boundary.
@@ -105,6 +107,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
gender: params.gender,
admissions_policy: params.admissions_policy,
has_sixth_form: params.has_sixth_form,
faith: params.faith,
});
} else {
// Empty state by default
+44 -6
View File
@@ -59,6 +59,7 @@ function SelectShell({
const FILTER_KEYS = [
"phase",
"school_type",
"faith",
"local_authority",
"gender",
"has_sixth_form",
@@ -186,12 +187,14 @@ export function FilterBar({
const currentGender = searchParams.get("gender") || "";
const currentAdmissionsPolicy = searchParams.get("admissions_policy") || "";
const currentHasSixthForm = searchParams.get("has_sixth_form") || "";
const currentFaith = searchParams.get("faith") || "";
// Count the filters behind "More filters". Search, postcode, distance, phase
// and school type each have their own control in the toolbar, so they are
// visible without opening anything and are not counted here.
const activeDropdownFilters = [
currentLA,
currentFaith,
currentGender,
currentAdmissionsPolicy,
currentHasSixthForm,
@@ -318,6 +321,7 @@ export function FilterBar({
currentPhase && `phase=${currentPhase}`,
currentLA && `la=${currentLA}`,
currentType && `type=${currentType}`,
currentFaith && `faith=${currentFaith}`,
currentGender && `gender=${currentGender}`,
currentAdmissionsPolicy && `admissions=${currentAdmissionsPolicy}`,
currentHasSixthForm && `sixth_form=${currentHasSixthForm}`,
@@ -370,6 +374,7 @@ export function FilterBar({
currentSearch ||
currentLA ||
currentType ||
currentFaith ||
currentPhase ||
currentPostcode ||
currentGender ||
@@ -387,7 +392,9 @@ export function FilterBar({
*/
const laOptions =
resultFilters?.local_authorities ?? filters.local_authorities;
const typeOptions = filters.school_types;
// Six groups a parent recognises, not GIAS's 34 establishment types.
const typeOptions = filters.school_type_groups ?? [];
const faithOptions = filters.faiths ?? [];
const phaseOptions = filters.phases ?? [];
const genderOptions = filters.genders ?? [];
const admissionsPolicyOptions = filters.admissions_policies ?? [];
@@ -423,6 +430,7 @@ export function FilterBar({
const values: Record<FilterKey, string> = {
phase: currentPhase,
school_type: currentType,
faith: currentFaith,
local_authority: currentLA,
gender: currentGender,
has_sixth_form: currentHasSixthForm,
@@ -432,6 +440,11 @@ export function FilterBar({
if (key === "has_sixth_form") {
return value === "yes" ? "With sixth form" : "Without sixth form";
}
// An old link may carry a raw GIAS type, which reads as itself.
const labelled = { school_type: typeOptions, faith: faithOptions }[
key as "school_type" | "faith"
];
if (labelled) return labelled.find((o) => o.value === value)?.label ?? value;
// Phase, gender and admissions values are lowercased option names; the
// rest are the names themselves.
const named: Partial<Record<FilterKey, string[]>> = {
@@ -487,9 +500,9 @@ export function FilterBar({
disabled={isPending && look !== "sheet"}
>
<option value="">Any school type</option>
{typeOptions.map((type) => (
<option key={type} value={type}>
{type}
{typeOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
@@ -515,6 +528,25 @@ export function FilterBar({
</SelectShell>
);
const faithSelect = (look: Look) => (
<SelectShell wide>
<select
value={currentFaith}
onChange={(e) => handleFilterChange("faith", e.target.value)}
className={selectClass(look, currentFaith)}
aria-label="Faith"
disabled={isPending && look !== "sheet"}
>
<option value="">Any faith or none</option>
{faithOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</SelectShell>
);
const genderSelect = (look: Look) => (
<SelectShell wide>
<select
@@ -764,7 +796,7 @@ export function FilterBar({
{phaseOptions.length > 0 && phaseSelect("pill")}
{typeSelect("pill")}
{typeOptions.length > 0 && typeSelect("pill")}
<button
type="button"
@@ -797,6 +829,7 @@ export function FilterBar({
{filtersOpen && (
<div className={styles.filters}>
{laSelect("panel")}
{faithOptions.length > 0 && faithSelect("panel")}
{isSecondaryMode && (
<>
@@ -869,7 +902,12 @@ export function FilterBar({
{phaseOptions.length > 0 && (
<SheetField label="Phase">{phaseSelect("sheet")}</SheetField>
)}
<SheetField label="School type">{typeSelect("sheet")}</SheetField>
{typeOptions.length > 0 && (
<SheetField label="School type">{typeSelect("sheet")}</SheetField>
)}
{faithOptions.length > 0 && (
<SheetField label="Faith">{faithSelect("sheet")}</SheetField>
)}
<SheetField label="Local authority">{laSelect("sheet")}</SheetField>
{isSecondaryMode && (
<>
+10
View File
@@ -484,6 +484,12 @@ export interface RankingsResponse {
local_authority?: string;
}
/** A filter option whose URL value differs from what a parent reads. */
export interface FilterOption {
value: string;
label: string;
}
export interface Filters {
local_authorities: string[];
school_types: string[];
@@ -491,6 +497,9 @@ export interface Filters {
phases: string[];
genders: string[];
admissions_policies: string[];
/** Parent-facing school type groups; the URL's school_type takes their value. */
school_type_groups?: FilterOption[];
faiths?: FilterOption[];
}
export interface NationalAveragesYear {
@@ -567,6 +576,7 @@ export interface SchoolSearchParams {
gender?: string;
admissions_policy?: string;
has_sixth_form?: string;
faith?: string;
}
export interface RankingsParams {