Check Performance Data - Content page builder (`content-page-builder` branch)
A ground-up rewrite of the CMS around a single unified page tree. Replaces the old ContentPage / GuidancePage-in-code / wiki-only split with one entity — PageNode — that every page on the public site now resolves through. Content pages are authored as a widget tree (heading, rich-text, cards, page-nav, search, …) inside a region-based layout, published through versions, and edited from a page-tree admin with drag-and-drop reordering, right-click actions, and a live left-nav that mirrors the site.
This document is the dev-onboarding overview. Feature docs that predate this branch (CMS-search.md, guidance-and-content-staging.md) still describe the pieces that survived; anything they contradict, this branch wins.
What ships on this branch
The unified page tree
-
PageNode+PageNodeVersiontables. Every page — folders, wiki pages, and the new widget-based content pages — is a row inPageNodeswith aPageType(folder/wiki/content) and aPath(the URL, computed fromSegment+ ancestor path). Versioned content lives inPageNodeVersionswith per-version publish windows (StartDate/EndDate), aContenttext column (widget JSON for content pages, sanitised HTML for wiki pages), and decimal draft version numbers that promote to whole integers on publish. -
Catch-all route resolver.
PageControllerbinds/{*path}withOrder = int.MaxValue, so any URL not claimed by a real controller route falls through to a PageNode lookup. Assertion test (PageControllerRouteOrderTests) guarantees the catch-all can never shadow app routes. Missing pages render a CMS-authored/help/not-foundpage with HTTP 404 (falls back to bareNotFound()if that page is deleted, so the resolver never returns nothing). -
Retired the old ContentPage stack.
ContentPagesandContentPageVersionstables + the whole ContentPage web/service/persistence layer are gone (Remove ContentPage …commits). Old routes redirect via the page tree.GuidanceControlleris retired too —/guidancenow resolves to a PageNode. -
Soft delete + reorder. Deletes mark
DeletedDate; deleted pages surface under/admin/pages/deleted(moved off the wiki-flavored/help/deletedroute). Up/down reorder is aMoveTotree op with sibling-index maths that survives moves within the same parent.
Content-page authoring model
-
Widget tree stored as JSON. A content page's
Contentis a JSON array of nodes. Top-level is one or moreregionnodes; each region carriescolumns, each column an ordered list ofwidgetnodes. Round-tripped throughContentPageJson(System.Text.Json), addressed by dotted paths for insert/remove/move (ContentTreeEditor). -
Eight widgets ship.
Heading(h1–h6, auto-anchored so the auto side-nav renders),RichText(TinyMCE-authored, sanitised viaGanss.Xsswith the GOV.UK-safe allow-list),Divider,Card(title, body, optional image, optional link — equal card heights across wrapped rows),SummaryList,Published(last-reviewed callout),Search(with an optionalscopeprop that constrains results to a subtree — e.g. every KS4 split page searches within/guidance), andPageNav(renders the direct-children nav from the tree). Widgets live underViews/Shared/ContentPages/Widgets/as Razor partials. -
Region layouts map to the GDS grid. Regions carry a
layoutname (one-column,two-thirds-one-third,one-half-one-half, etc).RegionLayoutsmaps each to the equivalentgovuk-grid-column-*classes so authored content lines up with the design system without editors having to know CSS. -
PageNode properties. Beyond
Title, pages carry an editableSubtitle, aPageName(short label for menu use), aSegment(URL slug), and a booleanShowInMenu(default true) that hides the page from public side-nav + search widgets when off. Menu / search widgets skipfolder-typed pages and pages with no live version. -
Content-block auto-provisioning. The
EditableContentview component seeds a block on first render if none exists for the givenkey. Falling back todefaultHtmlin code lets a template ship with useful default content that becomes editable in/admin/content-blocksthe first time the page is viewed — no migration required.
Admin CMS (/admin/…)
- Live page tree in the admin left-nav. The full page tree renders in the sidebar on every admin screen, with a localStorage-backed collapse state, an "open by default" reset for stale states, and a "highlight + expand branch" pass for the currently-loaded node.
-
Content blocks in the same tree. A parallel tree under
/admin/content-blocksmirrors the pages tree and lists the blocks used on each page.LastSeenPath/LastSeenAtonContentBlocks(from the guidance branch) drives the location column. - Right-click context menu on every tree node. Edit, Versions, New child page, Delete — each with an icon, each honouring modifier keys (right-click on Edit / Versions / New child opens in a new tab).
-
Drag-and-drop reorder + reparent. HTML5 DnD with a placeholder slot that inflates between siblings so the drop target is unambiguous (see
admin-page-tree-dnd.js). Client-side sort-index maths accounts for the source being filtered out of the sibling list when it's in the same parent as the target. Anti-forgery header wired asX-XSRF-TOKENfor the JSON move POST. After a move, the browser lands on the moved page so its branch stays open. - Edit page with tabs. Content editor, Properties, Versions. Content editor: widget palette sorted alphabetically, drag-and-drop widget reordering (including into regions), inline "Add content here" slots between widgets, per-widget forms (rich-text uses TinyMCE), light-orange region backgrounds so the layout is visible. Properties: editable Title, URL segment (via rename modal), Subtitle, PageName, ShowInMenu, PageType. Versions: full history with Save / Publish draft / Unpublish / schedule window / restore.
-
Version numbers. Whole integers on released versions, decimal fractions on drafts,
Paststatus tag for expired-window versions, and the edit page's status badge is reconciled with the actual publish state (drafts show as drafts even when a live version exists). Publish window can be pre-filled but the Publish button ignores it and goes live now. - Pages admin header. H1 shows the current page's Title (not "Pages"). Action toolbar (New child page, Delete, View, Search) is a compact icon row stacked immediately under the title. Empty-state green "New child page" button removed in favour of the icon-row entry. Search widget hidden when the section has no children and no active query.
-
In-page Edit shortcut on published pages. Logged-in editors/admins see a floating pencil chip in the top-right of any published content page. Clicking opens
/admin/pages/{id}/editin a new tab (so the public view stays in place). Rendered only forEditor/Adminroles; invisible to end users. -
Post-delete navigation. Deleting a page from the admin now redirects to the deleted page's parent (falls back to
/admin/pagesat the root) instead of hanging on the just-deleted URL. -
Deleted pages view. Lives under
/admin/pages/deleted— the old/help/deletedwiki-flavoured route is gone.
Unified search
-
/search?q=…returns pages and content blocks in one ranked list. PostgresILIKEfor both (title + body for pages, key + rendered HTML for blocks). Results de-duplicate by URL, snippet-highlight matches with<mark>, and every card is one clickable link — no type-filter checkboxes, no bottom URL row. -
Search widget scope. The
Searchwidget accepts an optionalscopeprop that pins the search to a subtree — the KS4 split pages setscope="guidance"so their bottom-of-page search only surfaces guidance results. - Search results exclude folder-typed pages and pages with no live version, matching the menu widget so the two never diverge.
KS4 split (public /guidance)
- The 22222222-… KS4 monster page has been broken into 13 sibling pages under
/guidance, one per top-level section of the source docx spec. Titles moved into the right-hand column above content (matching the split-page comp), left column is the MoJ side-navigation, breadcrumbs anchored to the top of both columns for a clean lock-up. - Each split page ends with a
Dividerimmediately above a scopedSearchwidget so it's visually clear where the page body ends and the search hand-off begins. - Anchors on copied headings are regenerated on the way in so the auto side-nav renders (anchorize pass in the copy tool).
- Links on the
/guidancelanding page have been updated to point at the corresponding split page rather than the monster page.
Roles + system administration
-
Role-based section access grid at
/admin/system-administration/roles. One column per role, one row per admin section.Adminis locked to every section (checkboxes rendereddisabledalongside a hiddentrueinput so the server still receives the value on save). New roles can be registered with an empty grant and persist (a__registered__sentinel row keeps the role in the schema until real grants are added). -
Native
<input type="checkbox">, notgovuk-checkboxes__input. The GDS class visually-hides the input and relies on a label, which doesn't fit a compact grid — the grid uses plain natives with a light custom style.
Analytics (280389-analytics, merged into this branch)
- DfE Analytics
web_requestmiddleware and organisation enricher wired intoProgram.cs. - Custom events:
journey_funnel,evidence,validation_error,pupil-search, and arules-enginedecision-mix event that fires from the rules engine worker.
Footer content block
- The
Support and guidancesection in_Layout.cshtml— heading, intro paragraph, and the Privacy / Cookies / Accessibility / Guidance link row — is now a singleEditableContentblock keyedfooter-support-and-guidance. Editors update all four labels + the intro text from/admin/content-blockswithout a redeploy.
Database
Migrations applied on this branch (apply clean on a fresh DB and over current main):
-
PageNode+PageNodeVersiontables — the unified page tree and per-page versioned content. Filtered unique index onPathwhereDeletedDate IS NULLso a soft-deleted path can be reused by a new page (positive test inPageNodeRepositoryTests). -
Drop ContentPages / ContentPageVersions— the old tables are removed once the code that reads them is gone. -
ShowInMenucolumn onPageNodes(defaulttrue) driving the public menu / search filter. -
PageNamecolumn onPageNodesfor the short label used in menus. -
Subtitlecolumn onPageNodesfor content-page subheadings. -
MinorVersioncolumn onPageNodeVersionsfor decimal draft version numbers. - The guidance branch's
ContentIdandLastSeenPath/LastSeenAtcolumns onContentBlocksare still in play.
Route resolution order
- Real controller routes (attribute + conventional) win.
- The catch-all
PageController.Show([HttpGet("/{*path}", Order = int.MaxValue)])picks up anything unclaimed. - It resolves the path to a
PageNode. If the node is afolder, it renders a child index; otherwise it looks up the live version. - If there's a live version,
contentrenders through the widget template,wikithrough the sanitised-HTML template. - If there's no live version,
Editor/Adminroles see a draft preview of the working/latest version; everyone else gets the CMS-authored 404 page.
Testing
- Unit — page-tree services, widget-tree edit operations, region-layout mapping, version-window logic, path validator (route-collision guard), HTML sanitiser allow-list, block auto-provisioning.
-
Integration — Postgres via Testcontainers. Page-tree CRUD, publish-window resolution over time, soft-delete + path re-use, cross-version scheduling,
MoveTosibling-index maths, and content-staging bundle round-trip against the new tree. - E2E (Playwright) — page-tree admin drag-and-drop, right-click context menu, widget-editor drag-into-region, publish → view flow, unified search page, KS4 split navigation, in-page Edit shortcut.
Local dev
Unchanged from before: docker compose --project-name check-performance-data up -d from check-performance-data/. Web at http://localhost:8080/; admin at http://localhost:8080/admin. DOTNET_NUGET_SIGNATURE_VERIFICATION=false is still required for local dotnet build because of the upstream Refit signing-cert issue.
Anything that touched view code needs a docker compose --project-name check-performance-data build web --no-cache if you see stale HTML after a rebuild — the Razor compile step can hold onto a stale layer under BuildKit.