feat(search): align the map view with the mockup and open it on desktop
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
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 26s
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
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 26s
The map view now follows option B of the results-controls mockups: - The list sits in a pane on the left, under the result count and sort, and the map fills the rest of the screen below the pinned toolbar. The toolbar's height is measured, so the split ends 1rem above the bottom of the screen however the controls wrap. - Pins are brand-teal dots and the selected one is coral. The search location is an ink dot, the search radius a dashed circle with its distance, and the view fits that circle. Tiles are muted and the zoom sits under the fullscreen button. - A school picked on the map or in the list opens a card on the map (View, + Compare, following the basket), and its list card is ringed and scrolled into view. Phones keep the bottom sheet. - The list cards show the full name, Ofsted and school type, the headline figure and pupils. The map cards now follow the list rows: no England benchmark for special schools, PRUs and AP, and no placeholder all-zero RWM (Greenmead showed "0% RWM -62 pts vs national"). That rule moves to a shared listRwmValue helper. A postcode search opens on the map for desktop browsers, chosen on the server from the user agent so the list never paints first. The client falls back to the list below 1024px, and follows the default through client-side navigation from the hero until the reader picks a view. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0e177ca2ec
commit
4e4b30e812
14 files changed
+1118
-440
No files matched your search
@@ -0,0 +1,97 @@
|
||||
import { act, fireEvent, render } from '@testing-library/react';
|
||||
import LeafletMapInner from '@/components/LeafletMapInner';
|
||||
import { primaryFixture } from '../support/schoolFixtures';
|
||||
import type { School } from '@/lib/types';
|
||||
|
||||
/*
|
||||
* The results map's own logic, against real Leaflet in jsdom: which pin is
|
||||
* selected, whether the card opens, and what the card offers. jsdom lays out
|
||||
* nothing, so this pins behaviour, never positions.
|
||||
*/
|
||||
|
||||
const base = primaryFixture.schoolInfo;
|
||||
const a: School = { ...base, urn: 1, school_name: 'Southmead Primary School', latitude: 51.43, longitude: -0.21, distance: 0.2, rwm_expected_pct: 52 };
|
||||
const b: School = { ...base, urn: 2, school_name: 'Greenmead School', latitude: 51.431, longitude: -0.205, distance: 0.2,
|
||||
school_type: 'Community special school', rwm_expected_pct: 0, reading_expected_pct: 0, writing_expected_pct: 0, maths_expected_pct: 0 };
|
||||
const schools = [a, b];
|
||||
const centre: [number, number] = [51.43, -0.21];
|
||||
|
||||
function setWide(wide: boolean) {
|
||||
window.matchMedia = ((q: string) => ({
|
||||
matches: wide, media: q, addEventListener() {}, removeEventListener() {},
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
}
|
||||
|
||||
function renderMap(props: Partial<React.ComponentProps<typeof LeafletMapInner>> = {}) {
|
||||
const all = {
|
||||
schools, center: centre, zoom: 13, referencePoint: centre, radiusMiles: 1,
|
||||
nationalAvgRwm: 62, ...props,
|
||||
};
|
||||
const view = render(<LeafletMapInner {...all} />);
|
||||
return { ...view, rerender: (next: Partial<typeof all>) => view.rerender(<LeafletMapInner {...all} {...next} />) };
|
||||
}
|
||||
|
||||
beforeEach(() => setWide(true));
|
||||
|
||||
it('draws a pin per school, the search location and the radius', () => {
|
||||
const { container } = renderMap();
|
||||
expect(container.querySelectorAll('.sc-pin')).toHaveLength(2);
|
||||
expect(container.querySelector('.sc-home')).not.toBeNull();
|
||||
expect(container.querySelector('.sc-radius-label')).toHaveTextContent('1 mile');
|
||||
});
|
||||
|
||||
it('reports a pin click, and marks and opens the selected school', () => {
|
||||
const onMarkerClick = jest.fn();
|
||||
const { container, rerender } = renderMap({ onMarkerClick });
|
||||
fireEvent.click(container.querySelectorAll('.sc-pin')[0]);
|
||||
expect(onMarkerClick).toHaveBeenCalledWith(a);
|
||||
|
||||
rerender({ onMarkerClick, selectedUrn: 1 });
|
||||
expect(container.querySelectorAll('.sc-pin--selected')).toHaveLength(1);
|
||||
expect(container.querySelector('.sc-popup')).toHaveTextContent('Southmead Primary School');
|
||||
expect(container.querySelector('.sc-popup')).toHaveTextContent('52% RWM -10 pts');
|
||||
});
|
||||
|
||||
it('keeps the special-school card free of a benchmark and a placeholder 0%', () => {
|
||||
const { container } = renderMap({ selectedUrn: 2 });
|
||||
const card = container.querySelector('.sc-popup')!;
|
||||
expect(card).toHaveTextContent('Greenmead School');
|
||||
expect(card).not.toHaveTextContent(/%|pts/);
|
||||
});
|
||||
|
||||
it('adds to compare from the card, and the card follows the basket', () => {
|
||||
const onAddToCompare = jest.fn();
|
||||
const { container, rerender } = renderMap({ onAddToCompare, selectedUrn: 1, compareUrns: [] });
|
||||
fireEvent.click(container.querySelector('[data-compare]')!);
|
||||
expect(onAddToCompare).toHaveBeenCalledWith(a);
|
||||
|
||||
rerender({ onAddToCompare, selectedUrn: 1, compareUrns: [1] });
|
||||
expect(container.querySelector('[data-compare]')).toHaveTextContent('✓ Comparing');
|
||||
fireEvent.click(container.querySelector('[data-compare]')!);
|
||||
expect(onAddToCompare).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('tells the page when the card is closed from the map, but not when replaced', () => {
|
||||
const onDeselect = jest.fn();
|
||||
const { container, rerender } = renderMap({ onDeselect, selectedUrn: 1 });
|
||||
rerender({ onDeselect, selectedUrn: 2 });
|
||||
expect(onDeselect).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(container.querySelector('.leaflet-popup-close-button')!);
|
||||
expect(onDeselect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the selection when the results reload under it', () => {
|
||||
const onDeselect = jest.fn();
|
||||
const { container, rerender } = renderMap({ onDeselect, selectedUrn: 1 });
|
||||
act(() => rerender({ onDeselect, selectedUrn: 1, schools: [...schools] }));
|
||||
expect(onDeselect).not.toHaveBeenCalled();
|
||||
expect(container.querySelector('.sc-popup')).toHaveTextContent('Southmead Primary School');
|
||||
});
|
||||
|
||||
it('opens no card on a narrow screen, where the page shows a bottom sheet', () => {
|
||||
setWide(false);
|
||||
const { container } = renderMap({ selectedUrn: 1 });
|
||||
expect(container.querySelectorAll('.sc-pin--selected')).toHaveLength(1);
|
||||
expect(container.querySelector('.sc-popup')).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { HomeView } from '@/components/HomeView';
|
||||
import { fetchSchools, fetchNationalAverages } from '@/lib/api';
|
||||
import { primaryFixture } from '../support/schoolFixtures';
|
||||
import type { School, SchoolsResponse } from '@/lib/types';
|
||||
|
||||
/*
|
||||
* The map view: the list beside the map (mockup B), opening on the map for
|
||||
* desktop. The map itself is Leaflet and mocked here; what is pinned is what
|
||||
* HomeView hands it and the list it draws beside it.
|
||||
*/
|
||||
|
||||
let params = new URLSearchParams('postcode=SW196AR&radius=1');
|
||||
jest.mock('next/navigation', () => ({
|
||||
useSearchParams: () => params,
|
||||
usePathname: () => '/',
|
||||
useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }),
|
||||
}));
|
||||
jest.mock('@/context/ComparisonContext', () => ({
|
||||
useComparisonContext: () => ({ addSchool: jest.fn(), removeSchool: jest.fn(), selectedSchools: [] }),
|
||||
}));
|
||||
jest.mock('@/lib/api', () => ({
|
||||
fetchSchools: jest.fn(),
|
||||
fetchNationalAverages: jest.fn(),
|
||||
fetchLAaverages: jest.fn(async () => ({ secondary: { attainment_8_by_la: {} } })),
|
||||
}));
|
||||
jest.mock('@/components/FilterBar', () => ({ FilterBar: () => null }));
|
||||
jest.mock('@/components/SchoolMap', () => ({
|
||||
SchoolMap: ({ selectedUrn, radiusMiles, onMarkerClick, schools }: {
|
||||
selectedUrn: number | null; radiusMiles?: number;
|
||||
onMarkerClick: (s: School) => void; schools: School[];
|
||||
}) => (
|
||||
<div data-testid="map" data-selected={selectedUrn ?? ''} data-radius={radiusMiles}>
|
||||
<button type="button" onClick={() => onMarkerClick(schools[1])}>pin</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const base = primaryFixture.schoolInfo;
|
||||
const southmead: School = {
|
||||
...base, urn: 2, school_name: 'Southmead Primary School', distance: 0.2,
|
||||
school_type: 'Community school', rwm_expected_pct: 52, total_pupils: 269,
|
||||
};
|
||||
const greenmead: School = {
|
||||
...base, urn: 3, school_name: 'Greenmead School', distance: 0.2,
|
||||
school_type: 'Community special school', rwm_expected_pct: 0,
|
||||
reading_expected_pct: 0, writing_expected_pct: 0, maths_expected_pct: 0, total_pupils: 62,
|
||||
};
|
||||
const ourLady: School = {
|
||||
...base, urn: 1, school_name: 'Our Lady Queen of Heaven RC School', distance: 0,
|
||||
school_type: 'Voluntary aided school', rwm_expected_pct: 70, total_pupils: 224,
|
||||
};
|
||||
|
||||
function results(): SchoolsResponse {
|
||||
return {
|
||||
schools: [ourLady, southmead, greenmead], total: 3, page: 1, page_size: 25, total_pages: 1,
|
||||
location_info: { postcode: 'SW196AR', radius: 1.60934, coordinates: [51.42, -0.21] },
|
||||
} as SchoolsResponse;
|
||||
}
|
||||
const filters = { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
|
||||
|
||||
beforeEach(() => {
|
||||
params = new URLSearchParams('postcode=SW196AR&radius=1');
|
||||
jest.mocked(fetchSchools).mockReset().mockResolvedValue(results());
|
||||
jest.mocked(fetchNationalAverages).mockResolvedValue({ primary: { rwm_expected_pct: 62 } } as never);
|
||||
window.innerWidth = 1440;
|
||||
});
|
||||
|
||||
async function renderMap() {
|
||||
const view = render(<HomeView initialSchools={results()} filters={filters} initialView="map" />);
|
||||
await act(async () => {});
|
||||
return view;
|
||||
}
|
||||
|
||||
it('opens on the map when the server says desktop', async () => {
|
||||
await renderMap();
|
||||
expect(screen.getByTestId('map')).toHaveAttribute('data-radius', '1');
|
||||
expect(screen.getByRole('button', { name: 'Map' })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('falls back to the list in a window too narrow for the split', async () => {
|
||||
window.innerWidth = 800;
|
||||
await renderMap();
|
||||
expect(screen.queryByTestId('map')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'List' })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('puts the count and the sort in the list beside the map, once', async () => {
|
||||
await renderMap();
|
||||
expect(screen.getAllByRole('heading', { name: /3 schools within 1.0 miles of SW196AR/ })).toHaveLength(1);
|
||||
expect(screen.getAllByRole('combobox')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('selects the pin from the card, and the card from the pin', async () => {
|
||||
const { container } = await renderMap();
|
||||
const card = (urn: number) => container.querySelector(`[data-urn="${urn}"]`) as HTMLElement;
|
||||
|
||||
fireEvent.click(within(card(1)).getByText(/pupils/));
|
||||
expect(screen.getByTestId('map')).toHaveAttribute('data-selected', '1');
|
||||
expect(card(1).className).toMatch(/compactItemSelected/);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'pin' }));
|
||||
expect(card(2).className).toMatch(/compactItemSelected/);
|
||||
expect(card(1).className).not.toMatch(/compactItemSelected/);
|
||||
});
|
||||
|
||||
it('clicking a card\'s link or button does not also select it', async () => {
|
||||
const { container } = await renderMap();
|
||||
const card = container.querySelector('[data-urn="1"]') as HTMLElement;
|
||||
fireEvent.click(within(card).getByRole('button', { name: '+ Compare' }));
|
||||
expect(screen.getByTestId('map')).toHaveAttribute('data-selected', '');
|
||||
});
|
||||
|
||||
it('shows the England comparison for mainstream schools only, and never a placeholder 0%', async () => {
|
||||
const { container } = await renderMap();
|
||||
const card = (urn: number) => container.querySelector(`[data-urn="${urn}"]`) as HTMLElement;
|
||||
expect(card(2)).toHaveTextContent('52% RWM -10 pts · 269 pupils');
|
||||
expect(card(3)).toHaveTextContent('62 pupils');
|
||||
expect(card(3)).not.toHaveTextContent(/%|pts/);
|
||||
});
|
||||
|
||||
it('follows a new default after a client-side search, until the reader picks', async () => {
|
||||
params = new URLSearchParams('');
|
||||
const empty = { schools: [], total: 0, page: 1, page_size: 25, total_pages: 0 } as SchoolsResponse;
|
||||
const view = render(<HomeView initialSchools={empty} filters={filters} initialView="list" />);
|
||||
|
||||
// Hero search: same component, new props.
|
||||
params = new URLSearchParams('postcode=SW196AR&radius=1');
|
||||
view.rerender(<HomeView initialSchools={results()} filters={filters} initialView="map" />);
|
||||
await act(async () => {});
|
||||
expect(screen.getByTestId('map')).toBeInTheDocument();
|
||||
|
||||
// Chosen: a later search keeps the reader's view.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'List' }));
|
||||
params = new URLSearchParams('postcode=SW170AA&radius=1');
|
||||
view.rerender(<HomeView initialSchools={results()} filters={filters} initialView="list" />);
|
||||
view.rerender(<HomeView initialSchools={results()} filters={filters} initialView="map" />);
|
||||
await act(async () => {});
|
||||
expect(screen.queryByTestId('map')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -119,10 +119,13 @@ describe('third-party surfaces under themed text', () => {
|
||||
|
||||
it('still finds a component painting themed text into a Leaflet popup', () => {
|
||||
// Guards the rule below against passing vacuously if the popups are ever
|
||||
// rewritten as React components rather than HTML strings.
|
||||
// rewritten as React components rather than HTML strings. The results
|
||||
// map's card themes its text through classes in SchoolMap.module.css
|
||||
// rather than inline, so a class-built popup counts as well.
|
||||
const themed = sources(COMPONENTS).filter((file) => {
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
return /bindPopup\(/.test(src) && /color:var\(--|color: var\(--/.test(src);
|
||||
return /bindPopup\(|L\.popup\(/.test(src)
|
||||
&& (/color:var\(--|color: var\(--/.test(src) || /class="sc-card/.test(src));
|
||||
});
|
||||
|
||||
expect(themed.length).toBeGreaterThan(0);
|
||||
|
||||
Reference in new issue
Block a user