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) => (