@planning-inspectorate/dynamic-forms
v3.8.0
Published
This package is for building GDS forms using a configuration-based approach.
Keywords
Readme
Dynamics Forms
This package is for building GDS forms using a configuration-based approach. It allows you to define a set of questions, and combine them into journeys. Questions can be configured to be included conditionally, based on the answers to other questions.
The functionality for 'check-your-answers' pages can also be useful for generating pages outside the context of a journey or form, such as for managing data. Each row shown on the page can include a link to edit that data.
Note this is ported from Appeals: dynamic forms, but transformed for ES6 modules and Node Test Runner. Not all functionality has been brought across, such as 'add more'. It is hoped that this version can be developed over time so Appeals can move to this version.
Terminology
- Component - A "blueprint" of a type of question. i.e. input, radio button, checkbox etc.
- Question - A specific question within a journey which is made up of one (usually) or many (sometimes) components and their required content.
- Section - A group of Questions
- Journey - An entire set of questions required for a completion of a submission
- Answer - Data input by a user against a specific Question.
- Response - a collection of answers submitted as part of a Journey.
- Validation - Verification that an individual Answer meets the criteria of that Question. i.e. String is greater than 3 characters.
Requirements
To use this package nunjucks must be configured so that the component view files are available, and static assets must be made available for certain components and styling. Some nunjucks templates are required to correctly render the question pages and task list views.
Nunjucks
Nunjucks template paths needs configuring with the govuk-frontend and the dynamic forms folder, e.g.
import { createRequire } from 'node:module';
import path from 'node:path';
import nunjucks from 'nunjucks';
export function configureNunjucks() {
// get the require function, see https://nodejs.org/api/module.html#modulecreaterequirefilename
const require = createRequire(import.meta.url);
// path to dynamic forms folder
const dynamicFormsRoot = path.resolve(require.resolve('@planning-inspectorate/dynamic-forms'), '..');
// get the path to the govuk-frontend folder, in node_modules, using the node require resolution
const govukFrontendRoot = path.resolve(require.resolve('govuk-frontend'), '../..');
const appDir = path.join('some/path/to/the', 'app');
// configure nunjucks
return nunjucks.configure(
// ensure nunjucks templates can use govuk-frontend components, and templates we've defined in `web/src/app`
[dynamicFormsRoot, govukFrontendRoot, appDir],
{
// output with dangerous characters are escaped automatically
autoescape: true,
// automatically remove trailing newlines from a block/tag
trimBlocks: true,
// automatically remove leading whitespace from a block/tag
lstripBlocks: true
}
);
}
Assets
The select component can use the accessible-autocomplete package. This requires configuring, the component assumes the following paths are available:
/assets/css/accessible-autocomplete.min.css/assets/js/accessible-autocomplete.min.js
Layouts
There are two layout files that need to be configured for dynamic-forms: journey and task list.
Journey template
The journey template is used for question pages. It must include a block called dynQuestionContent, and a block called head (which the default gov.uk template has).
It should also include the rendering of the errorSummary component. The path to this template is configured via the journeyTemplate property on a Journey.
e.g.
{% extends "govuk/template.njk" %}
{% block beforeContent %}
{% if errorSummary %}
<div class="govuk-grid-row">
<div class="govuk-grid-column-two-thirds">
{{ govukErrorSummary({
titleText: "There is a problem",
errorList: errorSummary,
attributes: {"data-cy": "error-wrapper"}
}) }}
</div>
</div>
{% endif %}
{% endblock %}
{% block content %}
<div class="govuk-grid-row">
<div class="govuk-grid-column-full">
<h1 class="govuk-heading-xl">
{% if pageCaption %}
<span class="govuk-caption-xl">{{ pageCaption }}</span>
{% endif %}
{{ pageHeading }}
</h1>
</div>
</div>
{% block dynQuestionContent %}{% endblock %}
{% endblock %}Task list template
The task list template is used for the task list or "check your answers" page. It must include a block called dynTaskList. The path to this template is configured via the taskListTemplate property on a Journey.
e.g.
{% extends "govuk/template.njk" %}
{% block content %}
<div class="govuk-grid-row">
<div class="govuk-grid-column-full">
<h1 class="govuk-heading-xl">
{% if pageCaption %}
<span class="govuk-caption-xl">{{ pageCaption }}</span>
{% endif %}
{{ pageHeading }}
</h1>
</div>
</div>
{% block dynTaskList %}{% endblock %}
{% endblock %}Usage
The test directory includes a very basic journey in test/journey.js. This may be a useful guide for setting up a journey and associated questions. Also in test/questions.test.js there is a function createAppWithQuestions which may be a useful guide for setting up the appropriate controllers and routes for a journey.
Conditions
Conditions are used to show/hide questions based on the answers to other questions. There are fluent methods on a Section to add conditions to questions in multiple ways.
The simplest condition applies to just one question:
import { whenQuestionHasAnswer } from '@planning-inspectorate/dynamic-forms';
new Section('section-key', 'Section title')
.addQuestion(questions.q1)
.addQuestion(questions.q2)
.withCondition(whenQuestionHasAnswer(questions.q1, 'yes'))In this scenario question two will only be shown if question one has an answer of 'yes'.
Conditions can also be added to an entire section:
new Section('section-key', 'Section title')
.withSectionCondition(whenQuestionHasAnswer(questions.q1, 'yes'))
.addQuestion(questions.q3)
.addQuestion(questions.q4)
.withCondition(whenQuestionHasAnswer(questions.q3, 'yes'))In this case q3 and q4 will only be shown if q1 (from a previous section) is answered 'yes'. Q4 will only show if q1 is answered 'yes' and q3 is answered 'yes'.
Finally, conditions can be added to multiple questions - these can overlap:
new Section('section-key', 'Section title')
.withSectionCondition(whenQuestionHasAnswer(questions.q1, 'yes'))
.addQuestion(questions.q2)
.startMultiQuestionCondition('group-1', whenQuestionHasAnswer(questions.q2, 'valid'))
.addQuestion(questions.q3)
.addQuestion(questions.q4)
.withCondition(whenQuestionHasAnswer(questions.q3, 'yes'))
.endMultiQuestionCondition('group-1')
.addQuestion(questions.q5)
.withCondition(whenQuestionHasAnswer(questions.q4, 'yes'))
.startMultiQuestionCondition('group-2', whenQuestionHasAnswer(questions.q5, 90))
.addQuestion(questions.q6)
.addQuestion(questions.q7)
.endMultiQuestionCondition('group-2')These conditions combine with individual conditions and section conditions. In the above scenario, the following applies:
| Question | Section Condition | Group Conditions | Question Conditions | |----------|-------------------|------------------|---------------------| | q2 | q1 = 'yes' | N/A | N/A | | q3 | q1 = 'yes' | q2 = 'valid' | N/A | | q4 | q1 = 'yes' | q2 = 'valid' | q3 = 'yes' | | q5 | q1 = 'yes' | N/A | q4 = 'yes' | | q6 | q1 = 'yes' | q5 = 90 | N/A | | q7 | q1 = 'yes' | q5 = 90 | N/A |
Email Validation
Email validation is provided through the EmailValidator class and EmailQuestion component. The validator uses express-validator's robust email validation with configurable options.
Basic Usage
import {
COMPONENT_TYPES,
createQuestions,
questionClasses,
EmailValidator
} from '@planning-inspectorate/dynamic-forms';
// Define question configuration
const questionProps = {
contactEmail: {
type: COMPONENT_TYPES.EMAIL,
title: 'Contact Information',
question: 'What is your email address?',
fieldName: 'contactEmail',
url: 'contact-email',
label: 'Email address',
validators: [
new EmailValidator({
errorMessage: 'Enter an email address in the correct format, like [email protected]'
})
]
}
};
// Create questions using the factory function
const questions = createQuestions(questionProps, questionClasses, {});
const emailQuestion = questions.contactEmail;The EmailQuestion uses the following default input attributes:
type="email"spellcheck="false"autocomplete="email"
Advanced Validation Options
For stricter email validation requirements:
const businessEmailValidator = new EmailValidator({
options: {
allowDisplayName: false, // Reject "Name <[email protected]>" format
requireTld: true, // Require top-level domain (default: true)
allowUtf8LocalPart: false, // Only ASCII characters in local part
allowIpDomain: false // Don't allow IP addresses as domain
},
errorMessage: 'Enter a valid business email address'
});Alternative: Single Line Input with Email Attributes
You can also configure a single line input with email-specific attributes:
import {
COMPONENT_TYPES,
createQuestions,
questionClasses,
EmailValidator
} from '@planning-inspectorate/dynamic-forms';
const questionProps = {
contactEmail: {
type: COMPONENT_TYPES.SINGLE_LINE_INPUT,
title: 'Contact Information',
question: 'What is your email address?',
fieldName: 'contactEmail',
url: 'contact-email',
label: 'Email address',
inputAttributes: { type: 'email', spellcheck: 'false' },
autocomplete: 'email',
validators: [new EmailValidator()]
}
};
const questions = createQuestions(questionProps, questionClasses, {});Contributing
When contributing to this package, ensure changes are generic and not service-specific. Speak to the R&D devs if you are not sure. Prefer configuration over hardcoding values, and ensure the code is well documented.
Commits must follow conventional commits, and the commit types will be used by semantic-release to determine the next version number. For example feat commits will result in a minor version bump, while fix commits will result in a patch version bump. Major version bumps should be reserved for breaking changes, and should be discussed with the R&D team before being made.
The package will be released automatically using semantic-release, on merge to main. This will include a git tag for the release, and publishing to NPM.
Type safety
We use JSDocs to describe the types used. This is helpful for the JavaScript developers, and crucial for this project being compatible with TypeScript. Type declarations for TypeScript users are auto-generated by tsc to create type declarations. So please:
- Add JSDocs annotations to your code, use
@importor an inline import, i.e.@param {import('#section').Section} section. Avoid a top-level import, e.g.@typedef {import('../validator/base-validator.js')} BaseValidator- this seems to create confusion when the compiler auto-generates the type declarations. - Maintain
index.js. If you're adding code that users of this module will import, please make it is being exported inindex.js.
Thank you for your co-operation and contributions!
Tests
There are some lightweight tests in the test directory which sets up a basic journey and checks the rendering for each question as well as redirect logic.
When adding a new question type, be sure to add an example question into test/questions.js, and mock answers into test/questions.test.js#mockAnswerBody and test/questions.test.js#mockAnswer. Also, the question should be added to the journey in test/journey.js.
To update any snapshots with rendering changes (or new questions), run node --test --test-update-snapshots.
Components
When implement a new component, extend the base Question class. Common methods to override are:
formatAnswerForSummary- used for check-your-answers displaygetDataToSave- answer data to saveaddCustomDataToViewModel- customise the view model with extra data/configurationanswerForViewModel- customise the view model answer value
Where possible addCustomDataToViewModel and answerForViewModel should be overridden instead of prepQuestionForRendering.
