feat(school): facts and flags in the school header, shared with search rows #177
No files matched your search
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* The facts row under the school name.
|
||||
* The school header: one fact line (phase, ages, type, pupils), then flags
|
||||
* only for what applies, then the address and the details.
|
||||
*
|
||||
* nursery_provision is GIAS text, not a boolean: "Has Nursery Classes",
|
||||
* "No Nursery Classes" or "Not applicable". Tested for truthiness, every one
|
||||
* of those read as a nursery, so secondaries aged 11–18 showed "Nursery".
|
||||
*/
|
||||
|
||||
import { screen } from '@testing-library/react';
|
||||
import { screen, within } from '@testing-library/react';
|
||||
import type { School } from '@/lib/types';
|
||||
import { primaryFixture, secondaryFixture } from '../support/schoolFixtures';
|
||||
import { renderSchoolDetail, renderSecondarySchoolDetail } from '../support/renderSchoolDetail';
|
||||
@@ -35,22 +36,54 @@ function withSchool<T extends { schoolInfo: School }>(fixture: T, info: Partial<
|
||||
return { ...fixture, schoolInfo: { ...fixture.schoolInfo, ...info } };
|
||||
}
|
||||
|
||||
describe('school header nursery chip', () => {
|
||||
it('shows Nursery when GIAS says the school has nursery classes', () => {
|
||||
const flagList = () => screen.queryByRole('list', { name: 'Admission and provision' });
|
||||
const flagLabels = () => within(flagList()!).getAllByRole('listitem').map((li) => li.textContent);
|
||||
|
||||
describe('school header fact line', () => {
|
||||
it('states phase, ages, type and pupils, in that order', () => {
|
||||
renderSchoolDetail(withSchool(primaryFixture, { type_group: 'state' }));
|
||||
const line = screen.getByText('Ages 4–11').parentElement!;
|
||||
expect(line).toHaveTextContent(/^PrimaryAges 4–11State school420 pupils$/);
|
||||
});
|
||||
|
||||
it("prints the register's type name for a type in no group", () => {
|
||||
renderSchoolDetail(withSchool(primaryFixture, { type_group: null, school_type: 'Secure units' }));
|
||||
expect(screen.getByText('Secure units')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('no longer prints the GIAS establishment type', () => {
|
||||
renderSecondarySchoolDetail(withSchool(secondaryFixture, { type_group: 'state' }));
|
||||
expect(screen.queryByText('Academy converter')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('school header flags', () => {
|
||||
it('shows no flag list for a school with nothing to flag', () => {
|
||||
renderSchoolDetail(withSchool(primaryFixture, { type_group: 'state' }));
|
||||
expect(flagList()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists who-can-apply flags, then what the school offers', () => {
|
||||
renderSecondarySchoolDetail(withSchool(secondaryFixture, {
|
||||
type_group: 'independent', admissions_policy: 'Selective', gender: 'Girls',
|
||||
religious_denomination: 'Church of England', has_sixth_form: true,
|
||||
}));
|
||||
expect(flagLabels()).toEqual(['Fee-paying', 'Selective', "Girls' school", 'Church of England', 'Sixth form']);
|
||||
});
|
||||
|
||||
it('shows Nursery class when GIAS says the school has nursery classes', () => {
|
||||
renderSchoolDetail(withSchool(primaryFixture, { nursery_provision: 'Has Nursery Classes' }));
|
||||
expect(screen.getByText('Nursery', { selector: 'span' })).toBeInTheDocument();
|
||||
expect(flagLabels()).toEqual(['Nursery class']);
|
||||
});
|
||||
|
||||
it.each(['No Nursery Classes', 'Not applicable', null])(
|
||||
'hides Nursery when GIAS says %p',
|
||||
'shows no nursery flag when GIAS says %p',
|
||||
(value) => {
|
||||
renderSecondarySchoolDetail(withSchool(secondaryFixture, { nursery_provision: value }));
|
||||
expect(screen.queryByText('Nursery', { selector: 'span' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/^Nursery/)).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('school header single-sex chip', () => {
|
||||
it.each([['Girls', "Girls' school"], ['Boys', "Boys' school"]])(
|
||||
'labels a %s school with a plural possessive',
|
||||
(gender, label) => {
|
||||
@@ -65,3 +98,44 @@ describe('school header single-sex chip', () => {
|
||||
expect(screen.queryByText(/^(Girls|Boys|Mixed)'s? school$/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('school header address', () => {
|
||||
it('names the council after the postcode', () => {
|
||||
renderSchoolDetail(primaryFixture);
|
||||
expect(screen.getByText(/TE1 1ST · Westshire/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leaves the council out when the address already names it', () => {
|
||||
renderSchoolDetail(withSchool(primaryFixture, { local_authority: 'Testville' }));
|
||||
expect(screen.queryByText(/· Testville/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('school header details', () => {
|
||||
const detailed = {
|
||||
headteacher_name: 'Mrs A Head', capacity: 426, county: 'Surrey',
|
||||
parliamentary_constituency: 'Putney', religious_denomination: 'Church of England',
|
||||
};
|
||||
|
||||
it('drops county, constituency and religious character', () => {
|
||||
renderSchoolDetail(withSchool(primaryFixture, detailed));
|
||||
expect(screen.queryByText('County:')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Constituency:')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Religious character:')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the capacity', () => {
|
||||
renderSchoolDetail(withSchool(primaryFixture, detailed));
|
||||
expect(screen.getByText('Capacity:').parentElement).toHaveTextContent('Capacity: 426');
|
||||
});
|
||||
|
||||
it('names an academy trust', () => {
|
||||
renderSchoolDetail(withSchool(primaryFixture, { trust_name: 'BURNTWOOD TRUST' }));
|
||||
expect(screen.getByText('Academy trust:').parentElement).toHaveTextContent('BURNTWOOD TRUST');
|
||||
});
|
||||
|
||||
it("hides a trust that has the school's own name", () => {
|
||||
renderSchoolDetail(withSchool(primaryFixture, { trust_name: 'TEST PRIMARY SCHOOL' }));
|
||||
expect(screen.queryByText('Academy trust:')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -43,14 +43,13 @@ function withoutCensus(schoolTotal: number | null) {
|
||||
describe('pupil count without a census record', () => {
|
||||
it('uses the register count in the header, not the results cohort', () => {
|
||||
renderSecondarySchoolDetail(withoutCensus(1462));
|
||||
const pupils = screen.getByText('Pupils:').parentElement!;
|
||||
expect(pupils).toHaveTextContent('1,462');
|
||||
expect(pupils).not.toHaveTextContent('245');
|
||||
expect(screen.getByText('1,462 pupils')).toBeInTheDocument();
|
||||
expect(screen.queryByText('245 pupils')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no count rather than the results cohort when the register has none', () => {
|
||||
renderSecondarySchoolDetail(withoutCensus(null));
|
||||
expect(screen.queryByText('Pupils:')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/^[\d,]+ pupils$/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Total pupils')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* The facts the school header and the search rows print: a type in the search
|
||||
* filter's terms, and flags only for what applies.
|
||||
*/
|
||||
|
||||
import { schoolFlags, schoolTypeLabel } from '@/lib/schoolFacts';
|
||||
import type { School } from '@/lib/types';
|
||||
|
||||
const school = (over: Partial<School>): School =>
|
||||
({ urn: 1, school_name: 'Test School', ...over }) as School;
|
||||
|
||||
const labels = (over: Partial<School>) => schoolFlags(school(over)).map((f) => f.label);
|
||||
|
||||
describe('schoolTypeLabel', () => {
|
||||
it.each([
|
||||
['state', 'State school'],
|
||||
['independent', 'Independent school'],
|
||||
['special', 'Special school (SEND)'],
|
||||
['post16', 'Sixth form or college'],
|
||||
['alternative', 'Alternative provision'],
|
||||
])("names the %s group in the search filter's terms", (type_group, label) => {
|
||||
expect(schoolTypeLabel(school({ type_group, school_type: 'Academy converter' }))).toBe(label);
|
||||
});
|
||||
|
||||
it("prints the register's own name for a type in no group", () => {
|
||||
expect(schoolTypeLabel(school({ type_group: null, school_type: 'Secure units' }))).toBe('Secure units');
|
||||
});
|
||||
|
||||
it('returns null when there is no type at all', () => {
|
||||
expect(schoolTypeLabel(school({}))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('schoolFlags', () => {
|
||||
it('flags nothing for a mixed, non-faith, non-selective state school', () => {
|
||||
expect(labels({
|
||||
type_group: 'state', gender: 'Mixed', admissions_policy: 'Non-selective',
|
||||
religious_denomination: 'None', nursery_provision: 'No Nursery Classes',
|
||||
has_sixth_form: false, phase: 'Secondary',
|
||||
})).toEqual([]);
|
||||
});
|
||||
|
||||
it('lists who-can-apply flags first, then what the school offers, in a fixed order', () => {
|
||||
expect(labels({
|
||||
type_group: 'independent', admissions_policy: 'Selective', gender: 'Boys',
|
||||
religious_denomination: 'Christian', nursery_provision: 'Has Nursery Classes',
|
||||
has_sixth_form: true, phase: 'All-through',
|
||||
})).toEqual(['Fee-paying', 'Selective', "Boys' school", 'Christian', 'Nursery class', 'Sixth form']);
|
||||
});
|
||||
|
||||
it('marks who-can-apply flags as conditions and offers as provision', () => {
|
||||
const kinds = Object.fromEntries(
|
||||
schoolFlags(school({ admissions_policy: 'Selective', has_sixth_form: true, phase: 'Secondary' }))
|
||||
.map((f) => [f.label, f.kind]),
|
||||
);
|
||||
expect(kinds).toEqual({ Selective: 'condition', 'Sixth form': 'provision' });
|
||||
});
|
||||
|
||||
it('never prints Non-selective, and never a no-faith value', () => {
|
||||
expect(labels({ admissions_policy: 'Non-selective', religious_denomination: 'Does not apply' })).toEqual([]);
|
||||
expect(labels({ admissions_policy: 'Not applicable', religious_denomination: 'None' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('prints the religious character as the register records it', () => {
|
||||
expect(labels({ religious_denomination: 'Church of England/Methodist' })).toEqual(['Church of England/Methodist']);
|
||||
});
|
||||
|
||||
it('does not flag a nursery class on a nursery school, or a sixth form on a post-16 one', () => {
|
||||
expect(labels({ phase: 'Nursery', nursery_provision: 'Has Nursery Classes' })).toEqual([]);
|
||||
expect(labels({ phase: '16 plus', has_sixth_form: true })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -145,20 +145,76 @@
|
||||
}
|
||||
|
||||
|
||||
.meta {
|
||||
/* Fact line: the phase pill, then plain register values joined by dots. */
|
||||
.facts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
row-gap: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
|
||||
.metaItem {
|
||||
font-size: 0.8125rem;
|
||||
.fact + .fact::before {
|
||||
content: "·";
|
||||
margin: 0 0.5rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
|
||||
/* The search rows' phase pill, in the same phase colours. */
|
||||
.phasePill {
|
||||
margin-right: 0.625rem;
|
||||
padding: 0.0625rem 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.phasePillPrimary { background: var(--phase-primary-bg); color: var(--phase-primary-text); }
|
||||
.phasePillSecondary { background: var(--phase-secondary-bg); color: var(--phase-secondary-text); }
|
||||
.phasePillAllThrough { background: var(--phase-all-through-bg); color: var(--phase-all-through-text); }
|
||||
.phasePillPost16 { background: var(--phase-post16-bg); color: var(--phase-post16-text); }
|
||||
.phasePillNursery { background: var(--phase-nursery-bg); color: var(--phase-nursery-text); }
|
||||
|
||||
|
||||
/* Flags are facts, not verdicts, so they carry no hue. Outlined changes who
|
||||
can apply or what it costs; filled is what the school offers. Text may
|
||||
wrap: the longest religious character runs past a 360px line. */
|
||||
.flags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
margin: 0 0 0.625rem;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
|
||||
.flagCondition,
|
||||
.flagProvision {
|
||||
padding: 0.0625rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
|
||||
.flagCondition {
|
||||
border-color: rgba(var(--ink-rgb), 0.4);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
.flagProvision {
|
||||
background: rgba(var(--ink-rgb), 0.07);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
|
||||
@@ -703,17 +759,8 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Pills wrap horizontally instead of stacking — short tokens like
|
||||
"Manchester" / "Voluntary aided" fit 2 per row instead of 3 full
|
||||
rows of empty horizontal space. */
|
||||
.meta {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
/* Secondary header info (headteacher, website, pupil count, trust,
|
||||
contact, area) isn't needed above the fold on phones/tablets, so it's
|
||||
/* Secondary header info (headteacher, website, phone, trust, capacity)
|
||||
isn't needed above the fold on phones/tablets, so it's
|
||||
collapsed by default and revealed on demand via the "Show all details"
|
||||
link — reclaiming the vertical space so the metrics surface sooner. */
|
||||
.detailsToggle {
|
||||
|
||||
@@ -20,8 +20,9 @@ import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useComparison } from '@/hooks/useComparison';
|
||||
import { SchoolHeroMap, type SchoolHeroMapHandle } from '../SchoolHeroMap';
|
||||
import type { School, SchoolResult, SchoolCensus } from '@/lib/types';
|
||||
import { formatAgeRange, hasNurseryClasses, isProposedToClose, singleSexLabel } from '@/lib/utils';
|
||||
import type { School, SchoolCensus } from '@/lib/types';
|
||||
import { formatAgeRange, getPhaseStyle, isProposedToClose } from '@/lib/utils';
|
||||
import { schoolFlags, schoolTypeLabel } from '@/lib/schoolFacts';
|
||||
import type { NavItem } from '@/lib/schoolSections';
|
||||
import { track, getNavigationSource } from '@/lib/analytics';
|
||||
import styles from './SchoolDetailShell.module.css';
|
||||
@@ -41,6 +42,12 @@ export interface SchoolDetailShellProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** Equal ignoring case and punctuation: "TIFFIN SCHOOL" is Tiffin School. */
|
||||
function sameName(a: string, b: string): boolean {
|
||||
const key = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
return key(a) === key(b);
|
||||
}
|
||||
|
||||
export function SchoolDetailShell({
|
||||
schoolInfo, census, navItems, children,
|
||||
}: SchoolDetailShellProps) {
|
||||
@@ -61,7 +68,7 @@ export function SchoolDetailShell({
|
||||
const heroMapRef = useRef<SchoolHeroMapHandle>(null);
|
||||
// "All ▾" jump menu listing every section.
|
||||
const [sectionsOpen, setSectionsOpen] = useState(false);
|
||||
// Header details (headteacher, contact, trust, area) collapse behind a
|
||||
// Header details (headteacher, contact, trust, capacity) collapse behind a
|
||||
// "Show all details" link on mobile/tablet, where they're below the fold.
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
|
||||
@@ -125,9 +132,28 @@ export function SchoolDetailShell({
|
||||
// composers; recomputing them here would duplicate that work for values
|
||||
// this component never renders.
|
||||
const phase = schoolInfo.phase ?? '';
|
||||
const isAllThrough = phase.toLowerCase() === 'all-through';
|
||||
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
|
||||
const singleSex = singleSexLabel(schoolInfo.gender);
|
||||
|
||||
// Header facts: each is one register value in a fixed slot (lib/schoolFacts).
|
||||
const phasePill = getPhaseStyle(schoolInfo.phase);
|
||||
// Never latestResults.total_pupils: that is the results cohort, which for a
|
||||
// secondary is the GCSE year group alone.
|
||||
const pupils = census?.total_pupils ?? schoolInfo.total_pupils ?? null;
|
||||
const facts = [
|
||||
formatAgeRange(schoolInfo.age_range),
|
||||
schoolTypeLabel(schoolInfo),
|
||||
pupils != null ? `${pupils.toLocaleString()} pupils` : null,
|
||||
].filter((fact): fact is string => !!fact);
|
||||
const flags = schoolFlags(schoolInfo);
|
||||
// The council, unless the address already names it.
|
||||
const council = schoolInfo.local_authority
|
||||
&& !(schoolInfo.address ?? '').toLowerCase().includes(schoolInfo.local_authority.toLowerCase())
|
||||
? schoolInfo.local_authority
|
||||
: null;
|
||||
// A single-academy trust carries the school's own name, which says nothing.
|
||||
const trust = schoolInfo.trust_name && !sameName(schoolInfo.trust_name, schoolInfo.school_name)
|
||||
? schoolInfo.trust_name
|
||||
: null;
|
||||
|
||||
const handleComparisonToggle = () => {
|
||||
if (isInComparison) {
|
||||
@@ -202,27 +228,31 @@ export function SchoolDetailShell({
|
||||
<div className={styles.headerContent}>
|
||||
<div className={styles.titleSection}>
|
||||
<h1 className={styles.schoolName}>{schoolInfo.school_name}</h1>
|
||||
<div className={styles.meta}>
|
||||
{schoolInfo.local_authority && (
|
||||
<span className={styles.metaItem}>{schoolInfo.local_authority}</span>
|
||||
)}
|
||||
{schoolInfo.school_type && (
|
||||
<span className={styles.metaItem}>{schoolInfo.school_type}</span>
|
||||
)}
|
||||
{isAllThrough && (
|
||||
<span className={styles.metaItem}>All-through (primary & secondary)</span>
|
||||
)}
|
||||
{singleSex && <span className={styles.metaItem}>{singleSex}</span>}
|
||||
{schoolInfo.age_range && (
|
||||
<span className={styles.metaItem}>{formatAgeRange(schoolInfo.age_range)}</span>
|
||||
)}
|
||||
{hasNurseryClasses(schoolInfo.nursery_provision) && (
|
||||
<span className={styles.metaItem}>Nursery</span>
|
||||
)}
|
||||
{schoolInfo.has_sixth_form && (
|
||||
<span className={styles.metaItem}>Sixth form</span>
|
||||
)}
|
||||
</div>
|
||||
{(phasePill.label || facts.length > 0) && (
|
||||
<div className={styles.facts}>
|
||||
{phasePill.label && (
|
||||
<span className={`${styles.phasePill} ${styles[`phasePill${phasePill.key}`]}`}>
|
||||
{phasePill.label}
|
||||
</span>
|
||||
)}
|
||||
{facts.map((fact) => (
|
||||
<span key={fact} className={styles.fact}>{fact}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{flags.length > 0 && (
|
||||
// role="list": list-style: none drops list semantics in Safari.
|
||||
<ul role="list" className={styles.flags} aria-label="Admission and provision">
|
||||
{flags.map((flag) => (
|
||||
<li
|
||||
key={flag.label}
|
||||
className={flag.kind === 'condition' ? styles.flagCondition : styles.flagProvision}
|
||||
>
|
||||
{flag.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{isProposedToClose(schoolInfo) && (
|
||||
<div className={styles.closingStrip} role="note">
|
||||
<strong>⚠ Proposed to close.</strong> Check with the local authority before
|
||||
@@ -232,6 +262,7 @@ export function SchoolDetailShell({
|
||||
{schoolInfo.address && (
|
||||
<p className={styles.address}>
|
||||
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
|
||||
{council && ` · ${council}`}
|
||||
{hasLocation && (
|
||||
<>
|
||||
{' · '}
|
||||
@@ -278,23 +309,6 @@ export function SchoolDetailShell({
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
// Never latestResults.total_pupils: that is the results
|
||||
// cohort, which for a secondary is the GCSE year group alone.
|
||||
const total = census?.total_pupils ?? schoolInfo.total_pupils ?? null;
|
||||
if (total == null) return null;
|
||||
return (
|
||||
<span className={styles.headerDetail}>
|
||||
<strong>Pupils:</strong> {total.toLocaleString()}
|
||||
{schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{schoolInfo.trust_name && (
|
||||
<span className={styles.headerDetail}>
|
||||
Part of <strong>{schoolInfo.trust_name}</strong>
|
||||
</span>
|
||||
)}
|
||||
{schoolInfo.telephone && (
|
||||
<span className={styles.headerDetail}>
|
||||
<strong>Phone:</strong>{' '}
|
||||
@@ -303,22 +317,14 @@ export function SchoolDetailShell({
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
{schoolInfo.religious_denomination && (
|
||||
{trust && (
|
||||
<span className={styles.headerDetail}>
|
||||
<strong>Religious character:</strong>{' '}
|
||||
{['Does not apply', 'None'].includes(schoolInfo.religious_denomination)
|
||||
? 'None'
|
||||
: schoolInfo.religious_denomination}
|
||||
<strong>Academy trust:</strong> {trust}
|
||||
</span>
|
||||
)}
|
||||
{schoolInfo.county && (
|
||||
{schoolInfo.capacity != null && (
|
||||
<span className={styles.headerDetail}>
|
||||
<strong>County:</strong> {schoolInfo.county}
|
||||
</span>
|
||||
)}
|
||||
{schoolInfo.parliamentary_constituency && (
|
||||
<span className={styles.headerDetail}>
|
||||
<strong>Constituency:</strong> {schoolInfo.parliamentary_constituency}
|
||||
<strong>Capacity:</strong> {schoolInfo.capacity.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* The facts the school header and the search rows print, so a parent reads
|
||||
* the same words in the list and on the page.
|
||||
*
|
||||
* Every value comes from one register field. Nothing is inferred or explained
|
||||
* inline, and a missing field prints nothing.
|
||||
*/
|
||||
|
||||
import type { School } from './types';
|
||||
import { hasNurseryClasses, hasReligiousCharacter, singleSexLabel } from './utils';
|
||||
|
||||
/** The search filter's type groups (backend/school_groups.py), without its
|
||||
* parenthesised notes: fees have a flag of their own. */
|
||||
const TYPE_GROUP_LABELS: Record<string, string> = {
|
||||
state: 'State school',
|
||||
independent: 'Independent school',
|
||||
special: 'Special school (SEND)',
|
||||
post16: 'Sixth form or college',
|
||||
alternative: 'Alternative provision',
|
||||
};
|
||||
|
||||
/** The school's type in the search filter's terms, or the register's own name
|
||||
* for a type in no group (secure units, online providers). */
|
||||
export function schoolTypeLabel(school: Pick<School, 'type_group' | 'school_type'>): string | null {
|
||||
const grouped = school.type_group ? TYPE_GROUP_LABELS[school.type_group] : undefined;
|
||||
return grouped ?? (school.school_type?.trim() || null);
|
||||
}
|
||||
|
||||
/** "condition": changes who can apply or what it costs.
|
||||
* "provision": what the school offers. */
|
||||
export type SchoolFlagKind = 'condition' | 'provision';
|
||||
|
||||
export interface SchoolFlag {
|
||||
label: string;
|
||||
kind: SchoolFlagKind;
|
||||
}
|
||||
|
||||
type FlagFields = Pick<
|
||||
School,
|
||||
'type_group' | 'admissions_policy' | 'gender' | 'religious_denomination'
|
||||
| 'nursery_provision' | 'has_sixth_form' | 'phase'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Flags for what applies, in a fixed order: conditions first, then provision.
|
||||
*
|
||||
* Selective needs the exact value. The register files a partly selective
|
||||
* school as "Non-selective", so that value is never printed.
|
||||
*/
|
||||
export function schoolFlags(school: FlagFields): SchoolFlag[] {
|
||||
const flags: SchoolFlag[] = [];
|
||||
const condition = (label: string) => flags.push({ label, kind: 'condition' });
|
||||
const provision = (label: string) => flags.push({ label, kind: 'provision' });
|
||||
const phase = school.phase?.trim().toLowerCase();
|
||||
|
||||
if (school.type_group === 'independent') condition('Fee-paying');
|
||||
if (school.admissions_policy?.trim().toLowerCase() === 'selective') condition('Selective');
|
||||
const singleSex = singleSexLabel(school.gender);
|
||||
if (singleSex) condition(singleSex);
|
||||
if (hasReligiousCharacter(school.religious_denomination)) {
|
||||
condition(school.religious_denomination!.trim());
|
||||
}
|
||||
if (hasNurseryClasses(school.nursery_provision) && phase !== 'nursery') provision('Nursery class');
|
||||
if (school.has_sixth_form && phase !== '16 plus') provision('Sixth form');
|
||||
return flags;
|
||||
}
|
||||
@@ -17,6 +17,8 @@ export interface School {
|
||||
local_authority_code: number | null;
|
||||
school_type: string | null;
|
||||
school_type_code: string | null;
|
||||
/** Search-filter type group ("state", "independent"…), null for a type in none. */
|
||||
type_group?: string | null;
|
||||
religious_denomination: string | null;
|
||||
age_range: string | null;
|
||||
has_sixth_form?: boolean | null;
|
||||
|
||||
Reference in new issue
Block a user