Skip to main content

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 + PageNodeVersion tables. Every page — folders, wiki pages, and the new widget-based content pages — is a row in PageNodes with a PageType (folder / wiki / content) and a Path (the URL, computed from Segment + ancestor path). Versioned content lives in PageNodeVersions with per-version publish windows (StartDate / EndDate), a Content text 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. PageController binds /{*path} with Order = 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-found page with HTTP 404 (falls back to bare NotFound() if that page is deleted, so the resolver never returns nothing).
  • Retired the old ContentPage stack. ContentPages and ContentPageVersions tables + the whole ContentPage web/service/persistence layer are gone (Remove ContentPage … commits). Old routes redirect via the page tree. GuidanceController is retired too — /guidance now resolves to a PageNode.
  • Soft delete + reorder. Deletes mark DeletedDate; deleted pages surface under /admin/pages/deleted (moved off the wiki-flavored /help/deleted route). Up/down reorder is a MoveTo tree 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 Content is a JSON array of nodes. Top-level is one or more region nodes; each region carries columns, each column an ordered list of widget nodes. Round-tripped through ContentPageJson (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 via Ganss.Xss with 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 optional scope prop that constrains results to a subtree — e.g. every KS4 split page searches within /guidance), and PageNav (renders the direct-children nav from the tree). Widgets live under Views/Shared/ContentPages/Widgets/ as Razor partials.
  • Region layouts map to the GDS grid. Regions carry a layout name (one-column, two-thirds-one-third, one-half-one-half, etc). RegionLayouts maps each to the equivalent govuk-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 editable Subtitle, a PageName (short label for menu use), a Segment (URL slug), and a boolean ShowInMenu (default true) that hides the page from public side-nav + search widgets when off. Menu / search widgets skip folder-typed pages and pages with no live version.
  • Content-block auto-provisioning. The EditableContent view component seeds a block on first render if none exists for the given key. Falling back to defaultHtml in code lets a template ship with useful default content that becomes editable in /admin/content-blocks the 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-blocks mirrors the pages tree and lists the blocks used on each page. LastSeenPath / LastSeenAt on ContentBlocks (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 as X-XSRF-TOKEN for 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, Past status 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}/edit in a new tab (so the public view stays in place). Rendered only for Editor / Admin roles; 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/pages at the root) instead of hanging on the just-deleted URL.
  • Deleted pages view. Lives under /admin/pages/deleted — the old /help/deleted wiki-flavoured route is gone.
  • /search?q=… returns pages and content blocks in one ranked list. Postgres ILIKE for 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 Search and Search results widgets limit a search to pages the author ticks in a page picker (_PageScopePicker.cshtml, fed by PageScopePickerModel), 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.js adds 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 Divider immediately above a scoped Search widget 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 /guidance landing 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. Admin is locked to every section (checkboxes rendered disabled alongside a hidden true input 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">, not govuk-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_request middleware and organisation enricher wired into Program.cs.
  • Custom events: journey_funnel, evidence, validation_error, pupil-search, and a rules-engine decision-mix event that fires from the rules engine worker.
  • The Support and guidance section in _Layout.cshtml — heading and contact prose — is an EditableContent block keyed footer-support-and-guidance-v2, which editors update from /admin/content-blocks without a redeploy. The Privacy / Cookies / Accessibility / Guidance link row is not part of that block: _Layout.cshtml renders it as static markup.

Database

Migrations applied on this branch (apply clean on a fresh DB and over current main):

  • PageNode + PageNodeVersion tables — the unified page tree and per-page versioned content. Filtered unique index on Path where DeletedDate IS NULL so a soft-deleted path can be reused by a new page (positive test in PageNodeRepositoryTests).
  • Drop ContentPages / ContentPageVersions — the old tables are removed once the code that reads them is gone.
  • ShowInMenu column on PageNodes (default true) driving the public menu / search filter.
  • PageName column on PageNodes for the short label used in menus.
  • Subtitle column on PageNodes for content-page subheadings.
  • MinorVersion column on PageNodeVersions for decimal draft version numbers.
  • The guidance branch's ContentId and LastSeenPath / LastSeenAt columns on ContentBlocks are still in play.

Route resolution order

  1. Real controller routes (attribute + conventional) win.
  2. The catch-all PageController.Show([HttpGet("/{*path}", Order = int.MaxValue)]) picks up anything unclaimed.
  3. It resolves the path to a PageNode. If the node is a folder, it renders a child index; otherwise it looks up the live version.
  4. If there's a live version, content renders through the widget template, wiki through the sanitised-HTML template.
  5. If there's no live version, Editor/Admin roles 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, MoveTo sibling-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.