@planning-inspectorate/dynamic-forms
v3.25.0
Published
This package is for building GDS forms using a configuration-based approach.
Maintainers
Keywords
Readme
Dynamics Forms
This package is for building GOV.UK Frontend 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.
Getting started
In summary:
- Install the package:
npm install --save @planning-inspectorate/dynamic-forms - Configure nunjucks
- (optional) Configure accessible-autocomplete
- Add the question and check-your-answers templates
- Configure questions, journeys, and routes
For more details, see the Getting started guide.
Terminology
| Term | Description | |------------|----------------------------------------------------------------------------------------------------------------------| | Component | A reusable part of a user interface. Usually forming the main content of a page in the context of a user journey. | | Question | An instantiated component with associated configuration, a specific question within a journey | | Section | A group of questions, configured with any conditional logic | | Journey | An entire set of questions and sections which make up a user journey | | 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 |
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.
All exports are named exports from the root module:
import {COMPONENT_TYPES} from '@planning-inspectorate/dynamic-forms
Custom answer types
TypeScript consumers can supply an answer interface without an index signature. Both
JourneyResponse and Journey default to JourneyAnswers when no answer type is specified.
import { Journey, JourneyResponse } from '@planning-inspectorate/dynamic-forms';
import type { JourneyResponseLike } from '@planning-inspectorate/dynamic-forms';
interface HolidayAppViewModel {
holidayDestination: string;
holidayActivities: string[];
}
const answers: HolidayAppViewModel = {
holidayDestination: 'beach',
holidayActivities: ['swimming', 'sightseeing']
};
const response = new JourneyResponse<HolidayAppViewModel>('holiday-journey', 'ref', answers);
const journey = new Journey<HolidayAppViewModel>({
journeyId: 'holiday-journey',
response,
makeBaseUrl: (currentResponse) => `/journey/${currentResponse.referenceId}`,
journeyTemplate: 'views/layout-journey.njk',
taskListTemplate: 'views/layout-check-your-answers.njk',
journeyTitle: 'Holiday Booking',
sections: []
});
journey.response.answers.holidayDestination; // string
// Plain objects are also accepted in the constructor and by setResponse.
const plainResponse: JourneyResponseLike<HolidayAppViewModel> = {
journeyId: 'holiday-journey',
referenceId: 'ref',
answers
};
journey.setResponse(plainResponse);Answer types can also be inferred from the supplied response. makeBaseUrl and
setResponse retain the journey's answer type. Component, validator, controller,
and middleware APIs continue to use their existing default answer types.
Supply populated answers when using an interface with required properties:
JourneyResponse retains its existing empty-object fallback for null or missing
answers, which does not populate those properties.
Components
Components available are exported via the COMPONENT_TYPES constant.
Custom components
Components can be created and used with Dynamic Forms with some configuration. Extend the base Question class.
- Ensure nunjucks is configured with the folder containing your custom components
- Export the custom component type names and classes
- Merge the exported question classes object with the custom classes
See also Contributing > new components
Controllers
Dynamic forms includes several Express handlers/controllers which implement the core logic. They all have prerequisites and expect particular properties have been added to the res.locals object. These are:
res.locals.journeymust be an instance of theJourneyclassres.locals.journeyResponsemust be an instance of theJourneyResponseclass
They can be added using middleware, and utility functions are available for this, for example:
// get the answers from the session, and add JourneyResponse to `res.locals`
const getJourneyResponse = buildGetJourneyResponseFromSession(JOURNEY_ID);
// create the journey with the given journeyResponse and questions, and add to `res.locals`
const getJourney = buildGetJourney((req, journeyResponse) => createJourney(req, journeyResponse, questions));
router.use(
getJourneyResponse,
getJourney
);The controllers available are:
| Controller | Purpose | |----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------| | question | To render a question page as part of a journey | | buildSave | Returns a controller to handle saving the answer and either redirecting to the next question or show any validation errors if present. Accepts a save function. | | list/buildList | Renders a check-your-answers page showing all the answers, links to change any answer, and a button to submit the answers. |
Saving answers
The buildSave controller accepts a SaveDataFn which is passed the context required to save the answer(s). To save answers to session during a journey, a SaveDataFn implementation is provided called saveDataToSession.
For other use cases, write a custom SaveDataFn. A basic implementation (if you are not using ManageList or DynamicSection) needs to save the answers passed in as data.answers, which is an object whose keys are the question field names, and the values are the answers. For example:
const update: SaveDataFn = async ({data}) => {
const answers = data.answers;
console.log('saving', answers);
// TODO: map to database
// TODO: write to database
}To support ManageList questions as well, handle the isManageListItem, manageListQuestionFieldName, manageListItemRemove parameters. For example:
const update: SaveDataFn = async ({data, req, isManageListItem, manageListQuestionFieldName, manageListItemRemove}) => {
if (isManageListItem) {
const update = {
id: req.params.manageListItemId,
...data.answers
};
console.log('saving', update);
// TODO: map to database
// TODO: write to database
return;
} else if (manageListItemRemove) {
const id = req.params.manageListItemId;
// identify which type of item it is via the field name
const type = manageListQuestionFieldName;
console.log('removing item', {id, type});
// TODO: delete from database
return;
}
const answers = data.answers;
console.log('saving', answers);
// TODO: map to database
// TODO: write to database
}To support DynamicSection, handle isDynamicSection, dynamicSectionFieldName, and dynamicSectionId. The implementation is very similar to the manage lists and the logic can be combined. For example:
const update: SaveDataFn = async ({
data,
req,
isManageListItem,
manageListQuestionFieldName,
manageListItemRemove,
isDynamicSection,
dynamicSectionFieldName,
dynamicSectionId
}) => {
if (isManageListItem || isDynamicSection) {
// manage list and dynamic sections can be handled the same, just with different properties
// for the fieldName and item id
const fieldName = isManageListItem ? manageListQuestionFieldName : dynamicSectionFieldName;
const itemId = isManageListItem ? req.params.manageListItemId : dynamicSectionId;
const update = {
id: itemId,
...data.answers
};
console.log('saving', update);
// TODO: map to database
// TODO: write to database
return;
} else if (manageListItemRemove) {
const id = req.params.manageListItemId;
// identify which type of item it is via the field name
const type = manageListQuestionFieldName;
console.log('removing item', {id, type});
// TODO: delete from database
return;
}
const answers = data.answers;
console.log('saving', answers);
// TODO: map to database
// TODO: write to database
}SaveDataFn and SaveParams accept two optional generics: TData to type the data parameter, and TLocals to type res.locals. Both default to their current behaviour, so you only need to provide the ones you want to narrow. For example, to type data:
type MyData = {
answers: {
myField: string;
};
};
const update: SaveDataFn<MyData> = async ({data}) => {
const answers = data.answers; // typed as { myField: string }
console.log('saving', answers);
// TODO: map to database
// TODO: write to database
}To type res.locals as well, pass TLocals as the second generic:
type MyLocals = JourneyLocals & {
userId: string;
};
const update: SaveDataFn<MyData, MyLocals> = async ({data, res}) => {
const userId = res.locals.userId; // typed as string
console.log('saving', data.answers, 'for', userId);
// TODO: map to database
// TODO: write to database
}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 |
Validators
Many validators are available such as RequiredValidator and AddressValidator. More detail on email validation is available in docs/Email Validation.
Custom Summary Formatting
The formatSummaryValue option allows you to customise how answers are displayed on check-your-answers pages without needing to create a custom question class.
Basic Usage
Pass a formatSummaryValue function when creating a question:
import { RadioQuestion } from '@planning-inspectorate/dynamic-forms';
new RadioQuestion({
fieldName: 'status',
title: 'Application status',
question: 'What is the application status?',
options: [
{ text: 'Approved', value: 'approved' },
{ text: 'Rejected', value: 'rejected' }
],
formatSummaryValue: ({ formattedAnswer }) => `<strong>${formattedAnswer}</strong>`
});Formatter Context
The formatter function receives a context object with the following properties:
| Property | Type | Description |
|----------|------|------------------------------------------------|
| answer | unknown | The raw answer value |
| formattedAnswer | string | The default formatted display value |
| question | Question | The question instance |
| journey | Journey | The journey instance |
| sectionSegment | string | The current section segment |
Examples
Bold formatting:
formatSummaryValue: ({ formattedAnswer }) => `<strong>${escapeHtml(formattedAnswer)}</strong>`Conditional styling based on answer:
formatSummaryValue: ({ answer, formattedAnswer }) => {
const color = answer === 'approved' ? 'green' : 'red';
return `<span style="color: ${color}">${escapeHtml(formattedAnswer)}</span>`;
}Adding a suffix from another answer:
const suffixes = { solid: ' (tonnes)', liquid: ' (litres)' };
formatSummaryValue: ({ answer, formattedAnswer }) => {
const suffix = suffixes[answer] ?? '';
return `${formattedAnswer}${suffix}`;
}Accessing other journey answers:
formatSummaryValue: ({ formattedAnswer, journey }) => {
const capacity = journey.response.answers.wasteCapacity;
return capacity ? `${formattedAnswer}: ${capacity}` : formattedAnswer;
}Note:
formattedAnsweris already escaped by dynamic-forms; you are responsible for escaping any additional user-controlled values you include (for exampleansweror values read fromjourney.response.answers).
Other features
See documentation in docs, including:
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 inline alongside the source files.
Type definitions:
- Shared type definitions should be placed in the
typedefs/folder (e.g.typedefs/question-types.d.ts) - New type definition files need a corresponding empty
.jsfile, which needs to be exported fromindex.js, in order to be importable from the package root. - Types can be imported using path aliases, e.g.
@param {import('#typedefs/question-types.d.ts').SummaryRow[]} rows
Best practices:
- Always use inline imports for JSDoc types, e.g.
@param {import('#section').Section} section - Avoid top-level
@typedefimports like@typedef {import('../validator/base-validator.js')} BaseValidator- these can create confusion when the compiler auto-generates declarations - Maintain
index.js- if you're adding code that users of this module will import, ensure it is 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.
New components
When implement a new component, extend the base Question class. Common methods to override are:
formatAnswer- 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.
