diff --git a/nextjs-app/__tests__/components/FilterBarOptions.test.tsx b/nextjs-app/__tests__/components/FilterBarOptions.test.tsx
index 35355b4..723b117 100644
--- a/nextjs-app/__tests__/components/FilterBarOptions.test.tsx
+++ b/nextjs-app/__tests__/components/FilterBarOptions.test.tsx
@@ -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();
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();
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', () => {
diff --git a/nextjs-app/__tests__/components/FilterBarTypeFaith.test.tsx b/nextjs-app/__tests__/components/FilterBarTypeFaith.test.tsx
new file mode 100644
index 0000000..f03d1b9
--- /dev/null
+++ b/nextjs-app/__tests__/components/FilterBarTypeFaith.test.tsx
@@ -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();
+ 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();
+ 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();
+ 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();
+ expect(screen.getByRole('button', { name: 'Remove filter: Community school' })).toBeInTheDocument();
+ });
+
+ it('is left out when the API sends no groups', () => {
+ render();
+ 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();
+ 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();
+ 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();
+ 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();
+ expect(within(openSheet()).queryByRole('combobox', { name: 'Faith' })).not.toBeInTheDocument();
+ });
+});
diff --git a/nextjs-app/__tests__/components/FilterSheet.test.tsx b/nextjs-app/__tests__/components/FilterSheet.test.tsx
index ca17bf0..46dd87d 100644
--- a/nextjs-app/__tests__/components/FilterSheet.test.tsx
+++ b/nextjs-app/__tests__/components/FilterSheet.test.tsx
@@ -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();
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();
fireEvent.click(within(screen.getByRole('group', { name: 'Applied filters' }))
.getByRole('button', { name: 'Clear all' }));
diff --git a/nextjs-app/__tests__/components/FilterSheetPending.test.tsx b/nextjs-app/__tests__/components/FilterSheetPending.test.tsx
index 19dd2e7..be88818 100644
--- a/nextjs-app/__tests__/components/FilterSheetPending.test.tsx
+++ b/nextjs-app/__tests__/components/FilterSheetPending.test.tsx
@@ -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');
});
diff --git a/nextjs-app/__tests__/components/ResultsToolbar.test.tsx b/nextjs-app/__tests__/components/ResultsToolbar.test.tsx
index c59842e..bc16678 100644
--- a/nextjs-app/__tests__/components/ResultsToolbar.test.tsx
+++ b/nextjs-app/__tests__/components/ResultsToolbar.test.tsx
@@ -35,6 +35,7 @@ jest.mock('@/components/SchoolMap', () => ({ SchoolMap: () =>
= {
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> = {
@@ -487,9 +500,9 @@ export function FilterBar({
disabled={isPending && look !== "sheet"}
>
- {typeOptions.map((type) => (
-
))}
@@ -515,6 +528,25 @@ export function FilterBar({
);
+ const faithSelect = (look: Look) => (
+
+
+
+ );
+
const genderSelect = (look: Look) => (