From 3c7c97c436d37e98b41d3431a3ecc5cd9ab3c5e4 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 18 May 2026 17:40:34 +0100 Subject: [PATCH 1/6] document page custom controllers --- docs/features/code-based/index.md | 6 +- docs/features/code-based/page-controllers.md | 198 +++++++++++++++++++ docs/plugin-options.md | 43 +--- scripts/generate-component-docs.js | 10 + 4 files changed, 215 insertions(+), 42 deletions(-) create mode 100644 docs/features/code-based/page-controllers.md diff --git a/docs/features/code-based/index.md b/docs/features/code-based/index.md index 2dabf6e81..435764314 100644 --- a/docs/features/code-based/index.md +++ b/docs/features/code-based/index.md @@ -4,10 +4,14 @@ Code-based features let you extend forms-engine-plugin with custom TypeScript or > Only introduce code-based customisations where there is genuine business need. Custom code becomes your team's responsibility to test, maintain and keep accessible. -## [Components](./code-based/components) +## [Custom Components](./code-based/components) Build custom form components. Components can extend `ComponentBase` for display-only purposes or `FormComponent` to handle user input with validation, state management and rendering. +## [Custom Page Controllers](./code-based/page-controllers) + +Attach bespoke server-side logic to a specific page — for example, running an auth check before render, enriching the view model with external data, or intercepting form submission. + ## [Custom Services](./code-based/custom-services) Replace the default form-loading or submission behaviour by providing your own `formsService`, `formSubmissionService` or `outputService` implementations via the plugin registration options. diff --git a/docs/features/code-based/page-controllers.md b/docs/features/code-based/page-controllers.md new file mode 100644 index 000000000..e4d57d2f0 --- /dev/null +++ b/docs/features/code-based/page-controllers.md @@ -0,0 +1,198 @@ +# Custom page controllers + +Custom page controllers let you attach bespoke server-side logic to a specific page in your form — for example, fetching data from an external service before render, running an authorisation check, intercepting form submission, or writing additional data to session state. + +Use a custom controller when you need server-side behaviour that cannot be expressed through configuration alone. If you want to avoid writing TypeScript altogether, consider instead: + +- [Page views](./page-views.md) — to override the Nunjucks template for a page +- [Page events](../configuration-based/page-events.md) — to call an API or inject data on page load or save, without writing code + +## Choosing a base class + +Two base classes are available: + +| Base class | Import | Use when | +| ------------------------ | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- | +| `QuestionPageController` | `@defra/forms-engine-plugin/controllers/QuestionPageController.js` | Your page has form components with validation and state. This covers most use cases. | +| `PageController` | `@defra/forms-engine-plugin/controllers/PageController.js` | Your page is display-only with no form submission — for example a static message page or a redirect. | + +Most custom controllers extend `QuestionPageController`. Use `PageController` only when you need a fully bespoke page with no standard question page behaviour. + +## What QuestionPageController gives you + +`QuestionPageController` has validation, state management, and routing logic built in. When you extend it, you get this behaviour for free and only need to override the parts relevant to your use case: + +- **Schema validation** — the components declared in the form definition have their Joi schemas combined automatically. On POST, the payload is validated before your handler runs. If validation fails, `context.errors` is populated and the page is re-rendered with error messages. +- **Session state** — `context.state` is pre-populated from the session cache before your handler is called. The `setState()` and `mergeState()` methods write back to the cache. +- **Conditional routing** — `getNextPath(context)` evaluates any conditions defined in the form and returns the correct path for the next page. +- **Back link** — the back link is generated automatically based on the user's navigation history. +- **Save and exit** — if `allowSaveAndExit` is `true` and the `saveAndExit` plugin option is configured, the secondary button and its handler are wired up for you. + +## Registering a custom controller + +Pass a `controllers` object to the plugin options when registering the plugin. The key is the string you will set as the `controller` property in your form definition: + +```ts +import { plugin } from '@defra/forms-engine-plugin' +import { QuestionPageController } from '@defra/forms-engine-plugin/controllers/QuestionPageController.js' + +class EligibilityCheckController extends QuestionPageController { + // ... +} + +await server.register({ + plugin, + options: { + controllers: { + EligibilityCheckController + } + // ... other options + } +}) +``` + +Then in your form definition, set `controller` to the same key: + +```json +{ + "path": "/eligibility-check", + "title": "Check your eligibility", + "controller": "EligibilityCheckController", + "components": [] +} +``` + +The engine resolves built-in controller names first (such as `"TerminalPageController"` or `"SummaryPageController"`), then falls back to your `controllers` object. If no match is found, an error is thrown at startup. + +## Examples + +### Fetching data for the view model + +Override `makeGetRouteHandler()` to fetch data before the page renders and pass it to your Nunjucks template. Call `this.getViewModel()` to build the standard model, then spread in your additional data: + +```ts +import { QuestionPageController } from '@defra/forms-engine-plugin/controllers/QuestionPageController.js' + +import type { FormContext, FormRequest, FormResponseToolkit } from '@defra/forms-engine-plugin/types' + +class SelectHoldingController extends QuestionPageController { + makeGetRouteHandler() { + return async (request: FormRequest, context: FormContext, h: FormResponseToolkit) => { + const sbi = context.state.sbi as string + + // Fetch available holdings for this SBI from an external service + const holdings = await getHoldingsForSbi(sbi) + + // Build the standard view model and add the fetched data + const viewModel = this.getViewModel(request, context) + + return h.view(this.viewName, { ...viewModel, holdings }) + } + } +} +``` + +Your Nunjucks template can then reference `{{ holdings }}`. + +### Intercepting the GET handler + +Override `makeGetRouteHandler()` to run a check before the page renders and redirect if needed. Delegate to `super` when the check passes to preserve the standard rendering behaviour: + +```ts +import { QuestionPageController } from '@defra/forms-engine-plugin/controllers/QuestionPageController.js' + +import type { FormContext, FormRequest, FormResponseToolkit } from '@defra/forms-engine-plugin/types' + +class ParcelCheckController extends QuestionPageController { + makeGetRouteHandler() { + return async (request: FormRequest, context: FormContext, h: FormResponseToolkit) => { + const isAuthorised = await checkParcelAuthorisation(request) + + if (!isAuthorised) { + return h.redirect(this.getHref('/unauthorised')) + } + + return super.makeGetRouteHandler()(request, context, h) + } + } +} +``` + +### Writing to state on POST + +Override `makePostRouteHandler()` to validate form input against an external service and store additional data in the session alongside the standard component values. + +`context.errors` is populated by the engine before your handler runs. Check it first and re-render immediately if there are component-level validation errors, then apply your own logic: + +```ts +import { QuestionPageController } from '@defra/forms-engine-plugin/controllers/QuestionPageController.js' + +import type { FormContext, FormRequestPayload, FormResponseToolkit } from '@defra/forms-engine-plugin/types' + +class HoldingLookupController extends QuestionPageController { + makePostRouteHandler() { + return async (request: FormRequestPayload, context: FormContext, h: FormResponseToolkit) => { + // Re-render with component validation errors if any + if (context.errors) { + const viewModel = this.getViewModel(request, context) + return h.view(this.viewName, viewModel) + } + + const holdingNumber = context.payload.holdingNumber as string + + // Validate the submitted value against an external service + const holding = await getHoldingDetails(holdingNumber) + + if (!holding) { + // Re-render with a custom error — the input passed component validation + // (format/required checks) but was not found in the external system + const viewModel = this.getViewModel(request, context) + viewModel.errors = [{ text: 'Holding number not recognised. Check and try again.' }] + return h.view(this.viewName, viewModel) + } + + // Save the standard component state to the session + await this.setState(request, context.state) + + // Merge additional data from the external lookup into the session + // so it is available to later pages in the journey + await this.mergeState(request, context.state, { + holdingName: holding.name, + holdingType: holding.type + }) + + return this.proceed(request, h, this.getNextPath(context)) + } + } +} +``` + +### Display-only page (no form components) + +Extend `PageController` for a page with no form submission. Override `makeGetRouteHandler()` and render using `this.viewName` and `this.viewModel`: + +```ts +import { PageController } from '@defra/forms-engine-plugin/controllers/PageController.js' + +import type { FormContext, FormRequest, FormResponseToolkit } from '@defra/forms-engine-plugin/types' + +class IneligiblePageController extends PageController { + makeGetRouteHandler() { + return async (_request: FormRequest, _context: FormContext, h: FormResponseToolkit) => { + return h.view(this.viewName, this.viewModel) + } + } +} +``` + +`this.viewModel` contains the standard page properties (title, phase banner, service URL, feedback link). Set the `view` property on the page definition to use a custom Nunjucks template — see [Page views](./page-views.md). + +## Overridable members + +| Member | Description | +| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `viewName` | The Nunjucks template rendered for this page. Defaults to `'index'`. Set `view` on the page definition to override. | +| `allowSaveAndExit` | Whether the "Save and exit" button is shown. `true` on `QuestionPageController`, `false` on `PageController`. Override as a class property to change the default. | +| `getViewModel(request, context)` | Returns the view model passed to the Nunjucks template. Override to add or modify properties synchronously. Only available on `QuestionPageController`. | +| `makeGetRouteHandler()` | Returns the async GET handler function. Override to control page load behaviour, including async data fetching. | +| `makePostRouteHandler()` | Returns the async POST handler function. Override to control form submission behaviour and write custom data to state. | diff --git a/docs/plugin-options.md b/docs/plugin-options.md index 1c6c549be..886d99708 100644 --- a/docs/plugin-options.md +++ b/docs/plugin-options.md @@ -30,48 +30,9 @@ See [our services documentation](./features/code-based/custom-services). ### Custom controllers -The `controllers` option lets you register custom page controller classes that extend the built-in `PageController`. A custom controller is tied to a page in your form definition by setting the page's `controller` property to the key you register it under. +The `controllers` option lets you register custom page controller classes. A custom controller is tied to a page in your form definition by setting the page's `controller` property to the key you register it under. -```ts -import { PageController } from '@defra/forms-engine-plugin/controllers/PageController.js' -import { type FormModel } from '@defra/forms-engine-plugin/types' -import { type Page } from '@defra/forms-model' - -class ConfirmationPageController extends PageController { - constructor(model: FormModel, pageDef: Page) { - super(model, pageDef) - } - - makeGetRouteHandler() { - return async (request, h) => { - // custom logic before rendering - return h.view(this.viewName, { ...await this.getViewModel(request) }) - } - } -} - -await server.register({ - plugin, - options: { - controllers: { - ConfirmationPageController - } - } -}) -``` - -In your form definition, set the `controller` property of any page to the same key: - -```json -{ - "path": "/confirmation", - "title": "Confirmation", - "controller": "ConfirmationPageController", - "components": [] -} -``` - -When the engine instantiates pages, it first checks for a matching built-in controller, then falls back to the `controllers` map. If no match is found the default `PageController` is used. +See [Custom page controllers](./features/code-based/page-controllers.md) for a full guide including examples and a reference of overridable members. ### Nunjucks configuration diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js index da55ea3fd..9716300b8 100644 --- a/scripts/generate-component-docs.js +++ b/scripts/generate-component-docs.js @@ -1059,6 +1059,16 @@ function generatePagesIndex() { lines.push(`- [**${label}**](./${slug}.mdx) — ${description}`) } + lines.push(``) + lines.push(`## Build your own page type`) + lines.push(``) + lines.push( + `If none of the built-in page types meet your needs, you can write a custom page controller by extending \`QuestionPageController\` (for pages with form components) or \`PageController\` (for display-only pages). Custom controllers are registered via the \`controllers\` plugin option and referenced in your form definition by name.` + ) + lines.push(``) + lines.push( + `See [Custom page controllers](../code-based/page-controllers.md) for a full guide.` + ) lines.push(``) return lines.join('\n') } From 4006bac1673a62cdddc97ba03f309af5bdae6987 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 18 May 2026 17:47:18 +0100 Subject: [PATCH 2/6] Fix PR feedback from #400 --- scripts/generate-page-previews.test.js | 105 ++++++++++++++----------- scripts/page-preview-fixtures.js | 2 +- scripts/page-preview-fixtures.test.js | 6 +- 3 files changed, 64 insertions(+), 49 deletions(-) diff --git a/scripts/generate-page-previews.test.js b/scripts/generate-page-previews.test.js index b2d551551..7e89e0ae1 100644 --- a/scripts/generate-page-previews.test.js +++ b/scripts/generate-page-previews.test.js @@ -1,5 +1,3 @@ -// @ts-nocheck - jest.mock('fs', () => ({ mkdirSync: jest.fn(), writeFileSync: jest.fn() @@ -23,61 +21,68 @@ import { import { environment } from '~/src/server/plugins/nunjucks/environment.js' +const mockRender = /** @type {jest.Mock} */ (environment.render) +const mockBuildPartialMdx = /** @type {jest.Mock} */ (buildPartialMdx) + describe('renderPage', () => { beforeEach(() => { - environment.render.mockReturnValue('

Page

') + mockRender.mockReturnValue('

Page

') }) it('calls environment.render with the template name from context.page.viewName', () => { - renderPage({ pageTitle: 'Test', page: { viewName: 'index' } }) - expect(environment.render).toHaveBeenCalledWith( + renderPage( + /** @type {any} */ ({ pageTitle: 'Test', page: { viewName: 'index' } }) + ) + expect(mockRender).toHaveBeenCalledWith( 'index.html', expect.objectContaining({ pageTitle: 'Test' }) ) }) it('overrides baseLayoutPath with preview-layout.html', () => { - renderPage({ - pageTitle: 'Test', - baseLayoutPath: 'something-else.html', - page: { viewName: 'summary' } - }) - expect(environment.render).toHaveBeenCalledWith( + renderPage( + /** @type {any} */ ({ + pageTitle: 'Test', + baseLayoutPath: 'something-else.html', + page: { viewName: 'summary' } + }) + ) + expect(mockRender).toHaveBeenCalledWith( 'summary.html', expect.objectContaining({ baseLayoutPath: 'preview-layout.html' }) ) }) it('returns the rendered HTML string', () => { - environment.render.mockReturnValue('

Check your answers

') - const result = renderPage({ - pageTitle: 'Check your answers', - page: { viewName: 'summary' } - }) + mockRender.mockReturnValue('

Check your answers

') + const result = renderPage( + /** @type {any} */ ({ + pageTitle: 'Check your answers', + page: { viewName: 'summary' } + }) + ) expect(result).toBe('

Check your answers

') }) it('replaces all href values with # to neutralise links', () => { - environment.render.mockReturnValue( + mockRender.mockReturnValue( 'ChangeRemove' ) - const result = renderPage({ - pageTitle: 'Test', - page: { viewName: 'index' } - }) + const result = renderPage( + /** @type {any} */ ({ pageTitle: 'Test', page: { viewName: 'index' } }) + ) expect(result).not.toContain('href="/some/path"') expect(result).not.toContain('href="/another?q=1"') expect(result).toContain('href="#"') }) it('replaces
tags with divs to prevent form submission', () => { - environment.render.mockReturnValue( + mockRender.mockReturnValue( '
' ) - const result = renderPage({ - pageTitle: 'Test', - page: { viewName: 'index' } - }) + const result = renderPage( + /** @type {any} */ ({ pageTitle: 'Test', page: { viewName: 'index' } }) + ) expect(result).not.toContain('') expect(result).toContain('
') @@ -87,13 +92,16 @@ describe('renderPage', () => { describe('writePagePreviewPartial', () => { beforeEach(() => { - environment.render.mockReturnValue('
page html
') - buildPartialMdx.mockReturnValue('') + mockRender.mockReturnValue('
page html
') + mockBuildPartialMdx.mockReturnValue('') }) it('creates the output directory', () => { writePagePreviewPartial('/out/_previews', 'page-controller', { - context: { pageTitle: 'Question', page: { viewName: 'index' } } + context: /** @type {any} */ ({ + pageTitle: 'Question', + page: { viewName: 'index' } + }) }) expect(mkdirSync).toHaveBeenCalledWith('/out/_previews', { recursive: true @@ -102,10 +110,10 @@ describe('writePagePreviewPartial', () => { it('writes MDX to the correct path', () => { writePagePreviewPartial('/out/_previews', 'summary-page-controller', { - context: { + context: /** @type {any} */ ({ pageTitle: 'Check your answers', page: { viewName: 'summary' } - } + }) }) expect(writeFileSync).toHaveBeenCalledWith( '/out/_previews/summary-page-controller.mdx', @@ -115,9 +123,12 @@ describe('writePagePreviewPartial', () => { it('passes a single unlabelled render to buildPartialMdx for a flat fixture', () => { writePagePreviewPartial('/out/_previews', 'page-controller', { - context: { pageTitle: 'Question', page: { viewName: 'index' } } + context: /** @type {any} */ ({ + pageTitle: 'Question', + page: { viewName: 'index' } + }) }) - expect(buildPartialMdx).toHaveBeenCalledWith( + expect(mockBuildPartialMdx).toHaveBeenCalledWith( [{ html: '
page html
' }], 'component-preview component-preview--page' ) @@ -128,15 +139,21 @@ describe('writePagePreviewPartial', () => { variants: [ { label: 'No files uploaded', - context: { pageTitle: 'Upload', page: { viewName: 'file-upload' } } + context: /** @type {any} */ ({ + pageTitle: 'Upload', + page: { viewName: 'file-upload' } + }) }, { label: 'With files uploaded', - context: { pageTitle: 'Upload', page: { viewName: 'file-upload' } } + context: /** @type {any} */ ({ + pageTitle: 'Upload', + page: { viewName: 'file-upload' } + }) } ] }) - expect(buildPartialMdx).toHaveBeenCalledWith( + expect(mockBuildPartialMdx).toHaveBeenCalledWith( [ { label: 'No files uploaded', html: '
page html
' }, { label: 'With files uploaded', html: '
page html
' } @@ -146,7 +163,7 @@ describe('writePagePreviewPartial', () => { }) it('renders each variant context independently', () => { - environment.render + mockRender .mockReturnValueOnce('
empty
') .mockReturnValueOnce('
with files
') @@ -154,30 +171,30 @@ describe('writePagePreviewPartial', () => { variants: [ { label: 'No files uploaded', - context: { + context: /** @type {any} */ ({ pageTitle: 'Upload', formAction: null, page: { viewName: 'file-upload' } - } + }) }, { label: 'With files uploaded', - context: { + context: /** @type {any} */ ({ pageTitle: 'Upload', formAction: 'preview', page: { viewName: 'file-upload' } - } + }) } ] }) - expect(environment.render).toHaveBeenCalledTimes(2) - expect(environment.render).toHaveBeenNthCalledWith( + expect(mockRender).toHaveBeenCalledTimes(2) + expect(mockRender).toHaveBeenNthCalledWith( 1, 'file-upload.html', expect.objectContaining({ formAction: null }) ) - expect(environment.render).toHaveBeenNthCalledWith( + expect(mockRender).toHaveBeenNthCalledWith( 2, 'file-upload.html', expect.objectContaining({ formAction: 'preview' }) diff --git a/scripts/page-preview-fixtures.js b/scripts/page-preview-fixtures.js index 12b91e8b1..e4810c22c 100644 --- a/scripts/page-preview-fixtures.js +++ b/scripts/page-preview-fixtures.js @@ -8,7 +8,7 @@ const SUMMARY_PAGE_DEF = /** @type {import('@defra/forms-model').PageSummary} */ ({ path: '/summary', controller: ControllerType.Summary, - title: 'Check your answers', + title: 'Check your answers before sending your form', components: [] }) diff --git a/scripts/page-preview-fixtures.test.js b/scripts/page-preview-fixtures.test.js index e2517bc45..c6f29cde8 100644 --- a/scripts/page-preview-fixtures.test.js +++ b/scripts/page-preview-fixtures.test.js @@ -1,5 +1,3 @@ -// @ts-nocheck - import { pageFixtures } from './page-preview-fixtures.js' describe('page-preview-fixtures', () => { @@ -27,7 +25,7 @@ describe('page-preview-fixtures', () => { for (const [_key, fixture] of Object.entries(pageFixtures)) { const contexts = fixture.variants ? fixture.variants.map((v) => v.context) - : [fixture.context] + : [/** @type {NonNullable} */ (fixture.context)] for (const context of contexts) { expect(typeof context.page?.viewName).toBe('string') } @@ -40,7 +38,7 @@ describe('page-preview-fixtures', () => { ) expect(variantFixtures.length).toBeGreaterThan(0) for (const [, fixture] of variantFixtures) { - for (const variant of fixture.variants) { + for (const variant of fixture.variants ?? []) { expect(typeof variant.label).toBe('string') expect(variant.context).toBeDefined() } From 6aedb2629ab0dc9e72364516beedba3b78558b49 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 18 May 2026 18:06:23 +0100 Subject: [PATCH 3/6] document page controllers more --- docs/features/code-based/page-controllers.md | 66 ++++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/docs/features/code-based/page-controllers.md b/docs/features/code-based/page-controllers.md index e4d57d2f0..9e9410993 100644 --- a/docs/features/code-based/page-controllers.md +++ b/docs/features/code-based/page-controllers.md @@ -16,9 +16,7 @@ Two base classes are available: | `QuestionPageController` | `@defra/forms-engine-plugin/controllers/QuestionPageController.js` | Your page has form components with validation and state. This covers most use cases. | | `PageController` | `@defra/forms-engine-plugin/controllers/PageController.js` | Your page is display-only with no form submission — for example a static message page or a redirect. | -Most custom controllers extend `QuestionPageController`. Use `PageController` only when you need a fully bespoke page with no standard question page behaviour. - -## What QuestionPageController gives you +### What QuestionPageController gives you `QuestionPageController` has validation, state management, and routing logic built in. When you extend it, you get this behaviour for free and only need to override the parts relevant to your use case: @@ -28,6 +26,16 @@ Most custom controllers extend `QuestionPageController`. Use `PageController` on - **Back link** — the back link is generated automatically based on the user's navigation history. - **Save and exit** — if `allowSaveAndExit` is `true` and the `saveAndExit` plugin option is configured, the secondary button and its handler are wired up for you. +## Overridable members + +| Member | Description | +| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `viewName` | The Nunjucks template rendered for this page. Defaults to `'index'`. Set `view` on the page definition to override. | +| `allowSaveAndExit` | Whether the "Save and exit" button is shown. `true` on `QuestionPageController`, `false` on `PageController`. Override as a class property to change the default. | +| `getViewModel(request, context)` | Returns the view model passed to the Nunjucks template. Override to add or modify properties synchronously. Only available on `QuestionPageController`. | +| `makeGetRouteHandler()` | Returns the async GET handler function. Override to control page load behaviour, including async data fetching. | +| `makePostRouteHandler()` | Returns the async POST handler function. Override to control form submission behaviour and write custom data to state. | + ## Registering a custom controller Pass a `controllers` object to the plugin options when registering the plugin. The key is the string you will set as the `controller` property in your form definition: @@ -62,7 +70,7 @@ Then in your form definition, set `controller` to the same key: } ``` -The engine resolves built-in controller names first (such as `"TerminalPageController"` or `"SummaryPageController"`), then falls back to your `controllers` object. If no match is found, an error is thrown at startup. +The engine resolves built-in controller names first (such as `"TerminalPageController"` or `"SummaryPageController"`), then falls back to your `controllers` object. If no match is found, an error is thrown when the form is loaded. ## Examples @@ -75,24 +83,26 @@ import { QuestionPageController } from '@defra/forms-engine-plugin/controllers/Q import type { FormContext, FormRequest, FormResponseToolkit } from '@defra/forms-engine-plugin/types' -class SelectHoldingController extends QuestionPageController { +class SelectGrantSchemeController extends QuestionPageController { makeGetRouteHandler() { return async (request: FormRequest, context: FormContext, h: FormResponseToolkit) => { - const sbi = context.state.sbi as string + const farmType = context.state.farmType - // Fetch available holdings for this SBI from an external service - const holdings = await getHoldingsForSbi(sbi) + // Fetch grant schemes available for the user's farm type + const grantSchemes = await getEligibleGrantSchemes(farmType) // Build the standard view model and add the fetched data const viewModel = this.getViewModel(request, context) - return h.view(this.viewName, { ...viewModel, holdings }) + return h.view(this.viewName, { ...viewModel, grantSchemes }) } } } ``` -Your Nunjucks template can then reference `{{ holdings }}`. +Your Nunjucks template can then reference `{{ grantSchemes }}`. + +> **Note:** When you return directly from `makeGetRouteHandler()` without delegating to `super`, you own the full render. Standard GET behaviour — conditional component filtering, flash error handling, and URL pre-population — will not run. If your page relies on any of these, either delegate to `super.makeGetRouteHandler()(request, context, h)` and use a synchronous `getViewModel()` override instead, or replicate the behaviour you need in your handler. ### Intercepting the GET handler @@ -103,13 +113,13 @@ import { QuestionPageController } from '@defra/forms-engine-plugin/controllers/Q import type { FormContext, FormRequest, FormResponseToolkit } from '@defra/forms-engine-plugin/types' -class ParcelCheckController extends QuestionPageController { +class GrantEligibilityController extends QuestionPageController { makeGetRouteHandler() { return async (request: FormRequest, context: FormContext, h: FormResponseToolkit) => { - const isAuthorised = await checkParcelAuthorisation(request) + const isEligible = await checkGrantEligibility(request) - if (!isAuthorised) { - return h.redirect(this.getHref('/unauthorised')) + if (!isEligible) { + return h.redirect(this.getHref('/not-eligible')) } return super.makeGetRouteHandler()(request, context, h) @@ -129,7 +139,7 @@ import { QuestionPageController } from '@defra/forms-engine-plugin/controllers/Q import type { FormContext, FormRequestPayload, FormResponseToolkit } from '@defra/forms-engine-plugin/types' -class HoldingLookupController extends QuestionPageController { +class PassportLookupController extends QuestionPageController { makePostRouteHandler() { return async (request: FormRequestPayload, context: FormContext, h: FormResponseToolkit) => { // Re-render with component validation errors if any @@ -138,27 +148,27 @@ class HoldingLookupController extends QuestionPageController { return h.view(this.viewName, viewModel) } - const holdingNumber = context.payload.holdingNumber as string + const passportNumber = context.payload.passportNumber - // Validate the submitted value against an external service - const holding = await getHoldingDetails(holdingNumber) + // Validate the submitted passport number against an identity service + const passport = await verifyPassport(passportNumber) - if (!holding) { + if (!passport) { // Re-render with a custom error — the input passed component validation // (format/required checks) but was not found in the external system const viewModel = this.getViewModel(request, context) - viewModel.errors = [{ text: 'Holding number not recognised. Check and try again.' }] + viewModel.errors = [{ text: 'Passport number not recognised. Check and try again.' }] return h.view(this.viewName, viewModel) } // Save the standard component state to the session await this.setState(request, context.state) - // Merge additional data from the external lookup into the session + // Merge additional data from the identity lookup into the session // so it is available to later pages in the journey await this.mergeState(request, context.state, { - holdingName: holding.name, - holdingType: holding.type + verifiedName: passport.fullName, + nationality: passport.nationality }) return this.proceed(request, h, this.getNextPath(context)) @@ -186,13 +196,3 @@ class IneligiblePageController extends PageController { ``` `this.viewModel` contains the standard page properties (title, phase banner, service URL, feedback link). Set the `view` property on the page definition to use a custom Nunjucks template — see [Page views](./page-views.md). - -## Overridable members - -| Member | Description | -| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `viewName` | The Nunjucks template rendered for this page. Defaults to `'index'`. Set `view` on the page definition to override. | -| `allowSaveAndExit` | Whether the "Save and exit" button is shown. `true` on `QuestionPageController`, `false` on `PageController`. Override as a class property to change the default. | -| `getViewModel(request, context)` | Returns the view model passed to the Nunjucks template. Override to add or modify properties synchronously. Only available on `QuestionPageController`. | -| `makeGetRouteHandler()` | Returns the async GET handler function. Override to control page load behaviour, including async data fetching. | -| `makePostRouteHandler()` | Returns the async POST handler function. Override to control form submission behaviour and write custom data to state. | From 31d4d45ce198745aafa111cfff569dc214c365d0 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 18 May 2026 18:15:17 +0100 Subject: [PATCH 4/6] reorder --- docs/features/code-based/page-controllers.md | 90 ++++++++++++-------- 1 file changed, 53 insertions(+), 37 deletions(-) diff --git a/docs/features/code-based/page-controllers.md b/docs/features/code-based/page-controllers.md index 9e9410993..01e9fa256 100644 --- a/docs/features/code-based/page-controllers.md +++ b/docs/features/code-based/page-controllers.md @@ -2,51 +2,31 @@ Custom page controllers let you attach bespoke server-side logic to a specific page in your form — for example, fetching data from an external service before render, running an authorisation check, intercepting form submission, or writing additional data to session state. -Use a custom controller when you need server-side behaviour that cannot be expressed through configuration alone. If you want to avoid writing TypeScript altogether, consider instead: +Use a custom controller when you need server-side behaviour that cannot be expressed through configuration alone. If you want to avoid writing TypeScript altogether, explore the [configuration-based options](../configuration-based/index.md) first. -- [Page views](./page-views.md) — to override the Nunjucks template for a page -- [Page events](../configuration-based/page-events.md) — to call an API or inject data on page load or save, without writing code +## How it works -## Choosing a base class - -Two base classes are available: - -| Base class | Import | Use when | -| ------------------------ | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- | -| `QuestionPageController` | `@defra/forms-engine-plugin/controllers/QuestionPageController.js` | Your page has form components with validation and state. This covers most use cases. | -| `PageController` | `@defra/forms-engine-plugin/controllers/PageController.js` | Your page is display-only with no form submission — for example a static message page or a redirect. | - -### What QuestionPageController gives you +Extend one of the built-in base classes, register it with the plugin, then reference it by name in your form definition. -`QuestionPageController` has validation, state management, and routing logic built in. When you extend it, you get this behaviour for free and only need to override the parts relevant to your use case: - -- **Schema validation** — the components declared in the form definition have their Joi schemas combined automatically. On POST, the payload is validated before your handler runs. If validation fails, `context.errors` is populated and the page is re-rendered with error messages. -- **Session state** — `context.state` is pre-populated from the session cache before your handler is called. The `setState()` and `mergeState()` methods write back to the cache. -- **Conditional routing** — `getNextPath(context)` evaluates any conditions defined in the form and returns the correct path for the next page. -- **Back link** — the back link is generated automatically based on the user's navigation history. -- **Save and exit** — if `allowSaveAndExit` is `true` and the `saveAndExit` plugin option is configured, the secondary button and its handler are wired up for you. - -## Overridable members - -| Member | Description | -| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `viewName` | The Nunjucks template rendered for this page. Defaults to `'index'`. Set `view` on the page definition to override. | -| `allowSaveAndExit` | Whether the "Save and exit" button is shown. `true` on `QuestionPageController`, `false` on `PageController`. Override as a class property to change the default. | -| `getViewModel(request, context)` | Returns the view model passed to the Nunjucks template. Override to add or modify properties synchronously. Only available on `QuestionPageController`. | -| `makeGetRouteHandler()` | Returns the async GET handler function. Override to control page load behaviour, including async data fetching. | -| `makePostRouteHandler()` | Returns the async POST handler function. Override to control form submission behaviour and write custom data to state. | - -## Registering a custom controller - -Pass a `controllers` object to the plugin options when registering the plugin. The key is the string you will set as the `controller` property in your form definition: +**1. Create a controller class:** ```ts -import { plugin } from '@defra/forms-engine-plugin' import { QuestionPageController } from '@defra/forms-engine-plugin/controllers/QuestionPageController.js' class EligibilityCheckController extends QuestionPageController { - // ... + makeGetRouteHandler() { + return async (request, context, h) => { + // your logic here + return super.makeGetRouteHandler()(request, context, h) + } + } } +``` + +**2. Register it with the plugin:** + +```ts +import { plugin } from '@defra/forms-engine-plugin' await server.register({ plugin, @@ -59,7 +39,7 @@ await server.register({ }) ``` -Then in your form definition, set `controller` to the same key: +**3. Reference it in your form definition:** ```json { @@ -72,8 +52,22 @@ Then in your form definition, set `controller` to the same key: The engine resolves built-in controller names first (such as `"TerminalPageController"` or `"SummaryPageController"`), then falls back to your `controllers` object. If no match is found, an error is thrown when the form is loaded. +## Choosing a base class + +| Base class | Use when | +| ------------------------ | ---------------------------------------------------------------------------------------------------- | +| `QuestionPageController` | Your page has form components with validation and state. This covers most use cases. | +| `PageController` | Your page is display-only with no form submission — for example a static message page or a redirect. | + +Both are imported from `@defra/forms-engine-plugin/controllers/.js`. + ## Examples +- [Fetching data for the view model](#fetching-data-for-the-view-model) +- [Intercepting the GET handler](#intercepting-the-get-handler) +- [Writing to state on POST](#writing-to-state-on-post) +- [Display-only page (no form components)](#display-only-page-no-form-components) + ### Fetching data for the view model Override `makeGetRouteHandler()` to fetch data before the page renders and pass it to your Nunjucks template. Call `this.getViewModel()` to build the standard model, then spread in your additional data: @@ -196,3 +190,25 @@ class IneligiblePageController extends PageController { ``` `this.viewModel` contains the standard page properties (title, phase banner, service URL, feedback link). Set the `view` property on the page definition to use a custom Nunjucks template — see [Page views](./page-views.md). + +## Reference + +### What QuestionPageController gives you + +`QuestionPageController` has validation, state management, and routing logic built in. When you extend it, you get this behaviour for free and only need to override the parts relevant to your use case: + +- **Schema validation** — the components declared in the form definition have their Joi schemas combined automatically. On POST, the payload is validated before your handler runs. If validation fails, `context.errors` is populated and the page is re-rendered with error messages. +- **Session state** — `context.state` is pre-populated from the session cache before your handler is called. The `setState()` and `mergeState()` methods write back to the cache. +- **Conditional routing** — `getNextPath(context)` evaluates any conditions defined in the form and returns the correct path for the next page. +- **Back link** — the back link is generated automatically based on the user's navigation history. +- **Save and exit** — if `allowSaveAndExit` is `true` and the `saveAndExit` plugin option is configured, the secondary button and its handler are wired up for you. + +### Overridable members + +| Member | Description | +| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `viewName` | The Nunjucks template rendered for this page. Defaults to `'index'`. Set `view` on the page definition to override. | +| `allowSaveAndExit` | Whether the "Save and exit" button is shown. `true` on `QuestionPageController`, `false` on `PageController`. Override as a class property to change the default. | +| `getViewModel(request, context)` | Returns the view model passed to the Nunjucks template. Override to add or modify properties synchronously. Only available on `QuestionPageController`. | +| `makeGetRouteHandler()` | Returns the async GET handler function. Override to control page load behaviour, including async data fetching. | +| `makePostRouteHandler()` | Returns the async POST handler function. Override to control form submission behaviour and write custom data to state. | From e0eaeb1443a34a239f382f111486ae5531697267 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 18 May 2026 18:15:17 +0100 Subject: [PATCH 5/6] reorder --- docs/features/code-based/page-controllers.md | 92 ++++++++++++-------- 1 file changed, 55 insertions(+), 37 deletions(-) diff --git a/docs/features/code-based/page-controllers.md b/docs/features/code-based/page-controllers.md index 9e9410993..7378c378e 100644 --- a/docs/features/code-based/page-controllers.md +++ b/docs/features/code-based/page-controllers.md @@ -2,51 +2,31 @@ Custom page controllers let you attach bespoke server-side logic to a specific page in your form — for example, fetching data from an external service before render, running an authorisation check, intercepting form submission, or writing additional data to session state. -Use a custom controller when you need server-side behaviour that cannot be expressed through configuration alone. If you want to avoid writing TypeScript altogether, consider instead: +Use a custom controller when you need server-side behaviour that cannot be expressed through configuration alone. If you want to avoid writing TypeScript altogether, explore the [configuration-based options](../configuration-based/index.md) first. -- [Page views](./page-views.md) — to override the Nunjucks template for a page -- [Page events](../configuration-based/page-events.md) — to call an API or inject data on page load or save, without writing code +## How it works -## Choosing a base class - -Two base classes are available: - -| Base class | Import | Use when | -| ------------------------ | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- | -| `QuestionPageController` | `@defra/forms-engine-plugin/controllers/QuestionPageController.js` | Your page has form components with validation and state. This covers most use cases. | -| `PageController` | `@defra/forms-engine-plugin/controllers/PageController.js` | Your page is display-only with no form submission — for example a static message page or a redirect. | - -### What QuestionPageController gives you - -`QuestionPageController` has validation, state management, and routing logic built in. When you extend it, you get this behaviour for free and only need to override the parts relevant to your use case: - -- **Schema validation** — the components declared in the form definition have their Joi schemas combined automatically. On POST, the payload is validated before your handler runs. If validation fails, `context.errors` is populated and the page is re-rendered with error messages. -- **Session state** — `context.state` is pre-populated from the session cache before your handler is called. The `setState()` and `mergeState()` methods write back to the cache. -- **Conditional routing** — `getNextPath(context)` evaluates any conditions defined in the form and returns the correct path for the next page. -- **Back link** — the back link is generated automatically based on the user's navigation history. -- **Save and exit** — if `allowSaveAndExit` is `true` and the `saveAndExit` plugin option is configured, the secondary button and its handler are wired up for you. - -## Overridable members - -| Member | Description | -| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `viewName` | The Nunjucks template rendered for this page. Defaults to `'index'`. Set `view` on the page definition to override. | -| `allowSaveAndExit` | Whether the "Save and exit" button is shown. `true` on `QuestionPageController`, `false` on `PageController`. Override as a class property to change the default. | -| `getViewModel(request, context)` | Returns the view model passed to the Nunjucks template. Override to add or modify properties synchronously. Only available on `QuestionPageController`. | -| `makeGetRouteHandler()` | Returns the async GET handler function. Override to control page load behaviour, including async data fetching. | -| `makePostRouteHandler()` | Returns the async POST handler function. Override to control form submission behaviour and write custom data to state. | - -## Registering a custom controller +Extend one of the built-in base classes, register it with the plugin, then reference it by name in your form definition. -Pass a `controllers` object to the plugin options when registering the plugin. The key is the string you will set as the `controller` property in your form definition: +**1. Create a controller class:** ```ts -import { plugin } from '@defra/forms-engine-plugin' import { QuestionPageController } from '@defra/forms-engine-plugin/controllers/QuestionPageController.js' class EligibilityCheckController extends QuestionPageController { - // ... + makeGetRouteHandler() { + return async (request, context, h) => { + // your logic here + return super.makeGetRouteHandler()(request, context, h) + } + } } +``` + +**2. Register it with the plugin:** + +```ts +import { plugin } from '@defra/forms-engine-plugin' await server.register({ plugin, @@ -59,7 +39,7 @@ await server.register({ }) ``` -Then in your form definition, set `controller` to the same key: +**3. Reference it in your form definition:** ```json { @@ -72,8 +52,24 @@ Then in your form definition, set `controller` to the same key: The engine resolves built-in controller names first (such as `"TerminalPageController"` or `"SummaryPageController"`), then falls back to your `controllers` object. If no match is found, an error is thrown when the form is loaded. +## Choosing a base class + +| Base class | Use when | +| ------------------------ | ---------------------------------------------------------------------------------------------------- | +| `QuestionPageController` | Your page has form components with validation and state. This covers most use cases. | +| `PageController` | Your page is display-only with no form submission — for example a static message page or a redirect. | + +Both are imported from `@defra/forms-engine-plugin/controllers/.js`. + ## Examples +- [Fetching data for the view model](#fetching-data-for-the-view-model) +- [Intercepting the GET handler](#intercepting-the-get-handler) +- [Writing to state on POST](#writing-to-state-on-post) +- [Display-only page (no form components)](#display-only-page-no-form-components) + +> **Note:** Examples that call `h.view()` require Nunjucks to be configured with the correct template paths. See [plugin options](../../plugin-options.md). + ### Fetching data for the view model Override `makeGetRouteHandler()` to fetch data before the page renders and pass it to your Nunjucks template. Call `this.getViewModel()` to build the standard model, then spread in your additional data: @@ -196,3 +192,25 @@ class IneligiblePageController extends PageController { ``` `this.viewModel` contains the standard page properties (title, phase banner, service URL, feedback link). Set the `view` property on the page definition to use a custom Nunjucks template — see [Page views](./page-views.md). + +## Reference + +### What QuestionPageController gives you + +`QuestionPageController` has validation, state management, and routing logic built in. When you extend it, you get this behaviour for free and only need to override the parts relevant to your use case: + +- **Schema validation** — the components declared in the form definition have their Joi schemas combined automatically. On POST, the payload is validated before your handler runs. If validation fails, `context.errors` is populated and the page is re-rendered with error messages. +- **Session state** — `context.state` is pre-populated from the session cache before your handler is called. The `setState()` and `mergeState()` methods write back to the cache. +- **Conditional routing** — `getNextPath(context)` evaluates any conditions defined in the form and returns the correct path for the next page. +- **Back link** — the back link is generated automatically based on the user's navigation history. +- **Save and exit** — if `allowSaveAndExit` is `true` and the `saveAndExit` plugin option is configured, the secondary button and its handler are wired up for you. + +### Overridable members + +| Member | Description | +| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `viewName` | The Nunjucks template rendered for this page. Defaults to `'index'`. Set `view` on the page definition to override. | +| `allowSaveAndExit` | Whether the "Save and exit" button is shown. `true` on `QuestionPageController`, `false` on `PageController`. Override as a class property to change the default. | +| `getViewModel(request, context)` | Returns the view model passed to the Nunjucks template. Override to add or modify properties synchronously. Only available on `QuestionPageController`. | +| `makeGetRouteHandler()` | Returns the async GET handler function. Override to control page load behaviour, including async data fetching. | +| `makePostRouteHandler()` | Returns the async POST handler function. Override to control form submission behaviour and write custom data to state. | From 8e19a43faf48e9e25b28abf5d97892462d365472 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 18 May 2026 18:19:39 +0100 Subject: [PATCH 6/6] grammar update --- docs/features/code-based/index.md | 2 +- docs/features/code-based/page-controllers.md | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/features/code-based/index.md b/docs/features/code-based/index.md index 435764314..1a732fdf5 100644 --- a/docs/features/code-based/index.md +++ b/docs/features/code-based/index.md @@ -10,7 +10,7 @@ Build custom form components. Components can extend `ComponentBase` for display- ## [Custom Page Controllers](./code-based/page-controllers) -Attach bespoke server-side logic to a specific page — for example, running an auth check before render, enriching the view model with external data, or intercepting form submission. +Attach bespoke server-side logic to a specific page. For example: running an auth check before render, enriching the view model with external data, or intercepting form submission. ## [Custom Services](./code-based/custom-services) diff --git a/docs/features/code-based/page-controllers.md b/docs/features/code-based/page-controllers.md index 7378c378e..ddc923ee4 100644 --- a/docs/features/code-based/page-controllers.md +++ b/docs/features/code-based/page-controllers.md @@ -1,6 +1,6 @@ # Custom page controllers -Custom page controllers let you attach bespoke server-side logic to a specific page in your form — for example, fetching data from an external service before render, running an authorisation check, intercepting form submission, or writing additional data to session state. +Custom page controllers let you attach bespoke server-side logic to a specific page in your form. For example, fetching data from an external service before render, running an authorisation check, intercepting form submission, or writing additional data to session state. Use a custom controller when you need server-side behaviour that cannot be expressed through configuration alone. If you want to avoid writing TypeScript altogether, explore the [configuration-based options](../configuration-based/index.md) first. @@ -63,10 +63,17 @@ Both are imported from `@defra/forms-engine-plugin/controllers/.js`. ## Examples -- [Fetching data for the view model](#fetching-data-for-the-view-model) -- [Intercepting the GET handler](#intercepting-the-get-handler) -- [Writing to state on POST](#writing-to-state-on-post) -- [Display-only page (no form components)](#display-only-page-no-form-components) +- [Custom page controllers](#custom-page-controllers) + - [How it works](#how-it-works) + - [Choosing a base class](#choosing-a-base-class) + - [Examples](#examples) + - [Fetching data for the view model](#fetching-data-for-the-view-model) + - [Intercepting the GET handler](#intercepting-the-get-handler) + - [Writing to state on POST](#writing-to-state-on-post) + - [Display-only page (no form components)](#display-only-page-no-form-components) + - [Reference](#reference) + - [What QuestionPageController gives you](#what-questionpagecontroller-gives-you) + - [Overridable members](#overridable-members) > **Note:** Examples that call `h.view()` require Nunjucks to be configured with the correct template paths. See [plugin options](../../plugin-options.md).