Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55363cbd18 | ||
|
|
868eb344f5 | ||
|
|
d55f6cce23 | ||
|
|
d5a6db289d |
No files matched your search
@@ -2094,12 +2094,32 @@ test('a place page lists its schools alphabetically', async ({ page }) => {
|
|||||||
expect(town).toBeTruthy();
|
expect(town).toBeTruthy();
|
||||||
|
|
||||||
await page.goto(`/schools/${town.slug}`);
|
await page.goto(`/schools/${town.slug}`);
|
||||||
const names = await page.locator('a[href^="/school/"]').allTextContents();
|
|
||||||
expect(names.length).toBeGreaterThan(1);
|
|
||||||
|
|
||||||
const sorted = [...names].sort((a, b) =>
|
/*
|
||||||
a.toLowerCase().localeCompare(b.toLowerCase()));
|
* Per table, not per page.
|
||||||
expect(names).toEqual(sorted);
|
*
|
||||||
|
* An unphased place page renders one table per phase, and an all-through
|
||||||
|
* school legitimately appears in both — so the page's school links are not
|
||||||
|
* one alphabetical run and never were. This assertion used to collect them
|
||||||
|
* all together and only passed because no town it picked happened to hold an
|
||||||
|
* all-through school; when the data gave Abbots Langley one, Breakspeare
|
||||||
|
* School showed up in the primary table and again in the secondary, and the
|
||||||
|
* test failed on correct behaviour.
|
||||||
|
*/
|
||||||
|
const tables = page.locator('table');
|
||||||
|
const tableCount = await tables.count();
|
||||||
|
expect(tableCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
let checked = 0;
|
||||||
|
for (let i = 0; i < tableCount; i++) {
|
||||||
|
const names = await tables.nth(i).locator('a[href^="/school/"]').allTextContents();
|
||||||
|
if (names.length < 2) continue; // a one-row table says nothing about order
|
||||||
|
const sorted = [...names].sort((a, b) =>
|
||||||
|
a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||||
|
expect(names, `table ${i + 1} is not alphabetical`).toEqual(sorted);
|
||||||
|
checked++;
|
||||||
|
}
|
||||||
|
expect(checked, 'no table had enough rows to check the ordering').toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the rankings page still orders by score, not name', async ({ page }) => {
|
test('the rankings page still orders by score, not name', async ({ page }) => {
|
||||||
@@ -2163,6 +2183,65 @@ test('typing a school name suggests it, and choosing it opens that school', asyn
|
|||||||
await expect(page).toHaveURL(/\/school\/\d+/);
|
await expect(page).toHaveURL(/\/school\/\d+/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the whole dropdown is reachable, not clipped by the hero', async ({ page }) => {
|
||||||
|
/*
|
||||||
|
* The hero panel had overflow: hidden to clip its artwork to the rounded
|
||||||
|
* corners, and it clipped the dropdown too — 320px of list against 145px of
|
||||||
|
* panel below the input, so roughly half was cut off with nothing to say so.
|
||||||
|
*
|
||||||
|
* toBeVisible() does not catch this: it checks the box is non-empty and not
|
||||||
|
* visibility:hidden, and an ancestor's overflow clips neither. The invariant
|
||||||
|
* that does catch it is that the LAST option is the thing actually painted
|
||||||
|
* at its own coordinates — which fails for clipping and for occlusion alike.
|
||||||
|
*/
|
||||||
|
test.skip(!(await autosuggestIsOn(page)),
|
||||||
|
'the school_autosuggest flag is off in this environment');
|
||||||
|
|
||||||
|
const { schools } = await (await page.request.get('/api/schools?page_size=1')).json();
|
||||||
|
test.skip(!schools?.length, 'no schools in this environment');
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('combobox').first().fill(
|
||||||
|
(schools[0].school_name as string).slice(0, 6));
|
||||||
|
|
||||||
|
const options = page.getByRole('option');
|
||||||
|
await expect(options.first()).toBeVisible();
|
||||||
|
const count = await options.count();
|
||||||
|
|
||||||
|
const painted = await options.nth(count - 1).evaluate((el) => {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||||
|
return { inside: el.contains(hit) || el === hit, bottom: Math.round(r.bottom) };
|
||||||
|
});
|
||||||
|
expect(painted.inside,
|
||||||
|
`the last option is not painted at its own coordinates (bottom ${painted.bottom}) `
|
||||||
|
+ '— an ancestor is clipping or covering the dropdown').toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the dropdown does not survive into the results it produced', async ({ page }) => {
|
||||||
|
/*
|
||||||
|
* The bug that took the staging gate down, and it was not a test problem:
|
||||||
|
* after a search the results-page bar still holds the term, so the dropdown
|
||||||
|
* reopened on top of the results and swallowed the click on the first one.
|
||||||
|
* Playwright reported it as "<li role=option> intercepts pointer events"; a
|
||||||
|
* reader would simply have found their first result unclickable.
|
||||||
|
*/
|
||||||
|
test.skip(!(await autosuggestIsOn(page)),
|
||||||
|
'the school_autosuggest flag is off in this environment');
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('combobox').first().fill('school');
|
||||||
|
await expect(page.getByRole('option').first()).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Search/i }).first().click();
|
||||||
|
await page.waitForURL(/search=school/);
|
||||||
|
|
||||||
|
await expect(page.getByRole('listbox')).toHaveCount(0);
|
||||||
|
// And the results underneath are actually reachable, which is the point.
|
||||||
|
await page.locator('a[href^="/school/"]').first().click({ timeout: 15_000 });
|
||||||
|
await expect(page).toHaveURL(/\/school\//);
|
||||||
|
});
|
||||||
|
|
||||||
test('with autosuggest off, the search box is a plain input', async ({ page }) => {
|
test('with autosuggest off, the search box is a plain input', async ({ page }) => {
|
||||||
test.skip(await autosuggestIsOn(page),
|
test.skip(await autosuggestIsOn(page),
|
||||||
'the school_autosuggest flag is on in this environment');
|
'the school_autosuggest flag is on in this environment');
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import { FilterBar } from '@/components/FilterBar';
|
import { FilterBar } from '@/components/FilterBar';
|
||||||
|
|
||||||
const push = jest.fn();
|
const push = jest.fn();
|
||||||
|
let searchParams = new URLSearchParams();
|
||||||
jest.mock('next/navigation', () => ({
|
jest.mock('next/navigation', () => ({
|
||||||
useRouter: () => ({ push, replace: jest.fn(), prefetch: jest.fn() }),
|
useRouter: () => ({ push, replace: jest.fn(), prefetch: jest.fn() }),
|
||||||
usePathname: () => '/',
|
usePathname: () => '/',
|
||||||
useSearchParams: () => new URLSearchParams(),
|
useSearchParams: () => searchParams,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const FILTERS = {
|
const FILTERS = {
|
||||||
@@ -24,6 +25,7 @@ beforeEach(() => {
|
|||||||
phase: 'Primary', school_type: 'Community school' }] }),
|
phase: 'Primary', school_type: 'Community school' }] }),
|
||||||
})) as unknown as typeof fetch;
|
})) as unknown as typeof fetch;
|
||||||
push.mockClear();
|
push.mockClear();
|
||||||
|
searchParams = new URLSearchParams();
|
||||||
});
|
});
|
||||||
afterEach(() => { global.fetch = realFetch; });
|
afterEach(() => { global.fetch = realFetch; });
|
||||||
|
|
||||||
@@ -74,3 +76,35 @@ describe('FilterBar autosuggest', () => {
|
|||||||
expect.stringContaining('search=brecknock')));
|
expect.stringContaining('search=brecknock')));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('FilterBar autosuggest does not reopen over results', () => {
|
||||||
|
it('stays shut when the input arrives pre-filled from the URL', async () => {
|
||||||
|
/*
|
||||||
|
* The results-page bar renders with the search term already in the input.
|
||||||
|
* Opening on that would drop the dropdown on top of the results the search
|
||||||
|
* just produced — which is exactly what happened: the first result became
|
||||||
|
* unclickable, because the list sat over it and swallowed the pointer.
|
||||||
|
*
|
||||||
|
* Suggestions answer typing, not the presence of a value.
|
||||||
|
*/
|
||||||
|
searchParams = new URLSearchParams('search=brecknock');
|
||||||
|
render(<FilterBar filters={FILTERS} autosuggest />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('combobox')).toHaveValue('brecknock');
|
||||||
|
await new Promise((r) => setTimeout(r, 300)); // past the 200ms debounce
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes the dropdown when the search is submitted', async () => {
|
||||||
|
render(<FilterBar filters={FILTERS} autosuggest />);
|
||||||
|
const input = screen.getByRole('combobox');
|
||||||
|
|
||||||
|
await userEvent.type(input, 'brecknock');
|
||||||
|
expect(await screen.findByRole('listbox')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.type(input, '{Enter}');
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -69,14 +69,28 @@ export function FilterBar({
|
|||||||
const [omniValue, setOmniValue] = useState(initialOmniValue);
|
const [omniValue, setOmniValue] = useState(initialOmniValue);
|
||||||
|
|
||||||
const suggestId = `school-suggest-${isHero ? "hero" : "bar"}`;
|
const suggestId = `school-suggest-${isHero ? "hero" : "bar"}`;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Suggestions answer typing, not the mere presence of a value.
|
||||||
|
*
|
||||||
|
* Without this the results-page bar reopened the dropdown over the results:
|
||||||
|
* after a search the input still holds the term, so on every render the
|
||||||
|
* query was >= 2 characters and the list opened again — on top of the very
|
||||||
|
* results the search had just produced, swallowing the click on the first
|
||||||
|
* one. The E2E gate caught it as "<li role=option> intercepts pointer
|
||||||
|
* events", but a reader would just have found the page unclickable.
|
||||||
|
*/
|
||||||
|
const [hasTyped, setHasTyped] = useState(false);
|
||||||
|
|
||||||
// Suppressed once the value parses as a postcode: the box takes a school
|
// Suppressed once the value parses as a postcode: the box takes a school
|
||||||
// name OR a postcode, and suggesting schools during postcode entry fights
|
// name OR a postcode, and suggesting schools during postcode entry fights
|
||||||
// the user rather than helping them.
|
// the user rather than helping them.
|
||||||
const suggestEnabled = autosuggest && !isValidPostcode(omniValue);
|
const suggestEnabled = autosuggest && hasTyped && !isValidPostcode(omniValue);
|
||||||
const { suggestions, open, activeIndex, setActiveIndex, close } =
|
const { suggestions, open, activeIndex, setActiveIndex, close } =
|
||||||
useSchoolSuggest(omniValue, suggestEnabled);
|
useSchoolSuggest(omniValue, suggestEnabled);
|
||||||
|
|
||||||
const pickSuggestion = (s: Suggestion) => {
|
const pickSuggestion = (s: Suggestion) => {
|
||||||
|
setHasTyped(false);
|
||||||
close();
|
close();
|
||||||
track('search_submitted', {
|
track('search_submitted', {
|
||||||
query: s.school_name.toLowerCase(),
|
query: s.school_name.toLowerCase(),
|
||||||
@@ -169,6 +183,9 @@ export function FilterBar({
|
|||||||
|
|
||||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
// The search has been made; the suggestions that led to it are spent.
|
||||||
|
setHasTyped(false);
|
||||||
|
close();
|
||||||
if (!omniValue.trim()) {
|
if (!omniValue.trim()) {
|
||||||
updateURL({ search: "", postcode: "", radius: "" });
|
updateURL({ search: "", postcode: "", radius: "" });
|
||||||
return;
|
return;
|
||||||
@@ -271,7 +288,7 @@ export function FilterBar({
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="search"
|
type="search"
|
||||||
value={omniValue}
|
value={omniValue}
|
||||||
onChange={(e) => setOmniValue(e.target.value)}
|
onChange={(e) => { setOmniValue(e.target.value); setHasTyped(true); }}
|
||||||
onKeyDown={handleOmniKeyDown}
|
onKeyDown={handleOmniKeyDown}
|
||||||
onBlur={close}
|
onBlur={close}
|
||||||
placeholder="School name or postcode"
|
placeholder="School name or postcode"
|
||||||
|
|||||||
@@ -90,7 +90,18 @@
|
|||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
background: var(--hero-ground);
|
background: var(--hero-ground);
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
overflow: hidden;
|
/*
|
||||||
|
* Deliberately NOT overflow: hidden.
|
||||||
|
*
|
||||||
|
* It used to be, to clip the artwork and the scrim to the rounded corners —
|
||||||
|
* and it also clipped the search box's suggestion dropdown, which is 320px
|
||||||
|
* tall against 145px of panel below the input. Roughly half the list was cut
|
||||||
|
* off with no indication anything was missing.
|
||||||
|
*
|
||||||
|
* The two things that actually needed clipping round themselves instead, so
|
||||||
|
* the panel can let a dropdown out. Anything absolutely positioned inside
|
||||||
|
* this panel and taller than the space below it depends on this.
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroContent {
|
.heroContent {
|
||||||
@@ -107,6 +118,10 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
|
/* Rounds itself, because the panel no longer clips it. inset: 0 makes this
|
||||||
|
exactly the panel's own corners. */
|
||||||
|
border-radius: inherit;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroArt picture,
|
.heroArt picture,
|
||||||
@@ -143,6 +158,9 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
/* Same reason as .heroArt: the panel stopped clipping, so the scrim keeps
|
||||||
|
its own corners rather than squaring off over the panel's. */
|
||||||
|
border-radius: inherit;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to right,
|
to right,
|
||||||
var(--hero-ground) 0%,
|
var(--hero-ground) 0%,
|
||||||
@@ -331,6 +349,10 @@
|
|||||||
position: static;
|
position: static;
|
||||||
order: -1;
|
order: -1;
|
||||||
height: 13rem;
|
height: 13rem;
|
||||||
|
/* Top corners only. Here the artwork is a band flush with the top of the
|
||||||
|
panel, not a layer covering it — inheriting all four would leave it
|
||||||
|
floating with rounded bottom corners against the copy below. */
|
||||||
|
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||||||
}
|
}
|
||||||
/* The band crop puts the schoolhouse at 73% across — reported by
|
/* The band crop puts the schoolhouse at 73% across — reported by
|
||||||
scripts/build-hero-images.js, which derives it from the crop box rather
|
scripts/build-hero-images.js, which derives it from the crop box rather
|
||||||
|
|||||||
Reference in new issue
Block a user