npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@planning-inspectorate/dynamic-forms

v3.25.0

Published

This package is for building GDS forms using a configuration-based approach.

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:

  1. Install the package: npm install --save @planning-inspectorate/dynamic-forms
  2. Configure nunjucks
  3. (optional) Configure accessible-autocomplete
  4. Add the question and check-your-answers templates
  5. 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.

  1. Ensure nunjucks is configured with the folder containing your custom components
  2. Export the custom component type names and classes
  3. 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.journey must be an instance of the Journey class
  • res.locals.journeyResponse must be an instance of the JourneyResponse class

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: formattedAnswer is already escaped by dynamic-forms; you are responsible for escaping any additional user-controlled values you include (for example answer or values read from journey.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 .js file, which needs to be exported from index.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:

  1. Always use inline imports for JSDoc types, e.g. @param {import('#section').Section} section
  2. Avoid top-level @typedef imports like @typedef {import('../validator/base-validator.js')} BaseValidator - these can create confusion when the compiler auto-generates declarations
  3. Maintain index.js - if you're adding code that users of this module will import, ensure it is exported in index.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 display
  • getDataToSave - answer data to save
  • addCustomDataToViewModel - customise the view model with extra data/configuration
  • answerForViewModel - customise the view model answer value

Where possible addCustomDataToViewModel and answerForViewModel should be overridden instead of prepQuestionForRendering.