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 page scope. The
SearchandSearch resultswidgets limit a search to pages the author ticks in a page picker (_PageScopePicker.cshtml, fed byPageScopePickerModel), which lists the site's pages in tree order, indented by depth. The picker posts page ids; a widget saved with the older path-based scope has its matching pages ticked, and anything stored that no longer matches a page stays in the list, ticked, so saving does not silently drop it. Above the list,wwwroot/js/cms-page-scope-filter.jsadds a progressive enhancement: a "Filter pages" box that narrows the list by page title or path as the author types, a "Show only selected pages" checkbox, and a status line (Showing X of N pages. Y selected., announced to screen readers; "No pages match your filter." when nothing matches). Ticked pages hidden by the filter are still saved, and pressing Enter in the filter does not submit the form. With JavaScript off the filter is not shown and the full list works as before. There is no server-side change. - 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 and contact prose — is anEditableContentblock keyedfooter-support-and-guidance-v2, which editors update from/admin/content-blockswithout a redeploy. The Privacy / Cookies / Accessibility / Guidance link row is not part of that block:_Layout.cshtmlrenders it as static markup.
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.