Skip to content

Commit 39d92d1

Browse files
authored
Merge pull request #406 from DEFRA/docs/page-controllers
Docs: custom page controllers
2 parents 4d33838 + f688be6 commit 39d92d1

7 files changed

Lines changed: 295 additions & 91 deletions

File tree

docs/features/code-based/index.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ Code-based features let you extend forms-engine-plugin with custom TypeScript or
44

55
> Only introduce code-based customisations where there is genuine business need. Custom code becomes your team's responsibility to test, maintain and keep accessible.
66
7-
## [Components](./code-based/components)
7+
## [Custom Components](./code-based/components)
88

99
Build custom form components. Components can extend `ComponentBase` for display-only purposes or `FormComponent` to handle user input with validation, state management and rendering.
1010

11+
## [Custom Page Controllers](./code-based/page-controllers)
12+
13+
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.
14+
1115
## [Custom Services](./code-based/custom-services)
1216

1317
Replace the default form-loading or submission behaviour by providing your own `formsService`, `formSubmissionService` or `outputService` implementations via the plugin registration options.
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
# Custom page controllers
2+
3+
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.
4+
5+
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.
6+
7+
## How it works
8+
9+
Extend one of the built-in base classes, register it with the plugin, then reference it by name in your form definition.
10+
11+
**1. Create a controller class:**
12+
13+
```ts
14+
import { QuestionPageController } from '@defra/forms-engine-plugin/controllers/QuestionPageController.js'
15+
16+
class EligibilityCheckController extends QuestionPageController {
17+
makeGetRouteHandler() {
18+
return async (request, context, h) => {
19+
// your logic here
20+
return super.makeGetRouteHandler()(request, context, h)
21+
}
22+
}
23+
}
24+
```
25+
26+
**2. Register it with the plugin:**
27+
28+
```ts
29+
import { plugin } from '@defra/forms-engine-plugin'
30+
31+
await server.register({
32+
plugin,
33+
options: {
34+
controllers: {
35+
EligibilityCheckController
36+
}
37+
// ... other options
38+
}
39+
})
40+
```
41+
42+
**3. Reference it in your form definition:**
43+
44+
```json
45+
{
46+
"path": "/eligibility-check",
47+
"title": "Check your eligibility",
48+
"controller": "EligibilityCheckController",
49+
"components": []
50+
}
51+
```
52+
53+
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.
54+
55+
## Choosing a base class
56+
57+
| Base class | Use when |
58+
| ------------------------ | ---------------------------------------------------------------------------------------------------- |
59+
| `QuestionPageController` | Your page has form components with validation and state. This covers most use cases. |
60+
| `PageController` | Your page is display-only with no form submission — for example a static message page or a redirect. |
61+
62+
Both are imported from `@defra/forms-engine-plugin/controllers/<ClassName>.js`.
63+
64+
## Examples
65+
66+
- [Fetching data for the view model](#fetching-data-for-the-view-model)
67+
- [Intercepting the GET handler](#intercepting-the-get-handler)
68+
- [Writing to state on POST](#writing-to-state-on-post)
69+
- [Display-only page (no form components)](#display-only-page-no-form-components)
70+
71+
### Fetching data for the view model
72+
73+
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:
74+
75+
```ts
76+
import { QuestionPageController } from '@defra/forms-engine-plugin/controllers/QuestionPageController.js'
77+
78+
import type { FormContext, FormRequest, FormResponseToolkit } from '@defra/forms-engine-plugin/types'
79+
80+
class SelectGrantSchemeController extends QuestionPageController {
81+
makeGetRouteHandler() {
82+
return async (request: FormRequest, context: FormContext, h: FormResponseToolkit) => {
83+
const farmType = context.state.farmType
84+
85+
// Fetch grant schemes available for the user's farm type
86+
const grantSchemes = await getEligibleGrantSchemes(farmType)
87+
88+
// Build the standard view model and add the fetched data
89+
const viewModel = this.getViewModel(request, context)
90+
91+
return h.view(this.viewName, { ...viewModel, grantSchemes })
92+
}
93+
}
94+
}
95+
```
96+
97+
Your Nunjucks template can then reference `{{ grantSchemes }}`.
98+
99+
> **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.
100+
101+
### Intercepting the GET handler
102+
103+
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:
104+
105+
```ts
106+
import { QuestionPageController } from '@defra/forms-engine-plugin/controllers/QuestionPageController.js'
107+
108+
import type { FormContext, FormRequest, FormResponseToolkit } from '@defra/forms-engine-plugin/types'
109+
110+
class GrantEligibilityController extends QuestionPageController {
111+
makeGetRouteHandler() {
112+
return async (request: FormRequest, context: FormContext, h: FormResponseToolkit) => {
113+
const isEligible = await checkGrantEligibility(request)
114+
115+
if (!isEligible) {
116+
return h.redirect(this.getHref('/not-eligible'))
117+
}
118+
119+
return super.makeGetRouteHandler()(request, context, h)
120+
}
121+
}
122+
}
123+
```
124+
125+
### Writing to state on POST
126+
127+
Override `makePostRouteHandler()` to validate form input against an external service and store additional data in the session alongside the standard component values.
128+
129+
`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:
130+
131+
```ts
132+
import { QuestionPageController } from '@defra/forms-engine-plugin/controllers/QuestionPageController.js'
133+
134+
import type { FormContext, FormRequestPayload, FormResponseToolkit } from '@defra/forms-engine-plugin/types'
135+
136+
class PassportLookupController extends QuestionPageController {
137+
makePostRouteHandler() {
138+
return async (request: FormRequestPayload, context: FormContext, h: FormResponseToolkit) => {
139+
// Re-render with component validation errors if any
140+
if (context.errors) {
141+
const viewModel = this.getViewModel(request, context)
142+
return h.view(this.viewName, viewModel)
143+
}
144+
145+
const passportNumber = context.payload.passportNumber
146+
147+
// Validate the submitted passport number against an identity service
148+
const passport = await verifyPassport(passportNumber)
149+
150+
if (!passport) {
151+
// Re-render with a custom error — the input passed component validation
152+
// (format/required checks) but was not found in the external system
153+
const viewModel = this.getViewModel(request, context)
154+
viewModel.errors = [{ text: 'Passport number not recognised. Check and try again.' }]
155+
return h.view(this.viewName, viewModel)
156+
}
157+
158+
// Save the standard component state to the session
159+
await this.setState(request, context.state)
160+
161+
// Merge additional data from the identity lookup into the session
162+
// so it is available to later pages in the journey
163+
await this.mergeState(request, context.state, {
164+
verifiedName: passport.fullName,
165+
nationality: passport.nationality
166+
})
167+
168+
return this.proceed(request, h, this.getNextPath(context))
169+
}
170+
}
171+
}
172+
```
173+
174+
### Display-only page (no form components)
175+
176+
Extend `PageController` for a page with no form submission. Override `makeGetRouteHandler()` and render using `this.viewName` and `this.viewModel`:
177+
178+
```ts
179+
import { PageController } from '@defra/forms-engine-plugin/controllers/PageController.js'
180+
181+
import type { FormContext, FormRequest, FormResponseToolkit } from '@defra/forms-engine-plugin/types'
182+
183+
class IneligiblePageController extends PageController {
184+
makeGetRouteHandler() {
185+
return async (_request: FormRequest, _context: FormContext, h: FormResponseToolkit) => {
186+
return h.view(this.viewName, this.viewModel)
187+
}
188+
}
189+
}
190+
```
191+
192+
`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).
193+
194+
## Reference
195+
196+
### What QuestionPageController gives you
197+
198+
`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:
199+
200+
- **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.
201+
- **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.
202+
- **Conditional routing**`getNextPath(context)` evaluates any conditions defined in the form and returns the correct path for the next page.
203+
- **Back link** — the back link is generated automatically based on the user's navigation history.
204+
- **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.
205+
206+
### Overridable members
207+
208+
| Member | Description |
209+
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
210+
| `viewName` | The Nunjucks template rendered for this page. Defaults to `'index'`. Set `view` on the page definition to override. |
211+
| `allowSaveAndExit` | Whether the "Save and exit" button is shown. `true` on `QuestionPageController`, `false` on `PageController`. Override as a class property to change the default. |
212+
| `getViewModel(request, context)` | Returns the view model passed to the Nunjucks template. Override to add or modify properties synchronously. Only available on `QuestionPageController`. |
213+
| `makeGetRouteHandler()` | Returns the async GET handler function. Override to control page load behaviour, including async data fetching. |
214+
| `makePostRouteHandler()` | Returns the async POST handler function. Override to control form submission behaviour and write custom data to state. |

docs/plugin-options.md

Lines changed: 2 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -30,48 +30,9 @@ See [our services documentation](./features/code-based/custom-services).
3030

3131
### Custom controllers
3232

33-
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.
33+
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.
3434

35-
```ts
36-
import { PageController } from '@defra/forms-engine-plugin/controllers/PageController.js'
37-
import { type FormModel } from '@defra/forms-engine-plugin/types'
38-
import { type Page } from '@defra/forms-model'
39-
40-
class ConfirmationPageController extends PageController {
41-
constructor(model: FormModel, pageDef: Page) {
42-
super(model, pageDef)
43-
}
44-
45-
makeGetRouteHandler() {
46-
return async (request, h) => {
47-
// custom logic before rendering
48-
return h.view(this.viewName, { ...await this.getViewModel(request) })
49-
}
50-
}
51-
}
52-
53-
await server.register({
54-
plugin,
55-
options: {
56-
controllers: {
57-
ConfirmationPageController
58-
}
59-
}
60-
})
61-
```
62-
63-
In your form definition, set the `controller` property of any page to the same key:
64-
65-
```json
66-
{
67-
"path": "/confirmation",
68-
"title": "Confirmation",
69-
"controller": "ConfirmationPageController",
70-
"components": []
71-
}
72-
```
73-
74-
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.
35+
See [Custom page controllers](./features/code-based/page-controllers.md) for a full guide including examples and a reference of overridable members.
7536

7637
### Nunjucks configuration
7738

scripts/generate-component-docs.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1059,6 +1059,16 @@ function generatePagesIndex() {
10591059
lines.push(`- [**${label}**](./${slug}.mdx) — ${description}`)
10601060
}
10611061

1062+
lines.push(``)
1063+
lines.push(`## Build your own page type`)
1064+
lines.push(``)
1065+
lines.push(
1066+
`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.`
1067+
)
1068+
lines.push(``)
1069+
lines.push(
1070+
`See [Custom page controllers](../code-based/page-controllers.md) for a full guide.`
1071+
)
10621072
lines.push(``)
10631073
return lines.join('\n')
10641074
}

0 commit comments

Comments
 (0)