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

Merged
tudor merged 9 commits from feat/school-type-groups-faith into main 2026-10-02 14:05:43 +00:00
4 changed files with 75 additions and 5 deletions
Showing only changes of commit 84caee9f72 - Show all commits

No files matched your search

@@ -112,3 +112,27 @@ describe('Faith', () => {
expect(within(openSheet()).queryByRole('combobox', { name: 'Faith' })).not.toBeInTheDocument();
});
});
describe('a URL value the options do not spell the same way', () => {
const row = () => screen.getByRole('group', { name: 'Filters' });
it('shows an old raw-label type in the select, and lets "Any" clear it', () => {
params = new URLSearchParams('search=school&school_type=Community+school');
render(<FilterBar filters={filters} />);
const type = within(row()).getByRole('combobox', { name: 'School type' });
expect(type).toHaveValue('Community school');
expect(within(type).getByRole('option', { name: 'Community school' })).toBeInTheDocument();
fireEvent.change(type, { target: { value: '' } });
expect(pushedParams().get('school_type')).toBeNull();
});
it('matches a key in another case, in the select and the chip', () => {
params = new URLSearchParams('postcode=SW196AR&radius=1&school_type=Special&faith=Roman_Catholic');
render(<FilterBar filters={filters} />);
expect(within(row()).getByRole('combobox', { name: 'School type' })).toHaveValue('special');
expect(screen.getByRole('button', { name: 'Remove filter: Special school (SEND)' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Remove filter: Roman Catholic' })).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: 'Faith' })).toHaveValue('roman_catholic');
});
});
@@ -166,3 +166,14 @@ describe('the folded search', () => {
expect(screen.queryByRole('button', { name: /Edit search/ })).not.toBeInTheDocument();
});
});
describe('the results list filter chips', () => {
it('name a school type group by its label, not its key', () => {
params = new URLSearchParams('search=southmead&school_type=council');
const { container } = render(<HomeView initialSchools={results()} filters={filters} />);
const chips = container.querySelector('[class*="activeFilters"]')!;
expect(chips).toHaveTextContent('State school: council-run');
expect(chips).not.toHaveTextContent(/\bcouncil\b(?!-run)/);
});
});
+34 -4
View File
@@ -9,7 +9,7 @@ import { useSchoolSuggest } from "@/hooks/useSchoolSuggest";
import { SuggestList, suggestOptionId } from "./SuggestList";
import { FilterSheet, SheetField, RADIUS_OPTIONS, radiusLabel as milesLabel } from "./FilterSheet";
import type { Suggestion } from "@/lib/suggest";
import type { Filters, ResultFilters } from "@/lib/types";
import type { FilterOption, Filters, ResultFilters } from "@/lib/types";
import styles from "./FilterBar.module.css";
interface FilterBarProps {
@@ -84,6 +84,27 @@ function hasSecondaryFilters(phase: string): boolean {
return !(p === "nursery" || (p.includes("primary") && !p.includes("secondary")));
}
/**
* The option a URL value names, ignoring case as the API does. An old link's
* raw GIAS type names none, and is shown as itself.
*/
function optionFor(options: FilterOption[], value: string) {
const v = value.toLowerCase();
return options.find((o) => o.value.toLowerCase() === v);
}
/**
* A select's value and, when the URL holds something no option spells, an
* extra option for it, so the select shows what is applied and "Any" can be
* chosen to clear it. Without one, React shows the first option, "Any",
* while the list is still filtered, and choosing it changes nothing.
*/
function selectedOf(options: FilterOption[], value: string) {
if (!value) return { value, unlisted: null };
const match = optionFor(options, value);
return match ? { value: match.value, unlisted: null } : { value, unlisted: value };
}
function SlidersIcon() {
return (
<svg
@@ -444,7 +465,7 @@ export function FilterBar({
const labelled = { school_type: typeOptions, faith: faithOptions }[
key as "school_type" | "faith"
];
if (labelled) return labelled.find((o) => o.value === value)?.label ?? value;
if (labelled) return optionFor(labelled, value)?.label ?? value;
// Phase, gender and admissions values are lowercased option names; the
// rest are the names themselves.
const named: Partial<Record<FilterKey, string[]>> = {
@@ -490,16 +511,22 @@ export function FilterBar({
</SelectShell>
);
const typeSelected = selectedOf(typeOptions, currentType);
const faithSelected = selectedOf(faithOptions, currentFaith);
const typeSelect = (look: Look) => (
<SelectShell wide={look !== "pill"}>
<select
value={currentType}
value={typeSelected.value}
onChange={(e) => handleFilterChange("school_type", e.target.value)}
className={selectClass(look, currentType)}
aria-label="School type"
disabled={isPending && look !== "sheet"}
>
<option value="">Any school type</option>
{typeSelected.unlisted && (
<option value={typeSelected.unlisted}>{typeSelected.unlisted}</option>
)}
{typeOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
@@ -531,13 +558,16 @@ export function FilterBar({
const faithSelect = (look: Look) => (
<SelectShell wide>
<select
value={currentFaith}
value={faithSelected.value}
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>
{faithSelected.unlisted && (
<option value={faithSelected.unlisted}>{faithSelected.unlisted}</option>
)}
{faithOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
+6 -1
View File
@@ -292,6 +292,11 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
const hasSearch = searchParams.get('search') || searchParams.get('postcode');
const isLocationSearch = !!searchParams.get('postcode');
const isSearchActive = !!(hasSearch || searchParams.get('local_authority') || searchParams.get('school_type'));
// The URL carries a type group's key; the chip reads its label. An old link's
// raw GIAS type reads as itself.
const schoolTypeParam = searchParams.get('school_type');
const schoolTypeChip = (schoolTypeParam && filters.school_type_groups?.find(
(o) => o.value.toLowerCase() === schoolTypeParam.toLowerCase())?.label) || schoolTypeParam;
const currentPhase = searchParams.get('phase') || '';
const secondaryCount = allSchools.filter(s => s.attainment_8_score != null).length;
const primaryCount = allSchools.filter(s => s.rwm_expected_pct != null).length;
@@ -811,7 +816,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
<div className={styles.activeFilters}>
{searchParams.get('search') && <span className={styles.filterChip}>Search: {searchParams.get('search')}<a href="/" className={styles.chipRemove} onClick={e => { e.preventDefault(); }}>×</a></span>}
{searchParams.get('local_authority') && <span className={styles.filterChip}>{searchParams.get('local_authority')}</span>}
{searchParams.get('school_type') && <span className={styles.filterChip}>{searchParams.get('school_type')}</span>}
{schoolTypeParam && <span className={styles.filterChip}>{schoolTypeChip}</span>}
</div>
)}