diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..e81f18e5f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +# Documentation + +## What is generated vs hand-written + +Before modifying anything under `docs/`, read the scripts in the `scripts/` directory to determine whether the target file is generated. Generated files are overwritten on every script run; edits to them will be lost. If a file is generated, modify the relevant script or its data sources instead of editing the output directly, then re-run the script to rebuild. + +## Preview wrapper classes + +All generated component and page previews include `app-no-prose` on their wrapper `
` by default. This prevents Docusaurus prose CSS from interfering with GOV.UK component styles. Do not remove it. diff --git a/docs/BUILDING_THE_PACKAGE.md b/docs/BUILDING_THE_PACKAGE.md index d39d05e76..18c3827da 100644 --- a/docs/BUILDING_THE_PACKAGE.md +++ b/docs/BUILDING_THE_PACKAGE.md @@ -1,10 +1,3 @@ ---- -layout: default -title: Building the package -render_with_liquid: false -nav_order: 5 ---- - # Building the package 1. [Overview](#overview) diff --git a/docs/contributing.md b/docs/contributing.md index c73f8df5d..2d7a77608 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -56,3 +56,7 @@ If you would like to fix the bug yourself, contributions are accepted through pu ### Adding features Features should be discussed with the Defra Forms team prior to implementation. This is to prevent wasted effort if the Defra Forms team decides not to accept it, or if we suggest any significant amendments. Reach out to us on [#defra-forms-support](https://defra-digital-team.slack.com) to discuss your requirements. If accepted by the product owner, we welcome a pull request. + +## Building and publishing the package + +See the [Building the package](./BUILDING_THE_PACKAGE) guide for documentation on the build pipeline, path alias resolution, and the npm publish workflow. diff --git a/docs/features/code-based/custom-services.md b/docs/features/code-based/custom-services.md index 22d15c71a..24f78951d 100644 --- a/docs/features/code-based/custom-services.md +++ b/docs/features/code-based/custom-services.md @@ -1,90 +1,134 @@ # Overriding forms-engine-plugin logic with custom services -## Customising where forms are loaded from +The `services` plugin option accepts three service objects — `formsService`, `formSubmissionService`, and `outputService` — which together cover where forms come from, where submission data goes, and how submission notifications are sent. Replace any or all of them to integrate with your own infrastructure. -The engine plugin registers several [routes](https://hapi.dev/tutorials/routing/?lang=en_US) on the hapi server. +## formsService -They look like this: +Responsible for loading form metadata and definitions. Called on every page request to check for definition changes and load the full definition when needed. -``` -GET /{slug}/{path} -POST /{slug}/{path} -``` - -A unique `slug` is used to route the user to the correct form, and the `path` used to identify the correct page within the form to show. - -The [plugin registration options](/plugin-options) have a `services` setting to provide a `formsService` that is responsible for returning `form definition` data. - -WARNING: This below is subject to change - -A `formsService` has two methods, one for returning `formMetadata` and another to return `formDefinition`s. - -```javascript -const formsService = { - getFormMetadata: async function (slug) { - // Returns the metadata for the slug - }, - getFormDefinition: async function (id, state) { - // Returns the form definition for the given id - } +```ts +interface FormsService { + getFormMetadata: (slug: string) => Promise + getFormMetadataById: (id: string) => Promise + getFormDefinition: (id: string, state: FormStatus) => Promise + getFormSecret: (formId: string, secretName: string) => Promise } ``` -The reason for the two separate methods is caching. -`formMetadata` is a lightweight record designed to give top level information about a form. -This method is invoked for every page request. +`getFormMetadata` is called on every request and should be fast. `getFormDefinition` is only called when the metadata signals the definition has changed, so it can be slower. `getFormMetadataById` is called by the status page to retrieve the submitted form's name by its ID — this allows the confirmation panel to display the correct form name even when the current URL belongs to a different form (for example, a shared feedback form). -Only when the `formMetadata` indicates that the definition has changed is a call to `getFormDefinition` is made. -The response from this can be quite big as it contains the entire form definition. +`getFormSecret` retrieves a secret that belongs to a specific form — for example, a payment API key scoped to that form. This is distinct from global secrets such as `ordnanceSurveyApiKey` and `ordnanceSurveyApiSecret`, which are passed once at plugin registration and apply across all forms. If your forms do not use any components that require per-form secrets, this method will never be called and you can safely return `undefined` (or leave it unimplemented). See the documentation for each component to find out whether it requires secrets and what names it requests. -## Loading forms from files +### Loading forms from files -To create a `formsService` from form config files that live on disk, you can use the `FileFormService` class. -Form definition config files can be either `.json` or `.yaml`. +For local or file-based forms, use the built-in `FileFormService`: -Once created and files have been loaded using the `addForm` method, -call the `toFormsService` method to return a `FormService` compliant interface which can be passed in to the `services` setting of the [plugin options](/plugin-options). - -```javascript +```js import { FileFormService } from '@defra/forms-engine-plugin/file-form-service.js' -// Create shared form metadata const now = new Date() const user = { id: 'user', displayName: 'Username' } const author = { createdAt: now, createdBy: user, updatedAt: now, updatedBy: user } -const metadata = { + +const loader = new FileFormService() + +await loader.addForm('src/definitions/example-form.yaml', { + id: '95e92559-968d-44ae-8666-2b1ad3dffd31', + title: 'Example form', + slug: 'example-form', organisation: 'Defra', teamName: 'Team name', teamEmail: 'team@defra.gov.uk', submissionGuidance: "Thanks for your submission, we'll be in touch", - notificationEmail: 'email@domain.com', + notificationEmail: 'team@defra.gov.uk', ...author, live: author +}) + +const formsService = loader.toFormsService() +``` + +--- + +## formSubmissionService + +Called during form submission to persist the submitted data and any uploaded files. The default implementation calls the Defra Forms submission API (`SUBMISSION_URL`), which is part of the Defra Forms hosting infrastructure. Teams not using that infrastructure must provide their own implementation. + +```ts +interface FormSubmissionService { + persistFiles: ( + files: { fileId: string; initiatedRetrievalKey: string }[], + persistedRetrievalKey: string + ) => Promise + submit: (data: SubmitPayload) => Promise } +``` -// Instantiate the file loader form service -const loader = new FileFormService() +`submit` is called first with the structured form payload. The `SubmitResponsePayload` it returns (including CSV file IDs) is then passed to `outputService.submit`. `persistFiles` is called by `FileUploadField` during submission to move uploaded files from temporary to permanent storage. -// Add a Json form -await loader.addForm( - 'src/definitions/example-form.json', { - ...metadata, - id: '95e92559-968d-44ae-8666-2b1ad3dffd31', - title: 'Example Json', - slug: 'example-json' - } -) - -// Add a Yaml form -await loader.addForm( - 'src/definitions/example-form.yaml', { - ...metadata, - id: '641aeafd-13dd-40fa-9186-001703800efb', - title: 'Example Yaml', - slug: 'example-yaml' +Override this service if you are not using the Defra Forms hosting infrastructure, or if you handle file persistence differently. + +--- + +## outputService + +Called after `formSubmissionService.submit` completes. Its job is to deliver the submission — by default, as a GOV.UK Notify email. + +```ts +interface OutputService { + submit: ( + context: FormContext, + request: FormRequestPayload, + model: FormModel, + emailAddress: string, + items: DetailItem[], + submitResponse: SubmitResponsePayload, + formMetadata?: FormMetadata + ) => Promise +} +``` + +The default implementation (`notifyService`) formats the submission using the [output formatter](#output-format) configured on the form definition and sends it to `emailAddress` via GOV.UK Notify. + +Override this service to deliver submissions differently — for example, publishing to an SNS topic, calling a webhook, or writing to a database. Your implementation receives the full `FormContext`, `FormModel`, `DetailItem[]` array, and the `SubmitResponsePayload` from `formSubmissionService`, giving you everything needed to format and route the submission however you need. + +```js +await server.register({ + plugin, + options: { + services: { + formsService, + formSubmissionService, + outputService: { + async submit(context, request, model, emailAddress, items, submitResponse, formMetadata) { + // publish to SNS, call a webhook, etc. + } + } + } } -) +}) +``` -// Get the forms service -const formsService = loader.toFormsService() +### Output format + +If you use the default `notifyService`, the format of the email body is controlled by the `output` field in the form definition: + +```json +{ + "output": { + "audience": "human", + "version": "1" + } +} ``` + +| Value | Description | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `audience: "human"` | Formats the submission as readable Markdown for a GOV.UK Notify email template. Default. | +| `audience: "machine"` | Formats the submission as a JSON payload, base64-encoded into the Notify email body. Useful when a downstream system reads the email programmatically. | + +`version` selects the formatter version within that audience. Currently `"1"` is the only stable version for `human`; `"1"` and `"2"` are available for `machine`. Defaults to `"1"` when omitted. + +If you provide a custom `outputService`, the `output` field has no effect — your service controls formatting entirely. + +> **Note:** Page events always use the `machine/v1` payload format regardless of the `output` setting. The `output` field only affects what the default `notifyService` sends. diff --git a/docs/features/code-based/form-definition-formats.md b/docs/features/code-based/form-definition-formats.md new file mode 100644 index 000000000..c9500e9af --- /dev/null +++ b/docs/features/code-based/form-definition-formats.md @@ -0,0 +1,102 @@ +# Form definition formats + +Form definitions are retrieved by `forms-engine-plugin` using the `formsService` plugin registration option. The plugin calls `getFormDefinition()` on every page request, which must return a JavaScript object matching the form definition schema. + +Two approaches are available: + +- **File-based loading** — store form definitions as YAML or JSON files in your repository and use the built-in `FileFormService` +- **Custom service** — implement your own `formsService` to load definitions from an API, database, or any other source + +## File-based loading + +The built-in `FileFormService` loads form definitions from disk. YAML is recommended for forms with multi-line HTML content, as it natively supports block scalars. JSON is more portable but requires manually escaping quotes and line breaks in string values. + +### Registering forms + +```js +import { FileFormService } from '@defra/forms-engine-plugin/file-form-service.js' + +const now = new Date() +const user = { id: 'user', displayName: 'Username' } +const author = { createdAt: now, createdBy: user, updatedAt: now, updatedBy: user } + +const loader = new FileFormService() + +await loader.addForm('src/definitions/example-form.yaml', { + id: '95e92559-968d-44ae-8666-2b1ad3dffd31', + title: 'Example form', + slug: 'example-form', + organisation: 'Defra', + teamName: 'Team name', + teamEmail: 'team@defra.gov.uk', + submissionGuidance: "Thanks for your submission, we'll be in touch", + notificationEmail: 'team@defra.gov.uk', + ...author, + live: author +}) + +const formsService = loader.toFormsService() +``` + +Pass the resulting `formsService` as a plugin registration option: + +```js +await server.register({ + plugin, + options: { + services: { formsService }, + // ... + } +}) +``` + +Call `loader.addForm()` once per form definition file. The `slug` controls the URL path — a form with `slug: 'example-form'` is served at `/example-form/*`. + +### YAML vs JSON + +```yaml +# example-form.yaml — YAML supports multi-line content natively +name: "Form name" +pages: + - title: "Page title" + components: + - type: "Html" + content: | +

Heading

+

Body text

+``` + +```jsonc +// example-form.json — JSON requires escaped quotes and no newlines in strings +{ + "name": "Form name", + "pages": [ + { + "title": "Page title", + "components": [ + { + "type": "Html", + "content": "

Heading

Body text

" + } + ] + } + ] +} +``` + +## Custom formsService + +To load form definitions from an API, database, or CMS, implement a custom `formsService` and pass it at plugin registration. The interface requires four methods: + +```ts +interface FormsService { + getFormMetadata: (slug: string) => Promise + getFormMetadataById: (id: string) => Promise + getFormDefinition: (id: string, state: FormStatus) => Promise + getFormSecret: (formId: string, secretName: string) => Promise +} +``` + +`getFormMetadata` is called on every page request and should be fast. `getFormDefinition` is only called when the metadata signals the definition has changed, so it can do heavier lifting. + +See [Custom Services](./custom-services) for a full implementation guide. diff --git a/docs/features/code-based/index.md b/docs/features/code-based/index.md index 1a732fdf5..d0e336928 100644 --- a/docs/features/code-based/index.md +++ b/docs/features/code-based/index.md @@ -31,3 +31,15 @@ Automatically copy query string parameter values into hidden fields on first loa ## [Save and Exit](./code-based/save-and-exit) Show a secondary "Save and exit" button on question pages and handle the persisted session using a route handler you supply, enabling users to leave and resume their journey later. + +## [Template Extensions](./code-based/template-extensions) + +Add custom globals and filters to the Nunjucks template environment, making them available across all form page templates and LiquidJS page templates. + +## [Form Definition Formats](./code-based/form-definition-formats) + +Options for loading form definitions — file-based loading with the built-in `FileFormService`, or a custom `formsService` implementation for API and database sources. + +## [Session Cache](./code-based/session-cache) + +Configuring the server-side session store for production: named catbox cache or a custom `CacheService` subclass. diff --git a/docs/features/code-based/pre-populate-state.md b/docs/features/code-based/pre-populate-state.md index 0e4a9374c..08b85eb36 100644 --- a/docs/features/code-based/pre-populate-state.md +++ b/docs/features/code-based/pre-populate-state.md @@ -1,13 +1,78 @@ -# Pre-populate state +# Pre-populating state -The forms engine supports the ability to pre-populate form state using query string parameters. This feature enables applications to support passing specific parameter values through the form and on to the submission without the user having to enter these values. +The engine supports pre-populating form state from URL query string parameters. This is useful when a calling system needs to pass known values into a form without the user re-entering them — for example, a reference number, an applicant ID, or a pre-selected option. -The feature uses the HiddenField component to prevent against rogue state injection. Only query string parameters whose names exist as HiddenField components will be copied into state. +Pre-populated values are stored in the session and submitted with the form as if the user had entered them. -The parameter values get copied on first load of the form, and are simple key/value parameters e.g.: +## How it works + +Only query string parameters whose names match a `HiddenField` component in the form are copied into state. All other query parameters are ignored. This prevents arbitrary state injection — a caller cannot set a value that the form definition doesn't explicitly allow. + +Pre-population fires inside `QuestionPageController`'s GET handler, which means it runs on any page whose controller is `QuestionPageController` or a subclass (`StartPageController`, `RepeatPageController`, `FileUploadPageController`, `SummaryPageController`). Display-only pages using the base `PageController` do not trigger it. The HiddenField can be defined on any page in the form — the engine searches the entire form definition for matching field names, not just the current page. + +On GET requests the engine forwards query parameters (minus `returnUrl`) through any routing redirects, so params passed to the first page in the journey will survive until they reach a `QuestionPageController` page that processes them. Once pre-population fires the user is redirected to the same URL with the query string stripped, so the values are in session state and the URL is clean. + +## Setup + +### 1. Add a HiddenField to your form definition + +The `HiddenField` can be placed on any page. The engine searches the entire form definition for matching field names, so it does not need to be on the page that receives the URL parameters. + +```json +{ + "pages": [ + { + "path": "/eligibility", + "title": "Check eligibility", + "components": [ + { + "type": "HiddenField", + "name": "applicantId", + "title": "Applicant ID" + } + ] + } + ] +} +``` + +The `name` of the `HiddenField` must exactly match the query parameter name (case-sensitive). + +### 2. Pass the value in the URL + +Pass the parameter to any page in the form — typically the first page the user lands on. The engine forwards query parameters through GET redirects until a `QuestionPageController` page processes them. ``` -?paramname1=paramval1,paramname2=paramval2 +https://your-service.gov.uk/your-form/start?applicantId=12345 ``` -There is no limit set on the number of parameters. The keys and values get copied as-is (no case changes get applied). +The value `12345` will be stored in session state under the key `applicantId` and submitted alongside the user's other answers. + +## Multiple parameters + +Multiple `HiddenField` components and corresponding parameters are supported: + +```json +{ + "type": "HiddenField", + "name": "applicantId", + "title": "Applicant ID" +}, +{ + "type": "HiddenField", + "name": "schemeCode", + "title": "Scheme code" +} +``` + +``` +?applicantId=12345&schemeCode=SRG-42 +``` + +## What happens if a parameter has no matching HiddenField + +The parameter is silently ignored. Only parameters whose names correspond to a `HiddenField` in the form definition are copied into state. + +## What happens if the parameter is missing from the URL + +The `HiddenField` is treated like any other required field — if `options.required` is `true` (the default), submission will fail validation unless the value was provided and stored in the session. Set `options.required: false` if the parameter is optional. diff --git a/docs/features/code-based/session-cache.md b/docs/features/code-based/session-cache.md new file mode 100644 index 000000000..ad6be0532 --- /dev/null +++ b/docs/features/code-based/session-cache.md @@ -0,0 +1,58 @@ +# Session cache + +The plugin stores form answers in a server-side cache keyed by the user's session. By default it uses the [hapi in-memory cache](https://hapi.dev/api/?v=21.4.0#-serveroptionscache), which is fine for development but unsuitable for production — sessions are lost on restart and are not shared across instances. + +Configure the cache via the `cache` plugin option. + +## Option 1 — named cache (recommended) + +For most deployments, register a named [catbox](https://hapi.dev/module/catbox/) cache on the hapi server and pass its name as the `cache` plugin option. The plugin handles all cache reads and writes internally — you only need to supply the backing store. + +```js +import { Engine as CatboxRedis } from '@hapi/catbox-redis' + +const server = new Hapi.Server({ + cache: [ + { + name: 'session', + provider: { + constructor: CatboxRedis, + options: { + host: process.env.REDIS_HOST, + port: 6379 + } + } + } + ] +}) + +await server.register({ + plugin, + options: { + cache: 'session', + // ... + } +}) +``` + +Any catbox adapter works — Redis (`@hapi/catbox-redis`), Memcached (`@hapi/catbox-memcached`), or a custom implementation. + +## Option 2 — CacheService instance + +Use this when you need to subclass `CacheService` to customise its behaviour. The class exposes the full state lifecycle as overridable methods — key construction, TTL, state reads and writes, confirmation state, flash messages, and component state resets — so you can override whichever parts your use case requires. Pass your subclass instance directly: + +```js +import { CacheService } from '@defra/forms-engine-plugin/cache-service.js' + +const cacheService = new CacheService({ server, cacheName: 'session' }) + +await server.register({ + plugin, + options: { + cache: cacheService, + // ... + } +}) +``` + +`CacheService` accepts `{ server, cacheName }` where `cacheName` must match a cache already registered on the hapi server. Omitting `cacheName` falls back to the default in-memory cache with a warning logged. diff --git a/docs/features/code-based/template-extensions.md b/docs/features/code-based/template-extensions.md new file mode 100644 index 000000000..ac577d169 --- /dev/null +++ b/docs/features/code-based/template-extensions.md @@ -0,0 +1,70 @@ +# Template extensions + +The `globals` and `filters` plugin options let you extend the Nunjucks template environment with custom functions and filters. Both are available across all form page templates and in [page templates](../configuration-based/page-templates) (LiquidJS). + +## Globals + +Globals are functions you call directly in templates, without needing an input value. Register them via the `globals` plugin option: + +```js +await server.register({ + plugin, + options: { + globals: { + getCurrentYear: () => new Date().getFullYear(), + formatCurrency: (amount) => + new Intl.NumberFormat('en-GB', { + style: 'currency', + currency: 'GBP' + }).format(amount) + } + } +}) +``` + +Use them in any Nunjucks template: + +```njk +

Copyright {{ getCurrentYear() }}

+

Total: {{ formatCurrency(123.45) }}

+``` + +## Filters + +Filters transform a value passed on the left side of the `|` operator. Register them via the `filters` plugin option: + +```js +const formatter = new Intl.NumberFormat('en-GB') + +await server.register({ + plugin, + options: { + filters: { + money: (value) => formatter.format(value), + upper: (value) => (typeof value === 'string' ? value.toUpperCase() : value) + } + } +}) +``` + +Use them in any Nunjucks template, or in [LiquidJS page templates](../configuration-based/page-templates): + +```njk +

{{ amount | money }}

+

{{ name | upper }}

+``` + +## Built-in filters + +forms-engine-plugin registers several filters automatically. These are available in all templates without any configuration: + +| Filter | Description | +| ---------- | ------------------------------------------------------------------------------ | +| `markdown` | Renders a Markdown string to HTML | +| `answer` | Returns the user's answer for a given component name (LiquidJS only) | +| `page` | Returns the page definition for a given path (LiquidJS only) | +| `field` | Returns the component definition for a given name (LiquidJS only) | +| `href` | Returns the full page href for a given path (LiquidJS only) | +| `evaluate` | Evaluates a nested LiquidJS template using the current context (LiquidJS only) | + +The LiquidJS-only filters are documented in full in [Page templates](../configuration-based/page-templates#built-in-filters). diff --git a/docs/features/conditions.md b/docs/features/conditions.md new file mode 100644 index 000000000..ab9d6b6e3 --- /dev/null +++ b/docs/features/conditions.md @@ -0,0 +1,439 @@ +# Conditions + +Conditions make pages in a form journey conditional. When a condition is assigned to a page, the engine evaluates it at runtime and skips the page if the condition does not pass. Pages are always traversed in the order they appear in the `pages` array — conditions cause individual pages to be bypassed, not re-ordered. + +- [Defining a condition](#defining-a-condition) +- [Assigning a condition to a page](#assigning-a-condition-to-a-page) +- [Condition items](#condition-items) + - [Value comparison](#value-comparison) + - [Condition reference](#condition-reference) +- [Condition types](#condition-types) + - [StringValue](#stringvalue) + - [ListItemRef](#listitemref) + - [BooleanValue](#booleanvalue) + - [NumberValue](#numbervalue) + - [DateValue](#datevalue) + - [RelativeDate](#relativedate) +- [Operators](#operators) + - [String operators](#string-operators) + - [List operators](#list-operators) + - [Boolean operators](#boolean-operators) + - [Number operators](#number-operators) + - [Date operators](#date-operators) +- [Multi-item conditions](#multi-item-conditions) +- [Example](#example) + +## Defining a condition + +Add a `conditions` array to your form definition. Each condition has a unique `id`, a human-readable `displayName`, and one or more `items` specifying what is evaluated. + +```json +{ + "conditions": [ + { + "id": "8a3f6bb2-c305-410a-a037-7375be839105", + "displayName": "applicantIsUKResident", + "items": [ + { + "id": "f03a6735-0f7c-4dc9-b65c-7c42fcd0d189", + "componentId": "fa67e20d-a89b-4e8a-85ec-8a63923b7137", + "operator": "is", + "type": "BooleanValue", + "value": true + } + ] + } + ] +} +``` + +## Assigning a condition to a page + +Set the `condition` property on a page to the condition's `id`. The engine evaluates the condition when calculating the user's next destination — pages whose condition does not pass are silently skipped. + +```json +{ + "pages": [ + { + "id": "449c053b-9201-4312-9a75-187ac1b720eb", + "title": "Tell us where you live", + "path": "/where-do-you-live", + "condition": "8a3f6bb2-c305-410a-a037-7375be839105", + "components": [] + } + ] +} +``` + +## Condition items + +Items in a condition's `items` array take one of two forms: + +| Form | When to use | +| ------------------------------------------- | -------------------------------------------------------- | +| [Value comparison](#value-comparison) | Compare a component's answer against a specific value | +| [Condition reference](#condition-reference) | Compose conditions by referencing other named conditions | + +### Value comparison + +A value comparison item checks a component's answer against a target. It requires `componentId`, `operator`, `type`, and `value`. + +```json +{ + "id": "f03a6735-0f7c-4dc9-b65c-7c42fcd0d189", + "componentId": "87b987e8-bcf9-4ff9-92af-57c34c45995a", + "operator": "is", + "type": "StringValue", + "value": "Bob" +} +``` + +See [Condition types](#condition-types) for the full list of `type` values, and [Operators](#operators) for which operators apply to each type. + +### Condition reference + +An optional way to combine existing conditions without duplicating logic. If you already have an `isOver18` condition and need a new `isOver18AndLivesInScotland` condition, you can reference `isOver18` directly rather than repeating its items. If the age check ever changes, you update it in one place and every condition that references it picks up the change automatically. + +Most conditions are simple value comparisons and don't need this. It's worth considering when you find yourself repeating the same checks across multiple conditions. + +```json +{ + "conditions": [ + { + "id": "d1f9fcc7-f098-47e7-9d31-4f5ee57ba985", + "displayName": "isOver18", + "items": [ + { + "id": "c833b177-0cba-49de-b670-a297c6db45b8", + "componentId": "c977e76e-49ab-4443-b93e-e19e8d9c81ac", + "operator": "is", + "type": "BooleanValue", + "value": true + } + ] + }, + { + "id": "fa67e20d-a89b-4e8a-85ec-8a63923b7137", + "displayName": "livesInScotland", + "items": [ + { + "id": "b7c2d456-e789-0123-45ab-cdef01234567", + "componentId": "2e088e75-c6f6-4a0f-8f1f-3cee14c71e4c", + "operator": "is", + "type": "StringValue", + "value": "scotland" + } + ] + }, + { + "id": "db43c6bc-9ce6-478b-8345-4fff5eff2ba3", + "displayName": "isOver18AndLivesInScotland", + "coordinator": "and", + "items": [ + { + "id": "a906e343-5d0e-421e-81a4-3afa68fac011", + "conditionId": "d1f9fcc7-f098-47e7-9d31-4f5ee57ba985" + }, + { + "id": "3b306a85-a365-4bfc-b9f0-3f868e896da2", + "conditionId": "fa67e20d-a89b-4e8a-85ec-8a63923b7137" + } + ] + } + ] +} +``` + +Condition references can be nested to arbitrary depth — a composed condition can itself be referenced in another composed condition. + +## Condition types + +The `type` field on a value comparison item determines the format of `value` and which operators are applicable. + +| Type | Use with | `value` format | +| ------------------------------- | ----------------------------------------- | --------------------------------- | +| [`StringValue`](#stringvalue) | TextField, MultilineTextField | String | +| [`ListItemRef`](#listitemref) | RadiosField, SelectField, CheckboxesField | Object with `listId` and `itemId` | +| [`BooleanValue`](#booleanvalue) | YesNoField | `true` or `false` | +| [`NumberValue`](#numbervalue) | NumberField | Number | +| [`DateValue`](#datevalue) | DatePartsField — absolute dates | ISO date string (`"YYYY-MM-DD"`) | +| [`RelativeDate`](#relativedate) | DatePartsField — relative dates | Object | + +### StringValue + +Used with text input components — TextField and MultilineTextField. + +```json +{ + "componentId": "87b987e8-bcf9-4ff9-92af-57c34c45995a", + "operator": "is", + "type": "StringValue", + "value": "Bob" +} +``` + +### ListItemRef + +Used with list-backed selection components — RadiosField, SelectField, and CheckboxesField. The `value` references the list and item by their IDs. + +```json +{ + "componentId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "operator": "is", + "type": "ListItemRef", + "value": { + "listId": "23d5309e-1aed-427d-b8ee-87e14f673e7f", + "itemId": "bedd5984-fa95-48f9-87e2-1089d66574b2" + } +} +``` + +For Checkboxes (multi-select), use `contains` or `does not contain`: + +```json +{ + "componentId": "f0f67bf7-cdbb-4247-9f3c-8cd919183968", + "operator": "contains", + "type": "ListItemRef", + "value": { + "listId": "0e047f83-dbb6-4c82-b709-f9dbaddf8644", + "itemId": "0c546ae1-897e-48d0-9388-b0902fe23baf" + } +} +``` + +### BooleanValue + +Used with YesNoField. Value must be the boolean `true` or `false`. + +```json +{ + "componentId": "c977e76e-49ab-4443-b93e-e19e8d9c81ac", + "operator": "is", + "type": "BooleanValue", + "value": true +} +``` + +### NumberValue + +Used with NumberField. Value must be a number. + +```json +{ + "componentId": "2e088e75-c6f6-4a0f-8f1f-3cee14c71e4c", + "operator": "is more than", + "type": "NumberValue", + "value": 100 +} +``` + +### DateValue + +Used with DatePartsField for comparisons against a specific absolute date. Value must be an ISO date string. + +```json +{ + "componentId": "3733ff68-3c72-4e42-9362-a792217d235d", + "operator": "is before", + "type": "DateValue", + "value": "2001-01-01" +} +``` + +### RelativeDate + +Used with DatePartsField for comparisons against a date calculated relative to today — for example, "at least 18 years in the past" for an age check. + +```json +{ + "componentId": "34567ef1-49df-46fb-b0ed-2e0922c2b0d9", + "operator": "is at least", + "type": "RelativeDate", + "value": { + "period": 18, + "unit": "years", + "direction": "in the past" + } +} +``` + +| Property | Values | +| ----------- | ------------------------------------ | +| `period` | Positive integer | +| `unit` | `"days"`, `"months"`, `"years"` | +| `direction` | `"in the past"` or `"in the future"` | + +## Operators + +### String operators + +Used with `StringValue`. + +| Operator | Description | +| ----------------- | --------------------------------------------- | +| `is` | Answer exactly matches the value | +| `is not` | Answer does not exactly match | +| `is longer than` | Answer character count exceeds the value | +| `is shorter than` | Answer character count is less than the value | +| `has length` | Answer character count equals the value | + +### List operators + +Used with `ListItemRef`. + +| Operator | Description | +| ------------------ | --------------------------------------------------------------- | +| `is` | Selection matches the referenced item — use with Radios, Select | +| `is not` | Selection does not match | +| `contains` | Multi-select includes the referenced item — use with Checkboxes | +| `does not contain` | Multi-select does not include the item | + +### Boolean operators + +Used with `BooleanValue`. + +| Operator | Description | +| -------- | -------------------------------- | +| `is` | Answer matches `true` or `false` | +| `is not` | Answer does not match | + +### Number operators + +Used with `NumberValue`. + +| Operator | Description | +| -------------- | -------------------------------------------- | +| `is` | Answer exactly matches | +| `is not` | Answer does not match | +| `is more than` | Answer is greater than the value | +| `is less than` | Answer is less than the value | +| `is at least` | Answer is greater than or equal to the value | +| `is at most` | Answer is less than or equal to the value | + +### Date operators + +Used with `DateValue` for comparisons against a specific date: + +| Operator | Description | +| ----------- | ------------------------------- | +| `is` | Date exactly matches | +| `is not` | Date does not match | +| `is before` | Date is earlier than the target | +| `is after` | Date is later than the target | + +Used with `RelativeDate` for comparisons relative to today: + +| Operator | Description | +| -------------- | ------------------------------------------------ | +| `is at least` | Date is at least N units in the given direction | +| `is at most` | Date is at most N units in the given direction | +| `is less than` | Date is less than N units in the given direction | +| `is more than` | Date is more than N units in the given direction | + +## Multi-item conditions + +When a condition has two or more items, add a `coordinator` to specify how they are combined. + +```json +{ + "id": "0e7ae320-c876-40c2-8803-7848cc49689b", + "displayName": "applicantIsEligible", + "coordinator": "and", + "items": [ + { + "id": "f03a6735-0f7c-4dc9-b65c-7c42fcd0d189", + "componentId": "fa67e20d-a89b-4e8a-85ec-8a63923b7137", + "operator": "is", + "type": "BooleanValue", + "value": true + }, + { + "id": "a1b2c3d4-e5f6-7890-abcd-ef0123456789", + "componentId": "2e088e75-c6f6-4a0f-8f1f-3cee14c71e4c", + "operator": "is not", + "type": "StringValue", + "value": "ineligible" + } + ] +} +``` + +| Coordinator | Behaviour | +| ----------- | --------------------------- | +| `"and"` | All items must pass | +| `"or"` | At least one item must pass | + +## Example + +This example asks users which animals they prefer. The follow-up page only appears when the condition passes — the user selected "Monkey". + +```json +{ + "pages": [ + { + "id": "a86ea4ba-ae3b-4324-9acd-3a3f347cb0ec", + "title": "What are your favourite animals", + "path": "/favourite-animal", + "components": [ + { + "id": "f0f67bf7-cdbb-4247-9f3c-8cd919183968", + "type": "CheckboxesField", + "title": "What are your favourite animals", + "name": "nUaCCW", + "shortDescription": "Favourite animals", + "options": { "required": true }, + "schema": {}, + "list": "0e047f83-dbb6-4c82-b709-f9dbaddf8644" + } + ] + }, + { + "id": "c12b3e99-7374-4b2a-9f81-d6e4c7891234", + "title": "You picked a monkey as your favourite animal", + "path": "/monkey-chosen", + "condition": "8a3f6bb2-c305-410a-a037-7375be839105", + "components": [ + { + "id": "d7f3a456-1234-5678-90ab-cdef01234567", + "type": "Markdown", + "name": "mWnPqR", + "content": "What a fantastic choice." + } + ] + } + ], + "conditions": [ + { + "id": "8a3f6bb2-c305-410a-a037-7375be839105", + "displayName": "FaveAnimalIsMonkey", + "items": [ + { + "id": "86e63584-12a8-4f2b-b51b-49765518b811", + "componentId": "f0f67bf7-cdbb-4247-9f3c-8cd919183968", + "operator": "contains", + "type": "ListItemRef", + "value": { + "listId": "0e047f83-dbb6-4c82-b709-f9dbaddf8644", + "itemId": "0c546ae1-897e-48d0-9388-b0902fe23baf" + } + } + ] + } + ], + "lists": [ + { + "id": "0e047f83-dbb6-4c82-b709-f9dbaddf8644", + "title": "Animals", + "type": "string", + "items": [ + { "id": "fb3519b2-c6c7-40b6-8e03-2fb0db6d4f32", "text": "Horse", "value": "horse" }, + { "id": "0c546ae1-897e-48d0-9388-b0902fe23baf", "text": "Monkey", "value": "monkey" }, + { "id": "39f6fa65-1781-4569-9ba3-d8d13931f036", "text": "Giraffe", "value": "giraffe" } + ] + } + ], + "engine": "V2", + "schema": 2 +} +``` diff --git a/docs/features/configuration-based/page-events.md b/docs/features/configuration-based/page-events.md index 5b355cf0f..25dd544b7 100644 --- a/docs/features/configuration-based/page-events.md +++ b/docs/features/configuration-based/page-events.md @@ -4,7 +4,7 @@ Page events are a configuration-based way of triggering an action on an event tr forms-engine-plugin is a frontend service, which should remain as lightweight as possible with business logic being implemented in a backend/BFF API. Using page events, forms-engine-plugin can call your API and use the tailored response downstream, such a page templates to display the response value. -The downstream API response becomes available under the `{{ context.data }}` view model attribute for view templates, so it can be used when rendering a page. This attribute is directly accessible by our [page templates](/forms-engine-plugin/features/configuration-based/PAGE_TEMPLATES) feature and our Nunjucks-based views. +The downstream API response becomes available under the `{{ context.data }}` view model attribute for view templates, so it can be used when rendering a page. This attribute is directly accessible by our [page templates](./page-templates) feature and our Nunjucks-based views. ## Architecture @@ -53,7 +53,7 @@ Currently supported event types: forms-engine-plugin sends a standardised payload to each API configured with page events. The latest version of our payload [can be found in our outputFormatters module by opening the latest version, e.g. `v2.ts`](https://github.com/DEFRA/forms-engine-plugin/tree/main/src/server/plugins/engine/outputFormatters/machine). Our payload contains some metadata about the payload, along with a "data" section that contains the main body of the form as a JSON object, an array of repeatable pages, and a file ID and download link for all files submitted. -As of 2025-03-25, the payload would look something like this: +The payload takes the following shape: ```jsonc { diff --git a/docs/features/configuration-based/page-templates.md b/docs/features/configuration-based/page-templates.md index 16d0d1da5..2744db531 100644 --- a/docs/features/configuration-based/page-templates.md +++ b/docs/features/configuration-based/page-templates.md @@ -126,7 +126,7 @@ Here is an example of a Liquid template that renders a page title, displays a li

``` -When using these kind of multi-line HTML snippets, you would benefit from our [YAML-based form definitions](../../form-definition-formats) that provide a better developer experience compared to JSON files. +When using these kind of multi-line HTML snippets, you would benefit from our [YAML-based form definitions](../code-based/form-definition-formats) that provide a better developer experience compared to JSON files. If you choose to stick with JSON form definitions, the above template should be minified and inserted into the content field in the form definition example. E.g. quotes should be either replaced with `'` or escaped `\"`. Your IDE should do this automatically when pasting the into a JSON string, or a tool like https://www.freeformatter.com/json-escape.html can do it manually. @@ -149,8 +149,8 @@ Full example of the minified and escaped component, which can be appended to [th ## Providing your own filters -Whilst forms-engine-plugin offers some out of the box filters, teams using the plugin have the capability to provide their own. See [PLUGIN_OPTIONS.md](/plugin-options) for more information. +Whilst forms-engine-plugin offers some out of the box filters, teams using the plugin have the capability to provide their own. See [plugin options](/plugin-options) for more information. ## Using page templates with data from your own API -Page templates have access to `{{ context.data }}`, which is an attribute made available when a page event is triggered. It represents the entire response body from your API. To learn more about this, [see our guidance on page events](/forms-engine-plugin/features/configuration-based/PAGE_EVENTS). +Page templates have access to `{{ context.data }}`, which is an attribute made available when a page event is triggered. It represents the entire response body from your API. To learn more about this, [see our guidance on page events](./page-events). diff --git a/docs/features/form-definition-options.md b/docs/features/form-definition-options.md new file mode 100644 index 000000000..03e84aef5 --- /dev/null +++ b/docs/features/form-definition-options.md @@ -0,0 +1,233 @@ +# Form definition options + +These are top-level fields on the form definition that control global behaviour — things like how the reference number is displayed, what appears on the summary page, and whether user feedback is enabled. They sit alongside `pages`, `conditions`, `lists`, and `sections` in the form definition object. + +- [Lists](#lists) + - [Defining lists](#defining-lists) + - [Assigning a list to a component](#assigning-a-list-to-a-component) +- [Sections](#sections) + - [Defining sections](#defining-sections) + - [Assigning pages to a section](#assigning-pages-to-a-section) +- [Options](#options) + - [Reference number display](#reference-number-display) + - [Reference number prefix](#reference-number-prefix) + - [User feedback](#user-feedback) +- [Phase banner](#phase-banner) +- [Declaration](#declaration) + +## Lists + +Lists define the available options for selection components — RadiosField, CheckboxesField, SelectField, AutocompleteField, and the List display component. They are defined once at the top level of the form definition and referenced by components and conditions by ID. + +### Defining lists + +Add a `lists` array to the form definition. Each list has a unique `id`, a human-readable `title`, a value `type`, and an `items` array. + +```json +{ + "lists": [ + { + "id": "0e047f83-dbb6-4c82-b709-f9dbaddf8644", + "title": "Countries", + "type": "string", + "items": [ + { + "id": "fb3519b2-c6c7-40b6-8e03-2fb0db6d4f32", + "text": "England", + "value": "england" + }, + { + "id": "0c546ae1-897e-48d0-9388-b0902fe23baf", + "text": "Scotland", + "value": "scotland" + }, + { + "id": "39f6fa65-1781-4569-9ba3-d8d13931f036", + "text": "Wales", + "value": "wales" + } + ] + } + ] +} +``` + +| Property | Type | Required | Description | +| -------- | -------- | -------- | --------------------------------------------------- | +| `id` | `string` | Yes | Unique identifier (UUID) for this list. | +| `title` | `string` | Yes | Human-readable name for the list. | +| `type` | `string` | Yes | Value type for all items: `"string"` or `"number"`. | +| `items` | `array` | Yes | The selectable options. | + +Each item in `items`: + +| Property | Type | Required | Description | +| -------- | ------------------ | -------- | ------------------------------------------------------------------------------------------ | +| `id` | `string` | Yes | Unique identifier (UUID) for this item. Used by `ListItemRef` conditions. | +| `text` | `string` | Yes | Label displayed to the user. | +| `value` | `string \| number` | Yes | Value stored in the form state when this option is selected. Must match the list's `type`. | + +### Assigning a list to a component + +Set the `list` property on a component to the list's `id`: + +```json +{ + "type": "RadiosField", + "name": "country", + "title": "Which country do you live in?", + "list": "0e047f83-dbb6-4c82-b709-f9dbaddf8644" +} +``` + +Item `id` values are used when writing [conditions](./conditions.md) that compare against a specific list option — see [`ListItemRef`](./conditions.md#listitemref). + +--- + +## Sections + +Sections group pages together under a named heading in the check-your-answers summary. Pages in the same section are listed under a shared `

` on the summary page. Pages without a section appear under a default heading. + +### Defining sections + +Add a `sections` array to the form definition: + +```json +{ + "sections": [ + { + "name": "applicant", + "title": "About you" + }, + { + "name": "application", + "title": "Your application", + "hideTitle": true + } + ] +} +``` + +| Property | Type | Required | Description | +| ----------- | --------- | -------- | ------------------------------------------------------------------------------ | +| `name` | `string` | Yes | Identifier used to reference this section from a page definition. | +| `title` | `string` | Yes | Heading displayed above the section's rows on the check-your-answers page. | +| `hideTitle` | `boolean` | No | When `true`, the section heading is suppressed on the check-your-answers page. | + +### Assigning pages to a section + +Set the `section` property on a page definition to the section's `name`: + +```json +{ + "pages": [ + { + "path": "/full-name", + "title": "What is your full name?", + "section": "applicant", + "components": [...] + }, + { + "path": "/date-of-birth", + "title": "What is your date of birth?", + "section": "applicant", + "components": [...] + }, + { + "path": "/project-description", + "title": "Describe your project", + "section": "application", + "components": [...] + } + ] +} +``` + +Pages not assigned to a section appear in a separate, unlabelled group at the end of the summary. + +--- + +## Options + +The `options` object controls a handful of form-wide behaviours. + +```json +{ + "options": { + "showReferenceNumber": true, + "disableUserFeedback": false + } +} +``` + +### Reference number display + +`options.showReferenceNumber` — when `true`, the auto-generated reference number is displayed inside the GOV.UK panel on the [Status page](./pages/status-page.mdx) after submission. + +```json +{ + "options": { + "showReferenceNumber": true + } +} +``` + +The reference number is always generated for every form session regardless of this setting. The setting only controls whether it is visible to the user on the confirmation screen. + +When a form uses [GOV.UK Pay](./components/payment-field.mdx), the same reference number is passed to Gov.UK Pay as the payment reference. Enabling `showReferenceNumber` lets users correlate their form submission with their payment receipt. + +### Reference number prefix + +`metadata.referenceNumberPrefix` — a string prepended to the auto-generated reference number. Without a prefix the format is `XXX-XXX-XXX`. With a prefix the format is `PREFIX-XXX-XXX`. + +```json +{ + "metadata": { + "referenceNumberPrefix": "APP" + } +} +``` + +An `APP` prefix produces reference numbers like `APP-R3K-2WN`. The characters after the prefix are drawn from an unambiguous alphabet (no `0`, `O`, `1`, `I`, etc.) and filtered to avoid profanity. + +### User feedback + +`options.disableUserFeedback` — when `true`, the "What do you think of this service?" feedback link is suppressed on all pages, including the confirmation page. Defaults to `false`. + +```json +{ + "options": { + "disableUserFeedback": true + } +} +``` + +--- + +## Phase banner + +`phaseBanner.phase` sets the phase label shown in the [GOV.UK phase banner](https://design-system.service.gov.uk/components/phase-banner/) on every page of the form. Accepted values are `"alpha"` and `"beta"`. + +```json +{ + "phaseBanner": { + "phase": "beta" + } +} +``` + +The value is exposed as the `phaseTag` template variable. See [Page elements](./page-elements.mdx#phase-banner) for how to wire it into your base layout. + +--- + +## Declaration + +`declaration` adds a "Declaration" heading followed by rendered HTML immediately above the submit button on the check-your-answers page. Use it for legal declarations the user implicitly agrees to by submitting. + +```json +{ + "declaration": "

By submitting this form you confirm the information is correct to the best of your knowledge.

" +} +``` + +The value is rendered as raw HTML (marked safe). Unlike the `DeclarationField` component, this does not require an explicit checkbox — it is purely presentational. diff --git a/docs/features/index.md b/docs/features/index.md index a51bb03d6..4220be070 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -1,6 +1,6 @@ # Features -forms-engine-plugin provides built-in components and page types you can use immediately in your form definitions, as well as advanced features for driving dynamic behaviour or writing custom code. +forms-engine-plugin provides built-in components and page types you can use immediately in your form definitions, as well as extension points for driving dynamic behaviour or writing custom code. ## [Components](./features/components) @@ -10,11 +10,19 @@ A library of built-in form components — text fields, date inputs, radio button Built-in page controllers that define how a page behaves — question pages, repeating groups, file upload pages, summary and confirmation pages. +## [Conditions](./features/conditions) + +Make pages conditional — skip them based on the user's previous answers. Conditions support comparisons across all answer types and can be composed into nested logic groups. + ## [Page Elements](./features/page-elements) -View model properties that the plugin provides for page-level GOV.UK Frontend components — back link, phase banner, page title, service navigation, and footer — which your base layout template must render. +View model properties the plugin provides to your base layout template — back link, phase banner, page title, errors, and feedback link. Required reading when building or customising the base Nunjucks layout. + +## [Form Definition Options](./features/form-definition-options) + +Top-level form definition fields that control global behaviour: sections, lists, reference number display, user feedback, phase banner label, and declaration text on the summary page. -## Advanced +## Extending the plugin ### [Configuration-based Features](./features/configuration-based) diff --git a/docs/form-definition-formats.md b/docs/form-definition-formats.md deleted file mode 100644 index 7a51b2949..000000000 --- a/docs/form-definition-formats.md +++ /dev/null @@ -1,49 +0,0 @@ -# Form definition formats - -Form definitions are retrieved by `forms-engine-plugin` using the `formsService` plugin registration option. The relevant function is `getFormDefinition()`, which must return a JavaScript object that matches the schema of a form definition. - -You can choose: - -- To load your form definitions from an external source (e.g. an API you control, a database, etc) by building a custom `formsService` -- To use an out-of-the-box `formsService` called a 'loader' that loads forms from disk - this is an abstraction on top of the service interface - -## File-based form loading - -For convenience, the engine provides a pre-defined service that supports loading forms from disk using YAML (preferred for multi-line content) or JSON formats. YAML is recommended for developers as it natively supports line breaks in content blocks, making it ideal for forms with HTML content: - -```yaml -# example-form.yaml -name: "Form name" -pages: - - title: "Page title" - components: - - type: "Html" - content: | -

- govuk-heading-l -

- -

- govuk-body -

-``` - -```jsonc -# example-form.json -{ - "name": "Form name", - "pages": [ - { - "title": "Page title", - "components": [ - { - "type": "Html", - "hint": "

govuk-heading-l

govuk-body

" - } - ] - } - ] -} -``` - -See the [Custom Services guide](features/code-based/custom-services) for complete documentation on using the `FileFormService` class with the loader pattern, or for implementing custom `formsService` solutions for more complex requirements. diff --git a/docs/getting-started.md b/docs/getting-started.md index e531b5b7a..50ffd335e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -48,7 +48,7 @@ Additional npm dependencies that you will need are: ## Step 2: Decide where you want to store your forms and in what format -See [form definition formats](./form-definition-formats) to understand your options. For simple use-cases, we recommend you use our disk-based form loader using YAML form definitions. +See [form definition formats](./features/code-based/form-definition-formats) to understand your options. For simple use-cases, we recommend you use our disk-based form loader using YAML form definitions. This will influence the `services.formsService` you provide when registering the plugin (see step 3 below). @@ -319,139 +319,14 @@ Lists are used to populated selection components like Radios and Selects #### Conditions -Conditions bring logic to the form, when assigned to a page they make the page "conditional" and the page is only visited if the condition evaluates to "truthy" +Conditions make pages conditional — assign a condition to a page and the engine skips it if the condition does not pass. Pages are always traversed in the order they appear in the `pages` array; conditions cause individual pages to be bypassed, not re-ordered. -```jsonc -{ - // Each condition is identified by an UUID - "id": "0e7ae320-c876-40c2-8803-7848cc49689b", - - // Condition displayName should be unique - "displayName": "faveColourIsRed", - - "items": [ - { - // Each condition item is identified by an UUID - "id": "f03a6735-0f7c-4dc9-b65c-7c42fcd0d189", - - // `componentId` is a reference to the component - "componentId": "fa67e20d-a89b-4e8a-85ec-8a63923b7137", - - // Condition `operator` is a comparison operator ('is', 'is not', 'is longer than', 'contains', 'has length' etc.) - "operator": "is", - - // Conditions item values come in a few different forms: - - // 1. `ListItemRef` - use these when the condition references a question (componentId) that is a list selection - // The `value` of a `ListitemRef` should be an object with a listId and itemId keys pointing to the list and list item - "type": "ListItemRef", - "value": { - "listId": "23d5309e-1aed-427d-b8ee-87e14f673e7f", // References the "Colours" list - "itemId": "bedd5984-fa95-48f9-87e2-1089d66574b2" // References the "Red" item in the "Colours" list - }, - - // 2. `RelativeDate` - relative date for date-based conditions - // The `value` of a `RelativeDate` should be an object with a listId and itemId keys pointing to the list and list item - "type": "RelativeDate", - "value": { - "period": 1, // Numeric amount of the time period - "unit": "weeks", // Time unit (days, weeks, months, years), - "direction": "future" // Temporal direction (either "past" or "future"') - }, - - // 3. Scalar values can be `StringValue`, `NumberValue`, `BooleanValue` or `DateValue` - // and are used to check absolute values of strings (TextField), numbers (NumberField), booleans (YesNoField) or dates (DatePartsField) - // They are also used when the `operator` implies a numeric parameter e.g. 'has length' see below for examples. - // The `value` of a scalar value condition should be a literal of the same type e.g. - "type": "StringValue", - "value": "Enrique Chase" - } - ], - - // When the condition has 2 or more items, a coordinator is also required - "coordinator": "and", // Supports both "and" and "or" -} -``` +See the [Conditions guide](./features/conditions) for full documentation, including all supported operators, condition types, and worked examples. -#### Condition examples +## What to explore next -```jsonc -{ - "name": "Example form asking what a users favourite animal are, with an condition based on their answer", - "pages": [ - { - "id": "a86ea4ba-ae3b-4324-9acd-3a3f347cb0ec", - "title": "What are your favourite animals", - "path": "/favourite-animal", - "components": [ - { - // ComponentId - "id": "f0f67bf7-cdbb-4247-9f3c-8cd919183968", - "type": "CheckboxesField", - "title": "What are your favourite animals", - "name": "nUaCCW", - "shortDescription": "Favourite animals", - "hint": "", - "options": { - "required": true - }, - "schema": {}, - - // References the "Animals" list - "list": "0e047f83-dbb6-4c82-b709-f9dbaddf8644" - } - ], - "next": [] - } - ], - "conditions": [ - { - "items": [ - { - // This condition checks if the user chose "Monkey" as one of their favourite animals - "id": "86e63584-12a8-4f2b-b51b-49765518b811", - "componentId": "f0f67bf7-cdbb-4247-9f3c-8cd919183968", - "operator": "contains", - "type": "ListItemRef", - "value": { - // Reference to the "Animals" list - "listId": "0e047f83-dbb6-4c82-b709-f9dbaddf8644", - // Reference to "Monkey" in the "Animals" list - "itemId": "0c546ae1-897e-48d0-9388-b0902fe23baf" - } - } - ], - "displayName": "FaveAnimalIsMonkey", - "id": "8a3f6bb2-c305-410a-a037-7375be839105" - } - ], - "sections": [], - "lists": [ - { - "id": "0e047f83-dbb6-4c82-b709-f9dbaddf8644", - "name": "sdewRT", - "title": "Animals", - "type": "string", - "items": [ - { - "id": "fb3519b2-c6c7-40b6-8e03-2fb0db6d4f32", - "text": "Horse", - "value": "horse" - }, - { - "id": "0c546ae1-897e-48d0-9388-b0902fe23baf", - "text": "Monkey", - "value": "monkey" - }, - { - "id": "39f6fa65-1781-4569-9ba3-d8d13931f036", - "text": "Giraffe", - "value": "giraffe" - } - ] - } - ], - "engine": "V2", - "schema": 2 -} -``` +- [Components](./features/components) — the full library of built-in form components available in your form definitions +- [Page Types](./features/pages) — built-in page controllers for question pages, file upload, repeating sections, summary, and confirmation +- [Plugin Options](./plugin-options) — complete reference for all plugin registration options +- [Conditions](./features/conditions) — make pages conditional based on previous answers, with support for text, date, boolean, and number comparisons +- [Configuration-based Features](./features/configuration-based) — drive dynamic behaviour through form definitions, with page events and LiquidJS templates diff --git a/docs/plugin-options.md b/docs/plugin-options.md index 886d99708..8efca9f68 100644 --- a/docs/plugin-options.md +++ b/docs/plugin-options.md @@ -147,79 +147,17 @@ await server.register({ }) ``` -### Custom globals +### Custom globals and filters -Use the `globals` plugin option to provide custom functions that can be called from within Nunjucks templates. +The `globals` and `filters` options extend the Nunjucks template environment with custom functions and transform filters, available in all form page templates. -Unlike filters which transform values, globals are functions that can be called directly in templates. - -Example: - -```js -await server.register({ - plugin, - options: { - globals: { - getCurrentYear: () => new Date().getFullYear(), - formatCurrency: (amount) => new Intl.NumberFormat('en-GB', { - style: 'currency', - currency: 'GBP' - }).format(amount) - } - } -}) -``` - -In your templates: - -```html -

Copyright {{ getCurrentYear() }}

-

Total: {{ formatCurrency(123.45) }}

-``` - -### Custom filters - -Use the `filter` plugin option to provide custom template filters. -Filters are available in both [nunjucks](https://mozilla.github.io/nunjucks/templating.html#filters) and [liquid](https://liquidjs.com/filters/overview.html) templates. - -```js -const formatter = new Intl.NumberFormat('en-GB') - -await server.register({ - plugin, - options: { - filters: { - money: value => formatter.format(value), - upper: value => typeof value === 'string' ? value.toUpperCase() : value - } - } -}) -``` +See [Template extensions](./features/code-based/template-extensions) for registration examples and the list of built-in filters. ### Custom cache -The plugin will use the [default server cache](https://hapi.dev/api/?v=21.4.0#-serveroptionscache) to store form answers on the server. -This is just an in-memory cache which is fine for development. - -In production you should create a custom cache one of the available `@hapi/catbox` adapters. +The default in-memory cache is unsuitable for production. Pass a named hapi catbox cache string for most deployments, or a subclassed `CacheService` instance when you need to customise any part of the state storage lifecycle. -E.g. [Redis](https://github.com/hapijs/catbox-redis) - -```js -import { Engine as CatboxRedis } from '@hapi/catbox-redis' - -const server = new Hapi.Server({ - cache : [ - { - name: 'my_cache', - provider: { - constructor: CatboxRedis, - options: {} - } - } - ] -}) -``` +See [Session cache](./features/code-based/session-cache) for setup instructions. ### onRequest @@ -256,36 +194,9 @@ await server.register({ ### saveAndExit -The `saveAndExit` plugin option enables custom session handling to enable "Save and Exit" functionality. It is an optional route handler function that is called with the hapi request and response toolkit in addition to the last argument which is the [form context](./request-lifecycle) of the current page from which the save and exit button was pressed: - -```ts -export type SaveAndExitHandler = ( - request: FormRequestPayload, - h: FormResponseToolkit, - context: FormContext -) => ResponseObject -``` - -```js -await server.register({ - plugin, - options: { - saveAndExit: ( - request: FormRequestPayload, - h: FormResponseToolkit, - context: FormContext - ) => { - const { params } = request - const { slug } = params - - // Redirect user to custom page to handle saving - return h.redirect(`/custom-magic-link-save-and-exit/${slug}`) - } - } -}) -``` +The `saveAndExit` option adds a secondary button to question pages that lets users save their progress and return later. When clicked, the plugin calls your handler after validating the current page. -For detailed documentation and examples, see [Save and Exit](./features/code-based/save-and-exit). +See [Save and Exit](./features/code-based/save-and-exit) for the full guide including the handler signature and examples. ### Geospatial map diff --git a/docs/reference.md b/docs/reference.md new file mode 100644 index 000000000..f3ab1f2a3 --- /dev/null +++ b/docs/reference.md @@ -0,0 +1,7 @@ +# Reference + +Technical reference for forms-engine-plugin internals and runtime behaviour. + +## [Request Lifecycle](./request-lifecycle) + +How a request flows through the plugin: routing, validation, page controller resolution, pre- and post-controller event execution, and error handling. diff --git a/docs/request-lifecycle.md b/docs/request-lifecycle.md index 87d0eadfd..03ae962ff 100644 --- a/docs/request-lifecycle.md +++ b/docs/request-lifecycle.md @@ -30,7 +30,7 @@ When a request is received, the engine processes it through several stages: 4. **Pre-Controller Event Execution (onLoad)** - Before page controller execution, the engine checks for and executes any configured `onLoad` events. -- When an `onLoad` event of type `http` is present, the engine makes an HTTP request to the configured endpoint (see `handleHttpEvent` function). +- When an `onLoad` event of type `http` is present, the engine makes an HTTP request to the configured endpoint (see [Page events](./features/configuration-based/page-events)). - Event responses may update the context data before the page controller processes it. - onLoad events execute regardless of any conditions and are used to fetch external data needed for page rendering. @@ -61,20 +61,7 @@ When a request is received, the engine processes it through several stages: - The application uses this for error handling: if the response is a Boom error, it logs the error details and formats a structured error response. - After this processing, the final response is sent to the client. -## Key Components +## See also -- **Routes**: Defined in `src/server/plugins/engine/plugin.ts` and related route files -- **Page Controllers**: Handle the business logic for each form page type -- **Events**: Configurable hooks (`onLoad`, `onSave`) that can trigger HTTP requests or other actions -- **Context**: Carries validated state and data throughout the request lifecycle -- **Helpers**: Utility functions for redirecting, proceeding, and normalizing paths -- **Validation**: Joi schemas for route parameters and dynamic payload validation - -## Related Documentation - -For more technical details, see: - -- Route definitions: `src/server/plugins/engine/plugin.ts` -- Page controllers: `src/server/plugins/engine/pageControllers/` -- Event handling: `handleHttpEvent` function in `questions.ts` -- Context preparation: `src/server/plugins/engine/helpers.js` +- [Page events](./features/configuration-based/page-events) — configure `onLoad` and `onSave` hooks to call external APIs at specific points in the lifecycle +- [Custom page controllers](./features/code-based/page-controllers) — extend controller behaviour with custom GET and POST handlers diff --git a/docusaurus.config.cjs b/docusaurus.config.cjs index 6c5b7f068..73c052e8c 100644 --- a/docusaurus.config.cjs +++ b/docusaurus.config.cjs @@ -80,9 +80,14 @@ const config = { sidebar: [ { text: 'Components', href: '/features/components' }, { text: 'Page Types', href: '/features/pages' }, + { text: 'Conditions', href: '/features/conditions' }, { text: 'Page Elements', href: '/features/page-elements' }, { - text: 'Advanced', + text: 'Form Definition Options', + href: '/features/form-definition-options' + }, + { + text: 'Extending the plugin', href: '/features', items: [ { @@ -101,14 +106,8 @@ const config = { { text: 'Schema', href: '/schemas', sidebar: 'auto' }, { text: 'Reference', - href: '/request-lifecycle', - sidebar: [ - { text: 'Request Lifecycle', href: '/request-lifecycle' }, - { - text: 'Form Definition Formats', - href: '/form-definition-formats' - } - ] + href: '/reference', + sidebar: [{ text: 'Request Lifecycle', href: '/request-lifecycle' }] } ], diff --git a/scripts/component-metadata.json b/scripts/component-metadata.json index 5f88e3c64..f45dd7219 100644 --- a/scripts/component-metadata.json +++ b/scripts/component-metadata.json @@ -34,7 +34,8 @@ "TerminalPageController": "A dead-end page that does not route the user to another page. Use this for outcomes where the journey ends without proceeding to the summary — for example, an ineligibility screen.", "RepeatPageController": "Allows the user to add multiple sets of answers to the same group of questions. Answers are stored as an array under the key defined by `repeat.options.name`.", "FileUploadPageController": "A question page that handles file upload via the CDP file upload service. Must contain a `FileUploadField` component.", - "SummaryPageController": "Displays a check-your-answers summary of all form responses before submission. Must use the path `/summary`." + "SummaryPageController": "Displays a check-your-answers summary of all form responses before submission. Must use the path `/summary`.", + "StatusPageController": "The confirmation page shown after a successful form submission. Renders a GOV.UK panel with the page title and, optionally, the reference number. Automatically added to every form at `/status` — only define it explicitly to customise the title." }, "properties": { "required": "Whether the field must be filled in. Defaults to `true`.", @@ -79,6 +80,11 @@ "content": "HTML or Markdown content to display." }, "pageLinks": { + "StatusPageController": [ + "The status page is automatically injected into every form at `/status` if one is not already present in the form definition. You only need to add it explicitly to customise the title.", + "The submission guidance shown under \"What happens next\" comes from `formMetadata.submissionGuidance`, not the form definition itself. Set it on the metadata object returned by your `formsService`. The value is rendered as Markdown (headings level 3 and below).", + "Set `options.showReferenceNumber` to `true` in the form definition to display the auto-generated reference number inside the confirmation panel. See [Form definition options](../../form-definition-options.md) for details." + ], "FileUploadPageController": [ "This controller manages the CDP file upload lifecycle: it initiates an upload session with `cdp-uploader` on each page load, sets the CDP upload URL as the form action so the browser posts files directly to CDP, polls CDP for the upload status on return, and populates session state with completed `FileState` entries. It is tightly coupled to the CDP file upload service and is the provided implementation for Defra teams. See the [File Upload guide](../code-based/file-upload.md) for setup instructions, including the required `formSubmissionService` interface." ] @@ -109,6 +115,18 @@ "This component renders an inline Ordnance Survey map that lets users click a location to auto-populate the coordinate inputs across multiple coordinate formats. The map requires the `ordnanceSurveyApiKey` and `ordnanceSurveyApiSecret` [plugin options](../../plugin-options.md#geospatial-map) to be set — without them the component falls back to plain text inputs." ] }, + "componentSecrets": { + "PaymentField": [ + { + "name": "payment-test-api-key", + "description": "GOV.UK Pay API key used when the form is in test or draft mode." + }, + { + "name": "payment-live-api-key", + "description": "GOV.UK Pay API key used when the form is in live mode." + } + ] + }, "pageProperties": { "components": "Array of component definitions rendered on the page.", "condition": "Name of a condition that controls whether this page is shown.", diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js index 9716300b8..bd0ad1f1d 100644 --- a/scripts/generate-component-docs.js +++ b/scripts/generate-component-docs.js @@ -733,6 +733,7 @@ export function generateComponentMd( const { options = [], schema = [], props = [] } = interfaceData const links = metadata.componentLinks?.[componentName] ?? [] + const secrets = metadata.componentSecrets?.[componentName] ?? [] // leading '' ensures a blank line between frontmatter and the import // Level 1 components require client-side JavaScript to render and can't be statically previewed @@ -789,7 +790,7 @@ export function generateComponentMd( for (const prop of props) { const desc = metadata.properties[prop.name] ?? '' lines.push( - `| \`${prop.name}\` | \`${prop.type}\` | ${prop.optional ? 'No' : 'Yes'} | ${desc} |` + `| \`${prop.name}\` | \`${prop.type.replace(/\|/g, '\\|')}\` | ${prop.optional ? 'No' : 'Yes'} | ${desc} |` ) } lines.push(``) @@ -802,7 +803,7 @@ export function generateComponentMd( for (const prop of options) { const desc = metadata.properties[prop.name] ?? '' lines.push( - `| \`${prop.name}\` | \`${prop.type}\` | ${prop.optional ? 'No' : 'Yes'} | ${desc} |` + `| \`${prop.name}\` | \`${prop.type.replace(/\|/g, '\\|')}\` | ${prop.optional ? 'No' : 'Yes'} | ${desc} |` ) } lines.push(``) @@ -814,7 +815,23 @@ export function generateComponentMd( lines.push(`|----------|------|-------------|`) for (const prop of schema) { const desc = metadata.properties[prop.name] ?? '' - lines.push(`| \`${prop.name}\` | \`${prop.type}\` | ${desc} |`) + lines.push( + `| \`${prop.name}\` | \`${prop.type.replace(/\|/g, '\\|')}\` | ${desc} |` + ) + } + lines.push(``) + } + + if (secrets.length > 0) { + lines.push(`## Required secrets`, ``) + lines.push( + `This component retrieves secrets at runtime via [\`getFormSecret\`](../code-based/custom-services.md#formsservice) on your \`formsService\`. Implement it to return the correct value from your secrets store — do not use environment variables or plugin options for per-form secrets.`, + `` + ) + lines.push(`| Secret name | Description |`) + lines.push(`|---|---|`) + for (const secret of secrets) { + lines.push(`| \`${secret.name}\` | ${secret.description} |`) } lines.push(``) } @@ -977,7 +994,7 @@ export function generatePageMd( for (const prop of uniqueProps) { const desc = metadata.pageProperties?.[prop.name] ?? '' lines.push( - `| \`${prop.name}\` | \`${prop.type}\` | ${prop.optional ? 'No' : 'Yes'} | ${desc} |` + `| \`${prop.name}\` | \`${prop.type.replace(/\|/g, '\\|')}\` | ${prop.optional ? 'No' : 'Yes'} | ${desc} |` ) } lines.push(``) diff --git a/scripts/generate-component-docs.test.js b/scripts/generate-component-docs.test.js index 6173fec50..59a6802ae 100644 --- a/scripts/generate-component-docs.test.js +++ b/scripts/generate-component-docs.test.js @@ -47,7 +47,7 @@ jest.mock('fs', () => ({ readdirSync: jest.fn(), readFileSync: jest.fn().mockImplementation((filePath) => { if (String(filePath ?? '').includes('component-metadata.json')) { - return '{"components":{"TextField":"Single-line text input."},"pages":{"PageController":"The default page type.","RepeatPageController":"Allows repeated answers.","SummaryPageController":"Summary page type."},"properties":{"rows":"Number of rows for the textarea."},"pageProperties":{"repeat.options.name":"Identifier for the repeatable section."}}' + return '{"components":{"TextField":"Single-line text input.","PaymentField":"Redirects the user to GOV.UK Pay."},"pages":{"PageController":"The default page type.","RepeatPageController":"Allows repeated answers.","SummaryPageController":"Summary page type."},"properties":{"rows":"Number of rows for the textarea."},"pageProperties":{"repeat.options.name":"Identifier for the repeatable section."},"componentSecrets":{"PaymentField":[{"name":"payment-test-api-key","description":"GOV.UK Pay API key for test mode."},{"name":"payment-live-api-key","description":"GOV.UK Pay API key for live mode."}]}}' } return '' }), @@ -436,6 +436,24 @@ describe('Component Documentation Generator', () => { }) }) + describe('generateComponentMd with componentSecrets', () => { + const interfaceData = { options: [], schema: [], props: [] } + + it('renders a Required secrets section with blurb and table when secrets are defined', () => { + const result = generateComponentMd('PaymentField', interfaceData, 1) + expect(result).toContain('## Required secrets') + expect(result).toContain('`getFormSecret`') + expect(result).toContain('`payment-test-api-key`') + expect(result).toContain('`payment-live-api-key`') + }) + + it('omits Required secrets section for components with no secrets', () => { + const result = generateComponentMd('TextField', interfaceData, 1) + expect(result).not.toContain('## Required secrets') + expect(result).not.toContain('getFormSecret') + }) + }) + describe('buildJsNotice', () => { it('Level 1: renders a GOV.UK notification banner with banner structure', () => { const result = buildJsNotice(1, 'Notice text.') diff --git a/scripts/generate-component-previews.js b/scripts/generate-component-previews.js index 7fcf546b7..9ab7856b3 100644 --- a/scripts/generate-component-previews.js +++ b/scripts/generate-component-previews.js @@ -53,7 +53,10 @@ export function renderComponent(fixture) { * @param {string} [wrapperClass] * @returns {string} */ -export function buildPartialMdx(renders, wrapperClass = 'component-preview') { +export function buildPartialMdx( + renders, + wrapperClass = 'component-preview app-no-prose' +) { return renders .map(({ label, html }) => { const escaped = html.replace(/`/g, '\\`').replace(/\$\{/g, '\\${') diff --git a/scripts/generate-component-previews.test.js b/scripts/generate-component-previews.test.js index 52413f536..033e6213e 100644 --- a/scripts/generate-component-previews.test.js +++ b/scripts/generate-component-previews.test.js @@ -91,7 +91,7 @@ describe('component-preview-fixtures', () => { describe('buildPartialMdx', () => { it('wraps a single render in a component-preview div', () => { const result = buildPartialMdx([{ html: '' }]) - expect(result).toContain('className="component-preview"') + expect(result).toContain('className="component-preview app-no-prose"') expect(result).toContain('dangerouslySetInnerHTML') expect(result).toContain('govuk-input') }) @@ -103,7 +103,7 @@ describe('buildPartialMdx', () => { ]) expect(result).toContain('Before payment') expect(result).toContain('After payment') - const matches = result.match(/className="component-preview"/g) + const matches = result.match(/className="component-preview app-no-prose"/g) expect(matches).toHaveLength(2) }) @@ -199,7 +199,7 @@ describe('writePreviewPartial', () => { fixtures.PaymentField ) const written = writeFileSync.mock.calls[0][1] - const matches = written.match(/className="component-preview"/g) + const matches = written.match(/className="component-preview app-no-prose"/g) expect(matches).toHaveLength(2) }) diff --git a/scripts/generate-page-previews.js b/scripts/generate-page-previews.js index c87d1e4bb..12ba231a0 100644 --- a/scripts/generate-page-previews.js +++ b/scripts/generate-page-previews.js @@ -52,7 +52,10 @@ export function writePagePreviewPartial(previewsDir, slug, fixture) { fs.writeFileSync( path.join(previewsDir, `${slug}.mdx`), - buildPartialMdx(renders, 'component-preview component-preview--page') + buildPartialMdx( + renders, + 'component-preview component-preview--page app-no-prose' + ) ) } diff --git a/scripts/generate-page-previews.test.js b/scripts/generate-page-previews.test.js index 7e89e0ae1..7679dd70b 100644 --- a/scripts/generate-page-previews.test.js +++ b/scripts/generate-page-previews.test.js @@ -130,7 +130,7 @@ describe('writePagePreviewPartial', () => { }) expect(mockBuildPartialMdx).toHaveBeenCalledWith( [{ html: '
page html
' }], - 'component-preview component-preview--page' + 'component-preview component-preview--page app-no-prose' ) }) @@ -158,7 +158,7 @@ describe('writePagePreviewPartial', () => { { label: 'No files uploaded', html: '
page html
' }, { label: 'With files uploaded', html: '
page html
' } ], - 'component-preview component-preview--page' + 'component-preview component-preview--page app-no-prose' ) }) diff --git a/scripts/page-preview-fixtures.js b/scripts/page-preview-fixtures.js index e4810c22c..bc4858d29 100644 --- a/scripts/page-preview-fixtures.js +++ b/scripts/page-preview-fixtures.js @@ -258,6 +258,22 @@ export const pageFixtures = { ] }, + StatusPageController: { + // Stub context — StatusPageController builds its view model in the route + // handler (not getViewModel), so we construct it manually here. + context: /** @type {PageViewModel} */ ( + /** @type {unknown} */ ({ + page: { viewName: 'confirmation' }, + pageTitle: 'Application received', + submissionGuidance: + "We'll review your application and contact you within 10 working days.", + showReferenceNumber: true, + referenceNumber: 'R3K-2WN-P5M', + feedbackLink: '/form/feedback?formId=example' + }) + ) + }, + SummaryPageController: { context: pageViewContext({ pages: [