Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 215 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,209 @@
# forms-engine
# @defra/forms-engine-plugin

Form hapi-plugin
The `@defra/forms-engine-plugin` is a [plugin](https://hapi.dev/tutorials/plugins/?lang=en_US) for [hapi](https://hapi.dev/) used to serve GOV.UK-based form journeys.

...
It is designed to be embedded in the frontend of a digital service and provide a convenient, configuration driven approach to building forms that are aligned to [GDS Design System](https://design-system.service.gov.uk/) guidelines.

## Installation

`npm install @defra/forms-engine-plugin --save`

## Dependencies

The following are [plugin dependencies](<https://hapi.dev/api/?v=21.4.0#server.dependency()>) that are required to be registered with hapi:

`npm install hapi-pino @hapi/crumb @hapi/yar @hapi/vision --save`

- [hapi-pino](https://github.com/hapijs/hapi-pino) - [Pino](https://github.com/pinojs/pino) logger for hapi
- [@hapi/crumb](https://github.com/hapijs/crumb) - CSRF crumb generation and validation
- [@hapi/yar](https://github.com/hapijs/yar) - Session manager
- [@hapi/vision](https://github.com/hapijs/vision) - Template rendering support

Additional npm dependencies that you will need are:

`npm install nunjucks govuk-frontend --save`

- [nunjucks](https://www.npmjs.com/package/nunjucks) - [templating engine](https://mozilla.github.io/nunjucks/) used by GOV.UK design system
- [govuk-frontend](https://www.npmjs.com/package/govuk-frontend) - [code](https://github.com/alphagov/govuk-frontend) you need to build a user interface for government platforms and services

Optional dependencies

`npm install @hapi/inert --save`

- [@hapi/inert](https://www.npmjs.com/package/@hapi/inert) - static file and directory handlers for serving GOV.UK assets and styles

## Setup

### Form config

The `form-engine-plugin` uses JSON configuration files to serve form journeys.
These files are called `Form definitions` and are built up of:

- `pages` - includes a `path`, `title`
- `components` - one or more questions on a page
- `conditions` - used to conditionally show and hide pages and
- `lists` - data used to in selection fields like [Select](https://design-system.service.gov.uk/components/select/), [Checkboxes](https://design-system.service.gov.uk/components/checkboxes/) and [Radios](https://design-system.service.gov.uk/components/radios/)

The [types](https://github.com/DEFRA/forms-designer/blob/main/model/src/form/form-definition/types.ts), `joi` [schema](https://github.com/DEFRA/forms-designer/blob/main/model/src/form/form-definition/index.ts) and the [examples](test/form/definitions) folder are a good place to learn about the structure of these files.

TODO - Link to wiki for `Form metadata`
TODO - Link to wiki for `Form definition`

#### Providing form config to the engine

The engine plugin registers several [routes](https://hapi.dev/tutorials/routing/?lang=en_US) on the hapi server.

They look like this:

```
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](#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.

```
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
}
}
```

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.

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.

See [example](#example) below for more detail

### Static assets and styles

TODO

## Example

```
import hapi from '@hapi/hapi'
import yar from '@hapi/yar'
import crumb from '@hapi/crumb'
import inert from '@hapi/inert'
import pino from 'hapi-pino'
import plugin from '@defra/forms-engine-plugin'

const server = hapi.server({
port: 3000
})

// Register the dependent plugins
await server.register(pino)
await server.register(inert)
await server.register(crumb)
await server.register({
plugin: yar,
options: {
cookieOptions: {
password: 'ENTER_YOUR_SESSION_COOKIE_PASSWORD_HERE' // Must be > 32 chars
}
}
})

// Register the `forms-engine-plugin`
await server.register({
plugin
})

await server.start()
```

## Environment variables

## Options

The forms plugin is configured with [registration options](https://hapi.dev/api/?v=21.4.0#plugins)

- `services` (optional) - object containing `formsService`, `formSubmissionService` and `outputService`
- `formsService` - used to load `formMetadata` and `formDefinition`
- `formSubmissionService` - used prepare the form during submission (ignore - subject to change)
- `outputService` - used to save the submission
- `controllers` (optional) - Object map of custom page controllers used to override the default. See [custom controllers](#custom-controllers)
- `filters` (optional) - A map of custom template filters to include
- `cacheName` (optional) - The cache name to use. Defaults to hapi's [default server cache]. Recommended for production. See [here]
(#custom-cache) for more details
- `pluginPath` (optional) - The location of the plugin (defaults to `node_modules/@defra/forms-engine-plugin`)

### Services

TODO

### Custom controllers

TODO

### 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.

```
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
}
}
})
```

### 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.

E.g. [Redis](https://github.com/hapijs/catbox-redis)

```
import { Engine as CatboxRedis } from '@hapi/catbox-redis'

const server = new Hapi.Server({
cache : [
{
name: 'my_cache',
provider: {
constructor: CatboxRedis,
options: {}
}
}
]
})
```

## Exemplar

TODO: Link to CDP exemplar

## Templates

The following elements support [LiquidJS templates](https://liquidjs.com/):

- Page **title**
- Form component **titles**
- Form component **title**
- Support for fieldset legend text or label text
- This includes when the title is used in **error messages**
- Html (guidance) component **content**
Expand Down Expand Up @@ -85,3 +279,20 @@ There are a number of `LiquidJS` filters available to you from within the templa
}
]
```

## Templates and views: Extending the default layout

TODO

To override the default page template, vision and nunjucks both need to be configured to search in the `forms-engine-plugin` views directory when looking for template files.

For vision this is done through the `path` [plugin option](https://github.com/hapijs/vision/blob/master/API.md#options)
For nunjucks it is configured through the environment [configure options](https://mozilla.github.io/nunjucks/api.html#configure).

The `forms-engine-plugin` path to add can be imported from:

`import { VIEW_PATH } from '@defra/forms-engine-plugin'`

Which can then be appended to the `node_modules` path `node_modules/@defra/forms-engine`.

The main template layout is `govuk-frontend`'s `template.njk` file, this also needs to be added to the `path`s that nunjucks can look in.
12 changes: 0 additions & 12 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@
"@hapi/vision": "^7.0.3",
"@hapi/wreck": "^18.1.0",
"@hapi/yar": "^11.0.2",
"@hapipal/schmervice": "^3.0.0",
"@types/humanize-duration": "^3.27.4",
"accessible-autocomplete": "^3.0.1",
"atob": "^2.1.2",
Expand Down
5 changes: 0 additions & 5 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import hapi, {
import inert from '@hapi/inert'
import Scooter from '@hapi/scooter'
import Wreck from '@hapi/wreck'
import Schmervice from '@hapipal/schmervice'
import blipp from 'blipp'
import { ProxyAgent } from 'proxy-agent'

Expand All @@ -25,7 +24,6 @@ import pluginPulse from '~/src/server/plugins/pulse.js'
import pluginRouter from '~/src/server/plugins/router.js'
import pluginSession from '~/src/server/plugins/session.js'
import { prepareSecureContext } from '~/src/server/secure-context.js'
import { CacheService } from '~/src/server/services/index.js'
import { type RouteConfig } from '~/src/server/types.js'

const proxyAgent = new ProxyAgent()
Expand Down Expand Up @@ -94,9 +92,6 @@ export async function createServer(routeConfig?: RouteConfig) {
await server.register(Scooter)
await server.register(pluginBlankie)
await server.register(pluginCrumb)
await server.register(Schmervice)

server.registerService(CacheService)

server.ext('onPreResponse', (request: Request, h: ResponseToolkit) => {
const { response } = request
Expand Down
7 changes: 6 additions & 1 deletion src/server/plugins/engine/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
type Page
} from '@defra/forms-model'
import Boom from '@hapi/boom'
import { type ResponseToolkit } from '@hapi/hapi'
import { type ResponseToolkit, type Server } from '@hapi/hapi'
import { format, parseISO } from 'date-fns'
import { StatusCodes } from 'http-status-codes'
import { type Schema, type ValidationErrorItem } from 'joi'
Expand Down Expand Up @@ -362,6 +362,7 @@ export function getExponentialBackoffDelay(depth: number): number {
const delay = BASE_DELAY_MS * 2 ** (depth - 1)
return Math.min(delay, CAP_DELAY_MS)
}

export function evaluateTemplate(
template: string,
context: FormContext
Expand All @@ -377,3 +378,7 @@ export function evaluateTemplate(
globals
})
}

export function getCacheService(server: Server) {
return server.plugins['forms-engine-plugin'].cacheService
}
42 changes: 41 additions & 1 deletion src/server/plugins/engine/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,47 @@
import { type Environment } from 'nunjucks'

import { engine } from '~/src/server/plugins/engine/helpers.js'
import { plugin } from '~/src/server/plugins/engine/plugin.js'
import { type FilterFunction } from '~/src/server/plugins/engine/types.js'
import {
checkComponentTemplates,
checkErrorTemplates,
evaluate
} from '~/src/server/plugins/nunjucks/environment.js'
import * as filters from '~/src/server/plugins/nunjucks/filters/index.js'

export { getPageHref } from '~/src/server/plugins/engine/helpers.js'
export { configureEnginePlugin } from '~/src/server/plugins/engine/configureEnginePlugin.js'
export { CacheService } from '~/src/server/services/index.js'
export { context } from '~/src/server/plugins/nunjucks/context.js'

const globals = {
checkComponentTemplates,
checkErrorTemplates,
evaluate
}

export const VIEW_PATH = 'src/server/plugins/engine/views'
export const PLUGIN_PATH = 'node_modules/@defra/forms-engine-plugin'

export const prepareNunjucksEnvironment = function (
env: Environment,
additionalFilters?: Record<string, FilterFunction>
) {
for (const [name, nunjucksFilter] of Object.entries(filters)) {
env.addFilter(name, nunjucksFilter)
}

for (const [name, nunjucksGlobal] of Object.entries(globals)) {
env.addGlobal(name, nunjucksGlobal)
}

// Apply any additional filters to both the liquid and nunjucks engines
if (additionalFilters) {
for (const [name, filter] of Object.entries(additionalFilters)) {
env.addFilter(name, filter)
engine.registerFilter(name, filter)
}
}
}

export default plugin
Loading
Loading