Compare commits

...
Author SHA1 Message Date
tudor 0c414680fd Merge pull request 'fix(search): offer every phase while a phase filter is applied' (#162) from fix/phase-filter-global into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m25s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 29s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 3m0s
Reviewed-on: #162
2026-10-01 11:03:20 +00:00
TudorandClaude Opus 5.5 e211e1376d fix(search): offer every phase while a phase filter is applied
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m20s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 15s
The phase select read its options from the result-scoped filters, which
the backend computes after applying the phase filter. With secondary
chosen only secondary and all-through were offered, so switching to
primary meant going back to "Any phase" first. Read the global phase
list instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:06:04 +01:00
tudor 74418ca6b9 Merge pull request 'fix(search): keep the map list's count and sort on one line' (#161) from fix/map-pane-header into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 21s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m26s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 28s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 3m1s
Reviewed-on: #161
2026-10-01 09:25:17 +00:00
TudorandClaude Opus 5.5 5df8c93420 fix(search): keep the map list's count and sort on one line
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m21s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 18s
Beside the map, "19 schools within 1.0 miles of SW196AR" (359px) and the
sort menu (281px, as wide as its longest option) could not share the
pane's 427px, so the sort dropped to a second line.

In the map view the heading now reads "19 schools within 1 mile": the
postcode is already in the search box just above, and the radius reads
as the toolbar's does. The sort menu is capped at 11rem, truncating a
long choice, and the header no longer wraps; on a narrow pane the count
truncates rather than pushing the sort down. List view keeps the full
heading. Tried on staging by injecting the change: 243px + 176px, one
line at 1440.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:18:26 +01:00
tudor ebf9c12446 Merge pull request 'feat(search): show the list view's rows beside the map' (#160) from feat/map-list-rows into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m24s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 28s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 3m1s
Reviewed-on: #160
2026-10-01 09:04:30 +00:00
6 changed files with 99 additions and 7 deletions

No files matched your search

+17
View File
@@ -272,6 +272,17 @@ test('searching by postcode returns nearby schools', async ({ page }) => {
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 }); await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
}); });
test('the phase filter switches straight from secondary to primary', async ({ page }) => {
// The phase options once came from the result set, which the phase filter
// had already narrowed — so with secondary chosen, primary was not offered.
await page.goto('/?search=school&phase=secondary');
const phase = page.getByRole('combobox', { name: 'Phase' });
await expect(phase).toHaveValue('secondary', { timeout: 15_000 });
await phase.selectOption('primary');
await expect(page).toHaveURL(/[?&]phase=primary(&|$)/);
await expect(phase).toHaveValue('primary');
});
test('a report-card school shows a Report Card badge in search results, not its old grade', async ({ page }) => { test('a report-card school shows a Report Card badge in search results, not its old grade', async ({ page }) => {
// List/map badges keyed off ofsted_grade (the carried-forward legacy grade) // List/map badges keyed off ofsted_grade (the carried-forward legacy grade)
// and never reached the report-card branch, so report-card schools were // and never reached the report-card branch, so report-card schools were
@@ -567,6 +578,12 @@ test('a desktop postcode search opens on the map with the list beside it', async
await expect(card.getByRole('button', { name: /Compar/ })).toBeVisible(); await expect(card.getByRole('button', { name: /Compar/ })).toBeVisible();
await expect(page.locator('.sc-pin').first()).toBeVisible({ timeout: 15_000 }); await expect(page.locator('.sc-pin').first()).toBeVisible({ timeout: 15_000 });
// The count and the sort share one line at the top of the list.
const heading = await pane.getByRole('heading', { level: 2 }).boundingBox();
const sort = await pane.getByRole('combobox').boundingBox();
expect(Math.abs((heading!.y + heading!.height / 2) - (sort!.y + sort!.height / 2)))
.toBeLessThan(8);
// The split runs to the bottom of the screen rather than stopping short. // The split runs to the bottom of the screen rather than stopping short.
const split = await page.locator('[class*="mapViewContainer"]').boundingBox(); const split = await page.locator('[class*="mapViewContainer"]').boundingBox();
expect(split!.y + split!.height).toBeGreaterThan(800 - 40); expect(split!.y + split!.height).toBeGreaterThan(800 - 40);
@@ -0,0 +1,40 @@
import { render, screen, within } from '@testing-library/react';
import { FilterBar } from '@/components/FilterBar';
let searchParams = new URLSearchParams();
jest.mock('next/navigation', () => ({
useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }),
usePathname: () => '/',
useSearchParams: () => searchParams,
}));
const FILTERS = {
local_authorities: [], school_types: [], years: [],
phases: ['Primary', 'Secondary', 'All-through'],
genders: [], admissions_policies: [],
};
/**
* The phase options must not come from the result set. The backend scopes its
* result filters to the schools it returns, and it applies the phase filter
* first — so with "secondary" chosen the scoped list holds only secondary-ish
* phases, and switching to primary meant going back to "Any phase" first.
*/
describe('FilterBar phase options', () => {
it('offers every phase while a phase filter narrows the results', () => {
searchParams = new URLSearchParams('search=hampton&phase=secondary');
render(
<FilterBar
filters={FILTERS}
resultFilters={{
local_authorities: [], school_types: [],
phases: ['Secondary', 'All-through'],
genders: [], admissions_policies: [],
}}
/>,
);
const phase = screen.getByRole('combobox', { name: 'Phase' });
expect(within(phase).getByRole('option', { name: 'Primary' })).toBeInTheDocument();
expect(phase).toHaveValue('secondary');
});
});
@@ -94,7 +94,8 @@ it('lists a name search, which has no map', async () => {
it('puts the count and the sort in the list beside the map, once', async () => { it('puts the count and the sort in the list beside the map, once', async () => {
await renderMap(); await renderMap();
expect(screen.getAllByRole('heading', { name: /3 schools within 1.0 miles of SW196AR/ })).toHaveLength(1); // Short beside the map, so it shares one line with the sort.
expect(screen.getAllByRole('heading', { name: '3 schools within 1 mile' })).toHaveLength(1);
expect(screen.getAllByRole('combobox')).toHaveLength(1); expect(screen.getAllByRole('combobox')).toHaveLength(1);
}); });
@@ -172,3 +173,9 @@ it('lets a keyboard pick a pin from the list, with a real button', async () => {
expect(screen.getByTestId('map')).toHaveAttribute('data-selected', '2'); expect(screen.getByTestId('map')).toHaveAttribute('data-selected', '2');
expect(show).toHaveAttribute('aria-pressed', 'true'); expect(show).toHaveAttribute('aria-pressed', 'true');
}); });
it('keeps the postcode in the heading in list view, where there is room', async () => {
await renderMap();
fireEvent.click(screen.getByRole('button', { name: 'List' }));
expect(screen.getByRole('heading', { name: '3 schools within 1.0 miles of SW196AR' })).toBeInTheDocument();
});
+4 -1
View File
@@ -295,7 +295,10 @@ export function FilterBar({
const laOptions = const laOptions =
resultFilters?.local_authorities ?? filters.local_authorities; resultFilters?.local_authorities ?? filters.local_authorities;
const typeOptions = resultFilters?.school_types ?? filters.school_types; const typeOptions = resultFilters?.school_types ?? filters.school_types;
const phaseOptions = resultFilters?.phases ?? filters.phases ?? []; // Phase is the exception: always the full list. The result set has already
// been narrowed by the phase filter, so scoping to it would leave only the
// chosen phase on offer and switching phase would need "Any phase" first.
const phaseOptions = filters.phases ?? [];
const genderOptions = resultFilters?.genders ?? filters.genders ?? []; const genderOptions = resultFilters?.genders ?? filters.genders ?? [];
const admissionsPolicyOptions = const admissionsPolicyOptions =
resultFilters?.admissions_policies ?? filters.admissions_policies ?? []; resultFilters?.admissions_policies ?? filters.admissions_policies ?? [];
+21 -2
View File
@@ -608,13 +608,32 @@
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
} }
/* One line: the count takes what the sort leaves, and truncates rather than
wrap if a narrow pane cannot fit both. */
.mapListPane .resultsHeader { .mapListPane .resultsHeader {
gap: 0.5rem; flex-wrap: nowrap;
gap: 0.75rem;
padding: 0.875rem 1rem 0.75rem; padding: 0.875rem 1rem 0.75rem;
} }
.mapListPane .resultsHeader h2 { .mapListPane .resultsHeader h2 {
font-size: var(--step-1); flex: 1;
min-width: 0;
font-size: var(--step-0);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mapListPane .resultsHeaderActions {
flex-shrink: 0;
}
/* A select is as wide as its longest option ("Highest Reading, Writing &
Maths %", 281px), more than half the pane. Cap it; a long choice truncates. */
.mapListPane .sortSelect {
max-width: 11rem;
text-overflow: ellipsis;
} }
.mapContainer { .mapContainer {
+9 -3
View File
@@ -583,14 +583,20 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
) )
); );
const schoolCount = `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''}`;
// The count and the sort. Above the list in list view; at the top of the // The count and the sort. Above the list in list view; at the top of the
// list pane, beside the map, in map view. // list pane, beside the map, in map view.
const resultsHeader = ( const resultsHeader = (
<div className={styles.resultsHeader}> <div className={styles.resultsHeader}>
<h2 aria-live="polite" aria-atomic="true"> <h2 aria-live="polite" aria-atomic="true">
{isLocationSearch && initialSchools.location_info {/* Beside the map the heading shares one line with the sort, so it
? `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''} within ${(initialSchools.location_info.radius / 1.60934).toFixed(1)} miles of ${initialSchools.location_info.postcode}` drops the postcode, which the search box just above already shows. */}
: `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''} found` {isMapView && radiusMiles != null
? `${schoolCount} within ${radiusMiles} mile${radiusMiles === 1 ? '' : 's'}`
: isLocationSearch && initialSchools.location_info
? `${schoolCount} within ${(initialSchools.location_info.radius / 1.60934).toFixed(1)} miles of ${initialSchools.location_info.postcode}`
: `${schoolCount} found`
} }
</h2> </h2>
<div className={styles.resultsHeaderActions}> <div className={styles.resultsHeaderActions}>