From 7d707684cadc9ebb37b5c3b4e27108d9a6403b8a Mon Sep 17 00:00:00 2001 From: Jez Barnsley <114290619+jbarnsley10@users.noreply.github.com> Date: Wed, 12 Mar 2025 14:55:10 +0000 Subject: [PATCH 01/20] Feat/468292 check answers settings (#732) * Initial for mardown declaration in V2 * Handles markdown component * CHanges after review --- .../engine/components/Markdown.test.ts | 48 +++++++++++++++++++ .../plugins/engine/components/Markdown.ts | 29 +++++++++++ .../plugins/engine/components/helpers.test.ts | 24 ++++++++++ .../plugins/engine/components/helpers.ts | 5 ++ src/server/plugins/engine/components/index.ts | 1 + .../plugins/engine/models/SummaryViewModel.ts | 6 ++- .../FileUploadPageController.test.ts | 1 + .../pageControllers/SummaryPageController.ts | 22 +++++++-- .../plugins/engine/views/components/html.html | 2 +- .../engine/views/components/markdown.html | 5 ++ src/server/views/summary.html | 8 +++- 11 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 src/server/plugins/engine/components/Markdown.test.ts create mode 100644 src/server/plugins/engine/components/Markdown.ts create mode 100644 src/server/plugins/engine/components/helpers.test.ts create mode 100644 src/server/plugins/engine/views/components/markdown.html diff --git a/src/server/plugins/engine/components/Markdown.test.ts b/src/server/plugins/engine/components/Markdown.test.ts new file mode 100644 index 000000000..6756e9bd2 --- /dev/null +++ b/src/server/plugins/engine/components/Markdown.test.ts @@ -0,0 +1,48 @@ +import { ComponentType, type MarkdownComponent } from '@defra/forms-model' + +import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' +import { type Guidance } from '~/src/server/plugins/engine/components/helpers.js' +import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import definition from '~/test/form/definitions/basic.js' + +describe('Markdown', () => { + let model: FormModel + + beforeEach(() => { + model = new FormModel(definition, { + basePath: 'test' + }) + }) + + describe('Defaults', () => { + let def: MarkdownComponent + let collection: ComponentCollection + let guidance: Guidance + + beforeEach(() => { + def = { + title: 'Markdown guidance', + name: 'myComponent', + type: ComponentType.Markdown, + content: '# Heading 1 ## Heading 2', + options: {} + } satisfies MarkdownComponent + + collection = new ComponentCollection([def], { model }) + guidance = collection.guidance[0] + }) + + describe('View model', () => { + it('sets Nunjucks component defaults', () => { + const viewModel = guidance.getViewModel() + + expect(viewModel).toEqual( + expect.objectContaining({ + attributes: {}, + content: def.content + }) + ) + }) + }) + }) +}) diff --git a/src/server/plugins/engine/components/Markdown.ts b/src/server/plugins/engine/components/Markdown.ts new file mode 100644 index 000000000..c97208d7a --- /dev/null +++ b/src/server/plugins/engine/components/Markdown.ts @@ -0,0 +1,29 @@ +import { type MarkdownComponent } from '@defra/forms-model' + +import { ComponentBase } from '~/src/server/plugins/engine/components/ComponentBase.js' + +export class Markdown extends ComponentBase { + declare options: MarkdownComponent['options'] + content: MarkdownComponent['content'] + + constructor( + def: MarkdownComponent, + props: ConstructorParameters[1] + ) { + super(def, props) + + const { content, options } = def + + this.content = content + this.options = options + } + + getViewModel() { + const { content, viewModel } = this + + return { + ...viewModel, + content + } + } +} diff --git a/src/server/plugins/engine/components/helpers.test.ts b/src/server/plugins/engine/components/helpers.test.ts new file mode 100644 index 000000000..c7397a322 --- /dev/null +++ b/src/server/plugins/engine/components/helpers.test.ts @@ -0,0 +1,24 @@ +import { type ComponentDef } from '@defra/forms-model' + +import { createComponent } from '~/src/server/plugins/engine/components/helpers.js' +import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import definition from '~/test/form/definitions/basic.js' + +const formModel = new FormModel(definition, { + basePath: 'test' +}) + +describe('helpers tests', () => { + test('should throw if invalid type', () => { + expect(() => + createComponent( + { + type: 'invalid-type' + } as unknown as ComponentDef, + { + model: formModel + } + ) + ).toThrow('Component type invalid-type does not exist') + }) +}) diff --git a/src/server/plugins/engine/components/helpers.ts b/src/server/plugins/engine/components/helpers.ts index a1b0dd50d..3362670c9 100644 --- a/src/server/plugins/engine/components/helpers.ts +++ b/src/server/plugins/engine/components/helpers.ts @@ -72,6 +72,7 @@ export type Field = InstanceType< export type Guidance = InstanceType< | typeof Components.Details | typeof Components.Html + | typeof Components.Markdown | typeof Components.InsetText | typeof Components.List > @@ -118,6 +119,10 @@ export function createComponent( component = new Components.List(def, options) break + case ComponentType.Markdown: + component = new Components.Markdown(def, options) + break + case ComponentType.MultilineTextField: component = new Components.MultilineTextField(def, options) break diff --git a/src/server/plugins/engine/components/index.ts b/src/server/plugins/engine/components/index.ts index 545ef55d0..d4a1065b4 100644 --- a/src/server/plugins/engine/components/index.ts +++ b/src/server/plugins/engine/components/index.ts @@ -12,6 +12,7 @@ export { EmailAddressField } from '~/src/server/plugins/engine/components/EmailA export { Html } from '~/src/server/plugins/engine/components/Html.js' export { InsetText } from '~/src/server/plugins/engine/components/InsetText.js' export { List } from '~/src/server/plugins/engine/components/List.js' +export { Markdown } from '~/src/server/plugins/engine/components/Markdown.js' export { MultilineTextField } from '~/src/server/plugins/engine/components/MultilineTextField.js' export { NumberField } from '~/src/server/plugins/engine/components/NumberField.js' export { RadiosField } from '~/src/server/plugins/engine/components/RadiosField.js' diff --git a/src/server/plugins/engine/models/SummaryViewModel.ts b/src/server/plugins/engine/models/SummaryViewModel.ts index 9198aae01..87a9e805d 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.ts @@ -4,7 +4,10 @@ import { getAnswer, type Field } from '~/src/server/plugins/engine/components/helpers.js' -import { type BackLink } from '~/src/server/plugins/engine/components/types.js' +import { + type BackLink, + type ComponentViewModel +} from '~/src/server/plugins/engine/components/types.js' import { evaluateTemplate, getError, @@ -47,6 +50,7 @@ export class SummaryViewModel { errors?: FormSubmissionError[] serviceUrl: string hasMissingNotificationEmail?: boolean + components?: ComponentViewModel[] constructor( request: FormContextRequest, diff --git a/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts b/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts index d72230db1..1e44ba5a6 100644 --- a/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts +++ b/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts @@ -203,6 +203,7 @@ describe('FileUploadPageController', () => { expect(getUploadStatusSpy).toHaveBeenCalledTimes(2) expect(request.logger.info).toHaveBeenCalled() + /* eslint-disable-next-line @typescript-eslint/no-unsafe-member-access */ const logMsg = (request.logger.info as jest.Mock).mock.calls[0][0] expect(logMsg).toEqual(expect.stringContaining('Waiting')) expect(logMsg).toEqual(expect.stringContaining('some-id')) diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index be9a8ecf1..6c5add81f 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -1,7 +1,12 @@ -import { type PageSummary, type SubmitPayload } from '@defra/forms-model' +import { + hasComponentsEvenIfNoNext, + type Page, + type SubmitPayload +} from '@defra/forms-model' import Boom from '@hapi/boom' import { type ResponseToolkit, type RouteOptions } from '@hapi/hapi' +import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { FileUploadField } from '~/src/server/plugins/engine/components/FileUploadField.js' import { getAnswer } from '~/src/server/plugins/engine/components/helpers.js' import { @@ -29,15 +34,21 @@ import { } from '~/src/server/routes/types.js' export class SummaryPageController extends QuestionPageController { - declare pageDef: PageSummary + declare pageDef: Page /** * The controller which is used when Page["controller"] is defined as "./pages/summary.js" */ - constructor(model: FormModel, pageDef: PageSummary) { + constructor(model: FormModel, pageDef: Page) { super(model, pageDef) this.viewName = 'summary' + + // Components collection + this.collection = new ComponentCollection( + hasComponentsEvenIfNoNext(pageDef) ? pageDef.components : [], + { model, page: this } + ) } getSummaryViewModel( @@ -46,11 +57,16 @@ export class SummaryPageController extends QuestionPageController { ): SummaryViewModel { const viewModel = new SummaryViewModel(request, this, context) + const { query } = request + const { payload, errors } = context + const components = this.collection.getViewModel(payload, errors, query) + // We already figure these out in the base page controller. Take them and apply them to our page-specific model. // This is a stop-gap until we can add proper inheritance in place. viewModel.backLink = this.getBackLink(request, context) viewModel.feedbackLink = this.feedbackLink viewModel.phaseTag = this.phaseTag + viewModel.components = components return viewModel } diff --git a/src/server/plugins/engine/views/components/html.html b/src/server/plugins/engine/views/components/html.html index 0456cae4f..349b5b459 100644 --- a/src/server/plugins/engine/views/components/html.html +++ b/src/server/plugins/engine/views/components/html.html @@ -1,3 +1,3 @@ {% macro Html(component) %} - {{ component.model.content | safe }} + {{ component.model.content | safe }} {% endmacro %} diff --git a/src/server/plugins/engine/views/components/markdown.html b/src/server/plugins/engine/views/components/markdown.html new file mode 100644 index 000000000..754bc5a08 --- /dev/null +++ b/src/server/plugins/engine/views/components/markdown.html @@ -0,0 +1,5 @@ +{% macro Markdown(component) %} +
+ {{ component.model.content | markdown | safe }} +
+{% endmacro %} diff --git a/src/server/views/summary.html b/src/server/views/summary.html index 014adfce9..83b38e680 100644 --- a/src/server/views/summary.html +++ b/src/server/views/summary.html @@ -2,6 +2,7 @@ {% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} {% from "govuk/components/summary-list/macro.njk" import govukSummaryList %} +{% from "partials/components.html" import componentList with context %} {% block content %}
@@ -34,11 +35,16 @@

{% if declaration %}

Declaration

+
{{ declaration | safe }} +
{% endif %} + {{ componentList(components) }} + + {% set isDeclaration = declaration or components | length %}
From 46d547beaeb7e4dcaba2927ced7036e80787a1ca Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Thu, 20 Mar 2025 10:37:04 +0000 Subject: [PATCH 02/20] feat: Update accessibility statement with revised review and audit dates (#748) --- src/server/views/help/accessibility-statement.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/views/help/accessibility-statement.html b/src/server/views/help/accessibility-statement.html index bdef99acf..4846f9826 100644 --- a/src/server/views/help/accessibility-statement.html +++ b/src/server/views/help/accessibility-statement.html @@ -37,10 +37,10 @@

Content that’s not within the scope of the accessi

Preparation of this accessibility statement

-

This statement was prepared on 24 June 2024. It was last reviewed on 26 June 2024.

+

This statement was prepared on 24 June 2024. It was last reviewed on 14 March 2025.

The Defra Accessibility team tested 2 online forms against the WCAG 2.2 AA standard on 17 June 2024. The tests were done using automated testing tools.

-

We will commission a full accessibility audit of forms created by Defra before April 2025.

+

We will commission a full accessibility audit of forms created by Defra before June 2025.

Feedback and contact information

If you find any problems not listed on this page or think we’re not meeting accessibility From b7cac4392e0547373f624f2e07ad3f32b9c5684c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Mar 2025 16:40:15 +0000 Subject: [PATCH 03/20] Bump the npm_and_yarn group with 2 updates (#707) Bumps the npm_and_yarn group with 2 updates: [esbuild](https://github.com/evanw/esbuild) and [tsx](https://github.com/privatenumber/tsx). Updates `esbuild` from 0.23.0 to 0.25.0 - [Release notes](https://github.com/evanw/esbuild/releases) - [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md) - [Commits](https://github.com/evanw/esbuild/compare/v0.23.0...v0.25.0) Updates `tsx` from 4.19.2 to 4.19.3 - [Release notes](https://github.com/privatenumber/tsx/releases) - [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs) - [Commits](https://github.com/privatenumber/tsx/compare/v4.19.2...v4.19.3) --- updated-dependencies: - dependency-name: esbuild dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: tsx dependency-type: direct:development dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 252 +++++++++++++++++++++++++++------------------- package.json | 2 +- 2 files changed, 149 insertions(+), 105 deletions(-) diff --git a/package-lock.json b/package-lock.json index ec3cd0b04..de46ad80a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -125,7 +125,7 @@ "stylelint": "^16.12.0", "stylelint-config-gds": "^2.0.0", "terser-webpack-plugin": "^5.3.11", - "tsx": "^4.19.2", + "tsx": "^4.19.3", "typescript": "^5.7.2", "webpack": "^5.97.1", "webpack-assets-manifest": "^5.2.1", @@ -2118,13 +2118,14 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.0.tgz", - "integrity": "sha512-3sG8Zwa5fMcA9bgqB8AfWPQ+HFke6uD3h1s3RIwUNK8EG7a4buxvuFTs3j1IMs2NXAk9F30C/FF4vxRgQCcmoQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", + "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "aix" @@ -2134,13 +2135,14 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.0.tgz", - "integrity": "sha512-+KuOHTKKyIKgEEqKbGTK8W7mPp+hKinbMBeEnNzjJGyFcWsfrXjSTNluJHCY1RqhxFurdD8uNXQDei7qDlR6+g==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", + "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -2150,13 +2152,14 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.0.tgz", - "integrity": "sha512-EuHFUYkAVfU4qBdyivULuu03FhJO4IJN9PGuABGrFy4vUuzk91P2d+npxHcFdpUnfYKy0PuV+n6bKIpHOB3prQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", + "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -2166,13 +2169,14 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.0.tgz", - "integrity": "sha512-WRrmKidLoKDl56LsbBMhzTTBxrsVwTKdNbKDalbEZr0tcsBgCLbEtoNthOW6PX942YiYq8HzEnb4yWQMLQuipQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", + "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -2182,13 +2186,14 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.0.tgz", - "integrity": "sha512-YLntie/IdS31H54Ogdn+v50NuoWF5BDkEUFpiOChVa9UnKpftgwzZRrI4J132ETIi+D8n6xh9IviFV3eXdxfow==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", + "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -2198,13 +2203,14 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.0.tgz", - "integrity": "sha512-IMQ6eme4AfznElesHUPDZ+teuGwoRmVuuixu7sv92ZkdQcPbsNHzutd+rAfaBKo8YK3IrBEi9SLLKWJdEvJniQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", + "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -2214,13 +2220,14 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.0.tgz", - "integrity": "sha512-0muYWCng5vqaxobq6LB3YNtevDFSAZGlgtLoAc81PjUfiFz36n4KMpwhtAd4he8ToSI3TGyuhyx5xmiWNYZFyw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", + "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -2230,13 +2237,14 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.0.tgz", - "integrity": "sha512-XKDVu8IsD0/q3foBzsXGt/KjD/yTKBCIwOHE1XwiXmrRwrX6Hbnd5Eqn/WvDekddK21tfszBSrE/WMaZh+1buQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", + "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -2246,13 +2254,14 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.0.tgz", - "integrity": "sha512-SEELSTEtOFu5LPykzA395Mc+54RMg1EUgXP+iw2SJ72+ooMwVsgfuwXo5Fn0wXNgWZsTVHwY2cg4Vi/bOD88qw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", + "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2262,13 +2271,14 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.0.tgz", - "integrity": "sha512-j1t5iG8jE7BhonbsEg5d9qOYcVZv/Rv6tghaXM/Ug9xahM0nX/H2gfu6X6z11QRTMT6+aywOMA8TDkhPo8aCGw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", + "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2278,13 +2288,14 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.0.tgz", - "integrity": "sha512-P7O5Tkh2NbgIm2R6x1zGJJsnacDzTFcRWZyTTMgFdVit6E98LTxO+v8LCCLWRvPrjdzXHx9FEOA8oAZPyApWUA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", + "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2294,13 +2305,14 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.0.tgz", - "integrity": "sha512-InQwepswq6urikQiIC/kkx412fqUZudBO4SYKu0N+tGhXRWUqAx+Q+341tFV6QdBifpjYgUndV1hhMq3WeJi7A==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", + "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", "cpu": [ "loong64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2310,13 +2322,14 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.0.tgz", - "integrity": "sha512-J9rflLtqdYrxHv2FqXE2i1ELgNjT+JFURt/uDMoPQLcjWQA5wDKgQA4t/dTqGa88ZVECKaD0TctwsUfHbVoi4w==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", + "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", "cpu": [ "mips64el" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2326,13 +2339,14 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.0.tgz", - "integrity": "sha512-cShCXtEOVc5GxU0fM+dsFD10qZ5UpcQ8AM22bYj0u/yaAykWnqXJDpd77ublcX6vdDsWLuweeuSNZk4yUxZwtw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", + "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2342,13 +2356,14 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.0.tgz", - "integrity": "sha512-HEtaN7Y5UB4tZPeQmgz/UhzoEyYftbMXrBCUjINGjh3uil+rB/QzzpMshz3cNUxqXN7Vr93zzVtpIDL99t9aRw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", + "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2358,13 +2373,14 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.0.tgz", - "integrity": "sha512-WDi3+NVAuyjg/Wxi+o5KPqRbZY0QhI9TjrEEm+8dmpY9Xir8+HE/HNx2JoLckhKbFopW0RdO2D72w8trZOV+Wg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", + "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2374,13 +2390,14 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.0.tgz", - "integrity": "sha512-a3pMQhUEJkITgAw6e0bWA+F+vFtCciMjW/LPtoj99MhVt+Mfb6bbL9hu2wmTZgNd994qTAEw+U/r6k3qHWWaOQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", + "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2389,14 +2406,32 @@ "node": ">=18" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", + "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.0.tgz", - "integrity": "sha512-cRK+YDem7lFTs2Q5nEv/HHc4LnrfBCbH5+JHu6wm2eP+d8OZNoSMYgPZJq78vqQ9g+9+nMuIsAO7skzphRXHyw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", + "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" @@ -2406,13 +2441,14 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.0.tgz", - "integrity": "sha512-suXjq53gERueVWu0OKxzWqk7NxiUWSUlrxoZK7usiF50C6ipColGR5qie2496iKGYNLhDZkPxBI3erbnYkU0rQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", + "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -2422,13 +2458,14 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.0.tgz", - "integrity": "sha512-6p3nHpby0DM/v15IFKMjAaayFhqnXV52aEmv1whZHX56pdkK+MEaLoQWj+H42ssFarP1PcomVhbsR4pkz09qBg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", + "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -2438,13 +2475,14 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.0.tgz", - "integrity": "sha512-BFelBGfrBwk6LVrmFzCq1u1dZbG4zy/Kp93w2+y83Q5UGYF1d8sCzeLI9NXjKyujjBBniQa8R8PzLFAUrSM9OA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", + "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "sunos" @@ -2454,13 +2492,14 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.0.tgz", - "integrity": "sha512-lY6AC8p4Cnb7xYHuIxQ6iYPe6MfO2CC43XXKo9nBXDb35krYt7KGhQnOkRGar5psxYkircpCqfbNDB4uJbS2jQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", + "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -2470,13 +2509,14 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.0.tgz", - "integrity": "sha512-7L1bHlOTcO4ByvI7OXVI5pNN6HSu6pUQq9yodga8izeuB1KcT2UkHaH6118QJwopExPn0rMHIseCTx1CRo/uNA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", + "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -2486,13 +2526,14 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.0.tgz", - "integrity": "sha512-Arm+WgUFLUATuoxCJcahGuk6Yj9Pzxd6l11Zb/2aAuv5kWWvvfhLFo2fni4uSK5vzlUdCGZ/BdV5tH8klj8p8g==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", + "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -7504,11 +7545,12 @@ } }, "node_modules/esbuild": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.0.tgz", - "integrity": "sha512-1lvV17H2bMYda/WaFb2jLPeHU3zml2k4/yagNMG8Q/YtfMjCwEUZa2eXXMgZTVSL5q1n4H7sQ0X6CdJDqqeCFA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", + "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -7516,30 +7558,31 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.23.0", - "@esbuild/android-arm": "0.23.0", - "@esbuild/android-arm64": "0.23.0", - "@esbuild/android-x64": "0.23.0", - "@esbuild/darwin-arm64": "0.23.0", - "@esbuild/darwin-x64": "0.23.0", - "@esbuild/freebsd-arm64": "0.23.0", - "@esbuild/freebsd-x64": "0.23.0", - "@esbuild/linux-arm": "0.23.0", - "@esbuild/linux-arm64": "0.23.0", - "@esbuild/linux-ia32": "0.23.0", - "@esbuild/linux-loong64": "0.23.0", - "@esbuild/linux-mips64el": "0.23.0", - "@esbuild/linux-ppc64": "0.23.0", - "@esbuild/linux-riscv64": "0.23.0", - "@esbuild/linux-s390x": "0.23.0", - "@esbuild/linux-x64": "0.23.0", - "@esbuild/netbsd-x64": "0.23.0", - "@esbuild/openbsd-arm64": "0.23.0", - "@esbuild/openbsd-x64": "0.23.0", - "@esbuild/sunos-x64": "0.23.0", - "@esbuild/win32-arm64": "0.23.0", - "@esbuild/win32-ia32": "0.23.0", - "@esbuild/win32-x64": "0.23.0" + "@esbuild/aix-ppc64": "0.25.0", + "@esbuild/android-arm": "0.25.0", + "@esbuild/android-arm64": "0.25.0", + "@esbuild/android-x64": "0.25.0", + "@esbuild/darwin-arm64": "0.25.0", + "@esbuild/darwin-x64": "0.25.0", + "@esbuild/freebsd-arm64": "0.25.0", + "@esbuild/freebsd-x64": "0.25.0", + "@esbuild/linux-arm": "0.25.0", + "@esbuild/linux-arm64": "0.25.0", + "@esbuild/linux-ia32": "0.25.0", + "@esbuild/linux-loong64": "0.25.0", + "@esbuild/linux-mips64el": "0.25.0", + "@esbuild/linux-ppc64": "0.25.0", + "@esbuild/linux-riscv64": "0.25.0", + "@esbuild/linux-s390x": "0.25.0", + "@esbuild/linux-x64": "0.25.0", + "@esbuild/netbsd-arm64": "0.25.0", + "@esbuild/netbsd-x64": "0.25.0", + "@esbuild/openbsd-arm64": "0.25.0", + "@esbuild/openbsd-x64": "0.25.0", + "@esbuild/sunos-x64": "0.25.0", + "@esbuild/win32-arm64": "0.25.0", + "@esbuild/win32-ia32": "0.25.0", + "@esbuild/win32-x64": "0.25.0" } }, "node_modules/escalade": { @@ -16065,12 +16108,13 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/tsx": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.2.tgz", - "integrity": "sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==", + "version": "4.19.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.3.tgz", + "integrity": "sha512-4H8vUNGNjQ4V2EOoGw005+c+dGuPSnhpPBPHBtsZdGZBk/iJb4kguGlPWaZTZ3q5nMtFOEsY0nRDlh9PJyd6SQ==", "dev": true, + "license": "MIT", "dependencies": { - "esbuild": "~0.23.0", + "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "bin": { diff --git a/package.json b/package.json index a8adbec7f..7382bf5db 100644 --- a/package.json +++ b/package.json @@ -155,7 +155,7 @@ "stylelint": "^16.12.0", "stylelint-config-gds": "^2.0.0", "terser-webpack-plugin": "^5.3.11", - "tsx": "^4.19.2", + "tsx": "^4.19.3", "typescript": "^5.7.2", "webpack": "^5.97.1", "webpack-assets-manifest": "^5.2.1", From ef4927d4b4d5ec82746ac0e3be0049f1bb36c80e Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 28 Mar 2025 09:41:52 +0000 Subject: [PATCH 04/20] Feature flag GA (#752) * Feature flag GA * Delete unused t&c page --- src/server/views/help/privacy-notice.html | 30 ++++--- .../views/help/terms-and-conditions.html | 83 ------------------- test/form/privacy.test.js | 75 +++++++++++++++++ 3 files changed, 93 insertions(+), 95 deletions(-) delete mode 100644 src/server/views/help/terms-and-conditions.html create mode 100644 test/form/privacy.test.js diff --git a/src/server/views/help/privacy-notice.html b/src/server/views/help/privacy-notice.html index 67a208d7b..eb4cf00ac 100644 --- a/src/server/views/help/privacy-notice.html +++ b/src/server/views/help/privacy-notice.html @@ -17,19 +17,25 @@

Who collects your personal data

The data protection officer for Defra is responsible for checking that Defra complies with legislation. You can contact them at DefraGroupDataProtectionOfficer@defra.gov.uk.

Data we collect from you and what we do with it

-

If you give your consent, we use Google Analytics cookies to collect information about how you use ‘{{ config.serviceName }}’. Read the data privacy and security policy for Google Analytics.

-

Google Analytics processes information about:

- -

Defra will make sure you cannot be directly identified by Google Analytics data. We do this by using Google Analytics’ IP address anonymisation feature and by removing any other personal data from the titles or URLs of the pages you visit.

-

Defra will not combine analytics information with other data sets in a way that would directly identify who you are.

+ + {% if config.googleAnalyticsTrackingId %} + +

If you give your consent, we use Google Analytics cookies to collect information about how you use ‘{{ config.serviceName }}’. Read the data privacy and security policy for Google Analytics.

+

Google Analytics processes information about:

+
    +
  • your IP address
  • +
  • the pages you visit on ‘{{ config.serviceName }}’
  • +
  • how long you spend on each ‘{{ config.serviceName }}’ page
  • +
  • how you got to the site
  • +
  • what you select while you’re visiting the site
  • +
+

Defra will make sure you cannot be directly identified by Google Analytics data. We do this by using Google Analytics’ IP address anonymisation feature and by removing any other personal data from the titles or URLs of the pages you visit.

+

Defra will not combine analytics information with other data sets in a way that would directly identify who you are.

+
+ {% endif %} +

We use system logs to collect information about the usage of forms. The logs are stored in Amazon Web Services based in London.

-

We use the system logs and Google Analytics data to create anonymised reports about the performance of forms that use ‘{{ config.serviceName }}’. We use this data to improve forms, for example, if we discover a high number of drops offs at a certain point within a form. We may share this information with the data controller of the form.

+

We use the system logs {% if config.googleAnalyticsTrackingId %}and Google Analytics data{% endif %} to create anonymised reports about the performance of forms that use ‘{{ config.serviceName }}’. We use this data to improve forms, for example, if we discover a high number of drops offs at a certain point within a form. We may share this information with the data controller of the form.

If you email us feedback about a form that uses ‘{{ config.serviceName }}’, we’ll send your email address and any other personal information you choose to include in your email to the data controller for review. The data controller may use your personal information to reply to your query to update a form based on your feedback where it is appropriate.

Lawful basis for processing your personal data

diff --git a/src/server/views/help/terms-and-conditions.html b/src/server/views/help/terms-and-conditions.html deleted file mode 100644 index 60144ec7a..000000000 --- a/src/server/views/help/terms-and-conditions.html +++ /dev/null @@ -1,83 +0,0 @@ -{% extends 'layout.html' %} - -{% block content %} -
-
-

Terms and conditions

-

By using this digital service you agree to our - privacy policy and to these terms and conditions. Read them carefully. -

-

General

-

These terms and conditions affect your rights and liabilities under - the law. They govern your use of, and relationship with, the service. They don’t apply to other services - provided by the Foreign & Commonwealth Office, or to any other department or service which is linked - to in this service.

-

You agree to use this site only for lawful purposes, and in a - manner that does not infringe the rights of, or restrict or inhibit the use and enjoyment of, this site - by any third party.

-

We may occasionally update these terms and conditions. This might happen if there’s a change in the law or to the way this service works. Check these terms and conditions regularly, as continued use of the service after a change has been made is your acceptance of the change. If you don't agree to the terms and conditions and - privacy policy, you should not use this service.

-

Applicable law

-

Your use of this service and any dispute arising from its use will - be governed by and construed in accordance with the laws of England and Wales, including but not limited - to the:

-
    -
  • Computer Misuse Act 1990
  • -
  • Data Protection Act
  • -
  • Mental Capacity Act 2005
  • -
-

How to use this service responsibly

-

You must provide us with enough information for us to assess the - case fairly and if appropriate to provide the service. We also need these details to provide information - about the appointment, including rescheduling and cancellation when necessary, so you must provide - accurate details where we can reliably contact you.

-

When attending their appointment the person requiring an emergency - travel document will have to pass through Security checks. They should expect their belongings to be - checked, and will have to leave their mobile phone and other belongings with the Security Officer. Do - not bring laptops or other electronic devices. Arrive in advance of the appointment. If you need to - cancel or modify your booking, you will be able to do so by following the link in the confirmation - email.

-

There are risks in using a shared computer, such as in an internet - café, to use this digital service. It’s your responsibility to be aware of these risks and to avoid - using any computer which may leave your personal information accessible to others. You are responsible - if you choose to leave a computer unprotected while in the process of using the service.

-

We make every effort to check and test this service whenever we - amend or update it. However, you must take your own precautions to ensure that the way you access this - service does not expose you to the risk of viruses, malicious computer code or other forms of - interference which may damage your own computer system.

-

You must not misuse our service by knowingly introducing viruses, - trojans, worms, logic bombs or other material which is malicious or technologically harmful. You must - not attempt to gain unauthorised access to our service, the system on which our service is stored or any - server, computer or database connected to our service. You must not attack our site via a - denial-of-service attack or a distributed denial-of-service attack.

-

Disclaimer

-

While we make every effort to keep this service up to date, we - don’t provide any guarantees, conditions or warranties as to the accuracy of the information on the - site.

-

While Consular staff will give you as much advice as they can, it - is your responsibility to ensure that you are obtaining the correct service and that you bring all the - necessary documentation for the service. If you are unsure, you may wish to take legal advice before - booking the service

-

Our consular fees are not refundable, and are subject to change without notice. Check the relevant consular fees list for the latest information.

-

We don’t accept liability for loss or damage incurred by users of - this service, whether direct, indirect or consequential, whether caused by tort, breach of contract or - otherwise. This includes loss of income or revenue, business, profits or contracts, anticipated savings, - data, goodwill, tangible property or wasted time in connection with this service or any websites linked - to it and any materials posted on it. This condition shall not prevent claims for loss of or damage to - your tangible property or any other claims for direct financial loss that are not excluded by any of the - categories set out above.

-

This does not affect our liability for death or personal injury - arising from our negligence, nor our liability for fraudulent misrepresentation or misrepresentation as - to a fundamental matter, nor any other liability which cannot be excluded or limited under applicable - law.

-

Information provided by this service

-

We work hard to ensure that information within this service is - accurate. However, we can’t guarantee the accuracy and completeness of any information at all times. - While we make every effort to ensure this service is accessible at all times, we are not liable if it is - unavailable for any period of time.

-
-
-{% endblock %} diff --git a/test/form/privacy.test.js b/test/form/privacy.test.js new file mode 100644 index 000000000..b762bf922 --- /dev/null +++ b/test/form/privacy.test.js @@ -0,0 +1,75 @@ +import { join } from 'node:path' + +import { config } from '~/src/config/index.js' +import { createServer } from '~/src/server/index.js' +import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' +import * as fixtures from '~/test/fixtures/index.js' +import { renderResponse } from '~/test/helpers/component-helpers.js' + +jest.mock('~/src/server/plugins/engine/services/formsService.js') + +const gaContainerId = 'ga-analytics-segment' +const gaText = + 'We use the system logs and Google Analytics data to create anonymised reports about the performance of forms that use' + +describe(`Privacy policy`, () => { + /** @type {Server} */ + let server + + beforeEach(() => { + jest.mocked(getFormMetadata).mockResolvedValue(fixtures.form.metadata) + }) + + afterEach(async () => { + await server.stop() + }) + + it('shows the GA text when enabled', async () => { + config.set('googleAnalyticsTrackingId', '12345') + config.set('serviceName', 'Submit a form to Defra') + + server = await createServer({ + formFileName: 'basic.js', + formFilePath: join(import.meta.dirname, 'definitions') + }) + await server.initialize() + + const options = { + method: 'GET', + url: '/help/privacy/basic' + } + + const { container, document } = await renderResponse(server, options) + + expect(document.getElementById(gaContainerId)).toBeInTheDocument() + expect( + container.getByText( + (content) => content.includes(gaText) // saves copying the entire

block from the template + ) + ).toBeInTheDocument() + }) + + it('hides the GA text when disabled', async () => { + config.set('googleAnalyticsTrackingId', '') + + server = await createServer({ + formFileName: 'basic.js', + formFilePath: join(import.meta.dirname, 'definitions') + }) + await server.initialize() + + const options = { + method: 'GET', + url: '/help/privacy/basic' + } + + const { container, document } = await renderResponse(server, options) + + expect(document.getElementById(gaContainerId)).not.toBeInTheDocument() + expect(container.queryByText(gaText)).not.toBeInTheDocument() + }) +}) + +/** + * @import { Server } from '@hapi/hapi' + */ From 1e1ce0579c4cb8f603f69a499e81977e10796862 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 3 Apr 2025 17:52:17 +0100 Subject: [PATCH 05/20] File upload support empty accept mime type string (#761) * Set mimeTypes to undefined when initiating a CDP upload if accept string is empty * Omit the accept html attribute if empty --- .../plugins/engine/components/FileUploadField.ts | 2 +- .../plugins/engine/services/uploadService.js | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/server/plugins/engine/components/FileUploadField.ts b/src/server/plugins/engine/components/FileUploadField.ts index bc285a4de..7cb74e8a3 100644 --- a/src/server/plugins/engine/components/FileUploadField.ts +++ b/src/server/plugins/engine/components/FileUploadField.ts @@ -231,7 +231,7 @@ export class FileUploadField extends FormComponent { }) // Set up the `accept` attribute - if ('accept' in options) { + if ('accept' in options && options.accept) { attributes.accept = options.accept } diff --git a/src/server/plugins/engine/services/uploadService.js b/src/server/plugins/engine/services/uploadService.js index 2d4b43b93..44d63c124 100644 --- a/src/server/plugins/engine/services/uploadService.js +++ b/src/server/plugins/engine/services/uploadService.js @@ -10,12 +10,19 @@ const stagingPrefix = config.get('stagingPrefix') * Initiates a CDP file upload * @param {string} path - the path of the page in the form * @param {string} retrievalKey - the retrieval key for the files - * @param {string} [mimeTypes] - the csv string of accepted mimeTypes + * @param {string} [mimeTypesCsv] - the csv string of accepted mimeTypes */ -export async function initiateUpload(path, retrievalKey, mimeTypes) { +export async function initiateUpload(path, retrievalKey, mimeTypesCsv) { const postJsonByType = /** @type {typeof postJson} */ (postJson) + const mimeTypesList = mimeTypesCsv + ?.split(',') + .map((type) => type.trim()) + .filter((type) => type !== '') + + const mimeTypes = mimeTypesList?.length ? mimeTypesList : undefined + const payload = { redirect: path, callback: `${submissionUrl}/file`, @@ -24,10 +31,7 @@ export async function initiateUpload(path, retrievalKey, mimeTypes) { metadata: { retrievalKey }, - mimeTypes: mimeTypes - ?.split(',') - .map((type) => type.trim()) - .filter((type) => type !== '') + mimeTypes // maxFileSize: 25 * 1000 * 1000 } From 7317d127faf71e7c0354c96e04f03f5eba66f230 Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 4 Apr 2025 16:39:41 +0100 Subject: [PATCH 06/20] Allow more than 1 error message for UK Address Field components (#759) * Allow more than 1 error message for UK Address Field components * Sonar changes * Refactor to getViewErrors * Add comments --- .../engine/components/ComponentCollection.ts | 61 +++++++++++++------ .../engine/components/FormComponent.ts | 13 +++- .../engine/components/UkAddressField.test.ts | 39 +++++++++++- .../engine/components/UkAddressField.ts | 12 ++++ .../plugins/engine/models/SummaryViewModel.ts | 2 +- .../pageControllers/QuestionPageController.ts | 4 +- 6 files changed, 106 insertions(+), 25 deletions(-) diff --git a/src/server/plugins/engine/components/ComponentCollection.ts b/src/server/plugins/engine/components/ComponentCollection.ts index a0c04fb1c..42880cd7b 100644 --- a/src/server/plugins/engine/components/ComponentCollection.ts +++ b/src/server/plugins/engine/components/ComponentCollection.ts @@ -212,25 +212,22 @@ export class ComponentCollection { return context } + /** + * Get all errors for all fields in this collection + */ getErrors(errors?: FormSubmissionError[]): FormSubmissionError[] | undefined { - const { fields } = this - - const list: FormSubmissionError[] = [] - - // Add only one error per field - for (const field of fields) { - const error = field.getError(errors) - - if (error) { - list.push(error) - } - } - - if (!list.length) { - return - } + return this.getFieldErrors((field) => field.getErrors(errors), errors) + } - return list + /** + * Get view errors for all fields in this collection. + * For most fields this means filtering to the first error in the list. + * Composite fields like UKAddress can choose to return more than one error. + */ + getViewErrors( + errors?: FormSubmissionError[] + ): FormSubmissionError[] | undefined { + return this.getFieldErrors((field) => field.getViewErrors(errors), errors) } getViewModel( @@ -266,6 +263,36 @@ export class ComponentCollection { errors: this.page?.getErrors(details) ?? getErrors(details) } } + + /** + * Helper to get errors from all fields + */ + private getFieldErrors( + callback: (field: Field) => FormSubmissionError[] | undefined, + errors?: FormSubmissionError[] + ): FormSubmissionError[] | undefined { + const { fields } = this + + if (!errors?.length) { + return + } + + const list: FormSubmissionError[] = [] + + for (const field of fields) { + const fieldErrors = callback(field) + + if (fieldErrors?.length) { + list.push(...fieldErrors) + } + } + + if (!list.length) { + return + } + + return list + } } /** diff --git a/src/server/plugins/engine/components/FormComponent.ts b/src/server/plugins/engine/components/FormComponent.ts index 0b8302c1a..f9f0f7fcb 100644 --- a/src/server/plugins/engine/components/FormComponent.ts +++ b/src/server/plugins/engine/components/FormComponent.ts @@ -100,10 +100,19 @@ export class FormComponent extends ComponentBase { return list } - getError(errors?: FormSubmissionError[]): FormSubmissionError | undefined { + getFirstError( + errors?: FormSubmissionError[] + ): FormSubmissionError | undefined { return this.getErrors(errors)?.[0] } + getViewErrors( + errors?: FormSubmissionError[] + ): FormSubmissionError[] | undefined { + const firstError = this.getFirstError(errors) + return firstError && [firstError] + } + getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { const { hint, name, options = {}, title, viewModel } = this @@ -119,7 +128,7 @@ export class FormComponent extends ComponentBase { // Filter component errors only const componentErrors = this.getErrors(errors) - const componentError = this.getError(componentErrors) + const componentError = this.getFirstError(componentErrors) if (componentErrors) { viewModel.errors = componentErrors diff --git a/src/server/plugins/engine/components/UkAddressField.test.ts b/src/server/plugins/engine/components/UkAddressField.test.ts index e3f52dfda..932679038 100644 --- a/src/server/plugins/engine/components/UkAddressField.test.ts +++ b/src/server/plugins/engine/components/UkAddressField.test.ts @@ -427,7 +427,8 @@ describe('UkAddressField', () => { postcode: ' WA4 1HT' }), output: { - value: getFormData(address) + value: getFormData(address), + errors: undefined } }, { @@ -438,7 +439,8 @@ describe('UkAddressField', () => { postcode: 'WA4 1HT ' }), output: { - value: getFormData(address) + value: getFormData(address), + errors: undefined } }, { @@ -449,7 +451,8 @@ describe('UkAddressField', () => { postcode: ' WA4 1HT \n\n' }), output: { - value: getFormData(address) + value: getFormData(address), + errors: undefined } } ] @@ -546,6 +549,33 @@ describe('UkAddressField', () => { }) ] } + }, + { + input: getFormData({ + addressLine1: '', + addressLine2: '', + town: '', + postcode: postcodeInvalid + }), + output: { + value: getFormData({ + addressLine1: '', + addressLine2: '', + town: '', + postcode: postcodeInvalid + }), + errors: [ + expect.objectContaining({ + text: 'Enter address line 1' + }), + expect.objectContaining({ + text: 'Enter town or city' + }), + expect.objectContaining({ + text: 'Enter a valid postcode' + }) + ] + } } ] } @@ -561,6 +591,9 @@ describe('UkAddressField', () => { ({ input, output }) => { const result = collection.validate(input) expect(result).toEqual(output) + + const errors = collection.getErrors(result.errors) + expect(errors).toEqual(output.errors) } ) }) diff --git a/src/server/plugins/engine/components/UkAddressField.ts b/src/server/plugins/engine/components/UkAddressField.ts index 4947e9fd3..f4dd07520 100644 --- a/src/server/plugins/engine/components/UkAddressField.ts +++ b/src/server/plugins/engine/components/UkAddressField.ts @@ -112,6 +112,18 @@ export class UkAddressField extends FormComponent { return Object.values(value).filter(Boolean) } + /** + * Returns one error per child field + */ + getViewErrors( + errors?: FormSubmissionError[] + ): FormSubmissionError[] | undefined { + return this.getErrors(errors)?.filter( + (error, index, self) => + index === self.findIndex((err) => err.name === error.name) + ) + } + getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { const { collection, name, options } = this diff --git a/src/server/plugins/engine/models/SummaryViewModel.ts b/src/server/plugins/engine/models/SummaryViewModel.ts index 87a9e805d..190887fe2 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.ts @@ -212,7 +212,7 @@ function ItemField( name: field.name, label: field.title, title: field.title, - error: field.getError(options.errors), + error: field.getFirstError(options.errors), value: getAnswer(field, state), href: getPageHref(page, options.path, { returnUrl: getPageHref(page, page.getSummaryPath()) diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index 9bb5e80e2..e4fa78642 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -394,7 +394,7 @@ export class QuestionPageController extends PageController { const { evaluationState } = context const viewModel = this.getViewModel(request, context) - viewModel.errors = collection.getErrors(viewModel.errors) + viewModel.errors = collection.getViewErrors(viewModel.errors) /** * Content components can be hidden based on a condition. If the condition evaluates to true, it is safe to be kept, otherwise discard it @@ -494,7 +494,7 @@ export class QuestionPageController extends PageController { */ if (context.errors || isForceAccess) { const viewModel = this.getViewModel(request, context) - viewModel.errors = collection.getErrors(viewModel.errors) + viewModel.errors = collection.getViewErrors(viewModel.errors) // Filter our components based on their conditions using our evaluated state viewModel.components = this.filterConditionalComponents( From 454445c29367ea1b6c22ed6273828af9be9c03ac Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 7 Apr 2025 14:55:53 +0100 Subject: [PATCH 07/20] Validate answers of fields that are bound to lists that have conditional items (#753) * Validate answers of fields that are bound to lists that have conditional item * Add missing YesNoField from isListFieldType helper * Add error message if conditional list items become invalid * Sonar changes * Add condition list item unit tests * Update conditional items validation error message --- .../plugins/engine/components/helpers.ts | 34 +++ .../plugins/engine/models/FormModel.test.ts | 73 ++++++ src/server/plugins/engine/models/FormModel.ts | 83 ++++++- test/form/definitions/conditions-list.js | 215 ++++++++++++++++++ 4 files changed, 403 insertions(+), 2 deletions(-) create mode 100644 test/form/definitions/conditions-list.js diff --git a/src/server/plugins/engine/components/helpers.ts b/src/server/plugins/engine/components/helpers.ts index 3362670c9..6f92be56e 100644 --- a/src/server/plugins/engine/components/helpers.ts +++ b/src/server/plugins/engine/components/helpers.ts @@ -55,6 +55,8 @@ export type Component = InstanceType< // Field component instances only export type Field = InstanceType< | typeof Components.AutocompleteField + | typeof Components.RadiosField + | typeof Components.YesNoField | typeof Components.CheckboxesField | typeof Components.DatePartsField | typeof Components.EmailAddressField @@ -77,6 +79,38 @@ export type Guidance = InstanceType< | typeof Components.List > +// List component instances only +export type ListField = InstanceType< + | typeof Components.AutocompleteField + | typeof Components.CheckboxesField + | typeof Components.RadiosField + | typeof Components.SelectField + | typeof Components.YesNoField +> + +/** + * Filter known components with lists + */ +export function hasListFormField( + field?: Partial +): field is ListFormComponent { + return !!field && isListFieldType(field.type) +} + +export function isListFieldType( + type?: ComponentType +): type is ListField['type'] { + const allowedTypes = [ + ComponentType.AutocompleteField, + ComponentType.CheckboxesField, + ComponentType.RadiosField, + ComponentType.SelectField, + ComponentType.YesNoField + ] + + return !!type && allowedTypes.includes(type) +} + /** * Create field instance for each {@link ComponentDef} type */ diff --git a/src/server/plugins/engine/models/FormModel.test.ts b/src/server/plugins/engine/models/FormModel.test.ts index 01d64df20..e53299ca4 100644 --- a/src/server/plugins/engine/models/FormModel.test.ts +++ b/src/server/plugins/engine/models/FormModel.test.ts @@ -1,6 +1,7 @@ import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { type FormContextRequest } from '~/src/server/plugins/engine/types.js' import definition from '~/test/form/definitions/conditions-escaping.js' +import conditionsListDefinition from '~/test/form/definitions/conditions-list.js' import fieldsRequiredDefinition from '~/test/form/definitions/fields-required.js' describe('FormModel', () => { @@ -38,5 +39,77 @@ describe('FormModel', () => { expect.objectContaining({ name: 'checkboxesSingle' }) ) }) + + it('redirects to the page if the list field (radio) is invalidated due to list item conditions', () => { + const formModel = new FormModel(conditionsListDefinition, { + basePath: '/conditional-list-items' + }) + + const state = { + gXsqLq: true, + QwcNsc: 'meat', + zeQDES: ['peppers', 'cheese', 'ham'] + } + const pageUrl = new URL( + 'http://example.com/conditional-list-items/summary' + ) + + const request: FormContextRequest = { + method: 'get', + query: {}, + path: pageUrl.pathname, + params: { path: 'summary', slug: 'conditional-list-items' }, + url: pageUrl, + app: { model: formModel } + } + + const context = formModel.getFormContext(request, state) + + expect(context.errors).toHaveLength(1) + expect(context.errors?.at(0)?.text).toBe( + 'Options are different because you changed a previous answer' + ) + expect(context.relevantPages).toHaveLength(2) + expect(context.paths).toHaveLength(2) + expect(context.relevantState).toEqual({ gXsqLq: true, QwcNsc: 'meat' }) + }) + + it('redirects to the page if the list field (check) is invalidated due to list item conditions', () => { + const formModel = new FormModel(conditionsListDefinition, { + basePath: '/conditional-list-items' + }) + + const state = { + gXsqLq: true, + QwcNsc: 'vegan', + zeQDES: ['peppers', 'cheese', 'ham'] + } + const pageUrl = new URL( + 'http://example.com/conditional-list-items/summary' + ) + + const request: FormContextRequest = { + method: 'get', + query: {}, + path: pageUrl.pathname, + params: { path: 'summary', slug: 'conditional-list-items' }, + url: pageUrl, + app: { model: formModel } + } + + const context = formModel.getFormContext(request, state) + + expect(context.errors).toHaveLength(1) + expect(context.errors?.at(0)?.text).toBe( + 'Options are different because you changed a previous answer' + ) + expect(context.relevantPages).toHaveLength(3) + expect(context.paths).toHaveLength(3) + expect(context.relevantState).toEqual({ + gXsqLq: true, + QwcNsc: 'vegan', + zeQDES: ['peppers', 'cheese', 'ham'] + }) + }) }) }) diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index ded1587b0..03d9407de 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -19,7 +19,11 @@ import { add } from 'date-fns' import { Parser, type Value } from 'expr-eval' import joi from 'joi' -import { type Component } from '~/src/server/plugins/engine/components/helpers.js' +import { type ListFormComponent } from '~/src/server/plugins/engine/components/ListFormComponent.js' +import { + hasListFormField, + type Component +} from '~/src/server/plugins/engine/components/helpers.js' import { findPage, getError, @@ -296,7 +300,10 @@ export class FormModel { this.assignRelevantState(context, nextPage) // Stop at current page - if (nextPage.path === currentPath) { + if ( + this.pageStateIsInvalid(context, nextPage) || + nextPage.path === currentPath + ) { break } @@ -350,6 +357,78 @@ export class FormModel { } } + private pageStateIsInvalid(context: FormContext, page: PageControllerClass) { + // Get any list-bound fields on the page + const listFields = page.collection.fields.filter(hasListFormField) + + // For each list field that is bound to a list that contains any conditional items, + // we need to check any answers are still valid. Do this by evaluating the conditions + // and ensuring any current answers are all included in the set of valid answers + for (const field of listFields) { + const list = field.list + + // Filter out YesNo as they can't be conditional + if (list !== undefined && field.type !== ComponentType.YesNoField) { + const hasOptionalItems = + list.items.filter((item) => item.condition).length > 0 + + if (hasOptionalItems) { + return this.fieldStateIsInvalid(context, field, list) + } + } + } + } + + private fieldStateIsInvalid( + context: FormContext, + field: ListFormComponent, + list: List + ) { + const { evaluationState, state } = context + + const validValues = list.items + .filter((item) => + item.condition + ? this.conditions[item.condition]?.fn(evaluationState) + : true + ) + .map((item) => item.value) + + // Get the field state + const fieldState = field.getFormValueFromState(state) + + if (fieldState !== undefined) { + let isInvalid = false + const isArray = Array.isArray(fieldState) + + // Check if any saved state value(s) are still valid + // and return true if any are invalid + if (isArray) { + isInvalid = !fieldState.every((item) => validValues.includes(item)) + } else { + isInvalid = !validValues.includes(fieldState) + } + + if (isInvalid) { + if (!context.errors) { + context.errors = [] + } + + const text = + 'Options are different because you changed a previous answer' + + context.errors.push({ + text, + name: field.name, + href: `#${field.name}`, + path: [`#${field.name}`] + }) + } + + return isInvalid + } + } + private assignPaths(context: FormContext) { for (const { keys, path } of context.relevantPages) { context.paths.push(path) diff --git a/test/form/definitions/conditions-list.js b/test/form/definitions/conditions-list.js new file mode 100644 index 000000000..5021beeb9 --- /dev/null +++ b/test/form/definitions/conditions-list.js @@ -0,0 +1,215 @@ +import { + ComponentType, + ConditionType, + ControllerType, + Engine, + OperatorName +} from '@defra/forms-model' + +export default /** @satisfies {FormDefinition} */ ({ + name: 'Conditional list items', + pages: [ + { + id: '449a45f6-4541-4a46-91bd-8b8931b07b50', + title: 'Summary', + path: '/summary', + controller: ControllerType.Summary + }, + { + title: 'Are you veggie?', + path: '/are-you-veggie', + next: [ + { + path: '/type' + } + ], + components: [ + { + name: 'gXsqLq', + title: 'Are you veggie?', + type: ComponentType.YesNoField, + options: {} + } + ] + }, + { + title: 'Type', + path: '/type', + next: [ + { + path: '/toppings' + } + ], + components: [ + { + name: 'QwcNsc', + title: 'Type', + type: ComponentType.RadiosField, + list: 'hIYxMw', + options: {} + } + ] + }, + { + title: 'Toppings', + path: '/toppings', + next: [ + { + path: '/summary' + } + ], + components: [ + { + name: 'zeQDES', + title: 'Toppings', + type: ComponentType.CheckboxesField, + list: 'pMdDIh', + options: {} + } + ] + } + ], + conditions: [ + { + name: 'sieBra', + displayName: 'isVeggie', + value: { + name: 'isVeggie', + conditions: [ + { + field: { + name: 'gXsqLq', + type: ComponentType.YesNoField, + display: 'Are you veggie?' + }, + operator: OperatorName.Is, + value: { + type: ConditionType.Value, + value: 'true', + display: 'Yes' + } + } + ] + } + }, + { + name: 'naJibN', + displayName: 'isNotVeggie', + value: { + name: 'isNotVeggie', + conditions: [ + { + field: { + name: 'gXsqLq', + type: ComponentType.YesNoField, + display: 'Are you veggie?' + }, + operator: OperatorName.Is, + value: { + type: ConditionType.Value, + value: 'false', + display: 'No' + } + } + ] + } + }, + { + name: 'NcPUbs', + displayName: 'isNotVegan', + value: { + name: 'isNotVegan', + conditions: [ + { + field: { + name: 'QwcNsc', + type: ComponentType.RadiosField, + display: 'Type' + }, + operator: OperatorName.IsNot, + value: { + type: ConditionType.Value, + value: 'vegan', + display: 'Vegan' + } + } + ] + } + } + ], + sections: [], + lists: [ + { + title: 'Type', + name: 'hIYxMw', + type: 'string', + items: [ + { + text: 'Vegetarian', + value: 'vegetarian', + condition: 'sieBra' + }, + { + text: 'Vegan', + value: 'vegan', + condition: 'sieBra' + }, + { + text: 'Meat eater', + value: 'meat', + condition: 'naJibN' + } + ] + }, + { + title: 'Topping', + name: 'pMdDIh', + type: 'string', + items: [ + { + text: 'Onions', + value: 'onions' + }, + { + text: 'Peppers', + value: 'peppers' + }, + { + text: 'Mushrooms', + value: 'mushrooms' + }, + { + text: 'Cheese', + value: 'cheese', + condition: 'NcPUbs' + }, + { + text: 'Ham', + value: 'ham', + condition: 'naJibN' + }, + { + text: 'Chicken', + value: 'chicken', + condition: 'naJibN' + }, + { + text: 'Pepperoni', + value: 'pepperoni', + condition: 'naJibN' + }, + { + text: 'Tofu', + value: 'tofu', + condition: 'sieBra' + } + ] + } + ], + engine: Engine.V1, + startPage: '/are-you-veggie' +}) + +/** + * @import { FormDefinition } from '@defra/forms-model' + */ From 061e27e4967e3116fdb964a83e9ea1c962be1c26 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Apr 2025 16:25:01 +0100 Subject: [PATCH 08/20] Bump @defra/forms-model from 3.0.409 to 3.0.438 (#767) * Bump @defra/forms-model from 3.0.409 to 3.0.438 Bumps [@defra/forms-model](https://github.com/DEFRA/forms-designer/tree/HEAD/model) from 3.0.409 to 3.0.438. - [Commits](https://github.com/DEFRA/forms-designer/commits/HEAD/model) --- updated-dependencies: - dependency-name: "@defra/forms-model" dependency-version: 3.0.438 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * test: 537088 - Fix linting and test errors with new schema --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Chris Cole --- package-lock.json | 52 ++++++++-- package.json | 2 +- src/server/forms/report-a-terrorist.json | 16 +-- src/server/forms/runner-components-test.json | 20 ++-- .../components/AutocompleteField.test.ts | 10 +- .../engine/components/CheckboxesField.test.ts | 14 +-- .../plugins/engine/components/List.test.ts | 3 + .../engine/components/RadiosField.test.ts | 10 +- .../engine/components/SelectField.test.ts | 10 +- .../QuestionPageController.test.ts | 4 + test/condition/checkboxes.test.js | 2 +- test/condition/radios.test.js | 2 +- test/fixtures/list.js | 99 +++++++++---------- test/form/definitions/basic.js | 24 +++-- test/form/definitions/titles.json | 16 +-- test/form/factory.js | 34 +++++++ test/form/titles.test.js | 28 +++--- 17 files changed, 212 insertions(+), 134 deletions(-) create mode 100644 test/form/factory.js diff --git a/package-lock.json b/package-lock.json index de46ad80a..2fe3f812f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.409", + "@defra/forms-model": "^3.0.438", "@defra/hapi-tracing": "^1.0.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", @@ -2039,13 +2039,15 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.409", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.409.tgz", - "integrity": "sha512-q7OekQSDd3TYDBS1mp9LHfbqnmwk/N+wM3z+qrV6I+sLgl5o32SJm8ufhjmvnn5FuvOmbDkBrr8rk/upkZqvSQ==", + "version": "3.0.438", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.438.tgz", + "integrity": "sha512-3Vn6ZeTy/Oajx5t5vG5/xTn0UPnas0fdtTYOYTfNQuco12RJRtmLeXaOcHyzJqZpxvAmqWL0AKKEUv4iSSMoZg==", "license": "OGL-UK-3.0", "dependencies": { - "marked": "^15.0.6", - "slug": "^10.0.0" + "marked": "^15.0.7", + "nanoid": "^5.0.7", + "slug": "^10.0.0", + "uuid": "^11.1.0" }, "engines": { "node": "^22.11.0", @@ -2055,6 +2057,24 @@ "joi": "^17.0.0" } }, + "node_modules/@defra/forms-model/node_modules/nanoid": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz", + "integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "node_modules/@defra/hapi-tracing": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@defra/hapi-tracing/-/hapi-tracing-1.0.0.tgz", @@ -12057,9 +12077,10 @@ } }, "node_modules/marked": { - "version": "15.0.6", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.6.tgz", - "integrity": "sha512-Y07CUOE+HQXbVDCGl3LXggqJDbXDP2pArc2C1N1RRMN0ONiShoSsIInMd5Gsxupe7fKLpgimTV+HOJ9r7bA+pg==", + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.8.tgz", + "integrity": "sha512-rli4l2LyZqpQuRve5C0rkn6pj3hT8EWPC+zkAxFTAJLxRbENfTAhEQq9itrmf1Y81QtAX5D/MYlGlIomNgj9lA==", + "license": "MIT", "bin": { "marked": "bin/marked.js" }, @@ -16367,6 +16388,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/v8-to-istanbul": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", diff --git a/package.json b/package.json index 7382bf5db..aab529b71 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ }, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.409", + "@defra/forms-model": "^3.0.438", "@defra/hapi-tracing": "^1.0.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", diff --git a/src/server/forms/report-a-terrorist.json b/src/server/forms/report-a-terrorist.json index 49762ef3d..d19b0d762 100644 --- a/src/server/forms/report-a-terrorist.json +++ b/src/server/forms/report-a-terrorist.json @@ -54,7 +54,7 @@ "schema": {} }, { - "name": "3jdOpV", + "name": "TjdOpV", "options": {}, "type": "Details", "title": "Help me take a screenshot", @@ -62,7 +62,7 @@ "schema": {} }, { - "name": "LU6RMD", + "name": "LUSRMD", "options": {}, "type": "RadiosField", "title": "Do you have any evidence?", @@ -92,7 +92,7 @@ "title": "Is there anything else you can tell us?", "components": [ { - "name": "HETM3o", + "name": "HETMEo", "title": "Html", "options": {}, "type": "Html", @@ -100,7 +100,7 @@ "schema": {} }, { - "name": "evZ-IJ", + "name": "evZDIJ", "options": { "required": false }, @@ -141,7 +141,7 @@ "title": "Yes I have evidence", "components": [ { - "name": "koE_ae", + "name": "koEEae", "options": { "required": false }, @@ -162,7 +162,7 @@ "lists": [ { "title": "linktomateriallist", - "name": "HTbt4V", + "name": "HTbtEV", "type": "string", "items": [ { @@ -177,7 +177,7 @@ }, { "title": "evidencelist", - "name": "mdmRq9", + "name": "mdmRqN", "type": "string", "items": [ { @@ -193,7 +193,7 @@ ], "sections": [ { - "name": "PMXq1s", + "name": "PMXqOs", "title": "Evidence" } ], diff --git a/src/server/forms/runner-components-test.json b/src/server/forms/runner-components-test.json index 3e0cb96da..5df2e4ce4 100644 --- a/src/server/forms/runner-components-test.json +++ b/src/server/forms/runner-components-test.json @@ -6,7 +6,7 @@ "path": "/do-you-own-a-vehicle", "components": [ { - "name": "qqbRw1", + "name": "qqbRwO", "options": {}, "type": "YesNoField", "title": "Do you own a vehicle?", @@ -32,7 +32,7 @@ "title": "What address is the vehicle registered to?", "components": [ { - "name": "sFR4aX", + "name": "sFRFaX", "options": {}, "type": "UkAddressField", "title": "What address is the vehicle registered to?", @@ -46,7 +46,7 @@ "schema": {} }, { - "name": "Z0Guyn", + "name": "ZZGuyn", "options": {}, "type": "CheckboxesField", "title": "Which Clean Air Zones are you claiming an exemption for?", @@ -66,7 +66,7 @@ "title": "Clean Air Zone (CAZ) Exemption", "components": [ { - "name": "MOB13t", + "name": "MOBOTt", "title": "Html", "options": {}, "type": "Html", @@ -86,7 +86,7 @@ "title": "Details about your vehicle", "components": [ { - "name": "0ZVmN_", + "name": "ZZVmNd", "options": {}, "type": "AutocompleteField", "title": "What is the make of you vehicle?", @@ -94,7 +94,7 @@ "schema": {} }, { - "name": "gHSgo2", + "name": "gHSgoT", "options": {}, "type": "TextField", "title": "Vehicle Model", @@ -102,7 +102,7 @@ "schema": {} }, { - "name": "4LZ9to", + "name": "FLZNto", "options": {}, "type": "DatePartsField", "title": "Date you purchased the vehicle?", @@ -152,7 +152,7 @@ "schema": {} }, { - "name": "0zL5bB", + "name": "ZzLFbB", "options": {}, "type": "TelephoneNumberField", "title": "Contact number", @@ -171,7 +171,7 @@ "title": "final steps", "components": [ { - "name": "fkd8av", + "name": "fkdEav", "options": {}, "type": "List", "title": "Declaration", @@ -179,7 +179,7 @@ "schema": {} }, { - "name": "L_2AYe", + "name": "LdTAYe", "options": {}, "type": "EmailAddressField", "title": "Your email address", diff --git a/src/server/plugins/engine/components/AutocompleteField.test.ts b/src/server/plugins/engine/components/AutocompleteField.test.ts index ac45b2fb0..eebb4a3dd 100644 --- a/src/server/plugins/engine/components/AutocompleteField.test.ts +++ b/src/server/plugins/engine/components/AutocompleteField.test.ts @@ -172,7 +172,7 @@ describe.each([ describe('State', () => { it.each([...options.examples])('returns text from state', (item) => { - const state1 = getFormState(item.state) + const state1 = getFormState(item.value) const state2 = getFormState(null) const answer1 = getAnswer(field, state1) @@ -183,7 +183,7 @@ describe.each([ }) it.each([...options.examples])('returns payload from state', (item) => { - const state1 = getFormState(item.state) + const state1 = getFormState(item.value) const state2 = getFormState(null) const payload1 = field.getFormDataFromState(state1) @@ -194,7 +194,7 @@ describe.each([ }) it.each([...options.examples])('returns value from state', (item) => { - const state1 = getFormState(item.state) + const state1 = getFormState(item.value) const state2 = getFormState(null) const value1 = field.getFormValueFromState(state1) @@ -207,7 +207,7 @@ describe.each([ it.each([...options.examples])( 'returns context for conditions and form submission', (item) => { - const state1 = getFormState(item.state) + const state1 = getFormState(item.value) const state2 = getFormState(null) const value1 = field.getContextValueFromState(state1) @@ -225,7 +225,7 @@ describe.each([ const value1 = field.getStateFromValidForm(payload1) const value2 = field.getStateFromValidForm(payload2) - expect(value1).toEqual(getFormState(item.state)) + expect(value1).toEqual(getFormState(item.value)) expect(value2).toEqual(getFormState(null)) }) }) diff --git a/src/server/plugins/engine/components/CheckboxesField.test.ts b/src/server/plugins/engine/components/CheckboxesField.test.ts index b34e7f5f7..9c33708cf 100644 --- a/src/server/plugins/engine/components/CheckboxesField.test.ts +++ b/src/server/plugins/engine/components/CheckboxesField.test.ts @@ -240,7 +240,7 @@ describe.each([ it.each([...options.examples])( 'returns text from state (single)', (item) => { - const state1 = getFormState([item.state]) + const state1 = getFormState([item.value]) const state2 = getFormState(null) const answer1 = getAnswer(field, state1) @@ -260,7 +260,7 @@ describe.each([ const item1 = options.examples[0] const item2 = options.examples[2] - const state = getFormState([item1.state, item2.state]) + const state = getFormState([item1.value, item2.value]) const answer = getAnswer(field, state) expect(answer).toBe(outdent` @@ -272,7 +272,7 @@ describe.each([ }) it.each([...options.examples])('returns payload from state', (item) => { - const state1 = getFormState([item.state]) + const state1 = getFormState([item.value]) const state2 = getFormState(null) const payload1 = field.getFormDataFromState(state1) @@ -283,7 +283,7 @@ describe.each([ }) it.each([...options.examples])('returns value from state', (item) => { - const state1 = getFormState([item.state]) + const state1 = getFormState([item.value]) const state2 = getFormState(null) const value1 = field.getFormValueFromState(state1) @@ -296,13 +296,13 @@ describe.each([ it.each([...options.examples])( 'returns context for conditions and form submission', (item) => { - const state1 = getFormState([item.state]) + const state1 = getFormState([item.value]) const state2 = getFormState(null) const value1 = field.getContextValueFromState(state1) const value2 = field.getContextValueFromState(state2) - expect(value1).toEqual([item.state]) + expect(value1).toEqual([item.value]) expect(value2).toEqual([]) } ) @@ -314,7 +314,7 @@ describe.each([ const value1 = field.getStateFromValidForm(payload1) const value2 = field.getStateFromValidForm(payload2) - expect(value1).toEqual(getFormState([item.state])) + expect(value1).toEqual(getFormState([item.value])) expect(value2).toEqual(getFormState(null)) }) }) diff --git a/src/server/plugins/engine/components/List.test.ts b/src/server/plugins/engine/components/List.test.ts index 85fd6ec93..dd188ceaa 100644 --- a/src/server/plugins/engine/components/List.test.ts +++ b/src/server/plugins/engine/components/List.test.ts @@ -52,18 +52,21 @@ describe('List', () => { it('returns list items', () => { expect(guidance).toHaveProperty('items', [ { + id: '52fc51fc-c75a-4b08-9c9e-6bd99b9bc49b', text: '1 day', value: 1, description: 'Valid for 24 hours from the start time that you select' }, { + id: '56b7b34f-23b3-4446-ac8e-b2443d18588e', text: '8 day', value: 8, description: 'Valid for 8 consecutive days from the start time that you select' }, { + id: '1af54fbc-eec2-4e1e-bd53-2415abf62677', text: '12 months', value: 365, description: diff --git a/src/server/plugins/engine/components/RadiosField.test.ts b/src/server/plugins/engine/components/RadiosField.test.ts index fbf2724d2..f0bb586c0 100644 --- a/src/server/plugins/engine/components/RadiosField.test.ts +++ b/src/server/plugins/engine/components/RadiosField.test.ts @@ -170,7 +170,7 @@ describe.each([ describe('State', () => { it.each([...options.examples])('returns text from state', (item) => { - const state1 = getFormState(item.state) + const state1 = getFormState(item.value) const state2 = getFormState(null) const answer1 = getAnswer(field, state1) @@ -181,7 +181,7 @@ describe.each([ }) it.each([...options.examples])('returns payload from state', (item) => { - const state1 = getFormState(item.state) + const state1 = getFormState(item.value) const state2 = getFormState(null) const payload1 = field.getFormDataFromState(state1) @@ -192,7 +192,7 @@ describe.each([ }) it.each([...options.examples])('returns value from state', (item) => { - const state1 = getFormState(item.state) + const state1 = getFormState(item.value) const state2 = getFormState(null) const value1 = field.getFormValueFromState(state1) @@ -205,7 +205,7 @@ describe.each([ it.each([...options.examples])( 'returns context for conditions and form submission', (item) => { - const state1 = getFormState(item.state) + const state1 = getFormState(item.value) const state2 = getFormState(null) const value1 = field.getContextValueFromState(state1) @@ -223,7 +223,7 @@ describe.each([ const value1 = field.getStateFromValidForm(payload1) const value2 = field.getStateFromValidForm(payload2) - expect(value1).toEqual(getFormState(item.state)) + expect(value1).toEqual(getFormState(item.value)) expect(value2).toEqual(getFormState(null)) }) }) diff --git a/src/server/plugins/engine/components/SelectField.test.ts b/src/server/plugins/engine/components/SelectField.test.ts index 903eb8c64..c6a2cda9b 100644 --- a/src/server/plugins/engine/components/SelectField.test.ts +++ b/src/server/plugins/engine/components/SelectField.test.ts @@ -171,7 +171,7 @@ describe.each([ describe('State', () => { it.each([...options.examples])('returns text from state', (item) => { - const state1 = getFormState(item.state) + const state1 = getFormState(item.value) const state2 = getFormState(null) const answer1 = getAnswer(field, state1) @@ -182,7 +182,7 @@ describe.each([ }) it.each([...options.examples])('returns payload from state', (item) => { - const state1 = getFormState(item.state) + const state1 = getFormState(item.value) const state2 = getFormState(null) const payload1 = field.getFormDataFromState(state1) @@ -193,7 +193,7 @@ describe.each([ }) it.each([...options.examples])('returns value from state', (item) => { - const state1 = getFormState(item.state) + const state1 = getFormState(item.value) const state2 = getFormState(null) const value1 = field.getFormValueFromState(state1) @@ -206,7 +206,7 @@ describe.each([ it.each([...options.examples])( 'returns context for conditions and form submission', (item) => { - const state1 = getFormState(item.state) + const state1 = getFormState(item.value) const state2 = getFormState(null) const value1 = field.getContextValueFromState(state1) @@ -224,7 +224,7 @@ describe.each([ const value1 = field.getStateFromValidForm(payload1) const value2 = field.getStateFromValidForm(payload2) - expect(value1).toEqual(getFormState(item.state)) + expect(value1).toEqual(getFormState(item.value)) expect(value2).toEqual(getFormState(null)) }) }) diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts index a197d71f3..cbd1f4585 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts @@ -441,6 +441,7 @@ describe('QuestionPageController', () => { expect(filtered[1].model.label?.text).toBe('Select from the list') expect(filtered[1].model.items).toEqual([ { + id: expect.any(String), checked: false, condition: 'isBarnOwl', selected: false, @@ -448,6 +449,7 @@ describe('QuestionPageController', () => { value: '1' }, { + id: expect.any(String), checked: false, condition: 'isBarnOwl', selected: false, @@ -497,6 +499,7 @@ describe('QuestionPageController', () => { expect(filtered[1].model.label?.text).toBe('Select from the list') expect(filtered[1].model.items).toEqual([ { + id: expect.any(String), checked: false, condition: 'notBarnOwl', selected: false, @@ -504,6 +507,7 @@ describe('QuestionPageController', () => { value: '3' }, { + id: expect.any(String), checked: false, condition: 'notBarnOwl', selected: false, diff --git a/test/condition/checkboxes.test.js b/test/condition/checkboxes.test.js index 42228c537..dfcbf4eda 100644 --- a/test/condition/checkboxes.test.js +++ b/test/condition/checkboxes.test.js @@ -70,7 +70,7 @@ describe('Checkboxes based conditions', () => { }) expect($checkbox).toBeInTheDocument() - expect($checkbox).toHaveAttribute('id', example.id) + expect($checkbox).toHaveAttribute('id', expect.any(String)) // id is now a uuid expect($checkbox).toHaveAttribute('name', example.name) expect($checkbox).toHaveAttribute('value', example.value) expect($checkbox).toHaveClass('govuk-checkboxes__input') diff --git a/test/condition/radios.test.js b/test/condition/radios.test.js index 98791f723..3730aecd9 100644 --- a/test/condition/radios.test.js +++ b/test/condition/radios.test.js @@ -70,7 +70,7 @@ describe('Radio based conditions', () => { }) expect($radio).toBeInTheDocument() - expect($radio).toHaveAttribute('id', example.id) + expect($radio).toHaveAttribute('id', expect.any(String)) // is now a uuid expect($radio).toHaveAttribute('name', example.name) expect($radio).toHaveAttribute('value', example.value) expect($radio).toHaveClass('govuk-radios__input') diff --git a/test/fixtures/list.js b/test/fixtures/list.js index 57ccf98f8..65985bd5c 100644 --- a/test/fixtures/list.js +++ b/test/fixtures/list.js @@ -1,90 +1,85 @@ +import { + createListFromFactory, + createListItemFactory +} from '~/test/form/factory.js' + /** * @satisfies {List} */ -export const listNumber = { +export const listNumber = createListFromFactory({ title: 'Example number list', name: 'listNumber', type: 'number', items: [ - { text: '1 point', value: 1 }, - { text: '2 points', value: 2 }, - { text: '3 points', value: 3 }, - { text: '4 points', value: 4 } + createListItemFactory({ text: '1 point', value: 1 }), + createListItemFactory({ text: '2 points', value: 2 }), + createListItemFactory({ text: '3 points', value: 3 }), + createListItemFactory({ text: '4 points', value: 4 }) ] -} +}) export const listNumberExamples = [ - { + createListItemFactory({ text: '1 point', - value: 1, - state: 1 - }, - { + value: 1 + }), + createListItemFactory({ text: '2 points', - value: 2, - state: 2 - }, - { + value: 2 + }), + createListItemFactory({ text: '3 points', - value: 3, - state: 3 - }, - { + value: 3 + }), + createListItemFactory({ text: '4 points', - value: 4, - state: 4 - } + value: 4 + }) ] /** * @satisfies {List} */ -export const listString = { +export const listString = createListFromFactory({ title: 'Example string list', name: 'listString', type: 'string', items: [ - { text: '1 hour', value: '1' }, - { text: '2 hours', value: '2' }, - { text: '3 hours', value: '3' }, - { text: '4 hours', value: '4' } + createListItemFactory({ text: '1 hour', value: '1' }), + createListItemFactory({ text: '2 hours', value: '2' }), + createListItemFactory({ text: '3 hours', value: '3' }), + createListItemFactory({ text: '4 hours', value: '4' }) ] -} +}) export const listStringExamples = [ - { + createListItemFactory({ text: '1 hour', - value: '1', - state: '1' - }, - { + value: '1' + }), + createListItemFactory({ text: '2 hours', - value: '2', - state: '2' - }, - { + value: '2' + }), + createListItemFactory({ text: '3 hours', - value: '3', - state: '3' - }, - { + value: '3' + }), + createListItemFactory({ text: '4 hours', - value: '4', - state: '4' - } + value: '4' + }) ] export const listYesNoExamples = [ - { + createListItemFactory({ text: 'Yes', - value: true, - state: true - }, - { + value: true + }), + createListItemFactory({ text: 'No', - value: false, - state: false - } + value: false + }) ] /** diff --git a/test/form/definitions/basic.js b/test/form/definitions/basic.js index 7488b548b..475c17315 100644 --- a/test/form/definitions/basic.js +++ b/test/form/definitions/basic.js @@ -4,6 +4,11 @@ import { ControllerType } from '@defra/forms-model' +import { + createListFromFactory, + createListItemFactory +} from '~/test/form/factory.js' + export default /** @satisfies {FormDefinition} */ ({ name: 'Basic', startPage: '/licence', @@ -68,30 +73,33 @@ export default /** @satisfies {FormDefinition} */ ({ ], conditions: [], lists: [ - { + createListFromFactory({ name: 'licenceLengthDays', title: 'Licence length (days)', type: 'number', items: [ - { + createListItemFactory({ + id: '52fc51fc-c75a-4b08-9c9e-6bd99b9bc49b', text: '1 day', value: 1, description: 'Valid for 24 hours from the start time that you select' - }, - { + }), + createListItemFactory({ + id: '56b7b34f-23b3-4446-ac8e-b2443d18588e', text: '8 day', value: 8, description: 'Valid for 8 consecutive days from the start time that you select' - }, - { + }), + createListItemFactory({ + id: '1af54fbc-eec2-4e1e-bd53-2415abf62677', text: '12 months', value: 365, description: '12-month licences are now valid for 365 days from their start date and can be purchased at any time during the year' - } + }) ] - } + }) ], outputEmail: 'enrique.chase@defra.gov.uk' }) diff --git a/test/form/definitions/titles.json b/test/form/definitions/titles.json index 76aabb00f..d739f5955 100644 --- a/test/form/definitions/titles.json +++ b/test/form/definitions/titles.json @@ -16,7 +16,7 @@ }, { "type": "TextField", - "name": "firstName1", + "name": "firstNameOne", "title": "First name", "options": { "required": true @@ -29,14 +29,14 @@ "optionalText": false }, "type": "TextField", - "name": "middleName1", + "name": "middleNameOne", "title": "Middle name", "hint": "If you have a middle name on your passport you must include it here", "schema": {} }, { "type": "TextField", - "name": "lastName1", + "name": "lastNameOne", "title": "Surname", "options": { "required": true @@ -56,7 +56,7 @@ "components": [ { "type": "UkAddressField", - "name": "address1", + "name": "addressOne", "title": "Address", "options": { "required": true @@ -86,7 +86,7 @@ }, { "type": "TextField", - "name": "firstName2", + "name": "firstNameTwo", "title": "First name", "options": { "required": true @@ -99,14 +99,14 @@ "optionalText": false }, "type": "TextField", - "name": "middleName2", + "name": "middleNameTwo", "title": "Middle name", "hint": "If you have a middle name on your passport you must include it here", "schema": {} }, { "type": "TextField", - "name": "lastName2", + "name": "lastNameTwo", "title": "Surname", "options": { "required": true @@ -126,7 +126,7 @@ "components": [ { "type": "UkAddressField", - "name": "address2", + "name": "addressTwo", "title": "Address", "options": { "required": false diff --git a/test/form/factory.js b/test/form/factory.js new file mode 100644 index 000000000..49ba49d1d --- /dev/null +++ b/test/form/factory.js @@ -0,0 +1,34 @@ +/** + * Factory method for list Item + * @param {Partial} partialListItem + * @returns {Item} + */ +export function createListItemFactory(partialListItem) { + return { + id: '52fc51fc-c75a-4b08-9c9e-6bd99b9bc49b', + text: 'text', + value: 1, + description: 'Valid for 24 hours from the start time that you select', + ...partialListItem + } +} + +/** + * Factory method for List + * @param {Partial} partialList + * @returns {List} + */ +export function createListFromFactory(partialList) { + return { + id: '4ebe4ef5-bd3c-499b-a179-7e7e86b0dc6f', + name: 'licenceLengthDays', + title: 'Licence length (days)', + type: 'number', + items: [], + ...partialList + } +} + +/** + * @import { Item, List } from '@defra/forms-model' + */ diff --git a/test/form/titles.test.js b/test/form/titles.test.js index 04a847e70..41a0ce9da 100644 --- a/test/form/titles.test.js +++ b/test/form/titles.test.js @@ -45,38 +45,38 @@ describe('Title and section title', () => { path: '/applicant-one', payload: { crumb: csrfToken, - firstName1: 'Firstname', - middleName1: '', - lastName1: 'Lastname' + firstNameOne: 'Firstname', + middleNameOne: '', + lastNameOne: 'Lastname' } }, { path: '/applicant-one-address', payload: { crumb: csrfToken, - address1__addressLine1: 'Richard Fairclough House', - address1__addressLine2: 'Knutsford Road', - address1__town: 'Warrington', - address1__postcode: 'WA4 1HT' + addressOne__addressLine1: 'Richard Fairclough House', + addressOne__addressLine2: 'Knutsford Road', + addressOne__town: 'Warrington', + addressOne__postcode: 'WA4 1HT' } }, { path: '/applicant-two', payload: { crumb: csrfToken, - firstName2: 'Firstname', - middleName2: '', - lastName2: 'Lastname' + firstNameTwo: 'Firstname', + middleNameTwo: '', + lastNameTwo: 'Lastname' } }, { path: '/applicant-two-address-optional', payload: { crumb: csrfToken, - address2__addressLine1: '', - address2__addressLine2: '', - address2__town: '', - address2__postcode: '' + addressTwo__addressLine1: '', + addressTwo__addressLine2: '', + addressTwo__town: '', + addressTwo__postcode: '' } } ]) { From bad8d768e9064173c344f19717389648dab569c3 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Fri, 11 Apr 2025 15:46:50 +0100 Subject: [PATCH 09/20] Fix(bug) 528388: Prevent duplicate error summaries (#770) * fix(bug): Enhance error handling in file upload component by preventing duplicate error summaries and improving accessibility. Added inline error styling and ARIA attributes to file input for better user experience. Updated tests to verify new behavior. * test: Add inline error styling test for file input field in file upload component * refactor: Replace hardcoded ARIA attributes with constants for improved maintainability and readability in file upload component --- src/client/javascripts/file-upload.js | 37 ++++++++++++- test/client/javascripts/file-upload.test.js | 60 +++++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/src/client/javascripts/file-upload.js b/src/client/javascripts/file-upload.js index 89c3d01e9..c1df273ae 100644 --- a/src/client/javascripts/file-upload.js +++ b/src/client/javascripts/file-upload.js @@ -1,4 +1,5 @@ export const MAX_POLLING_DURATION = 300 // 5 minutes +const ARIA_DESCRIBEDBY = 'aria-describedby' /** * Creates or updates status announcer for screen readers @@ -127,7 +128,7 @@ function renderSummary(selectedFile, statusText, form) { const fileInput = form.querySelector('input[type="file"]') if (fileInput) { - fileInput.setAttribute('aria-describedby', 'statusInformation') + fileInput.setAttribute(ARIA_DESCRIBEDBY, 'statusInformation') } const summaryList = findOrCreateSummaryList( @@ -150,12 +151,20 @@ function renderSummary(selectedFile, statusText, form) { /** * Shows an error message using the GOV.UK error summary component + * and adds inline error styling to the file input * @param {string} message - The error message to display * @param {HTMLElement | null} errorSummary - The error summary container * @param {HTMLInputElement} fileInput - The file input element * @returns {void} */ function showError(message, errorSummary, fileInput) { + const topErrorSummary = document.querySelector('.govuk-error-summary') + + if (topErrorSummary) { + fileInput.setAttribute(ARIA_DESCRIBEDBY, 'error-summary-title') + return + } + if (errorSummary) { errorSummary.innerHTML = `

@@ -173,7 +182,30 @@ function showError(message, errorSummary, fileInput) {
` - fileInput.setAttribute('aria-describedby', 'error-summary-title') + + fileInput.setAttribute(ARIA_DESCRIBEDBY, 'error-summary-title') + } + + const formGroup = fileInput.closest('.govuk-form-group') + if (formGroup) { + formGroup.classList.add('govuk-form-group--error') + fileInput.classList.add('govuk-file-upload--error') + + const inputId = fileInput.id + let errorMessage = document.getElementById(`${inputId}-error`) + + if (!errorMessage) { + errorMessage = document.createElement('p') + errorMessage.id = `${inputId}-error` + errorMessage.className = 'govuk-error-message' + errorMessage.innerHTML = `Error: ${message}` + formGroup.insertBefore(errorMessage, fileInput) + } + + fileInput.setAttribute( + ARIA_DESCRIBEDBY, + `error-summary-title ${inputId}-error` + ) } } @@ -343,6 +375,7 @@ export function initFileUpload() { if (errorSummary) { errorSummary.innerHTML = '' } + if (fileInput.files && fileInput.files.length > 0) { selectedFile = fileInput.files[0] } diff --git a/test/client/javascripts/file-upload.test.js b/test/client/javascripts/file-upload.test.js index fbc9905c9..f3589e438 100644 --- a/test/client/javascripts/file-upload.test.js +++ b/test/client/javascripts/file-upload.test.js @@ -1194,4 +1194,64 @@ describe('File Upload Client JS', () => { global.fetch = originalFetch global.FormData = originalFormData }) + + test('does not add new error summary if one already exists', () => { + document.body.innerHTML = ` +
+
+

Existing error

+
+
+
+
+ + +
+ ` + + const { triggerClick } = setupTestableComponent() + const errorSummary = document.querySelector( + '.govuk-error-summary-container' + ) + + triggerClick({ preventDefault: jest.fn() }) + + expect(document.querySelectorAll('.govuk-error-summary')).toHaveLength(1) + expect(errorSummary?.innerHTML).toBe('') + }) + + test('adds inline error styling to file input field', () => { + document.body.innerHTML = ` +
+
+
+ +
+ +
+ ` + + const { triggerClick } = setupTestableComponent() + + triggerClick({ preventDefault: jest.fn() }) + + const fileInput = document.querySelector('input[type="file"]') + const formGroup = fileInput?.closest('.govuk-form-group') + + expect(formGroup?.classList.contains('govuk-form-group--error')).toBe(true) + + expect(fileInput?.classList.contains('govuk-file-upload--error')).toBe(true) + + const errorMessage = document.getElementById(`${fileInput?.id}-error`) + expect(errorMessage).not.toBeNull() + expect(errorMessage?.textContent).toContain('Select a file') + + const hiddenSpan = errorMessage?.querySelector('.govuk-visually-hidden') + expect(hiddenSpan).not.toBeNull() + expect(hiddenSpan?.textContent).toBe('Error:') + + expect(fileInput?.getAttribute('aria-describedby')).toContain( + `${fileInput?.id}-error` + ) + }) }) From fcf217623ece2da9a2c1ed6a24f3ed3f5d186ac5 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Mon, 14 Apr 2025 12:20:12 +0100 Subject: [PATCH 10/20] Fix: Remove aria-describedby attribute based on the presence of an error summary title element (#773) * Enhance accessibility in file upload component by conditionally setting or removing aria-describedby attribute based on the presence of an error summary title element. Added unit tests to verify the new behavior for both scenarios. * Refactor file upload component to use constants for ARIA attributes, enhancing maintainability and readability. This change improves accessibility by ensuring consistent usage of the error summary title ID throughout the error handling logic. --- src/client/javascripts/file-upload.js | 12 ++++-- test/client/javascripts/file-upload.test.js | 44 +++++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/client/javascripts/file-upload.js b/src/client/javascripts/file-upload.js index c1df273ae..bae589cc8 100644 --- a/src/client/javascripts/file-upload.js +++ b/src/client/javascripts/file-upload.js @@ -1,5 +1,6 @@ export const MAX_POLLING_DURATION = 300 // 5 minutes const ARIA_DESCRIBEDBY = 'aria-describedby' +const ERROR_SUMMARY_TITLE_ID = 'error-summary-title' /** * Creates or updates status announcer for screen readers @@ -161,7 +162,12 @@ function showError(message, errorSummary, fileInput) { const topErrorSummary = document.querySelector('.govuk-error-summary') if (topErrorSummary) { - fileInput.setAttribute(ARIA_DESCRIBEDBY, 'error-summary-title') + const titleElement = document.getElementById(ERROR_SUMMARY_TITLE_ID) + if (titleElement) { + fileInput.setAttribute(ARIA_DESCRIBEDBY, ERROR_SUMMARY_TITLE_ID) + } else { + fileInput.removeAttribute(ARIA_DESCRIBEDBY) + } return } @@ -169,7 +175,7 @@ function showError(message, errorSummary, fileInput) { errorSummary.innerHTML = `
-

+

There is a problem

@@ -183,7 +189,7 @@ function showError(message, errorSummary, fileInput) {
` - fileInput.setAttribute(ARIA_DESCRIBEDBY, 'error-summary-title') + fileInput.setAttribute(ARIA_DESCRIBEDBY, ERROR_SUMMARY_TITLE_ID) } const formGroup = fileInput.closest('.govuk-form-group') diff --git a/test/client/javascripts/file-upload.test.js b/test/client/javascripts/file-upload.test.js index f3589e438..d85d4b958 100644 --- a/test/client/javascripts/file-upload.test.js +++ b/test/client/javascripts/file-upload.test.js @@ -1254,4 +1254,48 @@ describe('File Upload Client JS', () => { `${fileInput?.id}-error` ) }) + + test('sets aria-describedby when error summary exists with title element', () => { + document.body.innerHTML = ` +
+
+

Existing error

+
+
+
+
+ + +
+ ` + + const { triggerClick, fileInput } = setupTestableComponent() + triggerClick({ preventDefault: jest.fn() }) + + expect(fileInput?.getAttribute('aria-describedby')).toBe( + 'error-summary-title' + ) + }) + + test('removes aria-describedby when error summary exists without title element', () => { + document.body.innerHTML = ` +
+
+

Existing error without ID

+
+
+
+
+ + +
+ ` + + const { triggerClick, fileInput } = setupTestableComponent() + + fileInput?.setAttribute('aria-describedby', 'some-value') + triggerClick({ preventDefault: jest.fn() }) + + expect(fileInput?.hasAttribute('aria-describedby')).toBe(false) + }) }) From b2c9ea088832092cad7a8eb800b77be4ef242e05 Mon Sep 17 00:00:00 2001 From: Chris Cole <56303993+whitewaterdesign@users.noreply.github.com> Date: Mon, 14 Apr 2025 15:39:35 +0100 Subject: [PATCH 11/20] Feat/526386 render short description (#774) * feat: 537088 - Disable incorrect eslint rules * feat: 537088 - Use shortDescription for TextField label * build: fix jest setup * feat: 526386 - Use shortDescription for ListFormComponents label * feat: 526386 - CheckboxesField * feat: 526386 - Use shortDescription for label on EmailAddressField * feat: 526386 - Use shortDescription for label on FileUploadField * feat: 526386 - Use shortDescription for label on MultilineTextField * feat: 526386 - Use shortDescription for label on NumberField * feat: 526386 - Use shortDescription for label on TelephoneNumberField * feat: 526386 - Use shortDescription for label on Date parts fields * feat: 526386 - PR updates --------- Co-authored-by: Chris Cole --- .../components/AutocompleteField.test.ts | 66 ++++++++++++++++++- .../engine/components/AutocompleteField.ts | 2 + .../engine/components/CheckboxesField.test.ts | 40 ++++++++--- .../engine/components/CheckboxesField.ts | 10 ++- .../engine/components/ComponentCollection.ts | 2 +- .../engine/components/DatePartsField.test.ts | 9 +-- .../engine/components/DatePartsField.ts | 2 + .../components/EmailAddressField.test.ts | 44 ++++++++++++- .../engine/components/EmailAddressField.ts | 4 +- .../engine/components/FileUploadField.test.ts | 45 +++++++++++++ .../engine/components/FileUploadField.ts | 8 ++- .../engine/components/FormComponent.ts | 5 ++ .../engine/components/ListFormComponent.ts | 4 +- .../engine/components/MonthYearField.test.ts | 27 ++++++++ .../engine/components/MonthYearField.ts | 2 + .../components/MultilineTextField.test.ts | 24 ++++++- .../engine/components/MultilineTextField.ts | 4 +- .../engine/components/NumberField.test.ts | 18 ++++- .../plugins/engine/components/NumberField.ts | 5 +- .../engine/components/RadiosField.test.ts | 3 +- .../engine/components/SelectField.test.ts | 3 +- .../components/TelephoneNumberField.test.ts | 24 ++++++- .../engine/components/TelephoneNumberField.ts | 4 +- .../engine/components/TextField.test.ts | 26 +++++++- .../plugins/engine/components/TextField.ts | 4 +- tsconfig.json | 5 +- 26 files changed, 345 insertions(+), 45 deletions(-) diff --git a/src/server/plugins/engine/components/AutocompleteField.test.ts b/src/server/plugins/engine/components/AutocompleteField.test.ts index eebb4a3dd..4fdde74a6 100644 --- a/src/server/plugins/engine/components/AutocompleteField.test.ts +++ b/src/server/plugins/engine/components/AutocompleteField.test.ts @@ -2,6 +2,7 @@ import { ComponentType, type AutocompleteFieldComponent } from '@defra/forms-model' +import lowerFirst from 'lodash/lowerFirst.js' import { AutocompleteField } from '~/src/server/plugins/engine/components/AutocompleteField.js' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' @@ -32,7 +33,8 @@ describe.each([ options: { list: listString, examples: listStringExamples, - allow: ['1', '2', '3', '4'] + allow: ['1', '2', '3', '4'], + shortDescription: 'My string list' } }, { @@ -47,7 +49,8 @@ describe.each([ options: { list: listNumber, examples: listNumberExamples, - allow: [1, 2, 3, 4] + allow: [1, 2, 3, 4], + shortDescription: 'My number list' } } ])('AutocompleteField: $component.title', ({ component: def, options }) => { @@ -153,7 +156,35 @@ describe.each([ expect(result.errors).toEqual([ expect.objectContaining({ - text: `Enter ${def.title.toLowerCase()}` + text: `Enter ${lowerFirst(def.title)}` + }) + ]) + }) + + it('adds errors for empty value if shortDescription exists', () => { + collection = new ComponentCollection( + [{ ...def, shortDescription: options.shortDescription }], + { model } + ) + const result = collection.validate(getFormData('')) + + expect(result.errors).toEqual([ + expect.objectContaining({ + text: `Enter ${lowerFirst(options.shortDescription)}` + }) + ]) + }) + + it('adds errors for empty value if shortDescription exists but is empty', () => { + collection = new ComponentCollection( + [{ ...def, shortDescription: '' }], + { model } + ) + const result = collection.validate(getFormData('')) + + expect(result.errors).toEqual([ + expect.objectContaining({ + text: `Enter ${lowerFirst(def.title)}` }) ]) }) @@ -290,5 +321,34 @@ describe.each([ expect(items).toEqual([]) }) }) + + describe('Validation', () => { + describe.each([ + { + description: 'Use short description if it exists', + component: { + title: 'What is your example text?', + shortDescription: 'Your example text', + name: 'myComponent', + type: ComponentType.AutocompleteField, + list: 'ABCE', + options: {} + } satisfies AutocompleteFieldComponent, + assertions: [ + { + input: getFormData(''), + output: { + value: getFormData(''), + errors: [ + expect.objectContaining({ + text: 'Enter your example text' + }) + ] + } + } + ] + } + ]) + }) }) }) diff --git a/src/server/plugins/engine/components/AutocompleteField.ts b/src/server/plugins/engine/components/AutocompleteField.ts index 3ad7ede7e..21244de7e 100644 --- a/src/server/plugins/engine/components/AutocompleteField.ts +++ b/src/server/plugins/engine/components/AutocompleteField.ts @@ -23,7 +23,9 @@ export class AutocompleteField extends SelectField { const messages = options.customValidationMessages formSchema = formSchema.messages({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 'any.only': messages?.['any.only'] ?? messageTemplate.required, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 'any.required': messages?.['any.required'] ?? messageTemplate.required }) } diff --git a/src/server/plugins/engine/components/CheckboxesField.test.ts b/src/server/plugins/engine/components/CheckboxesField.test.ts index 9c33708cf..849a5f3fe 100644 --- a/src/server/plugins/engine/components/CheckboxesField.test.ts +++ b/src/server/plugins/engine/components/CheckboxesField.test.ts @@ -2,6 +2,8 @@ import { ComponentType, type CheckboxesFieldComponent } from '@defra/forms-model' +import { toLower } from 'lodash' +import lowerFirst from 'lodash/lowerFirst.js' import { outdent } from 'outdent' import { CheckboxesField } from '~/src/server/plugins/engine/components/CheckboxesField.js' @@ -23,7 +25,8 @@ import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' describe.each([ { component: { - title: 'String list', + title: 'String list title', + shortDescription: 'String list', name: 'myComponent', type: ComponentType.CheckboxesField, list: 'listString', @@ -31,6 +34,7 @@ describe.each([ } satisfies CheckboxesFieldComponent, options: { + label: 'string list', list: listString, examples: listStringExamples, allow: ['1', '2', '3', '4'], @@ -39,14 +43,34 @@ describe.each([ }, { component: { - title: 'Number list', + title: 'String list title', + shortDescription: 'String list', name: 'myComponent', type: ComponentType.CheckboxesField, + list: 'listString', + options: {} + } satisfies CheckboxesFieldComponent, + + options: { + label: 'string list', + list: listString, + examples: listStringExamples, + allow: ['1', '2', '3', '4'], + deny: ['5', '6', '7', '8'] + } + }, + { + component: { + title: 'Number list title', + name: 'myComponent', + shortDescription: 'Number list', + type: ComponentType.CheckboxesField, list: 'listNumber', options: {} } satisfies CheckboxesFieldComponent, options: { + label: 'number list', list: listNumber, examples: listNumberExamples, allow: [1, 2, 3, 4], @@ -72,14 +96,14 @@ describe.each([ describe('Defaults', () => { describe('Schema', () => { - it('uses component title as label', () => { + it('uses component short description as label', () => { const { formSchema } = collection const { keys } = formSchema.describe() expect(keys).toHaveProperty( 'myComponent', expect.objectContaining({ - flags: expect.objectContaining({ label: def.title }) + flags: expect.objectContaining({ label: def.shortDescription }) }) ) }) @@ -157,7 +181,7 @@ describe.each([ { allow: options.allow, flags: { - label: def.title, + label: def.shortDescription, only: true }, type: options.list.type @@ -172,7 +196,7 @@ describe.each([ expect(result.errors).toEqual([ expect.objectContaining({ - text: `Select ${def.title.toLowerCase()}` + text: `Select ${lowerFirst(options.label)}` }) ]) }) @@ -200,7 +224,7 @@ describe.each([ expect(result.errors).toEqual([ expect.objectContaining({ - text: `Select ${def.title.toLowerCase()}` + text: `Select ${toLower(def.shortDescription)}` }) ]) } @@ -213,7 +237,7 @@ describe.each([ expect(result.errors).toEqual([ expect.objectContaining({ - text: `Select ${def.title.toLowerCase()}` + text: `Select ${lowerFirst(options.label)}` }) ]) } diff --git a/src/server/plugins/engine/components/CheckboxesField.ts b/src/server/plugins/engine/components/CheckboxesField.ts index 484225af4..30707c3d1 100644 --- a/src/server/plugins/engine/components/CheckboxesField.ts +++ b/src/server/plugins/engine/components/CheckboxesField.ts @@ -23,16 +23,20 @@ export class CheckboxesField extends SelectionControlField { super(def, props) const { listType: type } = this - const { options, title } = def + const { options } = def let formSchema = type === 'string' ? joi.array() : joi.array() const itemsSchema = joi[type]() .valid(...this.values) - .label(title) + .label(this.label) - formSchema = formSchema.items(itemsSchema).single().label(title).required() + formSchema = formSchema + .items(itemsSchema) + .single() + .label(this.label) + .required() if (options.required === false) { formSchema = formSchema.optional() diff --git a/src/server/plugins/engine/components/ComponentCollection.ts b/src/server/plugins/engine/components/ComponentCollection.ts index 42880cd7b..d09ee1230 100644 --- a/src/server/plugins/engine/components/ComponentCollection.ts +++ b/src/server/plugins/engine/components/ComponentCollection.ts @@ -124,7 +124,7 @@ export class ComponentCollection { } // Update error with parent title - error.local.title ??= parent?.title + error.local.title ??= parent?.label return error }) diff --git a/src/server/plugins/engine/components/DatePartsField.test.ts b/src/server/plugins/engine/components/DatePartsField.test.ts index c82a20074..49d026ad6 100644 --- a/src/server/plugins/engine/components/DatePartsField.test.ts +++ b/src/server/plugins/engine/components/DatePartsField.test.ts @@ -31,6 +31,7 @@ describe('DatePartsField', () => { beforeEach(() => { def = { title: 'Example date parts field', + shortDescription: 'Example date parts', name: 'myComponent', type: ComponentType.DatePartsField, options: {} @@ -199,7 +200,7 @@ describe('DatePartsField', () => { expect(result3.errors).toBeUndefined() }) - it('adds errors for empty value', () => { + it('adds errors for empty value when short description exists', () => { const result = collection.validate( getFormData({ day: '', @@ -210,13 +211,13 @@ describe('DatePartsField', () => { expect(result.errors).toEqual([ expect.objectContaining({ - text: 'Example date parts field must include a day' + text: 'Example date parts must include a day' }), expect.objectContaining({ - text: 'Example date parts field must include a month' + text: 'Example date parts must include a month' }), expect.objectContaining({ - text: 'Example date parts field must include a year' + text: 'Example date parts must include a year' }) ]) }) diff --git a/src/server/plugins/engine/components/DatePartsField.ts b/src/server/plugins/engine/components/DatePartsField.ts index d88b3a434..f72fb3509 100644 --- a/src/server/plugins/engine/components/DatePartsField.ts +++ b/src/server/plugins/engine/components/DatePartsField.ts @@ -41,7 +41,9 @@ export class DatePartsField extends FormComponent { const isRequired = options.required !== false const customValidationMessages: LanguageMessages = { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 'any.required': messageTemplate.objectMissing, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 'number.base': messageTemplate.objectMissing, 'number.precision': messageTemplate.dateFormat, 'number.integer': messageTemplate.dateFormat, diff --git a/src/server/plugins/engine/components/EmailAddressField.test.ts b/src/server/plugins/engine/components/EmailAddressField.test.ts index 5deae9507..6695733e7 100644 --- a/src/server/plugins/engine/components/EmailAddressField.test.ts +++ b/src/server/plugins/engine/components/EmailAddressField.test.ts @@ -29,6 +29,7 @@ describe('EmailAddressField', () => { beforeEach(() => { def = { title: 'Example email address field', + shortDescription: 'Example email address', name: 'myComponent', type: ComponentType.EmailAddressField, options: {} @@ -47,7 +48,7 @@ describe('EmailAddressField', () => { 'myComponent', expect.objectContaining({ flags: expect.objectContaining({ - label: 'Example email address field' + label: 'Example email address' }) }) ) @@ -111,6 +112,24 @@ describe('EmailAddressField', () => { it('adds errors for empty value', () => { const result = collection.validate(getFormData('')) + expect(result.errors).toEqual([ + expect.objectContaining({ + text: 'Enter example email address' + }) + ]) + }) + + it('adds errors for empty value given no shortDescription', () => { + def = { + title: 'Example email address field', + name: 'myComponent', + type: ComponentType.EmailAddressField, + options: {} + } satisfies EmailAddressFieldComponent + + collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData('')) + expect(result.errors).toEqual([ expect.objectContaining({ text: 'Enter example email address field' @@ -269,6 +288,29 @@ describe('EmailAddressField', () => { } ] }, + { + description: 'Email address validation', + component: { + title: 'Example email address field', + shortDescription: 'Example email address', + name: 'myComponent', + type: ComponentType.EmailAddressField, + options: {} + } satisfies EmailAddressFieldComponent, + assertions: [ + { + input: getFormData('defra.helpline'), + output: { + value: getFormData('defra.helpline'), + errors: [ + expect.objectContaining({ + text: 'Enter example email address in the correct format' + }) + ] + } + } + ] + }, { description: 'Custom validation message', component: { diff --git a/src/server/plugins/engine/components/EmailAddressField.ts b/src/server/plugins/engine/components/EmailAddressField.ts index 493c3fbc4..54547de87 100644 --- a/src/server/plugins/engine/components/EmailAddressField.ts +++ b/src/server/plugins/engine/components/EmailAddressField.ts @@ -16,9 +16,9 @@ export class EmailAddressField extends FormComponent { ) { super(def, props) - const { options, title } = def + const { options } = def - let formSchema = joi.string().email().trim().label(title).required() + let formSchema = joi.string().email().trim().label(this.label).required() if (options.required === false) { formSchema = formSchema.allow('') diff --git a/src/server/plugins/engine/components/FileUploadField.test.ts b/src/server/plugins/engine/components/FileUploadField.test.ts index d96a294fd..92da87407 100644 --- a/src/server/plugins/engine/components/FileUploadField.test.ts +++ b/src/server/plugins/engine/components/FileUploadField.test.ts @@ -157,6 +157,7 @@ describe('FileUploadField', () => { beforeEach(() => { def = { title: 'Example file upload field', + shortDescription: 'Example file upload', name: 'myComponent', type: ComponentType.FileUploadField, options: {}, @@ -169,7 +170,32 @@ describe('FileUploadField', () => { }) describe('Schema', () => { + it('uses component short description as label', () => { + const { formSchema } = collection + const { keys } = formSchema.describe() + + expect(keys).toHaveProperty( + 'myComponent', + expect.objectContaining({ + flags: expect.objectContaining({ + label: 'Example file upload' + }) + }) + ) + }) + it('uses component title as label', () => { + def = { + title: 'Example file upload field', + name: 'myComponent', + type: ComponentType.FileUploadField, + options: {}, + schema: {} + } satisfies FileUploadFieldComponent + + page = createPage(model, definition.pages[0]) + collection = new ComponentCollection([def], { page, model }) + const { formSchema } = collection const { keys } = formSchema.describe() @@ -246,6 +272,25 @@ describe('FileUploadField', () => { it('adds errors for empty value', () => { const result = collection.validate(getFormData()) + expect(result.errors).toEqual([ + expect.objectContaining({ + text: 'Select example file upload' + }) + ]) + }) + + it('adds errors for empty value with no shortDescription', () => { + def = { + title: 'Example file upload field', + name: 'myComponent', + type: ComponentType.FileUploadField, + options: {}, + schema: {} + } satisfies FileUploadFieldComponent + + collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData()) + expect(result.errors).toEqual([ expect.objectContaining({ text: 'Select example file upload field' diff --git a/src/server/plugins/engine/components/FileUploadField.ts b/src/server/plugins/engine/components/FileUploadField.ts index 7cb74e8a3..ce116b62c 100644 --- a/src/server/plugins/engine/components/FileUploadField.ts +++ b/src/server/plugins/engine/components/FileUploadField.ts @@ -104,9 +104,13 @@ export class FileUploadField extends FormComponent { ) { super(def, props) - const { options, schema, title } = def + const { options, schema } = def - let formSchema = joi.array().label(title).single().required() + let formSchema = joi + .array() + .label(this.label) + .single() + .required() if (options.required === false) { formSchema = formSchema.optional() diff --git a/src/server/plugins/engine/components/FormComponent.ts b/src/server/plugins/engine/components/FormComponent.ts index f9f0f7fcb..969972fee 100644 --- a/src/server/plugins/engine/components/FormComponent.ts +++ b/src/server/plugins/engine/components/FormComponent.ts @@ -18,6 +18,7 @@ import { export class FormComponent extends ComponentBase { type: FormComponentsDef['type'] hint: FormComponentsDef['hint'] + label: string isFormComponent = true @@ -31,6 +32,10 @@ export class FormComponent extends ComponentBase { this.type = type this.hint = hint + this.label = + 'shortDescription' in def && def.shortDescription + ? def.shortDescription + : def.title } get keys() { diff --git a/src/server/plugins/engine/components/ListFormComponent.ts b/src/server/plugins/engine/components/ListFormComponent.ts index 4fedab1ec..49ce004d1 100644 --- a/src/server/plugins/engine/components/ListFormComponent.ts +++ b/src/server/plugins/engine/components/ListFormComponent.ts @@ -61,7 +61,7 @@ export class ListFormComponent extends FormComponent { ) { super(def, props) - const { options, title } = def + const { options } = def const { model } = props if ('list' in def) { @@ -71,7 +71,7 @@ export class ListFormComponent extends FormComponent { let formSchema = joi[this.listType]() .valid(...this.values) - .label(title) + .label(this.label) .required() if (options.customValidationMessages) { diff --git a/src/server/plugins/engine/components/MonthYearField.test.ts b/src/server/plugins/engine/components/MonthYearField.test.ts index ed5e449ad..32dc961b1 100644 --- a/src/server/plugins/engine/components/MonthYearField.test.ts +++ b/src/server/plugins/engine/components/MonthYearField.test.ts @@ -31,6 +31,7 @@ describe('MonthYearField', () => { beforeEach(() => { def = { title: 'Example month/year field', + shortDescription: 'Example month/year', name: 'myComponent', type: ComponentType.MonthYearField, options: {} @@ -168,6 +169,32 @@ describe('MonthYearField', () => { }) ) + expect(result.errors).toEqual([ + expect.objectContaining({ + text: 'Example month/year must include a month' + }), + expect.objectContaining({ + text: 'Example month/year must include a year' + }) + ]) + }) + + it('adds errors for empty value given no short desc exists', () => { + def = { + title: 'Example month/year field', + name: 'myComponent', + type: ComponentType.MonthYearField, + options: {} + } satisfies MonthYearFieldComponent + + collection = new ComponentCollection([def], { model }) + const result = collection.validate( + getFormData({ + month: '', + year: '' + }) + ) + expect(result.errors).toEqual([ expect.objectContaining({ text: 'Example month/year field must include a month' diff --git a/src/server/plugins/engine/components/MonthYearField.ts b/src/server/plugins/engine/components/MonthYearField.ts index 7e9519eb0..7e6908baa 100644 --- a/src/server/plugins/engine/components/MonthYearField.ts +++ b/src/server/plugins/engine/components/MonthYearField.ts @@ -41,7 +41,9 @@ export class MonthYearField extends FormComponent { const isRequired = options.required !== false const customValidationMessages: LanguageMessages = { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 'any.required': messageTemplate.objectMissing, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 'number.base': messageTemplate.objectMissing, 'number.precision': messageTemplate.dateFormat, 'number.integer': messageTemplate.dateFormat, diff --git a/src/server/plugins/engine/components/MultilineTextField.test.ts b/src/server/plugins/engine/components/MultilineTextField.test.ts index e0c09a652..ada8e71c8 100644 --- a/src/server/plugins/engine/components/MultilineTextField.test.ts +++ b/src/server/plugins/engine/components/MultilineTextField.test.ts @@ -29,7 +29,8 @@ describe('MultilineTextField', () => { beforeEach(() => { def = { - title: 'Example textarea', + title: 'Example textarea title', + shortDescription: 'Example textarea', name: 'myComponent', type: ComponentType.MultilineTextField, options: {}, @@ -41,7 +42,7 @@ describe('MultilineTextField', () => { }) describe('Schema', () => { - it('uses component title as label', () => { + it('uses component short description as label', () => { const { formSchema } = collection const { keys } = formSchema.describe() @@ -117,6 +118,25 @@ describe('MultilineTextField', () => { ]) }) + it('adds errors for empty value given no short description', () => { + def = { + title: 'Example textarea title', + name: 'myComponent', + type: ComponentType.MultilineTextField, + options: {}, + schema: {} + } satisfies MultilineTextFieldComponent + + collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData('')) + + expect(result.errors).toEqual([ + expect.objectContaining({ + text: 'Enter example textarea title' + }) + ]) + }) + it('adds errors for invalid values', () => { const result1 = collection.validate(getFormData(['invalid'])) const result2 = collection.validate( diff --git a/src/server/plugins/engine/components/MultilineTextField.ts b/src/server/plugins/engine/components/MultilineTextField.ts index a0f501440..9217e980a 100644 --- a/src/server/plugins/engine/components/MultilineTextField.ts +++ b/src/server/plugins/engine/components/MultilineTextField.ts @@ -22,9 +22,9 @@ export class MultilineTextField extends FormComponent { ) { super(def, props) - const { schema, options, title } = def + const { schema, options } = def - let formSchema = Joi.string().trim().label(title).required() + let formSchema = Joi.string().trim().label(this.label).required() if (options.required === false) { formSchema = formSchema.allow('') diff --git a/src/server/plugins/engine/components/NumberField.test.ts b/src/server/plugins/engine/components/NumberField.test.ts index 1e3c40b11..e8d10d6aa 100644 --- a/src/server/plugins/engine/components/NumberField.test.ts +++ b/src/server/plugins/engine/components/NumberField.test.ts @@ -27,6 +27,7 @@ describe('NumberField', () => { beforeEach(() => { def = { title: 'Example number field', + shortDescription: 'Example number', name: 'myComponent', type: ComponentType.NumberField, options: {}, @@ -46,7 +47,7 @@ describe('NumberField', () => { 'myComponent', expect.objectContaining({ flags: expect.objectContaining({ - label: 'Example number field' + label: 'Example number' }) }) ) @@ -113,9 +114,22 @@ describe('NumberField', () => { expect(result.errors).toEqual([ expect.objectContaining({ - text: 'Enter example number field' + text: 'Enter example number' }) ]) + }) + + it('adds errors for empty value given no short description exists', () => { + def = { + title: 'Example number field', + name: 'myComponent', + type: ComponentType.NumberField, + options: {}, + schema: {} + } satisfies NumberFieldComponent + + collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData('')) expect(result.errors).toEqual([ expect.objectContaining({ diff --git a/src/server/plugins/engine/components/NumberField.ts b/src/server/plugins/engine/components/NumberField.ts index dc8dd069c..1f17108ea 100644 --- a/src/server/plugins/engine/components/NumberField.ts +++ b/src/server/plugins/engine/components/NumberField.ts @@ -26,12 +26,12 @@ export class NumberField extends FormComponent { ) { super(def, props) - const { options, schema, title } = def + const { options, schema } = def let formSchema = joi .number() .custom(getValidatorPrecision(this)) - .label(title) + .label(this.label) .required() if (options.required === false) { @@ -40,6 +40,7 @@ export class NumberField extends FormComponent { const messages = options.customValidationMessages formSchema = formSchema.empty('').messages({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 'any.required': messages?.['any.required'] ?? messageTemplate.required }) } diff --git a/src/server/plugins/engine/components/RadiosField.test.ts b/src/server/plugins/engine/components/RadiosField.test.ts index f0bb586c0..117311fcc 100644 --- a/src/server/plugins/engine/components/RadiosField.test.ts +++ b/src/server/plugins/engine/components/RadiosField.test.ts @@ -1,4 +1,5 @@ import { ComponentType, type RadiosFieldComponent } from '@defra/forms-model' +import lowerFirst from 'lodash/lowerFirst.js' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { RadiosField } from '~/src/server/plugins/engine/components/RadiosField.js' @@ -151,7 +152,7 @@ describe.each([ expect(result.errors).toEqual([ expect.objectContaining({ - text: `Select ${def.title.toLowerCase()}` + text: `Select ${lowerFirst(def.title)}` }) ]) }) diff --git a/src/server/plugins/engine/components/SelectField.test.ts b/src/server/plugins/engine/components/SelectField.test.ts index c6a2cda9b..585ebbda8 100644 --- a/src/server/plugins/engine/components/SelectField.test.ts +++ b/src/server/plugins/engine/components/SelectField.test.ts @@ -1,4 +1,5 @@ import { ComponentType, type SelectFieldComponent } from '@defra/forms-model' +import lowerFirst from 'lodash/lowerFirst.js' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { SelectField } from '~/src/server/plugins/engine/components/SelectField.js' @@ -152,7 +153,7 @@ describe.each([ expect(result.errors).toEqual([ expect.objectContaining({ - text: `Select ${def.title.toLowerCase()}` + text: `Select ${lowerFirst(def.title)}` }) ]) }) diff --git a/src/server/plugins/engine/components/TelephoneNumberField.test.ts b/src/server/plugins/engine/components/TelephoneNumberField.test.ts index 97d713695..7e65d0140 100644 --- a/src/server/plugins/engine/components/TelephoneNumberField.test.ts +++ b/src/server/plugins/engine/components/TelephoneNumberField.test.ts @@ -29,6 +29,7 @@ describe('TelephoneNumberField', () => { beforeEach(() => { def = { title: 'Example telephone number field', + shortDescription: 'Example telephone number', name: 'myComponent', type: ComponentType.TelephoneNumberField, options: {} @@ -39,7 +40,7 @@ describe('TelephoneNumberField', () => { }) describe('Schema', () => { - it('uses component title as label', () => { + it('uses component short description as label', () => { const { formSchema } = collection const { keys } = formSchema.describe() @@ -47,7 +48,7 @@ describe('TelephoneNumberField', () => { 'myComponent', expect.objectContaining({ flags: expect.objectContaining({ - label: 'Example telephone number field' + label: 'Example telephone number' }) }) ) @@ -121,6 +122,25 @@ describe('TelephoneNumberField', () => { it('adds errors for empty value', () => { const result = collection.validate(getFormData('')) + expect(result.errors).toEqual([ + expect.objectContaining({ + text: 'Enter example telephone number' + }) + ]) + }) + + it('adds errors for empty value given no short description exists', () => { + def = { + title: 'Example telephone number field', + name: 'myComponent', + type: ComponentType.TelephoneNumberField, + options: {} + } satisfies TelephoneNumberFieldComponent + + collection = new ComponentCollection([def], { model }) + + const result = collection.validate(getFormData('')) + expect(result.errors).toEqual([ expect.objectContaining({ text: 'Enter example telephone number field' diff --git a/src/server/plugins/engine/components/TelephoneNumberField.ts b/src/server/plugins/engine/components/TelephoneNumberField.ts index e33919c3c..5ddbae686 100644 --- a/src/server/plugins/engine/components/TelephoneNumberField.ts +++ b/src/server/plugins/engine/components/TelephoneNumberField.ts @@ -21,13 +21,13 @@ export class TelephoneNumberField extends FormComponent { ) { super(def, props) - const { options, title } = def + const { options } = def let formSchema = joi .string() .trim() .pattern(PATTERN) - .label(title) + .label(this.label) .required() if (options.required === false) { diff --git a/src/server/plugins/engine/components/TextField.test.ts b/src/server/plugins/engine/components/TextField.test.ts index c3f5c7220..e6d3b4f9b 100644 --- a/src/server/plugins/engine/components/TextField.test.ts +++ b/src/server/plugins/engine/components/TextField.test.ts @@ -37,7 +37,7 @@ describe('TextField', () => { }) describe('Schema', () => { - it('uses component title as label', () => { + it('uses component title as label as default', () => { const { formSchema } = collection const { keys } = formSchema.describe() @@ -200,6 +200,30 @@ describe('TextField', () => { describe('Validation', () => { describe.each([ + { + description: 'Use short description if it exists', + component: { + title: 'What is your example text?', + shortDescription: 'Your example text', + name: 'myComponent', + type: ComponentType.TextField, + options: {}, + schema: {} + } satisfies TextFieldComponent, + assertions: [ + { + input: getFormData(''), + output: { + value: getFormData(''), + errors: [ + expect.objectContaining({ + text: 'Enter your example text' + }) + ] + } + } + ] + }, { description: 'Trim empty spaces', component: { diff --git a/src/server/plugins/engine/components/TextField.ts b/src/server/plugins/engine/components/TextField.ts index 04bcfab7a..d0625080d 100644 --- a/src/server/plugins/engine/components/TextField.ts +++ b/src/server/plugins/engine/components/TextField.ts @@ -30,10 +30,10 @@ export class TextField extends FormComponent { ) { super(def, props) - const { options, title } = def + const { options } = def const schema = 'schema' in def ? def.schema : {} - let formSchema = joi.string().trim().label(title).required() + let formSchema = joi.string().trim().label(this.label).required() if (options.required === false) { formSchema = formSchema.allow('') diff --git a/tsconfig.json b/tsconfig.json index 62935cac4..664f61292 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,7 @@ "paths": { "~/*": ["./*"] }, - "types": ["@testing-library/jest-dom"] + "types": ["@testing-library/jest-dom", "jest"] }, "include": [ "**/*.cjs", @@ -22,7 +22,8 @@ "**/*.ts", ".eslintrc.*", ".prettierrc.*", - ".lintstagedrc.*" + ".lintstagedrc.*", + "node_modules/@types/jest/index.d.ts" ], "exclude": ["coverage", "node_modules", ".public", ".server"] } From 4199680c1f82f68b84a43c427c2641f038e5009d Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Mon, 14 Apr 2025 17:03:48 +0100 Subject: [PATCH 12/20] Refactor: Update form submission messages for clarity in output formatters (#775) * Changed the wording in the output formatters to specify that the form received is associated with its name, enhancing clarity in the generated messages. * Updated test cases to reflect the new message format, ensuring consistency across the application. --- src/server/plugins/engine/outputFormatters/human/v1.test.ts | 3 +-- src/server/plugins/engine/outputFormatters/human/v1.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/server/plugins/engine/outputFormatters/human/v1.test.ts b/src/server/plugins/engine/outputFormatters/human/v1.test.ts index f5bc83c12..752fd5abb 100644 --- a/src/server/plugins/engine/outputFormatters/human/v1.test.ts +++ b/src/server/plugins/engine/outputFormatters/human/v1.test.ts @@ -92,10 +92,9 @@ describe('getPersonalisation', () => { `^ For security reasons, the links in this email expire at ${dateFormat(dateExpiry, 'h:mmaaa')} on ${dateFormat(dateExpiry, 'eeee d MMMM yyyy')}` ) - // Check for form answers expect(body).toContain( outdent` - Form submitted at ${dateFormat(dateNow, 'h:mmaaa')} on ${dateFormat(dateNow, 'd MMMM yyyy')}. + ${definition.name} form received at ${dateFormat(dateNow, 'h:mmaaa')} on ${dateFormat(dateNow, 'd MMMM yyyy')}. --- diff --git a/src/server/plugins/engine/outputFormatters/human/v1.ts b/src/server/plugins/engine/outputFormatters/human/v1.ts index ca670b7dd..79cd0f8cb 100644 --- a/src/server/plugins/engine/outputFormatters/human/v1.ts +++ b/src/server/plugins/engine/outputFormatters/human/v1.ts @@ -42,7 +42,7 @@ export function format( lines.push(`This is a test of the ${formName} ${formStatus.state} form.\n`) } - lines.push(`Form submitted at ${escapeMarkdown(formattedNow)}.\n`) + lines.push(`${formName} form received at ${escapeMarkdown(formattedNow)}.\n`) lines.push('---\n') items.forEach((item) => { From 71127cf9d04e59a3ad2b072e6adea1994937c3b9 Mon Sep 17 00:00:00 2001 From: Jez Barnsley <114290619+jbarnsley10@users.noreply.github.com> Date: Tue, 15 Apr 2025 16:57:29 +0100 Subject: [PATCH 13/20] Error preview page (#769) * Initial implementation * Fixed failing tests * Added component tests * Stash * Added tests * SonarCloud lint * Complexity issue * After review * Reduce method lines * Extra tests * Feat/532357 extra error messages (#777) * Extra error messages * Moved code location out of plugin * Stash during types change * Fixed types * Sorted type issues * Removed unnecessary isErrorPreview stuff * Corrected typo * Changed 'close' link in error preview * Added full stop Moved method out of ComponentBase --- package-lock.json | 8 +- package.json | 2 +- src/server/constants.js | 1 + .../components/AutocompleteField.test.ts | 8 + .../engine/components/AutocompleteField.ts | 6 +- .../engine/components/CheckboxesField.test.ts | 8 + .../engine/components/DatePartsField.test.ts | 8 + .../engine/components/DatePartsField.ts | 35 +- .../components/EmailAddressField.test.ts | 8 + .../engine/components/EmailAddressField.ts | 15 + .../engine/components/FileUploadField.test.ts | 8 + .../engine/components/FileUploadField.ts | 27 + .../engine/components/FormComponent.ts | 8 + .../engine/components/ListFormComponent.ts | 14 + .../engine/components/MonthYearField.test.ts | 8 + .../engine/components/MonthYearField.ts | 45 +- .../components/MultilineTextField.test.ts | 8 + .../engine/components/MultilineTextField.ts | 15 + .../engine/components/NumberField.test.ts | 8 + .../plugins/engine/components/NumberField.ts | 21 +- .../engine/components/RadiosField.test.ts | 8 + .../components/SelectionControlField.ts | 14 + .../components/TelephoneNumberField.test.ts | 8 + .../engine/components/TelephoneNumberField.ts | 15 + .../engine/components/TextField.test.ts | 8 + .../plugins/engine/components/TextField.ts | 15 + .../engine/components/UkAddressField.test.ts | 8 + .../engine/components/UkAddressField.ts | 16 + .../engine/components/YesNoField.test.ts | 8 + .../plugins/engine/components/YesNoField.ts | 14 + .../pageControllers/validationOptions.ts | 46 +- src/server/plugins/engine/types.ts | 12 +- .../error-preview/error-preview-helper.js | 232 +++++++++ .../error-preview-helper.test.js | 469 ++++++++++++++++++ .../plugins/error-preview/error-preview.js | 38 ++ .../error-preview/error-preview.test.js | 85 ++++ src/server/plugins/errorPages.ts | 1 + .../plugins/nunjucks/enviroment.test.js | 12 +- src/server/plugins/router.ts | 24 +- src/server/utils/type-utils.ts | 15 + src/server/views/error-preview.html | 54 ++ src/typings/joi/index.d.ts | 8 + test/fixtures/form.js | 44 ++ 43 files changed, 1366 insertions(+), 49 deletions(-) create mode 100644 src/server/plugins/error-preview/error-preview-helper.js create mode 100644 src/server/plugins/error-preview/error-preview-helper.test.js create mode 100644 src/server/plugins/error-preview/error-preview.js create mode 100644 src/server/plugins/error-preview/error-preview.test.js create mode 100644 src/server/utils/type-utils.ts create mode 100644 src/server/views/error-preview.html diff --git a/package-lock.json b/package-lock.json index 2fe3f812f..b65e827a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.438", + "@defra/forms-model": "^3.0.440", "@defra/hapi-tracing": "^1.0.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", @@ -2039,9 +2039,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.438", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.438.tgz", - "integrity": "sha512-3Vn6ZeTy/Oajx5t5vG5/xTn0UPnas0fdtTYOYTfNQuco12RJRtmLeXaOcHyzJqZpxvAmqWL0AKKEUv4iSSMoZg==", + "version": "3.0.440", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.440.tgz", + "integrity": "sha512-VQoGrqbDAHpfMaC2LX/P8Q49xBCBEE5mmRju+P3OdTEAFSBKcIq6jLAggypWeVNzbl8irwqF2X4QBF1kpxPysA==", "license": "OGL-UK-3.0", "dependencies": { "marked": "^15.0.7", diff --git a/package.json b/package.json index aab529b71..ef9bafdb3 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ }, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.438", + "@defra/forms-model": "^3.0.440", "@defra/hapi-tracing": "^1.0.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", diff --git a/src/server/constants.js b/src/server/constants.js index 1c7789b66..ee4ab4305 100644 --- a/src/server/constants.js +++ b/src/server/constants.js @@ -1 +1,2 @@ export const PREVIEW_PATH_PREFIX = '/preview' +export const ERROR_PREVIEW_PATH_PREFIX = '/error-preview' diff --git a/src/server/plugins/engine/components/AutocompleteField.test.ts b/src/server/plugins/engine/components/AutocompleteField.test.ts index 4fdde74a6..8c38e772d 100644 --- a/src/server/plugins/engine/components/AutocompleteField.test.ts +++ b/src/server/plugins/engine/components/AutocompleteField.test.ts @@ -322,6 +322,14 @@ describe.each([ }) }) + describe('AllPossibleErrors', () => { + it('should return errors', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).toBeEmpty() + }) + }) + describe('Validation', () => { describe.each([ { diff --git a/src/server/plugins/engine/components/AutocompleteField.ts b/src/server/plugins/engine/components/AutocompleteField.ts index 21244de7e..a1a21c591 100644 --- a/src/server/plugins/engine/components/AutocompleteField.ts +++ b/src/server/plugins/engine/components/AutocompleteField.ts @@ -24,9 +24,11 @@ export class AutocompleteField extends SelectField { formSchema = formSchema.messages({ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - 'any.only': messages?.['any.only'] ?? messageTemplate.required, + 'any.only': + messages?.['any.only'] ?? (messageTemplate.required as string), // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - 'any.required': messages?.['any.required'] ?? messageTemplate.required + 'any.required': + messages?.['any.required'] ?? (messageTemplate.required as string) }) } diff --git a/src/server/plugins/engine/components/CheckboxesField.test.ts b/src/server/plugins/engine/components/CheckboxesField.test.ts index 849a5f3fe..e4cbc675d 100644 --- a/src/server/plugins/engine/components/CheckboxesField.test.ts +++ b/src/server/plugins/engine/components/CheckboxesField.test.ts @@ -399,5 +399,13 @@ describe.each([ expect(items).toEqual([]) }) }) + + describe('AllPossibleErrors', () => { + it('should return errors', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).toBeEmpty() + }) + }) }) }) diff --git a/src/server/plugins/engine/components/DatePartsField.test.ts b/src/server/plugins/engine/components/DatePartsField.test.ts index 49d026ad6..4a69579df 100644 --- a/src/server/plugins/engine/components/DatePartsField.test.ts +++ b/src/server/plugins/engine/components/DatePartsField.test.ts @@ -410,6 +410,14 @@ describe('DatePartsField', () => { }) }) }) + + describe('AllPossibleErrors', () => { + it('should return errors', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).not.toBeEmpty() + }) + }) }) describe('Validation', () => { diff --git a/src/server/plugins/engine/components/DatePartsField.ts b/src/server/plugins/engine/components/DatePartsField.ts index f72fb3509..30692d8ed 100644 --- a/src/server/plugins/engine/components/DatePartsField.ts +++ b/src/server/plugins/engine/components/DatePartsField.ts @@ -1,11 +1,6 @@ import { ComponentType, type DatePartsFieldComponent } from '@defra/forms-model' import { add, format, isValid, parse, startOfToday, sub } from 'date-fns' -import { - type Context, - type CustomValidator, - type LanguageMessages, - type ObjectSchema -} from 'joi' +import { type Context, type CustomValidator, type ObjectSchema } from 'joi' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { @@ -17,12 +12,14 @@ import { NumberField } from '~/src/server/plugins/engine/components/NumberField. import { type DateInputItem } from '~/src/server/plugins/engine/components/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { + type ErrorMessageTemplateList, type FormPayload, type FormState, type FormStateValue, type FormSubmissionError, type FormSubmissionState } from '~/src/server/plugins/engine/types.js' +import { convertToLanguageMessages } from '~/src/server/utils/type-utils.js' export class DatePartsField extends FormComponent { declare options: DatePartsFieldComponent['options'] @@ -40,7 +37,7 @@ export class DatePartsField extends FormComponent { const isRequired = options.required !== false - const customValidationMessages: LanguageMessages = { + const customValidationMessages = convertToLanguageMessages({ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 'any.required': messageTemplate.objectMissing, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -50,7 +47,7 @@ export class DatePartsField extends FormComponent { 'number.unsafe': messageTemplate.dateFormat, 'number.min': messageTemplate.dateFormat, 'number.max': messageTemplate.dateFormat - } + }) this.collection = new ComponentCollection( [ @@ -194,6 +191,28 @@ export class DatePartsField extends FormComponent { return DatePartsField.isDateParts(value) } + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { type: 'required', template: messageTemplate.required }, + { type: 'dateFormat', template: messageTemplate.dateFormat }, + { type: 'dateFormatDay', template: '{{#label}} must include a day' }, + { + type: 'dateFormatMonth', + template: '{{#label}} must include a month' + }, + { type: 'dateFormatYear', template: '{{#label}} must include a year' } + ], + advancedSettingsErrors: [ + { type: 'dateMin', template: messageTemplate.dateMin }, + { type: 'dateMax', template: messageTemplate.dateMax } + ] + } + } + static isDateParts( value?: FormStateValue | FormState ): value is DatePartsState { diff --git a/src/server/plugins/engine/components/EmailAddressField.test.ts b/src/server/plugins/engine/components/EmailAddressField.test.ts index 6695733e7..b9d12c269 100644 --- a/src/server/plugins/engine/components/EmailAddressField.test.ts +++ b/src/server/plugins/engine/components/EmailAddressField.test.ts @@ -224,6 +224,14 @@ describe('EmailAddressField', () => { ) }) }) + + describe('AllPossibleErrors', () => { + it('should return errors', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).toBeEmpty() + }) + }) }) describe('Validation', () => { diff --git a/src/server/plugins/engine/components/EmailAddressField.ts b/src/server/plugins/engine/components/EmailAddressField.ts index 54547de87..44995ac18 100644 --- a/src/server/plugins/engine/components/EmailAddressField.ts +++ b/src/server/plugins/engine/components/EmailAddressField.ts @@ -2,7 +2,9 @@ import { type EmailAddressFieldComponent } from '@defra/forms-model' import joi from 'joi' import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { + type ErrorMessageTemplateList, type FormPayload, type FormSubmissionError } from '~/src/server/plugins/engine/types.js' @@ -52,4 +54,17 @@ export class EmailAddressField extends FormComponent { type: 'email' } } + + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { type: 'required', template: messageTemplate.required }, + { type: 'format', template: messageTemplate.format } + ], + advancedSettingsErrors: [] + } + } } diff --git a/src/server/plugins/engine/components/FileUploadField.test.ts b/src/server/plugins/engine/components/FileUploadField.test.ts index 92da87407..5aa24bab0 100644 --- a/src/server/plugins/engine/components/FileUploadField.test.ts +++ b/src/server/plugins/engine/components/FileUploadField.test.ts @@ -590,6 +590,14 @@ describe('FileUploadField', () => { ) }) }) + + describe('AllPossibleErrors', () => { + it('should return errors', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).not.toBeEmpty() + }) + }) }) describe('Validation', () => { diff --git a/src/server/plugins/engine/components/FileUploadField.ts b/src/server/plugins/engine/components/FileUploadField.ts index ce116b62c..8a2701461 100644 --- a/src/server/plugins/engine/components/FileUploadField.ts +++ b/src/server/plugins/engine/components/FileUploadField.ts @@ -5,9 +5,11 @@ import { FormComponent, isUploadState } from '~/src/server/plugins/engine/components/FormComponent.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { FileStatus, UploadStatus, + type ErrorMessageTemplateList, type FileState, type FileUpload, type FileUploadMetadata, @@ -263,4 +265,29 @@ export class FileUploadField extends FormComponent { isValue(value?: FormStateValue | FormState): value is UploadState { return isUploadState(value) } + + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { type: 'selectRequired', template: messageTemplate.selectRequired } + ], + advancedSettingsErrors: [ + { + type: 'filesMin', + template: 'You must upload {{#limit}} files or more' + }, + { + type: 'filesMax', + template: 'You can only upload {{#limit}} files or less' + }, + { + type: 'filesExact', + template: 'You must upload exactly {{#limit}} files' + } + ] + } + } } diff --git a/src/server/plugins/engine/components/FormComponent.ts b/src/server/plugins/engine/components/FormComponent.ts index 969972fee..c25a5a07c 100644 --- a/src/server/plugins/engine/components/FormComponent.ts +++ b/src/server/plugins/engine/components/FormComponent.ts @@ -3,6 +3,7 @@ import { type FormComponentsDef, type Item } from '@defra/forms-model' import { ComponentBase } from '~/src/server/plugins/engine/components/ComponentBase.js' import { optionalText } from '~/src/server/plugins/engine/components/constants.js' import { + type ErrorMessageTemplateList, type FileState, type FormPayload, type FormState, @@ -189,6 +190,13 @@ export class FormComponent extends ComponentBase { isState(value?: FormStateValue | FormState): value is FormState { return isFormState(value) } + + getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [], + advancedSettingsErrors: [] + } + } } /** diff --git a/src/server/plugins/engine/components/ListFormComponent.ts b/src/server/plugins/engine/components/ListFormComponent.ts index 49ce004d1..5488e84d4 100644 --- a/src/server/plugins/engine/components/ListFormComponent.ts +++ b/src/server/plugins/engine/components/ListFormComponent.ts @@ -14,7 +14,9 @@ import joi, { import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' import { type ListItem } from '~/src/server/plugins/engine/components/types.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { + type ErrorMessageTemplateList, type FormPayload, type FormSubmissionError, type FormSubmissionState @@ -137,4 +139,16 @@ export class ListFormComponent extends FormComponent { items } } + + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { type: 'selectRequired', template: messageTemplate.selectRequired } + ], + advancedSettingsErrors: [] + } + } } diff --git a/src/server/plugins/engine/components/MonthYearField.test.ts b/src/server/plugins/engine/components/MonthYearField.test.ts index 32dc961b1..28d70bd67 100644 --- a/src/server/plugins/engine/components/MonthYearField.test.ts +++ b/src/server/plugins/engine/components/MonthYearField.test.ts @@ -374,6 +374,14 @@ describe('MonthYearField', () => { }) }) }) + + describe('AllPossibleErrors', () => { + it('should return errors', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).not.toBeEmpty() + }) + }) }) describe('Validation', () => { diff --git a/src/server/plugins/engine/components/MonthYearField.ts b/src/server/plugins/engine/components/MonthYearField.ts index 7e6908baa..8040d9b14 100644 --- a/src/server/plugins/engine/components/MonthYearField.ts +++ b/src/server/plugins/engine/components/MonthYearField.ts @@ -17,12 +17,14 @@ import { NumberField } from '~/src/server/plugins/engine/components/NumberField. import { type DateInputItem } from '~/src/server/plugins/engine/components/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { + type ErrorMessageTemplateList, type FormPayload, type FormState, type FormStateValue, type FormSubmissionError, type FormSubmissionState } from '~/src/server/plugins/engine/types.js' +import { convertToLanguageMessages } from '~/src/server/utils/type-utils.js' export class MonthYearField extends FormComponent { declare options: MonthYearFieldComponent['options'] @@ -40,17 +42,18 @@ export class MonthYearField extends FormComponent { const isRequired = options.required !== false - const customValidationMessages: LanguageMessages = { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - 'any.required': messageTemplate.objectMissing, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - 'number.base': messageTemplate.objectMissing, - 'number.precision': messageTemplate.dateFormat, - 'number.integer': messageTemplate.dateFormat, - 'number.unsafe': messageTemplate.dateFormat, - 'number.min': messageTemplate.dateFormat, - 'number.max': messageTemplate.dateFormat - } + const customValidationMessages: LanguageMessages = + convertToLanguageMessages({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + 'any.required': messageTemplate.objectMissing, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + 'number.base': messageTemplate.objectMissing, + 'number.precision': messageTemplate.dateFormat, + 'number.integer': messageTemplate.dateFormat, + 'number.unsafe': messageTemplate.dateFormat, + 'number.min': messageTemplate.dateFormat, + 'number.max': messageTemplate.dateFormat + }) this.collection = new ComponentCollection( [ @@ -182,6 +185,26 @@ export class MonthYearField extends FormComponent { return MonthYearField.isMonthYear(value) } + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { type: 'required', template: messageTemplate.required }, + { + type: 'dateFormatMonth', + template: '{{#label}} must include a month' + }, + { type: 'dateFormatYear', template: '{{#label}} must include a year' } + ], + advancedSettingsErrors: [ + { type: 'dateMin', template: messageTemplate.dateMin }, + { type: 'dateMax', template: messageTemplate.dateMax } + ] + } + } + static isMonthYear( value?: FormStateValue | FormState ): value is MonthYearState { diff --git a/src/server/plugins/engine/components/MultilineTextField.test.ts b/src/server/plugins/engine/components/MultilineTextField.test.ts index ada8e71c8..f8202b662 100644 --- a/src/server/plugins/engine/components/MultilineTextField.test.ts +++ b/src/server/plugins/engine/components/MultilineTextField.test.ts @@ -254,6 +254,14 @@ describe('MultilineTextField', () => { ) }) }) + + describe('AllPossibleErrors', () => { + it('should return errors', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).not.toBeEmpty() + }) + }) }) describe('Validation', () => { diff --git a/src/server/plugins/engine/components/MultilineTextField.ts b/src/server/plugins/engine/components/MultilineTextField.ts index 9217e980a..0d2590fd1 100644 --- a/src/server/plugins/engine/components/MultilineTextField.ts +++ b/src/server/plugins/engine/components/MultilineTextField.ts @@ -3,7 +3,9 @@ import Joi, { type CustomValidator, type StringSchema } from 'joi' import { type ComponentBase } from '~/src/server/plugins/engine/components/ComponentBase.js' import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { + type ErrorMessageTemplateList, type FormPayload, type FormSubmissionError } from '~/src/server/plugins/engine/types.js' @@ -105,6 +107,19 @@ export class MultilineTextField extends FormComponent { rows } } + + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [{ type: 'required', template: messageTemplate.required }], + advancedSettingsErrors: [ + { type: 'min', template: messageTemplate.min }, + { type: 'max', template: messageTemplate.max } + ] + } + } } function getValidatorMaxWords(component: MultilineTextField) { diff --git a/src/server/plugins/engine/components/NumberField.test.ts b/src/server/plugins/engine/components/NumberField.test.ts index e8d10d6aa..afe6d5a70 100644 --- a/src/server/plugins/engine/components/NumberField.test.ts +++ b/src/server/plugins/engine/components/NumberField.test.ts @@ -272,6 +272,14 @@ describe('NumberField', () => { expect(viewModel).toHaveProperty('value', 'AA') }) + + describe('AllPossibleErrors', () => { + it('should return errors', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).not.toBeEmpty() + }) + }) }) describe('Validation', () => { diff --git a/src/server/plugins/engine/components/NumberField.ts b/src/server/plugins/engine/components/NumberField.ts index 1f17108ea..5433d1d60 100644 --- a/src/server/plugins/engine/components/NumberField.ts +++ b/src/server/plugins/engine/components/NumberField.ts @@ -7,6 +7,7 @@ import { } from '~/src/server/plugins/engine/components/FormComponent.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { + type ErrorMessageTemplateList, type FormPayload, type FormState, type FormStateValue, @@ -41,7 +42,8 @@ export class NumberField extends FormComponent { formSchema = formSchema.empty('').messages({ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - 'any.required': messages?.['any.required'] ?? messageTemplate.required + 'any.required': + messages?.['any.required'] ?? (messageTemplate.required as string) }) } @@ -130,6 +132,23 @@ export class NumberField extends FormComponent { return NumberField.isNumber(value) } + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { type: 'required', template: messageTemplate.required }, + { type: 'numberInteger', template: messageTemplate.numberInteger } + ], + advancedSettingsErrors: [ + { type: 'numberMin', template: messageTemplate.numberMin }, + { type: 'numberMax', template: messageTemplate.numberMax }, + { type: 'numberPrecision', template: messageTemplate.numberPrecision } + ] + } + } + static isNumber(value?: FormStateValue | FormState): value is number { return typeof value === 'number' } diff --git a/src/server/plugins/engine/components/RadiosField.test.ts b/src/server/plugins/engine/components/RadiosField.test.ts index 117311fcc..d071cb3a8 100644 --- a/src/server/plugins/engine/components/RadiosField.test.ts +++ b/src/server/plugins/engine/components/RadiosField.test.ts @@ -285,5 +285,13 @@ describe.each([ expect(items).toEqual([]) }) }) + + describe('AllPossibleErrors', () => { + it('should return errors', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).toBeEmpty() + }) + }) }) }) diff --git a/src/server/plugins/engine/components/SelectionControlField.ts b/src/server/plugins/engine/components/SelectionControlField.ts index 75a086ffb..348c12961 100644 --- a/src/server/plugins/engine/components/SelectionControlField.ts +++ b/src/server/plugins/engine/components/SelectionControlField.ts @@ -1,6 +1,8 @@ import { ListFormComponent } from '~/src/server/plugins/engine/components/ListFormComponent.js' import { type ListItem } from '~/src/server/plugins/engine/components/types.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { + type ErrorMessageTemplateList, type FormPayload, type FormSubmissionError } from '~/src/server/plugins/engine/types.js' @@ -40,4 +42,16 @@ export class SelectionControlField extends ListFormComponent { items } } + + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { type: 'selectRequired', template: messageTemplate.selectRequired } + ], + advancedSettingsErrors: [] + } + } } diff --git a/src/server/plugins/engine/components/TelephoneNumberField.test.ts b/src/server/plugins/engine/components/TelephoneNumberField.test.ts index 7e65d0140..0c828e9a7 100644 --- a/src/server/plugins/engine/components/TelephoneNumberField.test.ts +++ b/src/server/plugins/engine/components/TelephoneNumberField.test.ts @@ -235,6 +235,14 @@ describe('TelephoneNumberField', () => { ) }) }) + + describe('AllPossibleErrors', () => { + it('should return errors', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).toBeEmpty() + }) + }) }) describe('Validation', () => { diff --git a/src/server/plugins/engine/components/TelephoneNumberField.ts b/src/server/plugins/engine/components/TelephoneNumberField.ts index 5ddbae686..c8059b73a 100644 --- a/src/server/plugins/engine/components/TelephoneNumberField.ts +++ b/src/server/plugins/engine/components/TelephoneNumberField.ts @@ -3,7 +3,9 @@ import joi, { type StringSchema } from 'joi' import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' import { addClassOptionIfNone } from '~/src/server/plugins/engine/components/helpers.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { + type ErrorMessageTemplateList, type FormPayload, type FormSubmissionError } from '~/src/server/plugins/engine/types.js' @@ -64,4 +66,17 @@ export class TelephoneNumberField extends FormComponent { type: 'tel' } } + + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { type: 'required', template: messageTemplate.required }, + { type: 'format', template: messageTemplate.format } + ], + advancedSettingsErrors: [] + } + } } diff --git a/src/server/plugins/engine/components/TextField.test.ts b/src/server/plugins/engine/components/TextField.test.ts index e6d3b4f9b..f2c0bb26b 100644 --- a/src/server/plugins/engine/components/TextField.test.ts +++ b/src/server/plugins/engine/components/TextField.test.ts @@ -196,6 +196,14 @@ describe('TextField', () => { ) }) }) + + describe('AllPossibleErrors', () => { + it('should return errors', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).not.toBeEmpty() + }) + }) }) describe('Validation', () => { diff --git a/src/server/plugins/engine/components/TextField.ts b/src/server/plugins/engine/components/TextField.ts index d0625080d..6a4866cae 100644 --- a/src/server/plugins/engine/components/TextField.ts +++ b/src/server/plugins/engine/components/TextField.ts @@ -8,7 +8,9 @@ import { FormComponent, isFormValue } from '~/src/server/plugins/engine/components/FormComponent.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { + type ErrorMessageTemplateList, type FormState, type FormStateValue, type FormSubmissionState @@ -90,6 +92,19 @@ export class TextField extends FormComponent { return TextField.isText(value) } + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [{ type: 'required', template: messageTemplate.required }], + advancedSettingsErrors: [ + { type: 'min', template: messageTemplate.min }, + { type: 'max', template: messageTemplate.max } + ] + } + } + static isText(value?: FormStateValue | FormState): value is string { return isFormValue(value) && typeof value === 'string' } diff --git a/src/server/plugins/engine/components/UkAddressField.test.ts b/src/server/plugins/engine/components/UkAddressField.test.ts index 932679038..85b3f58e6 100644 --- a/src/server/plugins/engine/components/UkAddressField.test.ts +++ b/src/server/plugins/engine/components/UkAddressField.test.ts @@ -388,6 +388,14 @@ describe('UkAddressField', () => { }) }) }) + + describe('AllPossibleErrors', () => { + it('should return errors', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).toBeEmpty() + }) + }) }) describe('Validation', () => { diff --git a/src/server/plugins/engine/components/UkAddressField.ts b/src/server/plugins/engine/components/UkAddressField.ts index f4dd07520..b408f8439 100644 --- a/src/server/plugins/engine/components/UkAddressField.ts +++ b/src/server/plugins/engine/components/UkAddressField.ts @@ -9,6 +9,7 @@ import { import { TextField } from '~/src/server/plugins/engine/components/TextField.js' import { type QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' import { + type ErrorMessageTemplateList, type FormPayload, type FormState, type FormStateValue, @@ -164,6 +165,21 @@ export class UkAddressField extends FormComponent { return UkAddressField.isUkAddress(value) } + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { type: 'required', template: 'Enter address line 1' }, + { type: 'required', template: 'Enter town or city' }, + { type: 'required', template: 'Enter postcode' }, + { type: 'format', template: 'Enter valid postcode' } + ], + advancedSettingsErrors: [] + } + } + static isUkAddress( value?: FormStateValue | FormState ): value is UkAddressState { diff --git a/src/server/plugins/engine/components/YesNoField.test.ts b/src/server/plugins/engine/components/YesNoField.test.ts index 7debf9eee..d19650b48 100644 --- a/src/server/plugins/engine/components/YesNoField.test.ts +++ b/src/server/plugins/engine/components/YesNoField.test.ts @@ -245,4 +245,12 @@ describe('YesNoField', () => { ) }) }) + + describe('AllPossibleErrors', () => { + it('should return errors', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).toBeEmpty() + }) + }) }) diff --git a/src/server/plugins/engine/components/YesNoField.ts b/src/server/plugins/engine/components/YesNoField.ts index 6079d827b..4114ac8c2 100644 --- a/src/server/plugins/engine/components/YesNoField.ts +++ b/src/server/plugins/engine/components/YesNoField.ts @@ -2,6 +2,8 @@ import { type YesNoFieldComponent } from '@defra/forms-model' import { SelectionControlField } from '~/src/server/plugins/engine/components/SelectionControlField.js' import { addClassOptionIfNone } from '~/src/server/plugins/engine/components/helpers.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' +import { type ErrorMessageTemplateList } from '~/src/server/plugins/engine/types.js' /** * @description @@ -28,4 +30,16 @@ export class YesNoField extends SelectionControlField { this.formSchema = formSchema this.options = options } + + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { type: 'selectRequired', template: messageTemplate.selectRequired } + ], + advancedSettingsErrors: [] + } + } } diff --git a/src/server/plugins/engine/pageControllers/validationOptions.ts b/src/server/plugins/engine/pageControllers/validationOptions.ts index a088c7f7e..158b23ba3 100644 --- a/src/server/plugins/engine/pageControllers/validationOptions.ts +++ b/src/server/plugins/engine/pageControllers/validationOptions.ts @@ -1,32 +1,43 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ // Declaration above is needed for: https://github.com/hapijs/joi/issues/3064 -import joi, { type LanguageMessages, type ValidationOptions } from 'joi' +import joi, { + type JoiExpression, + type LanguageMessages, + type LanguageMessagesExt, + type ReferenceOptions, + type ValidationOptions +} from 'joi' import lowerFirst from 'lodash/lowerFirst.js' const opts = { functions: { lowerFirst } -} +} as ReferenceOptions /** * see @link https://joi.dev/api/?v=17.4.2#template-syntax for template syntax */ -export const messageTemplate = { - // @ts-expect-error - joi.expression options type issue - required: joi.expression('Enter {{lowerFirst(#label)}}', opts), - // @ts-expect-error - joi.expression options type issue - selectRequired: joi.expression('Select {{lowerFirst(#label)}}', opts), +export const messageTemplate: Record = { + required: joi.expression( + 'Enter {{lowerFirst(#label)}}', + opts + ) as JoiExpression, + selectRequired: joi.expression( + 'Select {{lowerFirst(#label)}}', + opts + ) as JoiExpression, max: '{{#label}} must be {{#limit}} characters or less', min: '{{#label}} must be {{#limit}} characters or more', - // @ts-expect-error - joi.expression options type issue - pattern: joi.expression('Enter a valid {{lowerFirst(#label)}}', opts), + pattern: joi.expression( + 'Enter a valid {{lowerFirst(#label)}}', + opts + ) as JoiExpression, format: joi.expression( 'Enter {{lowerFirst(#label)}} in the correct format', - // @ts-expect-error - joi.expression options type issue opts - ), + ) as JoiExpression, number: '{{#label}} must be a number', numberPrecision: '{{#label}} must have {{#limit}} or fewer decimal places', numberInteger: '{{#label}} must be a whole number', @@ -36,19 +47,17 @@ export const messageTemplate = { // Nested fields use component title - // @ts-expect-error - joi.expression options type issue - objectRequired: joi.expression('Enter {{#label}}', opts), + objectRequired: joi.expression('Enter {{#label}}', opts) as JoiExpression, objectMissing: joi.expression( '{{#title}} must include a {{lowerFirst(#label)}}', - // @ts-expect-error - joi.expression options type issue opts - ), + ) as JoiExpression, dateFormat: '{{#title}} must be a real date', dateMin: '{{#title}} must be the same as or after {{#limit}}', dateMax: '{{#title}} must be the same as or before {{#limit}}' } -export const messages: LanguageMessages = { +export const messages: LanguageMessagesExt = { 'string.base': messageTemplate.required, 'string.min': messageTemplate.min, 'string.empty': messageTemplate.required, @@ -77,9 +86,12 @@ export const messages: LanguageMessages = { 'date.max': messageTemplate.dateMax } +export const messagesPre: LanguageMessages = + messages as unknown as LanguageMessages + export const validationOptions: ValidationOptions = { abortEarly: false, - messages, + messages: messagesPre, errors: { wrap: { array: false, diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index c2532eea8..bbaac0e52 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -4,7 +4,7 @@ import { type List, type Page } from '@defra/forms-model' -import { type ValidationErrorItem } from 'joi' +import { type JoiExpression, type ValidationErrorItem } from 'joi' import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' import { type Component } from '~/src/server/plugins/engine/components/helpers.js' @@ -313,3 +313,13 @@ export type PageViewModel = | FormPageViewModel | RepeaterSummaryPageViewModel | FeaturedFormPageViewModel + +export interface ErrorMessageTemplate { + type: string + template: JoiExpression +} + +export interface ErrorMessageTemplateList { + baseErrors: ErrorMessageTemplate[] + advancedSettingsErrors: ErrorMessageTemplate[] +} diff --git a/src/server/plugins/error-preview/error-preview-helper.js b/src/server/plugins/error-preview/error-preview-helper.js new file mode 100644 index 000000000..f167430f0 --- /dev/null +++ b/src/server/plugins/error-preview/error-preview-helper.js @@ -0,0 +1,232 @@ +import { ComponentType, hasComponents } from '@defra/forms-model' +import Boom from '@hapi/boom' + +import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' +import { createComponent } from '~/src/server/plugins/engine/components/helpers.js' +import { FormModel } from '~/src/server/plugins/engine/models/index.js' +import { createJoiExpression } from '~/src/server/utils/type-utils.js' + +/** + * @param {ComponentDef} component + * @param {string} propertyName + * @param {string} fallbackText + * @returns { string | number } + */ +export function getSchemaProperty(component, propertyName, fallbackText) { + const schema = + /** @type {Record | undefined} */ ( + 'schema' in component ? component.schema : undefined + ) + const schemaVal = schema ? schema[propertyName] : undefined + return schemaVal ?? fallbackText +} + +/** + * @param {ComponentDef} component + * @param {string} propertyName + * @param {string} fallbackText + * @returns { string | number } + */ +export function getOptionsProperty(component, propertyName, fallbackText) { + const options = + /** @type {Record | undefined} */ ( + 'options' in component ? component.options : undefined + ) + const optionsVal = options ? options[propertyName] : undefined + return optionsVal ?? fallbackText +} + +/** + * @param {ComponentType} type + */ +export function isTypeForMinMax(type) { + return ( + type === ComponentType.TextField || + type === ComponentType.MultilineTextField || + type === ComponentType.EmailAddressField + ) +} + +/** + * @param {ComponentDef} component + * @param {string} type + * @returns { string | number } + */ +export function getNumberLimits(component, type) { + if (type === 'numberMin') { + return getSchemaProperty(component, 'min', '[lowest number]') + } + + if (type === 'numberMax') { + return getSchemaProperty(component, 'max', '[highest number]') + } + + if (type === 'numberPrecision') { + return getSchemaProperty(component, 'precision', '[precision]') + } + + return '[unknown]' +} + +/** + * @param {ComponentDef} component + * @param {string} type + * @returns { string | number } + */ +export function getDateLimits(component, type) { + if (type === 'dateMin') { + return getOptionsProperty(component, 'maxPast', '[max days in the past]') + } + + if (type === 'dateMax') { + return getOptionsProperty( + component, + 'maxFuture', + '[max days in the future]' + ) + } + + return '[unknown]' +} + +/** + * @param {ComponentDef} component + * @param {string} type + * @returns { string | number } + */ +export function getFileLimits(component, type) { + if (type === 'filesMin') { + return getSchemaProperty(component, 'min', '[min file count]') + } + + if (type === 'filesMax') { + return getSchemaProperty(component, 'max', '[max file count]') + } + + if (type === 'filesExact') { + return getSchemaProperty(component, 'length', '[exact file count]') + } + + return '[unknown]' +} + +/** + * Determine the limit (if any) relevant to the error type + * @param {string} type + * @param {ComponentDef} component + * @returns { number | string } + */ +export function determineLimit(type, component) { + if (type === 'min' && isTypeForMinMax(component.type)) { + return getSchemaProperty(component, 'min', '[min length]') + } + + if (type === 'max' && isTypeForMinMax(component.type)) { + return getSchemaProperty(component, 'max', '[max length]') + } + + if (type.startsWith('number')) { + return getNumberLimits(component, type) + } + + if (type.startsWith('date')) { + return getDateLimits(component, type) + } + + if (type.startsWith('files')) { + return getFileLimits(component, type) + } + + return '[unknown]' +} + +/** + * @param {ErrorMessageTemplate[]} templates + * @param {ComponentDef} component + */ +export function evaluateErrorTemplates(templates, component) { + return templates.map((templ) => { + return expandTemplate(templ.template, { + label: + 'shortDescription' in component + ? component.shortDescription + : '[short description]', + title: + 'shortDescription' in component + ? component.shortDescription + : '[short description]', + limit: determineLimit(templ.type, component) + }) + }) +} + +/** + * @param {FormDefinition} definition + * @param {string} path + * @param {string} questionId + */ +export function createErrorPreviewModel(definition, path, questionId) { + const pageIdx = definition.pages.findIndex((x) => x.path === `/${path}`) + if (pageIdx === -1) { + throw Boom.notFound( + `No page found for form ${definition.name} path ${path}` + ) + } + + const page = definition.pages[pageIdx] + const component = hasComponents(page) + ? page.components.find((x) => x.id === questionId) + : undefined + + if (!component) { + throw Boom.notFound( + `No question found for form ${definition.name} path ${path} questionId ${questionId}` + ) + } + + const dummyFormModel = new FormModel( + { pages: [], conditions: [], lists: [], sections: [] }, + { basePath: '' } + ) + const componentClass = createComponent(component, { model: dummyFormModel }) + const errors = + componentClass instanceof FormComponent + ? componentClass.getAllPossibleErrors() + : { baseErrors: [], advancedSettingsErrors: [] } + + const baseErrors = evaluateErrorTemplates(errors.baseErrors, component) + const advancedSettingsErrors = evaluateErrorTemplates( + errors.advancedSettingsErrors, + component + ) + + return { + pageNum: pageIdx + 1, + baseErrors, + advancedSettingsErrors + } +} + +/** + * Render a Joi template (expression) or tokenised string to generate complete error message + * @param { JoiExpression | string } template + * @param {{ label?: string, limit?: number | string, title?: string }} [local] + * @returns {string} + */ +export function expandTemplate(template, local = {}) { + const options = { errors: { escapeHtml: false } } + const prefs = { errors: { wrap: { label: false } } } + + const templateExpression = + typeof template === 'string' ? createJoiExpression(template) : template + + // @ts-expect-error Joi types are messed up + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call + return templateExpression.render('', {}, prefs, local, options) +} + +/** + * @import { ComponentDef, FormDefinition } from '@defra/forms-model' + * @import { JoiExpression } from 'joi' + * @import { ErrorMessageTemplate } from '~/src/server/plugins/engine/types.js' + */ diff --git a/src/server/plugins/error-preview/error-preview-helper.test.js b/src/server/plugins/error-preview/error-preview-helper.test.js new file mode 100644 index 000000000..855062f6b --- /dev/null +++ b/src/server/plugins/error-preview/error-preview-helper.test.js @@ -0,0 +1,469 @@ +import { ComponentType } from '@defra/forms-model' + +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' +import { + createErrorPreviewModel, + determineLimit, + evaluateErrorTemplates, + expandTemplate, + getOptionsProperty, + getSchemaProperty, + isTypeForMinMax +} from '~/src/server/plugins/error-preview/error-preview-helper.js' +import { componentId, definitionWithComponentId } from '~/test/fixtures/form.js' + +describe('Error preview helper', () => { + describe('expandTemplate', () => { + it('should return expanded template - simple single token', () => { + const template = messageTemplate.required + const res = expandTemplate(template, { label: 'Your name' }) + expect(res).toBe('Enter your name') + }) + + it('should return expanded template - multiple tokens', () => { + const template = messageTemplate.min + const res = expandTemplate(template, { label: 'Your age', limit: 7 }) + expect(res).toBe('Your age must be 7 characters or more') + }) + }) + + describe('getSchemaProperty', () => { + it('should return schema property', () => { + const component = /** @type {ComponentDef} */ ({ + name: 'abcdef', + title: 'Component title', + type: ComponentType.TextField, + schema: { + min: 5 + }, + options: {} + }) + const res = getSchemaProperty(component, 'min', '[min placeholder]') + expect(res).toBe(5) + }) + + it('should return alternative text if schema property undefined', () => { + const component = /** @type {ComponentDef} */ ({ + name: 'abcdef', + title: 'Component title', + type: ComponentType.TextField, + schema: { + min: undefined + }, + options: {} + }) + const res = getSchemaProperty(component, 'min', '[min placeholder]') + expect(res).toBe('[min placeholder]') + }) + + it('should return alternative text if schema property missing', () => { + const component = /** @type {ComponentDef} */ ({ + name: 'abcdef', + title: 'Component title', + type: ComponentType.TextField, + schema: {}, + options: {} + }) + const res = getSchemaProperty(component, 'min', '[min placeholder]') + expect(res).toBe('[min placeholder]') + }) + + it('should return alternative text if schema structure missing', () => { + const component = /** @type {ComponentDef} */ ({ + name: 'abcdef', + title: 'Component title', + type: ComponentType.TextField, + options: {} + }) + const res = getSchemaProperty(component, 'min', '[min placeholder]') + expect(res).toBe('[min placeholder]') + }) + }) + + describe('getOptionsProperty', () => { + it('should return options property', () => { + const component = /** @type {ComponentDef} */ ({ + name: 'abcdef', + title: 'Component title', + type: ComponentType.TextField, + schema: {}, + options: { + maxFuture: 15 + } + }) + const res = getOptionsProperty( + component, + 'maxFuture', + '[max days in the future]' + ) + expect(res).toBe(15) + }) + + it('should return alternative text if options property undefined', () => { + const component = /** @type {ComponentDef} */ ({ + name: 'abcdef', + title: 'Component title', + type: ComponentType.TextField, + schema: {}, + options: { + maxFuture: undefined + } + }) + const res = getOptionsProperty( + component, + 'maxFuture', + '[max days in the future]' + ) + expect(res).toBe('[max days in the future]') + }) + + it('should return alternative text if options property missing', () => { + const component = /** @type {ComponentDef} */ ({ + name: 'abcdef', + title: 'Component title', + type: ComponentType.TextField, + schema: {}, + options: {} + }) + const res = getOptionsProperty( + component, + 'maxFuture', + '[max days in the future]' + ) + expect(res).toBe('[max days in the future]') + }) + + it('should return alternative text if options structure missing', () => { + const component = /** @type {ComponentDef} */ ({ + name: 'abcdef', + title: 'Component title', + type: ComponentType.TextField, + schema: {} + }) + const res = getOptionsProperty( + component, + 'maxFuture', + '[max days in the future]' + ) + expect(res).toBe('[max days in the future]') + }) + }) + + describe('isTypeForMinMax', () => { + it('should return true for valid types', () => { + expect(isTypeForMinMax(ComponentType.TextField)).toBeTruthy() + expect(isTypeForMinMax(ComponentType.MultilineTextField)).toBeTruthy() + expect(isTypeForMinMax(ComponentType.EmailAddressField)).toBeTruthy() + }) + + it('should return false for invalid types', () => { + expect(isTypeForMinMax(ComponentType.NumberField)).toBeFalsy() + }) + }) + + describe('determineLimit', () => { + const component = /** @type {ComponentDef} */ ({ + name: 'abcdef', + title: 'Component title', + type: ComponentType.TextField, + schema: { + min: 7, + max: 30 + }, + options: { + maxPast: 21, + maxFuture: 35 + } + }) + + it.each([ + ComponentType.TextField, + ComponentType.MultilineTextField, + ComponentType.EmailAddressField + ])( + 'should return correct limit for min and TextField/MultilineTextField/EmailAddress', + (componentType) => { + const componentLocal = /** @type {ComponentDef} */ ({ + ...component, + type: componentType + }) + const res = determineLimit('min', componentLocal) + expect(res).toBe(7) + } + ) + + it.each([ + ComponentType.TextField, + ComponentType.MultilineTextField, + ComponentType.EmailAddressField + ])( + 'should return correct limit for mmax and TextField', + (componentType) => { + const componentLocal = /** @type {ComponentDef} */ ({ + ...component, + type: componentType + }) + const res = determineLimit('max', componentLocal) + expect(res).toBe(30) + } + ) + + it('should return correct limit for numberMin', () => { + const component = /** @type {ComponentDef} */ ({ + name: 'abcdef', + title: 'Component title', + type: ComponentType.TextField, + schema: { + min: 10, + max: 25 + }, + options: {} + }) + const res = determineLimit('numberMin', component) + expect(res).toBe(10) + }) + + it('should return correct limit for numberMax', () => { + const component = /** @type {ComponentDef} */ ({ + name: 'abcdef', + title: 'Component title', + type: ComponentType.TextField, + schema: { + min: 10, + max: 25 + }, + options: {} + }) + const res = determineLimit('numberMax', component) + expect(res).toBe(25) + }) + + it('should return correct limit for numberPrecision', () => { + const component = /** @type {ComponentDef} */ ({ + name: 'abcdef', + title: 'Component title', + type: ComponentType.TextField, + schema: { + min: 10, + max: 25, + precision: 2 + }, + options: {} + }) + const res = determineLimit('numberPrecision', component) + expect(res).toBe(2) + }) + + it('should return correct limit for dateMin', () => { + const component = /** @type {ComponentDef} */ ({ + name: 'abcdef', + title: 'Component title', + type: ComponentType.TextField, + schema: {}, + options: { + maxPast: 21, + maxFuture: 35 + } + }) + const res = determineLimit('dateMin', component) + expect(res).toBe(21) + }) + + it('should return correct limit for dateMax', () => { + const component = /** @type {ComponentDef} */ ({ + name: 'abcdef', + title: 'Component title', + type: ComponentType.TextField, + schema: {}, + options: { + maxPast: 21, + maxFuture: 35 + } + }) + const res = determineLimit('dateMax', component) + expect(res).toBe(35) + }) + + it('should return correct limit for filesMin', () => { + const component = /** @type {ComponentDef} */ ({ + name: 'abcdef', + title: 'Component title', + type: ComponentType.FileUploadField, + schema: { + min: 1, + max: 3 + }, + options: {} + }) + const res = determineLimit('filesMin', component) + expect(res).toBe(1) + }) + + it('should return correct limit for filesMax', () => { + const component = /** @type {ComponentDef} */ ({ + name: 'abcdef', + title: 'Component title', + type: ComponentType.FileUploadField, + schema: { + min: 1, + max: 3 + }, + options: {} + }) + const res = determineLimit('filesMax', component) + expect(res).toBe(3) + }) + + it('should return correct limit for filesExact', () => { + const component = /** @type {ComponentDef} */ ({ + name: 'abcdef', + title: 'Component title', + type: ComponentType.FileUploadField, + schema: { + length: 2 + }, + options: {} + }) + const res = determineLimit('filesExact', component) + expect(res).toBe(2) + }) + + it('should return unknown for invalid number type', () => { + const component = /** @type {ComponentDef} */ ({ + name: 'abcdef', + title: 'Component title', + type: ComponentType.NumberField, + schema: {}, + options: {} + }) + const res = determineLimit('numberInvalid', component) + expect(res).toBe('[unknown]') + }) + + it('should return unknown for invalid date type', () => { + const component = /** @type {ComponentDef} */ ({ + name: 'abcdef', + title: 'Component title', + type: ComponentType.DatePartsField, + schema: {}, + options: {} + }) + const res = determineLimit('dateInvalid', component) + expect(res).toBe('[unknown]') + }) + + it('should return unknown for invalid file type', () => { + const component = /** @type {ComponentDef} */ ({ + name: 'abcdef', + title: 'Component title', + type: ComponentType.FileUploadField, + schema: {}, + options: {} + }) + const res = determineLimit('filesInvalid', component) + expect(res).toBe('[unknown]') + }) + + it('should return unknown for invalid type', () => { + const component = /** @type {ComponentDef} */ ({ + name: 'abcdef', + title: 'Component title', + type: ComponentType.TextField, + schema: {}, + options: {} + }) + const res = determineLimit('invalid', component) + expect(res).toBe('[unknown]') + }) + }) + + describe('createErrorPreviewModel', () => { + const questionId = componentId + it('should throw if page not found', () => { + const def = structuredClone(definitionWithComponentId) + expect(() => + createErrorPreviewModel(def, 'wont-find-page', questionId) + ).toThrow('No page found for form path wont-find-page') + }) + + it('should throw if component not found', () => { + const def = structuredClone(definitionWithComponentId) + expect(() => + createErrorPreviewModel(def, 'page-one', 'invalid-id') + ).toThrow( + 'No question found for form path page-one questionId invalid-id' + ) + }) + + it('should return error messages inside object', () => { + const def = structuredClone(definitionWithComponentId) + const res = createErrorPreviewModel(def, 'page-one', questionId) + expect(res.pageNum).toBe(1) + expect(res.baseErrors).toHaveLength(1) + expect(res.baseErrors[0]).toBe('Enter [short description]') + expect(res.advancedSettingsErrors).toHaveLength(2) + expect(res.advancedSettingsErrors[0]).toBe( + '[short description] must be [min length] characters or more' + ) + expect(res.advancedSettingsErrors[1]).toBe( + '[short description] must be [max length] characters or less' + ) + }) + }) + + describe('evaluateErrorTemplates', () => { + it('should render all templates using short description', () => { + const component = /** @type {ComponentDef} */ ({ + name: 'abcdef', + title: 'Component title', + shortDescription: 'Your full name', + type: ComponentType.TextField, + schema: { + min: 5, + max: 30 + }, + options: {} + }) + + const templates = /** @type {ErrorMessageTemplate[]} */ ([ + { type: 'required', template: messageTemplate.required }, + { type: 'min', template: messageTemplate.min }, + { type: 'max', template: messageTemplate.max } + ]) + const res = evaluateErrorTemplates(templates, component) + expect(res).toHaveLength(3) + expect(res[0]).toBe('Enter your full name') + expect(res[1]).toBe('Your full name must be 5 characters or more') + expect(res[2]).toBe('Your full name must be 30 characters or less') + }) + + it('should render all templates with short description placeholder', () => { + const component = /** @type {ComponentDef} */ ({ + name: 'abcdef', + title: 'Component title', + type: ComponentType.TextField, + schema: { + min: 5, + max: 30 + }, + options: {} + }) + + const templates = /** @type {ErrorMessageTemplate[]} */ ([ + { type: 'required', template: messageTemplate.required }, + { type: 'min', template: messageTemplate.min }, + { type: 'max', template: messageTemplate.max } + ]) + const res = evaluateErrorTemplates(templates, component) + expect(res).toHaveLength(3) + expect(res[0]).toBe('Enter [short description]') + expect(res[1]).toBe('[short description] must be 5 characters or more') + expect(res[2]).toBe('[short description] must be 30 characters or less') + }) + }) +}) + +/** + * @import { ComponentDef } from '@defra/forms-model' + * @import { ErrorMessageTemplate } from '~/src/server/plugins/engine/types.js' + */ diff --git a/src/server/plugins/error-preview/error-preview.js b/src/server/plugins/error-preview/error-preview.js new file mode 100644 index 000000000..f2718f43b --- /dev/null +++ b/src/server/plugins/error-preview/error-preview.js @@ -0,0 +1,38 @@ +import Boom from '@hapi/boom' + +import { + getFormDefinition, + getFormMetadata +} from '~/src/server/plugins/engine/services/formsService.js' +import { createErrorPreviewModel } from '~/src/server/plugins/error-preview/error-preview-helper.js' +import { FormStatus } from '~/src/server/routes/types.js' + +/** + * @param {FormRequest} request + * @param {Pick} h + */ +export async function getErrorPreviewHandler(request, h) { + const { params } = request + const { slug, path, itemId } = params + + // Get the form metadata using the `slug` param + const metadata = await getFormMetadata(slug) + + // Get the form definition using the `id` from the metadata + const definition = await getFormDefinition(metadata.id, FormStatus.Draft) + if (!definition) { + throw Boom.notFound( + `No definition found for form metadata ${metadata.id} (${slug}) ${FormStatus.Draft}` + ) + } + + return h.view( + 'error-preview', + createErrorPreviewModel(definition, path, itemId ?? '') + ) +} + +/** + * @import { ResponseToolkit } from '@hapi/hapi' + * @import { FormRequest } from '~/src/server/routes/types.js' + */ diff --git a/src/server/plugins/error-preview/error-preview.test.js b/src/server/plugins/error-preview/error-preview.test.js new file mode 100644 index 000000000..7db2f8a54 --- /dev/null +++ b/src/server/plugins/error-preview/error-preview.test.js @@ -0,0 +1,85 @@ +import { createServer } from '~/src/server/index.js' +import { + getFormDefinition, + getFormMetadata +} from '~/src/server/plugins/engine/services/formsService.js' +import { + componentId, + definitionWithComponentId, + metadata +} from '~/test/fixtures/form.js' +import { renderResponse } from '~/test/helpers/component-helpers.js' + +jest.mock('~/src/server/plugins/engine/services/formsService.js') + +describe('Error preview route', () => { + /** @type {Server} */ + let server + + beforeAll(async () => { + server = await createServer() + await server.initialize() + }) + + afterAll(async () => { + await server.stop() + }) + + describe('route error-preview', () => { + test('validates and calls handle if valid payload', async () => { + jest.mocked(getFormMetadata).mockResolvedValueOnce(metadata) + const def = /** @type {FormDefinition} */ ( + structuredClone(definitionWithComponentId) + ) + jest.mocked(getFormDefinition).mockResolvedValueOnce(def) + const options = { + method: 'GET', + url: `/error-preview/draft/slug/page-one/${componentId}` + } + + const { container } = await renderResponse(server, options) + + const $headings = container.getAllByRole('heading') + const $links = container.getAllByRole('link') + + expect($headings[2].textContent?.trim()).toBe('There is a problem') + expect($headings[2]).toHaveClass( + 'govuk-error-summary__title govuk-!-margin-bottom-2' + ) + + expect($links[4].textContent).toBe('Enter [short description]') + + expect($headings[3].textContent?.trim()).toBe('If you set answer limits') + expect($headings[3]).toHaveClass( + 'govuk-error-summary__title govuk-!-margin-bottom-2' + ) + + expect($links[5].textContent).toBe( + '[short description] must be [min length] characters or more' + ) + expect($links[6].textContent).toBe( + '[short description] must be [max length] characters or less' + ) + }) + + test('should error if definition not found', async () => { + jest.mocked(getFormMetadata).mockResolvedValueOnce(metadata) + jest.mocked(getFormDefinition).mockResolvedValueOnce(undefined) + const options = { + method: 'GET', + url: `/error-preview/draft/slug/page-one/${componentId}` + } + + const { container } = await renderResponse(server, options) + + const $headings = container.getAllByRole('heading') + + expect($headings[0].textContent).toBe('Page not found') + }) + }) +}) + +/** + * @import { FormDefinition } from '@defra/forms-model' + * @import { Server } from '@hapi/hapi' + */ diff --git a/src/server/plugins/errorPages.ts b/src/server/plugins/errorPages.ts index 4718c62a9..0baa33ca0 100644 --- a/src/server/plugins/errorPages.ts +++ b/src/server/plugins/errorPages.ts @@ -41,6 +41,7 @@ export default { request.log('error', { statusCode, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment data: response.data, message: response.message, stack: response.stack diff --git a/src/server/plugins/nunjucks/enviroment.test.js b/src/server/plugins/nunjucks/enviroment.test.js index a6f1cbf14..4bdb62cda 100644 --- a/src/server/plugins/nunjucks/enviroment.test.js +++ b/src/server/plugins/nunjucks/enviroment.test.js @@ -88,7 +88,9 @@ describe('Nunjucks environment', () => { } } - const result = checkComponentTemplates.call(nunjucksCtx, component) + const result = /** @type {{ model: { content: string } }} */ ( + checkComponentTemplates.call(nunjucksCtx, component) + ) expect(helpers.evaluateTemplate).toHaveBeenCalledWith( 'Some {{ context.someData }} content', @@ -114,7 +116,9 @@ describe('Nunjucks environment', () => { } } - const result = checkComponentTemplates.call(nunjucksCtx, component) + const result = /** @type {{ model: { content: string } }} */ ( + checkComponentTemplates.call(nunjucksCtx, component) + ) expect(helpers.evaluateTemplate).not.toHaveBeenCalled() @@ -136,7 +140,9 @@ describe('Nunjucks environment', () => { } } - const result = checkComponentTemplates.call(nunjucksCtx, component) + const result = /** @type {{ model: { label?: { text: string } } }} */ ( + checkComponentTemplates.call(nunjucksCtx, component) + ) expect(helpers.evaluateTemplate).toHaveBeenCalledWith( 'Label with {{ context.someData }}', diff --git a/src/server/plugins/router.ts b/src/server/plugins/router.ts index 95ab2bc7d..05b9ef664 100644 --- a/src/server/plugins/router.ts +++ b/src/server/plugins/router.ts @@ -13,8 +13,14 @@ import { type CookieConsent } from '~/src/common/types.js' import { config } from '~/src/config/index.js' import { isPathRelative } from '~/src/server/plugins/engine/helpers.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' +import { getErrorPreviewHandler } from '~/src/server/plugins/error-preview/error-preview.js' import { healthRoute, publicRoutes } from '~/src/server/routes/index.js' -import { crumbSchema } from '~/src/server/schemas/index.js' +import { + crumbSchema, + itemIdSchema, + pathSchema, + stateSchema +} from '~/src/server/schemas/index.js' const routes = [...publicRoutes, healthRoute] @@ -196,6 +202,22 @@ export default { }, options }) + + server.route({ + method: 'get', + path: '/error-preview/{state}/{slug}/{path}/{itemId}', + handler: getErrorPreviewHandler, + options: { + validate: { + params: Joi.object().keys({ + state: stateSchema, + slug: slugSchema, + path: pathSchema, + itemId: itemIdSchema + }) + } + } + }) } } } satisfies ServerRegisterPluginObject diff --git a/src/server/utils/type-utils.ts b/src/server/utils/type-utils.ts new file mode 100644 index 000000000..80fe3f0cf --- /dev/null +++ b/src/server/utils/type-utils.ts @@ -0,0 +1,15 @@ +import Joi, { + type JoiExpression, + type LanguageMessages, + type LanguageMessagesExt +} from 'joi' + +export function convertToLanguageMessages( + extLanguageMessages: LanguageMessagesExt +): LanguageMessages { + return extLanguageMessages as unknown as LanguageMessages +} + +export function createJoiExpression(expr: string): JoiExpression { + return Joi.expression(expr) as unknown as JoiExpression +} diff --git a/src/server/views/error-preview.html b/src/server/views/error-preview.html new file mode 100644 index 000000000..7aa709a10 --- /dev/null +++ b/src/server/views/error-preview.html @@ -0,0 +1,54 @@ +{% extends "layout.html" %} + +{% from "govuk/components/notification-banner/macro.njk" import govukNotificationBanner %} + +{% set mainClasses = "govuk-main-wrapper--l" %} + +{% set html %} +

+ This is a preview of the error messages for page {{ pageNum }} in a draft form you are editing. +

+

Close this tab to go back to the editor.

+{% endset %} + +{% block content %} +
+
+ {{ govukNotificationBanner({ + html: html + }) }} + + +
+
+

+ There is a problem +

+
+
    + {% for error in baseErrors %} +
  • + {{ error }} +
  • + {% endfor %} +
+
+ {% if advancedSettingsErrors | length %} +

+ If you set answer limits +

+
+
    + {% for error in advancedSettingsErrors %} +
  • + {{ error }} +
  • + {% endfor %} +
+
+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/src/typings/joi/index.d.ts b/src/typings/joi/index.d.ts index 37dcdacb8..f57e62dd3 100644 --- a/src/typings/joi/index.d.ts +++ b/src/typings/joi/index.d.ts @@ -19,4 +19,12 @@ declare module 'joi' { title?: string } } + + interface JoiExpressionReturn { + render: (p1, p2, p3, p4, p5) => string + } + + type JoiExpression = JoiExpressionReturn | string + + type LanguageMessagesExt = Record } diff --git a/test/fixtures/form.js b/test/fixtures/form.js index ca7b4fc93..ef4e5e66c 100644 --- a/test/fixtures/form.js +++ b/test/fixtures/form.js @@ -81,6 +81,50 @@ export const definition = { outputEmail: 'enrique.chase@defra.gov.uk' } +export const componentId = '1491981d-99cd-485e-ab4a-f88275edeadc' + +/** + * @satisfies {FormDefinition} + */ +export const definitionWithComponentId = { + name: '', + startPage: '/page-one', + pages: [ + { + path: '/page-one', + title: 'Page one', + section: 'section', + components: [ + { + id: componentId, + type: ComponentType.TextField, + name: 'textField', + title: 'This is your first field', + hint: 'Help text', + options: {}, + schema: {} + } + ], + next: [{ path: ControllerPath.Summary }] + }, + { + title: 'Summary', + path: ControllerPath.Summary, + controller: ControllerType.Summary + } + ], + sections: [ + { + name: 'section', + title: 'Section title', + hideTitle: false + } + ], + conditions: [], + lists: [], + outputEmail: 'enrique.chase@defra.gov.uk' +} + /** * @import { FormDefinition, FormMetadata, FormMetadataAuthor, FormMetadataState } from '@defra/forms-model' */ From 09f6644d392b2bcc43e3d695059c5654f66bac8c Mon Sep 17 00:00:00 2001 From: Jez Barnsley <114290619+jbarnsley10@users.noreply.github.com> Date: Thu, 17 Apr 2025 08:27:36 +0100 Subject: [PATCH 14/20] Enhanced error messages for MultilineTextField and YesNoField (#778) * Enhanced error messages for MultilineTextField and YesNoField Added file upload errors messages * Reworked YesNo required error * Updated forms-model version * npm audit fix to remove high vulnerability * After review * Reverted reducer code as seems more complicated to understand * feat: 541181 - Optional update * Revert "feat: 541181 - Optional update" This reverts commit 33d7abb5e17a741530936f06b9de34b3d0f811b6. * Changed algorithm for file types list --------- Co-authored-by: Chris Cole --- package-lock.json | 163 ++++++++++-------- package.json | 2 +- .../engine/components/FileUploadField.ts | 20 ++- .../components/MultilineTextField.test.ts | 56 +++++- .../engine/components/MultilineTextField.ts | 22 ++- .../engine/components/YesNoField.test.ts | 2 +- .../plugins/engine/components/YesNoField.ts | 12 +- .../pageControllers/validationOptions.ts | 2 + .../error-preview/error-preview-helper.js | 65 ++++++- .../error-preview-helper.test.js | 46 ++++- test/form/template.test.js | 4 +- 11 files changed, 309 insertions(+), 85 deletions(-) diff --git a/package-lock.json b/package-lock.json index b65e827a0..b5aaa8155 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.440", + "@defra/forms-model": "^3.0.441", "@defra/hapi-tracing": "^1.0.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", @@ -222,10 +222,11 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.0.tgz", - "integrity": "sha512-INCKxTtbXtcNbUZ3YXutwMpEleqttcswhAdee7dhuoVrD2cnuc3PqtERBtxkX5nziX9vnBL8WXmSGwv8CuPV6g==", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", @@ -632,25 +633,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", - "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz", + "integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.0" + "@babel/template": "^7.27.0", + "@babel/types": "^7.27.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.1", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.1.tgz", - "integrity": "sha512-reoQYNiAJreZNsJzyrDNzFQ+IQ5JFiIzAHJg9bn94S3l+4++J7RsIhNMoB+lgP/9tpmiAQqspv+xfdxTSzREOw==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", + "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.26.0" + "@babel/types": "^7.27.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -1903,10 +1906,11 @@ } }, "node_modules/@babel/runtime": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.7.tgz", - "integrity": "sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", "dev": true, + "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -1915,14 +1919,15 @@ } }, "node_modules/@babel/template": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", - "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", + "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.25.9", - "@babel/parser": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.27.0", + "@babel/types": "^7.27.0" }, "engines": { "node": ">=6.9.0" @@ -1947,10 +1952,11 @@ } }, "node_modules/@babel/types": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.0.tgz", - "integrity": "sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", + "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" @@ -2039,9 +2045,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.440", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.440.tgz", - "integrity": "sha512-VQoGrqbDAHpfMaC2LX/P8Q49xBCBEE5mmRju+P3OdTEAFSBKcIq6jLAggypWeVNzbl8irwqF2X4QBF1kpxPysA==", + "version": "3.0.441", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.441.tgz", + "integrity": "sha512-j3PvXk4Ms/aiONJd/MA52sLnprMDNtRiJEXu0o6RipkX5EUBd8wh/MCemnisWbL2QTUwTWeroMAbhBJB1SD/Dw==", "license": "OGL-UK-3.0", "dependencies": { "marked": "^15.0.7", @@ -2998,17 +3004,6 @@ "node": ">=10" } }, - "node_modules/@hapi/scooter/node_modules/os-tmpdir": { - "version": "1.0.2", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@hapi/scooter/node_modules/pseudomap": { - "version": "1.0.2", - "license": "ISC" - }, "node_modules/@hapi/scooter/node_modules/semver": { "version": "7.6.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", @@ -3023,36 +3018,6 @@ "node": ">=10" } }, - "node_modules/@hapi/scooter/node_modules/tmp": { - "version": "0.0.33", - "license": "MIT", - "dependencies": { - "os-tmpdir": "~1.0.2" - }, - "engines": { - "node": ">=0.6.0" - } - }, - "node_modules/@hapi/scooter/node_modules/useragent": { - "version": "2.3.0", - "license": "MIT", - "dependencies": { - "lru-cache": "4.1.x", - "tmp": "0.0.x" - } - }, - "node_modules/@hapi/scooter/node_modules/useragent/node_modules/lru-cache": { - "version": "4.1.5", - "license": "ISC", - "dependencies": { - "pseudomap": "^1.0.2", - "yallist": "^2.1.2" - } - }, - "node_modules/@hapi/scooter/node_modules/useragent/node_modules/yallist": { - "version": "2.1.2", - "license": "ISC" - }, "node_modules/@hapi/shot": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@hapi/shot/-/shot-6.0.1.tgz", @@ -6664,10 +6629,11 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -12520,6 +12486,15 @@ "node": ">= 0.8.0" } }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/outdent": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.8.0.tgz", @@ -13763,6 +13738,12 @@ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, + "node_modules/pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==", + "license": "ISC" + }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -16055,6 +16036,18 @@ "dev": true, "license": "MIT" }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "license": "MIT", + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -16382,6 +16375,32 @@ "punycode": "^2.1.0" } }, + "node_modules/useragent": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/useragent/-/useragent-2.3.0.tgz", + "integrity": "sha512-4AoH4pxuSvHCjqLO04sU6U/uE65BYza8l/KKBS0b0hnUPWi+cQ2BpeTEwejCSx9SPV5/U03nniDTrWx5NrmKdw==", + "license": "MIT", + "dependencies": { + "lru-cache": "4.1.x", + "tmp": "0.0.x" + } + }, + "node_modules/useragent/node_modules/lru-cache": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", + "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", + "license": "ISC", + "dependencies": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, + "node_modules/useragent/node_modules/yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==", + "license": "ISC" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index ef9bafdb3..8db60ffb1 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ }, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.440", + "@defra/forms-model": "^3.0.441", "@defra/hapi-tracing": "^1.0.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", diff --git a/src/server/plugins/engine/components/FileUploadField.ts b/src/server/plugins/engine/components/FileUploadField.ts index 8a2701461..04caed0cc 100644 --- a/src/server/plugins/engine/components/FileUploadField.ts +++ b/src/server/plugins/engine/components/FileUploadField.ts @@ -272,7 +272,25 @@ export class FileUploadField extends FormComponent { getAllPossibleErrors(): ErrorMessageTemplateList { return { baseErrors: [ - { type: 'selectRequired', template: messageTemplate.selectRequired } + { type: 'selectRequired', template: messageTemplate.selectRequired }, + { + type: 'filesMimes', + template: 'The selected file must be a {{#limit}}' + }, + { + type: 'filesSize', + template: 'The selected file must be smaller than 100MB' + }, + { type: 'filesEmpty', template: 'The selected file is empty' }, + { type: 'filesVirus', template: 'The selected file contains a virus' }, + { + type: 'filesPartial', + template: 'The selected file has not fully uploaded' + }, + { + type: 'filesError', + template: 'The selected file could not be uploaded – try again' + } ], advancedSettingsErrors: [ { diff --git a/src/server/plugins/engine/components/MultilineTextField.test.ts b/src/server/plugins/engine/components/MultilineTextField.test.ts index f8202b662..092e37b35 100644 --- a/src/server/plugins/engine/components/MultilineTextField.test.ts +++ b/src/server/plugins/engine/components/MultilineTextField.test.ts @@ -322,7 +322,57 @@ describe('MultilineTextField', () => { ] }, { - description: 'Schema min and max', + description: 'Schema min', + component: { + title: 'Example textarea', + name: 'myComponent', + type: ComponentType.MultilineTextField, + options: {}, + schema: { + min: 5 + } + } satisfies MultilineTextFieldComponent, + assertions: [ + { + input: getFormData('Text'), + output: { + value: getFormData('Text'), + errors: [ + expect.objectContaining({ + text: 'Example textarea must be 5 characters or more' + }) + ] + } + } + ] + }, + { + description: 'Schema max', + component: { + title: 'Example textarea', + name: 'myComponent', + type: ComponentType.MultilineTextField, + options: {}, + schema: { + max: 8 + } + } satisfies MultilineTextFieldComponent, + assertions: [ + { + input: getFormData('Text too long'), + output: { + value: getFormData('Text too long'), + errors: [ + expect.objectContaining({ + text: 'Example textarea must be 8 characters or less' + }) + ] + } + } + ] + }, + { + description: 'Schema min and max together', component: { title: 'Example textarea', name: 'myComponent', @@ -340,7 +390,7 @@ describe('MultilineTextField', () => { value: getFormData('Text'), errors: [ expect.objectContaining({ - text: 'Example textarea must be 5 characters or more' + text: 'Example textarea must be between 5 and 8 characters' }) ] } @@ -351,7 +401,7 @@ describe('MultilineTextField', () => { value: getFormData('Textarea too long'), errors: [ expect.objectContaining({ - text: 'Example textarea must be 8 characters or less' + text: 'Example textarea must be between 5 and 8 characters' }) ] } diff --git a/src/server/plugins/engine/components/MultilineTextField.ts b/src/server/plugins/engine/components/MultilineTextField.ts index 0d2590fd1..b0ec0ab6c 100644 --- a/src/server/plugins/engine/components/MultilineTextField.ts +++ b/src/server/plugins/engine/components/MultilineTextField.ts @@ -73,6 +73,15 @@ export class MultilineTextField extends FormComponent { }) } else if (options.customValidationMessages) { formSchema = formSchema.messages(options.customValidationMessages) + } else if ( + typeof schema.max === 'number' && + typeof schema.min === 'number' + ) { + const minMaxErrorText = this.buildMinMaxText(schema.min, schema.max) + formSchema = formSchema.ruleset + .min(schema.min) + .max(schema.max) + .message(minMaxErrorText) } this.formSchema = formSchema.default('') @@ -108,6 +117,13 @@ export class MultilineTextField extends FormComponent { } } + buildMinMaxText(min?: number, max?: number): string { + const minMaxError = messageTemplate.minMax as string + return minMaxError + .replace('{{#min}}', min ? min.toString() : '[min length]') + .replace('{{#max}}', max ? max.toString() : '[max length]') + } + /** * For error preview page that shows all possible errors on a component */ @@ -116,7 +132,11 @@ export class MultilineTextField extends FormComponent { baseErrors: [{ type: 'required', template: messageTemplate.required }], advancedSettingsErrors: [ { type: 'min', template: messageTemplate.min }, - { type: 'max', template: messageTemplate.max } + { type: 'max', template: messageTemplate.max }, + { + type: 'minMax', + template: this.buildMinMaxText(this.schema.min, this.schema.max) + } ] } } diff --git a/src/server/plugins/engine/components/YesNoField.test.ts b/src/server/plugins/engine/components/YesNoField.test.ts index d19650b48..ff739a154 100644 --- a/src/server/plugins/engine/components/YesNoField.test.ts +++ b/src/server/plugins/engine/components/YesNoField.test.ts @@ -121,7 +121,7 @@ describe('YesNoField', () => { expect(result.errors).toEqual([ expect.objectContaining({ - text: 'Select example yes/no' + text: 'Select yes or no' }) ]) }) diff --git a/src/server/plugins/engine/components/YesNoField.ts b/src/server/plugins/engine/components/YesNoField.ts index 4114ac8c2..547c0744f 100644 --- a/src/server/plugins/engine/components/YesNoField.ts +++ b/src/server/plugins/engine/components/YesNoField.ts @@ -4,6 +4,7 @@ import { SelectionControlField } from '~/src/server/plugins/engine/components/Se import { addClassOptionIfNone } from '~/src/server/plugins/engine/components/helpers.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList } from '~/src/server/plugins/engine/types.js' +import { convertToLanguageMessages } from '~/src/server/utils/type-utils.js' /** * @description @@ -27,6 +28,12 @@ export class YesNoField extends SelectionControlField { formSchema = formSchema.optional() } + formSchema = formSchema.messages( + convertToLanguageMessages({ + 'any.required': messageTemplate.selectYesNoRequired + }) + ) + this.formSchema = formSchema this.options = options } @@ -37,7 +44,10 @@ export class YesNoField extends SelectionControlField { getAllPossibleErrors(): ErrorMessageTemplateList { return { baseErrors: [ - { type: 'selectRequired', template: messageTemplate.selectRequired } + { + type: 'selectYesNoRequired', + template: messageTemplate.selectYesNoRequired + } ], advancedSettingsErrors: [] } diff --git a/src/server/plugins/engine/pageControllers/validationOptions.ts b/src/server/plugins/engine/pageControllers/validationOptions.ts index 158b23ba3..7e8079fbc 100644 --- a/src/server/plugins/engine/pageControllers/validationOptions.ts +++ b/src/server/plugins/engine/pageControllers/validationOptions.ts @@ -28,8 +28,10 @@ export const messageTemplate: Record = { 'Select {{lowerFirst(#label)}}', opts ) as JoiExpression, + selectYesNoRequired: 'Select yes or no', max: '{{#label}} must be {{#limit}} characters or less', min: '{{#label}} must be {{#limit}} characters or more', + minMax: '{{#label}} must be between {{#min}} and {{#max}} characters', pattern: joi.expression( 'Enter a valid {{lowerFirst(#label)}}', opts diff --git a/src/server/plugins/error-preview/error-preview-helper.js b/src/server/plugins/error-preview/error-preview-helper.js index f167430f0..498cff151 100644 --- a/src/server/plugins/error-preview/error-preview-helper.js +++ b/src/server/plugins/error-preview/error-preview-helper.js @@ -1,4 +1,10 @@ -import { ComponentType, hasComponents } from '@defra/forms-model' +import { + ComponentType, + allDocumentTypes, + allImageTypes, + allTabularDataTypes, + hasComponents +} from '@defra/forms-model' import Boom from '@hapi/boom' import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' @@ -47,6 +53,58 @@ export function isTypeForMinMax(type) { ) } +/** + * @param {string[]} selectedMimeTypesFromCSV + * @param {{ value: string; text: string; mimeType: string; }[]} allTypes + */ +export function findFileTypeMappings(selectedMimeTypesFromCSV, allTypes) { + return selectedMimeTypesFromCSV + .map((currMimeType) => { + const found = allTypes.find((dt) => dt.mimeType === currMimeType) + return found ? found.text : undefined + }) + .filter((x) => typeof x === 'string') +} + +/** + * @param {string} types + * @returns {string} + */ +export function lookupFileTypes(types) { + const selectedMimeTypesFromCSV = types ? types.split(',') : [] + + const documentTypes = findFileTypeMappings( + selectedMimeTypesFromCSV, + allDocumentTypes + ) + + const imageTypes = findFileTypeMappings( + selectedMimeTypesFromCSV, + allImageTypes + ) + + const tabularDataTypes = findFileTypeMappings( + selectedMimeTypesFromCSV, + allTabularDataTypes + ) + + const totalTypes = documentTypes.concat(imageTypes).concat(tabularDataTypes) + + const lastItem = totalTypes.pop() + + if (!lastItem) { + return '[files types you accept]' + } + + const penultimate = totalTypes.pop() + + if (!penultimate) { + return lastItem + } + + return [...totalTypes, `${penultimate} or ${lastItem}`].join(', ') +} + /** * @param {ComponentDef} component * @param {string} type @@ -107,6 +165,11 @@ export function getFileLimits(component, type) { return getSchemaProperty(component, 'length', '[exact file count]') } + if (type === 'filesMimes') { + const accept = getOptionsProperty(component, 'accept', '') + return lookupFileTypes(typeof accept === 'string' ? accept : '') + } + return '[unknown]' } diff --git a/src/server/plugins/error-preview/error-preview-helper.test.js b/src/server/plugins/error-preview/error-preview-helper.test.js index 855062f6b..839723744 100644 --- a/src/server/plugins/error-preview/error-preview-helper.test.js +++ b/src/server/plugins/error-preview/error-preview-helper.test.js @@ -8,7 +8,8 @@ import { expandTemplate, getOptionsProperty, getSchemaProperty, - isTypeForMinMax + isTypeForMinMax, + lookupFileTypes } from '~/src/server/plugins/error-preview/error-preview-helper.js' import { componentId, definitionWithComponentId } from '~/test/fixtures/form.js' @@ -328,6 +329,20 @@ describe('Error preview helper', () => { expect(res).toBe(2) }) + it('should return correct limit for filesMimes', () => { + const component = /** @type {ComponentDef} */ ({ + name: 'abcdef', + title: 'Component title', + type: ComponentType.FileUploadField, + schema: {}, + options: { + accept: 'application/pdf' + } + }) + const res = determineLimit('filesMimes', component) + expect(res).toBe('PDF') + }) + it('should return unknown for invalid number type', () => { const component = /** @type {ComponentDef} */ ({ name: 'abcdef', @@ -461,6 +476,35 @@ describe('Error preview helper', () => { expect(res[2]).toBe('[short description] must be 30 characters or less') }) }) + + describe('lookupFileTypes', () => { + test('should ignore when not file upload Field', () => { + const res = lookupFileTypes('') + expect(res).toBe('[files types you accept]') + }) + + test('should handle doc types', () => { + const res = lookupFileTypes( + 'application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ) + expect(res).toBe('DOC or DOCX') + }) + + test('should handle image types', () => { + const res = lookupFileTypes('image/jpeg') + expect(res).toBe('JPG') + }) + + test('should handle tabular data types', () => { + const res = lookupFileTypes('text/csv') + expect(res).toBe('CSV') + }) + + test('should handle all types', () => { + const res = lookupFileTypes('text/csv,image/jpeg,application/msword') + expect(res).toBe('DOC, JPG or CSV') + }) + }) }) /** diff --git a/test/form/template.test.js b/test/form/template.test.js index 1507f7f8e..c29e4d024 100644 --- a/test/form/template.test.js +++ b/test/form/template.test.js @@ -137,9 +137,7 @@ describe('Form template journey', () => { expect($heading).toBeInTheDocument() const $errorItems = within($errorSummary).getAllByRole('listitem') - expect($errorItems[0]).toHaveTextContent( - 'Select are you in England, Enrique Chase?' - ) + expect($errorItems[0]).toHaveTextContent('Select yes or no') }) test('POST /are-you-in-england', async () => { From c6c395682c13f4136d2be4cc60495cd91d13b93b Mon Sep 17 00:00:00 2001 From: Chris Cole <56303993+whitewaterdesign@users.noreply.github.com> Date: Thu, 17 Apr 2025 08:54:16 +0100 Subject: [PATCH 15/20] Fix/526386 add short desc summary (#779) * feat: 537088 - Disable incorrect eslint rules * feat: 537088 - Use shortDescription for TextField label * stash * feat: 526386 - Show label for summary label * revert: 526386 - Revert ComponentBase change * revert: 526386 - Revert ComponentBase change 2 --------- Co-authored-by: Chris Cole --- .../engine/models/SummaryViewModel.test.ts | 48 +++++++++++++++++-- .../plugins/engine/models/SummaryViewModel.ts | 2 +- test/form/definitions/repeat-mixed.js | 1 + 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/server/plugins/engine/models/SummaryViewModel.test.ts b/src/server/plugins/engine/models/SummaryViewModel.test.ts index a1df5aa11..76f385948 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.test.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.test.ts @@ -54,7 +54,13 @@ describe('SummaryViewModel', () => { orderType: 'collection', pizza: [] } satisfies FormState, - keys: ['How would you like to receive your pizza?', 'Pizzas'], + keys: [ + 'How would you like to receive your pizza?', + 'Pizzas', + 'How you would like to receive your pizza', + 'Pizzas', + 'Pizza' + ], values: ['Collection', 'Not supplied'] }, { @@ -69,7 +75,13 @@ describe('SummaryViewModel', () => { } ] } satisfies FormState, - keys: ['How would you like to receive your pizza?', 'Pizza added'], + keys: [ + 'How would you like to receive your pizza?', + 'Pizza added', + 'How you would like to receive your pizza', + 'Pizzas', + 'Pizza' + ], values: ['Delivery', 'You added 1 Pizza'] }, { @@ -89,7 +101,13 @@ describe('SummaryViewModel', () => { } ] } satisfies FormState, - keys: ['How would you like to receive your pizza?', 'Pizzas added'], + keys: [ + 'How would you like to receive your pizza?', + 'Pizzas added', + 'How you would like to receive your pizza', + 'Pizzas', + 'Pizza' + ], values: ['Delivery', 'You added 2 Pizzas'] } ])('Check answers ($description)', ({ state, keys, values }) => { @@ -121,7 +139,7 @@ describe('SummaryViewModel', () => { expect(summaryList1).toHaveProperty('rows', [ { key: { - text: keys[0] + text: keys[2] }, value: { classes: 'app-prose-scope', @@ -178,7 +196,7 @@ describe('SummaryViewModel', () => { expect(summaryList1).toHaveProperty('rows', [ { key: { - text: keys[0] + text: keys[2] }, value: { classes: 'app-prose-scope', @@ -205,5 +223,25 @@ describe('SummaryViewModel', () => { } ]) }) + + it('should use correct summary labels', () => { + request.query.force = '' // Preview URL '?force' + context = model.getFormContext(request, state) + summaryViewModel = new SummaryViewModel(request, page, context) + + expect(summaryViewModel.details).toHaveLength(2) + + const [details1, details2] = summaryViewModel.details + + expect(details1.items[0]).toMatchObject({ + title: keys[2], + label: keys[0] + }) + + expect(details2.items[0]).toMatchObject({ + title: keys[1], + label: keys[4] + }) + }) }) }) diff --git a/src/server/plugins/engine/models/SummaryViewModel.ts b/src/server/plugins/engine/models/SummaryViewModel.ts index 190887fe2..8b0bccb10 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.ts @@ -211,7 +211,7 @@ function ItemField( return { name: field.name, label: field.title, - title: field.title, + title: field.label, error: field.getFirstError(options.errors), value: getAnswer(field, state), href: getPageHref(page, options.path, { diff --git a/test/form/definitions/repeat-mixed.js b/test/form/definitions/repeat-mixed.js index 7a7ecc292..3a8d266c0 100644 --- a/test/form/definitions/repeat-mixed.js +++ b/test/form/definitions/repeat-mixed.js @@ -15,6 +15,7 @@ export default /** @satisfies {FormDefinition} */ ({ { name: 'orderType', title: 'How would you like to receive your pizza?', + shortDescription: 'How you would like to receive your pizza', type: ComponentType.RadiosField, list: 'orderTypeOption', options: {} From 540fca43f58965b86c875b892a36376ece0f9ff2 Mon Sep 17 00:00:00 2001 From: Jez Barnsley <114290619+jbarnsley10@users.noreply.github.com> Date: Thu, 17 Apr 2025 13:52:01 +0100 Subject: [PATCH 16/20] Revert npm audit due to breaking changes (#781) --- package-lock.json | 155 ++++++++++++++++++++-------------------------- 1 file changed, 68 insertions(+), 87 deletions(-) diff --git a/package-lock.json b/package-lock.json index b5aaa8155..f3cb54d6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -222,11 +222,10 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.0.tgz", + "integrity": "sha512-INCKxTtbXtcNbUZ3YXutwMpEleqttcswhAdee7dhuoVrD2cnuc3PqtERBtxkX5nziX9vnBL8WXmSGwv8CuPV6g==", "dev": true, - "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", @@ -633,27 +632,25 @@ } }, "node_modules/@babel/helpers": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz", - "integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", + "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", "dev": true, - "license": "MIT", "dependencies": { - "@babel/template": "^7.27.0", - "@babel/types": "^7.27.0" + "@babel/template": "^7.25.9", + "@babel/types": "^7.26.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", - "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", + "version": "7.26.1", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.1.tgz", + "integrity": "sha512-reoQYNiAJreZNsJzyrDNzFQ+IQ5JFiIzAHJg9bn94S3l+4++J7RsIhNMoB+lgP/9tpmiAQqspv+xfdxTSzREOw==", "dev": true, - "license": "MIT", "dependencies": { - "@babel/types": "^7.27.0" + "@babel/types": "^7.26.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -1906,11 +1903,10 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", - "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.7.tgz", + "integrity": "sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==", "dev": true, - "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -1919,15 +1915,14 @@ } }, "node_modules/@babel/template": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", - "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", + "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", "dev": true, - "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.27.0", - "@babel/types": "^7.27.0" + "@babel/code-frame": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1952,11 +1947,10 @@ } }, "node_modules/@babel/types": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", - "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.0.tgz", + "integrity": "sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==", "dev": true, - "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" @@ -3004,6 +2998,17 @@ "node": ">=10" } }, + "node_modules/@hapi/scooter/node_modules/os-tmpdir": { + "version": "1.0.2", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@hapi/scooter/node_modules/pseudomap": { + "version": "1.0.2", + "license": "ISC" + }, "node_modules/@hapi/scooter/node_modules/semver": { "version": "7.6.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", @@ -3018,6 +3023,36 @@ "node": ">=10" } }, + "node_modules/@hapi/scooter/node_modules/tmp": { + "version": "0.0.33", + "license": "MIT", + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/@hapi/scooter/node_modules/useragent": { + "version": "2.3.0", + "license": "MIT", + "dependencies": { + "lru-cache": "4.1.x", + "tmp": "0.0.x" + } + }, + "node_modules/@hapi/scooter/node_modules/useragent/node_modules/lru-cache": { + "version": "4.1.5", + "license": "ISC", + "dependencies": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, + "node_modules/@hapi/scooter/node_modules/useragent/node_modules/yallist": { + "version": "2.1.2", + "license": "ISC" + }, "node_modules/@hapi/shot": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@hapi/shot/-/shot-6.0.1.tgz", @@ -6629,11 +6664,10 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", "dev": true, - "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -12486,15 +12520,6 @@ "node": ">= 0.8.0" } }, - "node_modules/os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/outdent": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.8.0.tgz", @@ -13738,12 +13763,6 @@ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, - "node_modules/pseudomap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", - "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==", - "license": "ISC" - }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -16036,18 +16055,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", - "license": "MIT", - "dependencies": { - "os-tmpdir": "~1.0.2" - }, - "engines": { - "node": ">=0.6.0" - } - }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -16375,32 +16382,6 @@ "punycode": "^2.1.0" } }, - "node_modules/useragent": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/useragent/-/useragent-2.3.0.tgz", - "integrity": "sha512-4AoH4pxuSvHCjqLO04sU6U/uE65BYza8l/KKBS0b0hnUPWi+cQ2BpeTEwejCSx9SPV5/U03nniDTrWx5NrmKdw==", - "license": "MIT", - "dependencies": { - "lru-cache": "4.1.x", - "tmp": "0.0.x" - } - }, - "node_modules/useragent/node_modules/lru-cache": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", - "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", - "license": "ISC", - "dependencies": { - "pseudomap": "^1.0.2", - "yallist": "^2.1.2" - } - }, - "node_modules/useragent/node_modules/yallist": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==", - "license": "ISC" - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", From e292556383e8a84beb97b45d625d7cebc087e466 Mon Sep 17 00:00:00 2001 From: Jez Barnsley <114290619+jbarnsley10@users.noreply.github.com> Date: Thu, 24 Apr 2025 10:59:28 +0100 Subject: [PATCH 17/20] Changed yes/no error message (#783) --- src/server/plugins/engine/components/YesNoField.test.ts | 2 +- .../plugins/engine/pageControllers/validationOptions.ts | 2 +- test/form/template.test.js | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/server/plugins/engine/components/YesNoField.test.ts b/src/server/plugins/engine/components/YesNoField.test.ts index ff739a154..e00498d38 100644 --- a/src/server/plugins/engine/components/YesNoField.test.ts +++ b/src/server/plugins/engine/components/YesNoField.test.ts @@ -121,7 +121,7 @@ describe('YesNoField', () => { expect(result.errors).toEqual([ expect.objectContaining({ - text: 'Select yes or no' + text: 'Example yes/no - select yes or no' }) ]) }) diff --git a/src/server/plugins/engine/pageControllers/validationOptions.ts b/src/server/plugins/engine/pageControllers/validationOptions.ts index 7e8079fbc..4c4575c58 100644 --- a/src/server/plugins/engine/pageControllers/validationOptions.ts +++ b/src/server/plugins/engine/pageControllers/validationOptions.ts @@ -28,7 +28,7 @@ export const messageTemplate: Record = { 'Select {{lowerFirst(#label)}}', opts ) as JoiExpression, - selectYesNoRequired: 'Select yes or no', + selectYesNoRequired: '{{#label}} - select yes or no', max: '{{#label}} must be {{#limit}} characters or less', min: '{{#label}} must be {{#limit}} characters or more', minMax: '{{#label}} must be between {{#min}} and {{#max}} characters', diff --git a/test/form/template.test.js b/test/form/template.test.js index c29e4d024..5d6817abf 100644 --- a/test/form/template.test.js +++ b/test/form/template.test.js @@ -137,7 +137,9 @@ describe('Form template journey', () => { expect($heading).toBeInTheDocument() const $errorItems = within($errorSummary).getAllByRole('listitem') - expect($errorItems[0]).toHaveTextContent('Select yes or no') + expect($errorItems[0]).toHaveTextContent( + 'Are you in England, Enrique Chase? - select yes or no' + ) }) test('POST /are-you-in-england', async () => { From 94ba2242d8678778a149b38af919911863dbbc64 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 24 Apr 2025 15:53:51 +0100 Subject: [PATCH 18/20] Add app-prose-scope class to the markdown component wrapper element (#784) --- src/server/plugins/engine/views/components/markdown.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/plugins/engine/views/components/markdown.html b/src/server/plugins/engine/views/components/markdown.html index 754bc5a08..fb014fa5d 100644 --- a/src/server/plugins/engine/views/components/markdown.html +++ b/src/server/plugins/engine/views/components/markdown.html @@ -1,5 +1,5 @@ {% macro Markdown(component) %} -
+
{{ component.model.content | markdown | safe }}
{% endmacro %} From c1e799a565492ee7c3ceca3301c88fa1b892af0f Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Mon, 28 Apr 2025 14:43:24 +0100 Subject: [PATCH 19/20] Feature 533156: Add /form prefix to routes & enforce strict path validation (#782) * Refactor: Update server routing and request handling - Changed Jest configuration to enable output logging. - Enhanced server request handling to redirect legacy paths for forms and previews, validating slugs and states against schemas. - Introduced a new proxy route for upload status checks with appropriate validation. - Updated engine plugin to ensure form paths are prefixed correctly. - Improved error handling for invalid paths and states, providing clearer responses. - Refactored path generation in PageController for consistency and clarity. * refactor: Removed unnecessary route prefix from plugin registration in the server setup. * refactor: Update plugin registration to spread plugin engine array and improve return type structure in configureEnginePlugin * refactor: refactored checkFormStatus() to dynamically calculate state index based on prefix depth * fix: checkFormStatus logic and tests * test: update base path in tests with form namespace prefix * test: add tests for onRequest hook legacy redirects * refactor: Removed redundant else block * test: added test for logger and boom error messages * Use prefix from server.realm * Simplify the checkFromStatus method * refactor: remove onRequest hook and simplify namespace changes * test: remove onRequest tests * test: fix feedback link tests * feat: add buildUploadStatusUrl function and refactor legacy redirect handling - Introduced to construct upload status URLs based on the current pathname and upload ID. - Refactored legacy redirect logic in the router to use a new helper function for improved readability and maintainability. - Added unit tests for to ensure correct URL construction across various scenarios. * test: legacy redirect routes * test: use form prefix var * refactor: move handler outside of options * fix: used checkFormStatus helper for preview mode --------- Co-authored-by: David Stone --- jest.config.cjs | 2 +- src/client/javascripts/file-upload.js | 19 ++- src/server/constants.js | 1 + src/server/index.test.ts | 63 ++++---- src/server/index.ts | 6 +- .../plugins/engine/configureEnginePlugin.ts | 25 ++- src/server/plugins/engine/helpers.test.ts | 91 ++++++++--- src/server/plugins/engine/helpers.ts | 38 ++--- .../engine/models/SummaryViewModel.test.ts | 5 +- .../pageControllers/PageController.test.ts | 15 +- .../engine/pageControllers/PageController.ts | 20 ++- .../pageControllers/SummaryPageController.ts | 5 +- src/server/plugins/engine/plugin.ts | 13 +- .../plugins/engine/services/notifyService.ts | 3 +- src/server/plugins/nunjucks/context.js | 8 +- src/server/plugins/router.ts | 67 ++++++++- test/client/javascripts/file-upload.test.js | 24 ++- test/condition/checkboxes.test.js | 3 +- test/condition/radios.test.js | 3 +- test/condition/text.test.js | 3 +- test/form/cookies.test.js | 16 +- test/form/csrf.test.js | 3 +- test/form/exit-page.test.js | 3 +- test/form/feedback.test.js | 8 +- test/form/fields-optional.test.js | 3 +- test/form/fields-required.test.js | 3 +- test/form/file-upload.test.js | 3 +- test/form/govuk-notify.test.js | 3 +- test/form/journey-basic.test.js | 5 +- test/form/legacy-redirects.test.js | 142 ++++++++++++++++++ test/form/persist-files.test.js | 3 +- test/form/phase-banner.test.js | 6 +- test/form/repeat.test.js | 20 ++- test/form/summary-submission-email.test.js | 3 +- test/form/template.test.js | 7 +- test/form/titles.test.js | 2 +- 36 files changed, 498 insertions(+), 146 deletions(-) create mode 100644 test/form/legacy-redirects.test.js diff --git a/jest.config.cjs b/jest.config.cjs index dec81f2d1..3ea0fe28c 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -9,7 +9,7 @@ module.exports = { resetModules: true, restoreMocks: true, clearMocks: true, - silent: true, + silent: false, testMatch: [ '/src/**/*.test.{cjs,js,mjs,ts}', '/test/**/*.test.{cjs,js,mjs,ts}' diff --git a/src/client/javascripts/file-upload.js b/src/client/javascripts/file-upload.js index bae589cc8..47cc5077d 100644 --- a/src/client/javascripts/file-upload.js +++ b/src/client/javascripts/file-upload.js @@ -228,6 +228,18 @@ function reloadPage() { window.location.href = window.location.pathname } +/** + * Build the upload status URL given the current pathname and the upload ID. + * @param {string} pathname – e.g. window.location.pathname + * @param {string} uploadId + * @returns {string} e.g. "/form/upload-status/abc123" + */ +export function buildUploadStatusUrl(pathname, uploadId) { + const pathSegments = pathname.split('/').filter((segment) => segment) + const prefix = pathSegments.length > 0 ? `/${pathSegments[0]}` : '' + return `${prefix}/upload-status/${uploadId}` +} + /** * Polls the upload status endpoint until the file is ready or timeout occurs * @param {string} uploadId - The upload ID to check @@ -243,7 +255,12 @@ function pollUploadStatus(uploadId) { return } - fetch(`/upload-status/${uploadId}`, { + const uploadStatusUrl = buildUploadStatusUrl( + window.location.pathname, + uploadId + ) + + fetch(uploadStatusUrl, { headers: { Accept: 'application/json' } diff --git a/src/server/constants.js b/src/server/constants.js index ee4ab4305..39af41be1 100644 --- a/src/server/constants.js +++ b/src/server/constants.js @@ -1,2 +1,3 @@ export const PREVIEW_PATH_PREFIX = '/preview' export const ERROR_PREVIEW_PATH_PREFIX = '/error-preview' +export const FORM_PREFIX = '/form' diff --git a/src/server/index.test.ts b/src/server/index.test.ts index d4f108397..312e78065 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -1,6 +1,7 @@ import { type Server } from '@hapi/hapi' import { StatusCodes } from 'http-status-codes' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { getFormDefinition, @@ -54,13 +55,13 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/slug' + url: `${FORM_PREFIX}/slug` } const res = await server.inject(options) expect(res.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) - expect(res.headers.location).toBe('/slug/page-one') + expect(res.headers.location).toBe(`${FORM_PREFIX}/slug/page-one`) expect(getCacheSize()).toBe(1) }) @@ -74,13 +75,15 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/preview/live/slug' + url: `${FORM_PREFIX}/preview/live/slug` } const res = await server.inject(options) expect(res.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) - expect(res.headers.location).toBe('/preview/live/slug/page-one') + expect(res.headers.location).toBe( + `${FORM_PREFIX}/preview/live/slug/page-one` + ) expect(getCacheSize()).toBe(1) }) @@ -94,13 +97,15 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/preview/draft/slug' + url: `${FORM_PREFIX}/preview/draft/slug` } const res = await server.inject(options) expect(res.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) - expect(res.headers.location).toBe('/preview/draft/slug/page-one') + expect(res.headers.location).toBe( + `${FORM_PREFIX}/preview/draft/slug/page-one` + ) expect(getCacheSize()).toBe(1) }) @@ -114,7 +119,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/slug/page-one' + url: `${FORM_PREFIX}/slug/page-one` } const res = await server.inject(options) @@ -133,7 +138,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/preview/live/slug/page-one' + url: `${FORM_PREFIX}/preview/live/slug/page-one` } const res = await server.inject(options) @@ -152,7 +157,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/preview/draft/slug/page-one' + url: `${FORM_PREFIX}/preview/draft/slug/page-one` } const res = await server.inject(options) @@ -171,7 +176,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/slug/page-one' + url: `${FORM_PREFIX}/slug/page-one` } const res = await server.inject(options) @@ -192,7 +197,7 @@ describe('Model cache', () => { // Populate live/live cache item const options1 = { method: 'GET', - url: '/slug/page-one' + url: `${FORM_PREFIX}/slug/page-one` } const res1 = await server.inject(options1) @@ -203,7 +208,7 @@ describe('Model cache', () => { // Populate live/preview cache item const options2 = { method: 'GET', - url: '/preview/live/slug/page-one' + url: `${FORM_PREFIX}/preview/live/slug/page-one` } const res2 = await server.inject(options2) @@ -214,7 +219,7 @@ describe('Model cache', () => { // Populate draft/preview cache item const options3 = { method: 'GET', - url: '/preview/draft/slug/page-one' + url: `${FORM_PREFIX}/preview/draft/slug/page-one` } const res3 = await server.inject(options3) @@ -266,7 +271,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/slug' + url: `${FORM_PREFIX}/slug` } const res = await server.inject(options) @@ -280,7 +285,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/preview/draft/slug' + url: `${FORM_PREFIX}/preview/draft/slug` } const res = await server.inject(options) @@ -294,7 +299,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/preview/live/slug' + url: `${FORM_PREFIX}/preview/live/slug` } const res = await server.inject(options) @@ -312,7 +317,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/slug' + url: `${FORM_PREFIX}/slug` } const res = await server.inject(options) @@ -330,7 +335,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/preview/draft/slug' + url: `${FORM_PREFIX}/preview/draft/slug` } const res = await server.inject(options) @@ -348,7 +353,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/preview/live/slug' + url: `${FORM_PREFIX}/preview/live/slug` } const res = await server.inject(options) @@ -362,7 +367,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/slug/page-one' + url: `${FORM_PREFIX}/slug/page-one` } const res = await server.inject(options) @@ -376,7 +381,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/preview/draft/slug/page-one' + url: `${FORM_PREFIX}/preview/draft/slug/page-one` } const res = await server.inject(options) @@ -390,7 +395,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/preview/live/slug/page-one' + url: `${FORM_PREFIX}/preview/live/slug/page-one` } const res = await server.inject(options) @@ -408,7 +413,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/slug/page-one' + url: `${FORM_PREFIX}/slug/page-one` } const res = await server.inject(options) @@ -426,7 +431,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/preview/draft/slug/page-one' + url: `${FORM_PREFIX}/preview/draft/slug/page-one` } const res = await server.inject(options) @@ -444,7 +449,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/preview/live/slug/page-one' + url: `${FORM_PREFIX}/preview/live/slug/page-one` } const res = await server.inject(options) @@ -527,7 +532,7 @@ describe('Upload status route', () => { const options = { method: 'GET', - url: '/upload-status/123e4567-e89b-12d3-a456-426614174000' + url: `${FORM_PREFIX}/upload-status/123e4567-e89b-12d3-a456-426614174000` } const res = await server.inject(options) @@ -544,7 +549,7 @@ describe('Upload status route', () => { const options = { method: 'GET', - url: '/upload-status/123e4567-e89b-12d3-a456-426614174000' + url: `${FORM_PREFIX}/upload-status/123e4567-e89b-12d3-a456-426614174000` } const res = await server.inject(options) @@ -560,7 +565,7 @@ describe('Upload status route', () => { const options = { method: 'GET', - url: '/upload-status/123e4567-e89b-12d3-a456-426614174000' + url: `${FORM_PREFIX}/upload-status/123e4567-e89b-12d3-a456-426614174000` } const res = await server.inject(options) @@ -572,7 +577,7 @@ describe('Upload status route', () => { test('GET /upload-status/{uploadId} returns 400 for invalid uploadId format', async () => { const options = { method: 'GET', - url: '/upload-status/not-a-valid-guid' + url: `${FORM_PREFIX}/upload-status/not-a-valid-guid` } const res = await server.inject(options) diff --git a/src/server/index.ts b/src/server/index.ts index 53af3e29a..f46cabe83 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -98,6 +98,10 @@ export async function createServer(routeConfig?: RouteConfig) { server.registerService(CacheService) + await server.register(...pluginEngine) + + await server.register(pluginRouter) + server.ext('onPreResponse', (request: Request, h: ResponseToolkit) => { const { response } = request @@ -121,8 +125,6 @@ export async function createServer(routeConfig?: RouteConfig) { }) await server.register(pluginViews) - await server.register(pluginEngine) - await server.register(pluginRouter) await server.register(pluginErrorPages) await server.register(blipp) await server.register(requestTracing) diff --git a/src/server/plugins/engine/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts index c183f58ab..c8da9698c 100644 --- a/src/server/plugins/engine/configureEnginePlugin.ts +++ b/src/server/plugins/engine/configureEnginePlugin.ts @@ -1,8 +1,8 @@ import { join, parse } from 'node:path' import { type FormDefinition } from '@defra/forms-model' -import { type ServerRegisterPluginObject } from '@hapi/hapi' +import { FORM_PREFIX } from '~/src/server/constants.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { plugin, @@ -15,20 +15,31 @@ export const configureEnginePlugin = async ({ formFilePath, services, controllers -}: RouteConfig = {}): Promise> => { +}: RouteConfig = {}): Promise< + [ + { plugin: typeof plugin; options: PluginOptions }, + { routes: { prefix: string } } + ] +> => { let model: FormModel | undefined if (formFileName && formFilePath) { const definition = await getForm(join(formFilePath, formFileName)) const { name } = parse(formFileName) - model = new FormModel(definition, { basePath: name }, services, controllers) - } + const initialBasePath = `${FORM_PREFIX.substring(1)}/${name}` - return { - plugin, - options: { model, services, controllers } + model = new FormModel( + definition, + { basePath: initialBasePath }, + services, + controllers + ) } + + const pluginObject = { plugin, options: { model, services, controllers } } + const routeOptions = { routes: { prefix: FORM_PREFIX } } + return [pluginObject, routeOptions] } export async function getForm(importPath: string) { diff --git a/src/server/plugins/engine/helpers.test.ts b/src/server/plugins/engine/helpers.test.ts index f2649c230..f7432a354 100644 --- a/src/server/plugins/engine/helpers.test.ts +++ b/src/server/plugins/engine/helpers.test.ts @@ -3,7 +3,6 @@ import { type ResponseObject, type ResponseToolkit } from '@hapi/hapi' import { StatusCodes } from 'http-status-codes' import { ValidationError } from 'joi' -import { PREVIEW_PATH_PREFIX } from '~/src/server/constants.js' import { checkEmailAddressForLiveFormSubmission, checkFormStatus, @@ -17,6 +16,7 @@ import { safeGenerateCrumb, type GlobalScope } from '~/src/server/plugins/engine/helpers.js' +import { handleLegacyRedirect } from '~/src/server/plugins/engine/helpers.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { createPage, @@ -311,35 +311,42 @@ describe('Helpers', () => { }) describe('checkFormStatus', () => { - it('should return true/live for paths starting with PREVIEW_PATH_PREFIX and form is live', () => { - const path = `${PREVIEW_PATH_PREFIX}/live/another/segment` - expect(checkFormStatus(path)).toStrictEqual({ + it('should return true/live for params that include live state segment', () => { + expect( + checkFormStatus({ + state: FormStatus.Live, + slug: 'another', + path: 'segment' + }) + ).toStrictEqual({ state: FormStatus.Live, isPreview: true }) }) - it('should return false for paths not starting with PREVIEW_PATH_PREFIX', () => { - const path = '/some/other/path' - expect(checkFormStatus(path)).toStrictEqual({ - state: FormStatus.Live, - isPreview: false - }) - }) - - it('should be case insensitive and return draft when form is draft', () => { - const path = `${PREVIEW_PATH_PREFIX.toUpperCase()}/draft/path` - expect(checkFormStatus(path)).toStrictEqual({ + it('should return true/draft for params that include draft state segment', () => { + expect( + checkFormStatus({ + state: FormStatus.Draft, + slug: 'another', + path: 'segment' + }) + ).toStrictEqual({ state: FormStatus.Draft, isPreview: true }) }) - it('should throw an error for invalid form state', () => { - const path = `${PREVIEW_PATH_PREFIX}/invalid-state` - expect(() => checkFormStatus(path)).toThrow( - 'Invalid form state: invalid-state' - ) + it('should return false/live for paths without a state segment', () => { + expect( + checkFormStatus({ + slug: 'some', + path: 'other' + }) + ).toStrictEqual({ + state: FormStatus.Live, + isPreview: false + }) }) }) @@ -788,4 +795,48 @@ describe('Helpers', () => { }) }) }) + + describe('handleLegacyRedirect', () => { + let mockH: jest.Mocked> + let mockRedirectResponse: jest.Mocked< + ReturnType + > + + beforeEach(() => { + mockRedirectResponse = { + permanent: jest.fn().mockReturnThis(), + takeover: jest.fn().mockReturnThis() + } as unknown as jest.Mocked> + + mockH = { + redirect: jest.fn().mockReturnValue(mockRedirectResponse) + } + }) + + it('should call h.redirect with the target URL', () => { + const targetUrl = '/another/target' + handleLegacyRedirect(mockH as unknown as ResponseToolkit, targetUrl) + + expect(mockH.redirect).toHaveBeenCalledTimes(1) + expect(mockH.redirect).toHaveBeenCalledWith(targetUrl) + }) + + it('should call permanent() and takeover() on the redirect response', () => { + const targetUrl = '/final/destination' + handleLegacyRedirect(mockH as unknown as ResponseToolkit, targetUrl) + + expect(mockRedirectResponse.permanent).toHaveBeenCalledTimes(1) + expect(mockRedirectResponse.takeover).toHaveBeenCalledTimes(1) + }) + + it('should return the final response object from takeover()', () => { + const targetUrl = '/the/end' + const response = handleLegacyRedirect( + mockH as unknown as ResponseToolkit, + targetUrl + ) + + expect(response).toBe(mockRedirectResponse) + }) + }) }) diff --git a/src/server/plugins/engine/helpers.ts b/src/server/plugins/engine/helpers.ts index c8403f9a1..df7c45281 100644 --- a/src/server/plugins/engine/helpers.ts +++ b/src/server/plugins/engine/helpers.ts @@ -12,7 +12,6 @@ import { type Schema, type ValidationErrorItem } from 'joi' import { Liquid } from 'liquidjs' import { createLogger } from '~/src/server/common/helpers/logging/logger.js' -import { PREVIEW_PATH_PREFIX } from '~/src/server/constants.js' import { getAnswer, type Field @@ -27,6 +26,7 @@ import { import { FormAction, FormStatus, + type FormParams, type FormQuery, type FormRequest, type FormRequestPayload @@ -253,29 +253,18 @@ export function getStartPath(model?: FormModel) { return startPath ? `/${startPath}` : ControllerPath.Start } -export function checkFormStatus(path: string) { - const isPreview = path.toLowerCase().startsWith(PREVIEW_PATH_PREFIX) +export function checkFormStatus(params?: FormParams) { + const isPreview = !!params?.state - let state: FormStatus | undefined + let state = FormStatus.Live - if (isPreview) { - const previewState = path.split('/')[2] - - for (const formState of Object.values(FormStatus)) { - if (previewState === formState.toString()) { - state = formState - break - } - } - - if (!state) { - throw new Error(`Invalid form state: ${previewState}`) - } + if (isPreview && params.state === FormStatus.Draft) { + state = FormStatus.Draft } return { isPreview, - state: state ?? FormStatus.Live + state } } @@ -337,7 +326,7 @@ export function safeGenerateCrumb( return undefined } - // crumb plugin or its generate method doesn’t exist + // crumb plugin or its generate method doesn't exist if (!request.server.plugins.crumb.generate) { return undefined } @@ -362,6 +351,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 @@ -377,3 +367,13 @@ export function evaluateTemplate( globals }) } + +/** + * Handles logging and issuing a permanent redirect for legacy routes. + * @param h - The Hapi response toolkit. + * @param targetUrl - The URL to redirect to. + * @returns The Hapi response object configured for permanent redirect. + */ +export function handleLegacyRedirect(h: ResponseToolkit, targetUrl: string) { + return h.redirect(targetUrl).permanent().takeover() +} diff --git a/src/server/plugins/engine/models/SummaryViewModel.test.ts b/src/server/plugins/engine/models/SummaryViewModel.test.ts index 76f385948..c06132227 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.test.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.test.ts @@ -1,3 +1,4 @@ +import { FORM_PREFIX } from '~/src/server/constants.js' import { FormModel, SummaryViewModel @@ -13,7 +14,7 @@ import { } from '~/src/server/plugins/engine/types.js' import definition from '~/test/form/definitions/repeat-mixed.js' -const basePath = '/test' +const basePath = `${FORM_PREFIX}/test` describe('SummaryViewModel', () => { const itemId1 = 'abc-123' @@ -28,7 +29,7 @@ describe('SummaryViewModel', () => { beforeEach(() => { model = new FormModel(definition, { - basePath: 'test' + basePath: `${FORM_PREFIX}/test` }) page = createPage(model, definition.pages[2]) diff --git a/src/server/plugins/engine/pageControllers/PageController.test.ts b/src/server/plugins/engine/pageControllers/PageController.test.ts index 9608f9996..5eb841f06 100644 --- a/src/server/plugins/engine/pageControllers/PageController.test.ts +++ b/src/server/plugins/engine/pageControllers/PageController.test.ts @@ -1,5 +1,6 @@ import { type ResponseToolkit } from '@hapi/hapi' +import { FORM_PREFIX } from '~/src/server/constants.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js' import { type FormRequest } from '~/src/server/routes/types.js' @@ -10,6 +11,8 @@ describe('PageController', () => { let controller1: PageController let controller2: PageController + const testBasePath = `${FORM_PREFIX}/test` + beforeEach(() => { const { pages } = definition @@ -17,7 +20,7 @@ describe('PageController', () => { const page2 = pages[1] model = new FormModel(definition, { - basePath: 'test' + basePath: testBasePath }) controller1 = new PageController(model, page1) @@ -31,8 +34,8 @@ describe('PageController', () => { }) it('returns href', () => { - expect(controller1).toHaveProperty('href', '/test/licence') - expect(controller2).toHaveProperty('href', '/test/full-name') + expect(controller1).toHaveProperty('href', `${testBasePath}/licence`) + expect(controller2).toHaveProperty('href', `${testBasePath}/full-name`) }) it('returns keys (empty)', () => { @@ -99,11 +102,11 @@ describe('PageController', () => { describe('Path methods', () => { describe('Link href', () => { it('prefixes paths into link hrefs', () => { - const href1 = controller1.getHref('/') + const href1 = controller1.getHref('') const href2 = controller1.getHref('/page-one') - expect(href1).toBe('/test') - expect(href2).toBe('/test/page-one') + expect(href1).toBe(testBasePath) + expect(href2).toBe(`${testBasePath}/page-one`) }) }) diff --git a/src/server/plugins/engine/pageControllers/PageController.ts b/src/server/plugins/engine/pageControllers/PageController.ts index 52180f168..93925cecb 100644 --- a/src/server/plugins/engine/pageControllers/PageController.ts +++ b/src/server/plugins/engine/pageControllers/PageController.ts @@ -135,12 +135,22 @@ export class PageController { return def.phaseBanner?.phase } - getHref(path: string) { - const { model } = this + getHref(path: string): string { + const basePath = this.model.basePath - return path === '/' - ? `/${model.basePath}` // Strip trailing slash - : `/${model.basePath}${path}` + if (path === '/') { + return `/${basePath}` + } + + // if ever the path is not prefixed with a slash, add it + const relativeTargetPath = path.startsWith('/') ? path.substring(1) : path + let finalPath = `/${basePath}` + if (relativeTargetPath) { + finalPath += `/${relativeTargetPath}` + } + finalPath = finalPath.replace(/\/{2,}/g, '/') + + return finalPath } getStartPath() { diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index 6c5add81f..fa8a8f4d1 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -111,7 +111,7 @@ export class SummaryPageController extends QuestionPageController { // Get the form metadata using the `slug` param const { notificationEmail } = await getFormMetadata(params.slug) - const { isPreview } = checkFormStatus(request.path) + const { isPreview } = checkFormStatus(request.params) const emailAddress = notificationEmail ?? this.model.def.outputEmail checkEmailAddressForLiveFormSubmission(emailAddress, isPreview) @@ -153,8 +153,7 @@ async function submitForm( ) { await extendFileRetention(model, state, emailAddress) - const { path } = request - const formStatus = checkFormStatus(path) + const formStatus = checkFormStatus(request.params) const logTags = ['submit', 'submissionApi'] request.logger.info(logTags, 'Preparing email', formStatus) diff --git a/src/server/plugins/engine/plugin.ts b/src/server/plugins/engine/plugin.ts index 7f7a89b87..93fe6fab2 100644 --- a/src/server/plugins/engine/plugin.ts +++ b/src/server/plugins/engine/plugin.ts @@ -61,6 +61,7 @@ export const plugin = { dependencies: '@hapi/vision', multiple: true, register(server, options) { + const prefix = server.realm.modifiers.route.prefix const { model, services = defaultServices, controllers } = options const { formsService } = services @@ -81,9 +82,9 @@ export const plugin = { return h.continue } - const { params, path } = request + const { params } = request const { slug } = params - const { isPreview, state: formState } = checkFormStatus(path) + const { isPreview, state: formState } = checkFormStatus(params) // Get the form metadata using the `slug` param const metadata = await formsService.getFormMetadata(slug) @@ -129,9 +130,11 @@ export const plugin = { ) // Set up the basePath for the model - const basePath = isPreview - ? `${PREVIEW_PATH_PREFIX.substring(1)}/${formState}/${slug}` - : slug + const basePath = ( + isPreview + ? `${prefix}${PREVIEW_PATH_PREFIX}/${formState}/${slug}` + : `${prefix}/${slug}` + ).substring(1) // Construct the form model const model = new FormModel( diff --git a/src/server/plugins/engine/services/notifyService.ts b/src/server/plugins/engine/services/notifyService.ts index 85c07651c..0feb665b4 100644 --- a/src/server/plugins/engine/services/notifyService.ts +++ b/src/server/plugins/engine/services/notifyService.ts @@ -19,8 +19,7 @@ export async function submit( submitResponse: SubmitResponsePayload ) { const logTags = ['submit', 'email'] - const { path } = request - const formStatus = checkFormStatus(path) + const formStatus = checkFormStatus(request.params) // Get submission email personalisation request.logger.info(logTags, 'Getting personalisation data') diff --git a/src/server/plugins/nunjucks/context.js b/src/server/plugins/nunjucks/context.js index dddf63255..e2ecccba8 100644 --- a/src/server/plugins/nunjucks/context.js +++ b/src/server/plugins/nunjucks/context.js @@ -8,8 +8,8 @@ import pkg from '~/package.json' with { type: 'json' } import { parseCookieConsent } from '~/src/common/cookies.js' import { config } from '~/src/config/index.js' import { createLogger } from '~/src/server/common/helpers/logging/logger.js' -import { PREVIEW_PATH_PREFIX } from '~/src/server/constants.js' import { + checkFormStatus, encodeUrl, safeGenerateCrumb } from '~/src/server/plugins/engine/helpers.js' @@ -34,10 +34,10 @@ export function context(request) { } } - const { params, path, query = {}, response, state } = request ?? {} + const { params, query = {}, response, state } = request ?? {} const isForceAccess = 'force' in query - const isPreviewMode = path?.startsWith(PREVIEW_PATH_PREFIX) + const { isPreview: isPreviewMode, state: formState } = checkFormStatus(params) // Only add the slug in to the context if the response is OK. // Footer meta links are not rendered when the slug is missing. @@ -60,7 +60,7 @@ export function context(request) { crumb: safeGenerateCrumb(request), cspNonce: request?.plugins.blankie?.nonces?.script, currentPath: request ? `${request.path}${request.url.search}` : undefined, - previewMode: isPreviewMode ? params?.state : undefined, + previewMode: isPreviewMode ? formState : undefined, slug: isResponseOK ? params?.slug : undefined, getAssetPath: (asset = '') => { diff --git a/src/server/plugins/router.ts b/src/server/plugins/router.ts index 05b9ef664..638e06357 100644 --- a/src/server/plugins/router.ts +++ b/src/server/plugins/router.ts @@ -1,6 +1,11 @@ import { slugSchema } from '@defra/forms-model' import Boom from '@hapi/boom' -import { type ServerRegisterPluginObject } from '@hapi/hapi' +import { + type Request, + type ResponseToolkit, + type ServerRegisterPluginObject, + type ServerRoute +} from '@hapi/hapi' import humanizeDuration from 'humanize-duration' import Joi from 'joi' @@ -11,7 +16,11 @@ import { } from '~/src/common/cookies.js' import { type CookieConsent } from '~/src/common/types.js' import { config } from '~/src/config/index.js' -import { isPathRelative } from '~/src/server/plugins/engine/helpers.js' +import { FORM_PREFIX } from '~/src/server/constants.js' +import { + handleLegacyRedirect, + isPathRelative +} from '~/src/server/plugins/engine/helpers.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' import { getErrorPreviewHandler } from '~/src/server/plugins/error-preview/error-preview.js' import { healthRoute, publicRoutes } from '~/src/server/routes/index.js' @@ -22,7 +31,7 @@ import { stateSchema } from '~/src/server/schemas/index.js' -const routes = [...publicRoutes, healthRoute] +const routes: ServerRoute[] = [...publicRoutes, healthRoute] export default { plugin: { @@ -30,6 +39,58 @@ export default { register: (server) => { server.route(routes) + // /preview/{state}/{slug} -> {FORM_PREFIX}/preview/{state}/{slug} + server.route({ + method: 'GET', + path: '/preview/{state}/{slug}', + handler: (request: Request, h: ResponseToolkit) => { + const { state, slug } = request.params + const { error: stateError } = stateSchema.validate(state) + const { error: slugError } = slugSchema.validate(slug) + + if (stateError || slugError) { + throw Boom.notFound() + } + + const targetUrl = `${FORM_PREFIX}${request.path}` + return handleLegacyRedirect(h, targetUrl) + } + }) + + // /{slug}/{path*} -> {FORM_PREFIX}/{slug}/{path*} + server.route({ + method: 'GET', + path: '/{slug}/{path*}', + handler: (request: Request, h: ResponseToolkit) => { + const { slug } = request.params + const { error } = slugSchema.validate(slug) + + if (error) { + throw Boom.notFound() + } + + const targetUrl = `${FORM_PREFIX}${request.path}` + return handleLegacyRedirect(h, targetUrl) + } + }) + + // /{slug} -> {FORM_PREFIX}/{slug} + server.route({ + method: 'GET', + path: '/{slug}', + handler: (request: Request, h: ResponseToolkit) => { + const { slug } = request.params + const { error } = slugSchema.validate(slug) + + if (error) { + throw Boom.notFound() + } + // Note: Target URL is slightly different for this specific route + const targetUrl = `${FORM_PREFIX}/${slug}` + return handleLegacyRedirect(h, targetUrl) + } + }) + // Shared help routes params schema & options const params = Joi.object() .keys({ diff --git a/test/client/javascripts/file-upload.test.js b/test/client/javascripts/file-upload.test.js index d85d4b958..1070f4bc6 100644 --- a/test/client/javascripts/file-upload.test.js +++ b/test/client/javascripts/file-upload.test.js @@ -1,4 +1,7 @@ -import { initFileUpload } from '~/src/client/javascripts/file-upload.js' +import { + buildUploadStatusUrl, + initFileUpload +} from '~/src/client/javascripts/file-upload.js' describe('File Upload Client JS', () => { beforeEach(() => { @@ -1299,3 +1302,22 @@ describe('File Upload Client JS', () => { expect(fileInput?.hasAttribute('aria-describedby')).toBe(false) }) }) + +describe('buildUploadStatusUrl()', () => { + it('builds URL with no prefix for root paths', () => { + expect(buildUploadStatusUrl('/', 'abc')).toBe('/upload-status/abc') + expect(buildUploadStatusUrl('', 'xyz')).toBe('/upload-status/xyz') + }) + + it('uses the first segment as prefix', () => { + expect(buildUploadStatusUrl('/form/mypage', 'id1')).toBe( + '/form/upload-status/id1' + ) + }) + + it('trims nested segments and trailing slashes', () => { + expect(buildUploadStatusUrl('/one/two/three/', 'id2')).toBe( + '/one/upload-status/id2' + ) + }) +}) diff --git a/test/condition/checkboxes.test.js b/test/condition/checkboxes.test.js index dfcbf4eda..4e3f943f3 100644 --- a/test/condition/checkboxes.test.js +++ b/test/condition/checkboxes.test.js @@ -2,12 +2,13 @@ import { resolve } from 'node:path' import { StatusCodes } from 'http-status-codes' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' -const basePath = '/checkboxes' +const basePath = `${FORM_PREFIX}/checkboxes` const key = 'wqJmSf' jest.mock('~/src/server/plugins/engine/services/formsService.js') diff --git a/test/condition/radios.test.js b/test/condition/radios.test.js index 3730aecd9..4290eab42 100644 --- a/test/condition/radios.test.js +++ b/test/condition/radios.test.js @@ -2,12 +2,13 @@ import { resolve } from 'node:path' import { StatusCodes } from 'http-status-codes' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' -const basePath = '/radios' +const basePath = `${FORM_PREFIX}/radios` const key = 'wqJmSf' jest.mock('~/src/server/plugins/engine/services/formsService.js') diff --git a/test/condition/text.test.js b/test/condition/text.test.js index e7ded99be..473895757 100644 --- a/test/condition/text.test.js +++ b/test/condition/text.test.js @@ -2,12 +2,13 @@ import { resolve } from 'node:path' import { StatusCodes } from 'http-status-codes' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' -const basePath = '/text' +const basePath = `${FORM_PREFIX}/text` const key = 'wqJmSf' jest.mock('~/src/server/plugins/engine/services/formsService.js') diff --git a/test/form/cookies.test.js b/test/form/cookies.test.js index c32d95ebf..12fa6f438 100644 --- a/test/form/cookies.test.js +++ b/test/form/cookies.test.js @@ -3,6 +3,7 @@ import { join } from 'node:path' import { within } from '@testing-library/dom' import { StatusCodes } from 'http-status-codes' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' import * as fixtures from '~/test/fixtures/index.js' @@ -24,8 +25,8 @@ describe(`Cookie banner and analytics`, () => { }) test.each([ - '/basic/licence', // form pages - '/help/accessibility-statement/basic' // non-form pages + `${FORM_PREFIX}/basic/licence`, // Form pages HAVE the prefix + '/help/accessibility-statement/basic' // Help pages DO NOT have the prefix ])('shows the cookie banner by default', async (path) => { server = await createServer({ formFileName: 'basic.js', @@ -53,10 +54,8 @@ describe(`Cookie banner and analytics`, () => { }) test.each([ - // form pages - '/basic/licence', - // non-form pages - '/help/accessibility-statement/basic' + `${FORM_PREFIX}/basic/licence`, // Form pages HAVE the prefix + '/help/accessibility-statement/basic' // Help pages DO NOT have the prefix ])('confirms when the user has accepted analytics cookies', async (path) => { server = await createServer({ formFileName: 'basic.js', @@ -106,7 +105,7 @@ describe(`Cookie banner and analytics`, () => { test.each([ // form pages - '/basic/licence', + `${FORM_PREFIX}/basic/licence`, // non-form pages '/help/accessibility-statement/basic' ])('confirms when the user has rejected analytics cookies', async (path) => { @@ -159,9 +158,8 @@ describe(`Cookie banner and analytics`, () => { test.each([ // form pages - '/basic/start', + `${FORM_PREFIX}/basic/start` // non-form pages - '/' ])('hides the cookie banner once dismissed', async (path) => { server = await createServer({ formFileName: 'basic.js', diff --git a/test/form/csrf.test.js b/test/form/csrf.test.js index 24078407e..3ab3fd959 100644 --- a/test/form/csrf.test.js +++ b/test/form/csrf.test.js @@ -2,13 +2,14 @@ import { join } from 'node:path' import { StatusCodes } from 'http-status-codes' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' import { getCookie } from '~/test/utils/get-cookie.js' -const basePath = '/basic' +const basePath = `${FORM_PREFIX}/basic` jest.mock('~/src/server/plugins/engine/services/formsService.js') diff --git a/test/form/exit-page.test.js b/test/form/exit-page.test.js index d37707d75..f37c76ca7 100644 --- a/test/form/exit-page.test.js +++ b/test/form/exit-page.test.js @@ -2,13 +2,14 @@ import { join } from 'node:path' import { StatusCodes } from 'http-status-codes' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' import { getCookie, getCookieHeader } from '~/test/utils/get-cookie.js' -const basePath = '/demo-cph-number' +const basePath = `${FORM_PREFIX}/demo-cph-number` jest.mock('~/src/server/utils/notify.ts') jest.mock('~/src/server/plugins/engine/services/formsService.js') diff --git a/test/form/feedback.test.js b/test/form/feedback.test.js index 894484bb3..0aa26f797 100644 --- a/test/form/feedback.test.js +++ b/test/form/feedback.test.js @@ -1,12 +1,13 @@ import { join } from 'node:path' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' const { FEEDBACK_LINK } = process.env -const basePath = '/feedback' +const basePath = `${FORM_PREFIX}/feedback` jest.mock('~/src/server/plugins/engine/services/formsService.js') @@ -34,8 +35,7 @@ describe('Feedback link', () => { it.each([ { - // Default feedback link - url: '/help/cookies', + url: `${FORM_PREFIX}/help/cookies`, name: 'give your feedback (opens in new tab)', href: FEEDBACK_LINK }, @@ -58,7 +58,7 @@ describe('Feedback link', () => { expect($link).toHaveAttribute('href', href) expect($link).toHaveClass('govuk-link') - expect($phaseBanner).toHaveAttribute('class', 'govuk-phase-banner') + expect($phaseBanner).toBeInTheDocument() expect($phaseBanner).toContainElement($link) }) }) diff --git a/test/form/fields-optional.test.js b/test/form/fields-optional.test.js index ba8c4b5aa..7da398a31 100644 --- a/test/form/fields-optional.test.js +++ b/test/form/fields-optional.test.js @@ -2,13 +2,14 @@ import { join } from 'node:path' import { StatusCodes } from 'http-status-codes' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' import { getCookie, getCookieHeader } from '~/test/utils/get-cookie.js' -const basePath = '/fields-optional' +const basePath = `${FORM_PREFIX}/fields-optional` jest.mock('~/src/server/plugins/engine/services/formsService.js') diff --git a/test/form/fields-required.test.js b/test/form/fields-required.test.js index 9ee39dda5..802ebe0be 100644 --- a/test/form/fields-required.test.js +++ b/test/form/fields-required.test.js @@ -3,13 +3,14 @@ import { join } from 'node:path' import { within } from '@testing-library/dom' import { StatusCodes } from 'http-status-codes' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' import { getCookie, getCookieHeader } from '~/test/utils/get-cookie.js' -const basePath = '/fields-required' +const basePath = `${FORM_PREFIX}/fields-required` jest.mock('~/src/server/plugins/engine/services/formsService.js') diff --git a/test/form/file-upload.test.js b/test/form/file-upload.test.js index 4e5b35330..3402877ef 100644 --- a/test/form/file-upload.test.js +++ b/test/form/file-upload.test.js @@ -3,6 +3,7 @@ import { resolve } from 'node:path' import { within } from '@testing-library/dom' import { StatusCodes } from 'http-status-codes' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' import { @@ -14,7 +15,7 @@ import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' import { getCookieHeader } from '~/test/utils/get-cookie.js' -const basePath = '/file-upload' +const basePath = `${FORM_PREFIX}/file-upload` jest.mock('~/src/server/plugins/engine/services/uploadService.js') jest.mock('~/src/server/plugins/engine/services/formsService.js') diff --git a/test/form/govuk-notify.test.js b/test/form/govuk-notify.test.js index c712ae109..5400e3e4e 100644 --- a/test/form/govuk-notify.test.js +++ b/test/form/govuk-notify.test.js @@ -3,6 +3,7 @@ import { join } from 'node:path' import { StatusCodes } from 'http-status-codes' import { outdent } from 'outdent' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { persistFiles, @@ -19,7 +20,7 @@ import { sendNotification } from '~/src/server/utils/notify.js' import * as fixtures from '~/test/fixtures/index.js' import { getCookieHeader } from '~/test/utils/get-cookie.js' -const basePath = '/components' +const basePath = `${FORM_PREFIX}/components` jest.mock('~/src/server/utils/notify.ts') jest.mock('~/src/server/plugins/engine/services/uploadService.js') diff --git a/test/form/journey-basic.test.js b/test/form/journey-basic.test.js index 81eabac36..b1801ac3a 100644 --- a/test/form/journey-basic.test.js +++ b/test/form/journey-basic.test.js @@ -3,6 +3,7 @@ import { join } from 'node:path' import { within } from '@testing-library/dom' import { StatusCodes } from 'http-status-codes' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { submit } from '~/src/server/plugins/engine/services/formSubmissionService.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' @@ -11,7 +12,7 @@ import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' import { getCookie, getCookieHeader } from '~/test/utils/get-cookie.js' -const basePath = '/basic' +const basePath = `${FORM_PREFIX}/basic` jest.mock('~/src/server/utils/notify.ts') jest.mock('~/src/server/plugins/engine/services/formsService.js') @@ -122,6 +123,8 @@ describe('Form journey', () => { }) beforeEach(() => { + // server.app.models.clear() + jest.clearAllMocks() jest.mocked(getFormMetadata).mockResolvedValue(fixtures.form.metadata) }) diff --git a/test/form/legacy-redirects.test.js b/test/form/legacy-redirects.test.js new file mode 100644 index 000000000..c8ad2cb0b --- /dev/null +++ b/test/form/legacy-redirects.test.js @@ -0,0 +1,142 @@ +import { StatusCodes } from 'http-status-codes' + +import { FORM_PREFIX } from '~/src/server/constants.js' +import { createServer } from '~/src/server/index.js' +import { FormStatus } from '~/src/server/routes/types.js' + +describe('Legacy Redirect Routes', () => { + /** @type {import('@hapi/hapi').Server} */ + let server + + beforeAll(async () => { + server = await createServer({}) + await server.initialize() + }) + + afterAll(async () => { + await server.stop() + }) + + describe('GET /preview/{state}/{slug}', () => { + it('should permanently redirect valid live preview paths', async () => { + const state = FormStatus.Live + const slug = 'my-valid-slug' + const response = await server.inject({ + method: 'GET', + url: `/preview/${state}/${slug}` + }) + + expect(response.statusCode).toBe(StatusCodes.MOVED_PERMANENTLY) + expect(response.headers.location).toBe( + `${FORM_PREFIX}/preview/${state}/${slug}` + ) + }) + + it('should permanently redirect valid draft preview paths', async () => { + const state = FormStatus.Draft + const slug = 'another-slug-123' + const response = await server.inject({ + method: 'GET', + url: `/preview/${state}/${slug}` + }) + + expect(response.statusCode).toBe(StatusCodes.MOVED_PERMANENTLY) + expect(response.headers.location).toBe( + `${FORM_PREFIX}/preview/${state}/${slug}` + ) + }) + + it('should return 404 for invalid state', async () => { + const state = 'invalid-state' + const slug = 'my-valid-slug' + const response = await server.inject({ + method: 'GET', + url: `/preview/${state}/${slug}` + }) + + expect(response.statusCode).toBe(StatusCodes.NOT_FOUND) + }) + + it('should return 404 for invalid slug', async () => { + const state = FormStatus.Live + const slug = 'InvalidSlugWithCaps' + const response = await server.inject({ + method: 'GET', + url: `/preview/${state}/${slug}` + }) + + expect(response.statusCode).toBe(StatusCodes.NOT_FOUND) + }) + + it('should return 404 for invalid state and slug', async () => { + const state = 'bad' + const slug = 'BadSlug' + const response = await server.inject({ + method: 'GET', + url: `/preview/${state}/${slug}` + }) + + expect(response.statusCode).toBe(StatusCodes.NOT_FOUND) + }) + }) + + describe('GET /{slug}/{path*}', () => { + it('should permanently redirect valid paths with path segments', async () => { + const slug = 'my-valid-slug' + const path = 'page-one/sub-page' + const response = await server.inject({ + method: 'GET', + url: `/${slug}/${path}` + }) + + expect(response.statusCode).toBe(StatusCodes.MOVED_PERMANENTLY) + expect(response.headers.location).toBe(`${FORM_PREFIX}/${slug}/${path}`) + }) + + it('should permanently redirect valid paths with single path segment', async () => { + const slug = 'another-slug' + const path = 'summary' + const response = await server.inject({ + method: 'GET', + url: `/${slug}/${path}` + }) + + expect(response.statusCode).toBe(StatusCodes.MOVED_PERMANENTLY) + expect(response.headers.location).toBe(`${FORM_PREFIX}/${slug}/${path}`) + }) + + it('should return 404 for invalid slug', async () => { + const slug = 'InvalidSlug' + const path = 'page-one' + const response = await server.inject({ + method: 'GET', + url: `/${slug}/${path}` + }) + + expect(response.statusCode).toBe(StatusCodes.NOT_FOUND) + }) + }) + + describe('GET /{slug}', () => { + it('should permanently redirect valid paths with only a slug', async () => { + const slug = 'my-valid-slug-123' + const response = await server.inject({ + method: 'GET', + url: `/${slug}` + }) + + expect(response.statusCode).toBe(StatusCodes.MOVED_PERMANENTLY) + expect(response.headers.location).toBe(`${FORM_PREFIX}/${slug}`) + }) + + it('should return 404 for invalid slug', async () => { + const slug = 'Invalid-Slug!' + const response = await server.inject({ + method: 'GET', + url: `/${slug}` + }) + + expect(response.statusCode).toBe(StatusCodes.NOT_FOUND) + }) + }) +}) diff --git a/test/form/persist-files.test.js b/test/form/persist-files.test.js index da4a70566..134a84703 100644 --- a/test/form/persist-files.test.js +++ b/test/form/persist-files.test.js @@ -2,6 +2,7 @@ import { join } from 'node:path' import { StatusCodes } from 'http-status-codes' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { persistFiles, @@ -15,7 +16,7 @@ import { CacheService } from '~/src/server/services/cacheService.js' import * as fixtures from '~/test/fixtures/index.js' import { getCookieHeader } from '~/test/utils/get-cookie.js' -const basePath = '/file-upload-basic' +const basePath = `${FORM_PREFIX}/file-upload-basic` jest.mock('~/src/server/utils/notify.ts') jest.mock('~/src/server/plugins/engine/services/formsService.js') diff --git a/test/form/phase-banner.test.js b/test/form/phase-banner.test.js index 5b0cb2a6f..88b0123a0 100644 --- a/test/form/phase-banner.test.js +++ b/test/form/phase-banner.test.js @@ -2,11 +2,11 @@ import { join } from 'node:path' import { within } from '@testing-library/dom' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' - jest.mock('~/src/server/plugins/engine/services/formsService.js') describe(`Phase banner`, () => { @@ -22,7 +22,7 @@ describe(`Phase banner`, () => { }) test('shows the server phase tag by default', async () => { - const basePath = '/phase-default' + const basePath = `${FORM_PREFIX}/phase-default` server = await createServer({ formFileName: 'phase-default.json', @@ -44,7 +44,7 @@ describe(`Phase banner`, () => { }) test('shows the form phase tag if provided', async () => { - const basePath = '/phase-alpha' + const basePath = `${FORM_PREFIX}/phase-alpha` server = await createServer({ formFileName: 'phase-alpha.json', diff --git a/test/form/repeat.test.js b/test/form/repeat.test.js index fe5338f08..509f9da37 100644 --- a/test/form/repeat.test.js +++ b/test/form/repeat.test.js @@ -5,6 +5,7 @@ import { hasRepeater } from '@defra/forms-model' import { within } from '@testing-library/dom' import { StatusCodes } from 'http-status-codes' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { isRepeatState } from '~/src/server/plugins/engine/components/FormComponent.js' import { submit } from '~/src/server/plugins/engine/services/formSubmissionService.js' @@ -18,7 +19,7 @@ jest.mock('~/src/server/utils/notify.ts') jest.mock('~/src/server/plugins/engine/services/formsService.js') jest.mock('~/src/server/plugins/engine/services/formSubmissionService.js') -const basePath = '/repeat' +const basePath = `${FORM_PREFIX}/repeat` /** * POST a new repeat item @@ -117,7 +118,9 @@ describe('Repeat GET tests', () => { }) expect(res.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) - expect(res.headers.location).toMatch(/^\/repeat\/pizza-order\/[0-9a-f-]+$/) + expect(res.headers.location).toMatch( + /^\/form\/repeat\/pizza-order\/[0-9a-f-]+$/ + ) }) test('GET /pizza-order with 1 item returns 302 to repeater summary', async () => { @@ -167,7 +170,9 @@ describe('Repeat GET tests', () => { }) expect(res.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) - expect(res.headers.location).toMatch(/^\/repeat\/pizza-order\/[0-9a-f-]+$/) + expect(res.headers.location).toMatch( + new RegExp(`^${FORM_PREFIX}/repeat/pizza-order/[0-9a-f-]+$`) + ) }) test('GET /pizza-order/{id} returns 200', async () => { @@ -393,7 +398,10 @@ describe('Repeat POST tests', () => { }) expect(res.statusCode).toBe(StatusCodes.SEE_OTHER) - expect(res.headers.location).toMatch(/^\/repeat\/pizza-order\/summary?/) + const expectedPathRegex = new RegExp( + `^${FORM_PREFIX}/repeat/pizza-order/summary$` + ) + expect(res.headers.location).toMatch(expectedPathRegex) }) test('POST /pizza-order/{id}/confirm-delete with 1 item returns 404', async () => { @@ -425,7 +433,9 @@ describe('Repeat POST tests', () => { }) expect(res.statusCode).toBe(StatusCodes.SEE_OTHER) - expect(res.headers.location).toMatch(/^\/repeat\/pizza-order\/summary/) + expect(res.headers.location).toMatch( + new RegExp(`^${FORM_PREFIX}/repeat/pizza-order/summary$`) + ) }) test('POST /pizza-order/summary ADD_ANOTHER returns 303', async () => { diff --git a/test/form/summary-submission-email.test.js b/test/form/summary-submission-email.test.js index 9ee6f0357..295f7b501 100644 --- a/test/form/summary-submission-email.test.js +++ b/test/form/summary-submission-email.test.js @@ -1,12 +1,13 @@ import { join } from 'node:path' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' import { getCookie, getCookieHeader } from '~/test/utils/get-cookie.js' -const basePath = '/minimal' +const basePath = `${FORM_PREFIX}/minimal` jest.mock('~/src/server/plugins/engine/services/formsService.js') jest.mock('~/src/server/plugins/engine/services/formSubmissionService.js') diff --git a/test/form/template.test.js b/test/form/template.test.js index 5d6817abf..009cb2501 100644 --- a/test/form/template.test.js +++ b/test/form/template.test.js @@ -3,13 +3,14 @@ import { join } from 'node:path' import { within } from '@testing-library/dom' import { StatusCodes } from 'http-status-codes' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' import { getCookie, getCookieHeader } from '~/test/utils/get-cookie.js' -const basePath = '/templates' +const basePath = `${FORM_PREFIX}/templates` jest.mock('~/src/server/plugins/engine/services/formsService.js') @@ -250,7 +251,9 @@ describe('Form template journey', () => { const $output4 = container.getByTestId('output-4') expect($output4).toBeInTheDocument() - expect($output4.textContent).toBe('/templates/are-you-in-england') + expect($output4.textContent).toBe( + `${FORM_PREFIX}/templates/are-you-in-england` + ) }) test('POST /information', async () => { diff --git a/test/form/titles.test.js b/test/form/titles.test.js index 41a0ce9da..44a0553f9 100644 --- a/test/form/titles.test.js +++ b/test/form/titles.test.js @@ -6,7 +6,7 @@ import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' import { getCookie, getCookieHeader } from '~/test/utils/get-cookie.js' -const basePath = '/titles' +const basePath = '/form/titles' jest.mock('~/src/server/plugins/engine/services/formsService.js') From 8daeefbeeecdf31a2a0b722dc3fc7738b197fd59 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 30 Apr 2025 12:12:45 +0100 Subject: [PATCH 20/20] Fixup page titles when empty (#786) * Fixup page titles when empty * Sonar --- src/server/plugins/engine/helpers.ts | 28 +++++++++++++++++++ .../plugins/engine/models/FormModel.test.ts | 15 ++++++++++ src/server/plugins/engine/models/FormModel.ts | 6 +++- 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/server/plugins/engine/helpers.ts b/src/server/plugins/engine/helpers.ts index df7c45281..9dbc9f81a 100644 --- a/src/server/plugins/engine/helpers.ts +++ b/src/server/plugins/engine/helpers.ts @@ -1,7 +1,10 @@ import { ControllerPath, Engine, + hasComponents, + isFormType, type ComponentDef, + type FormDefinition, type Page } from '@defra/forms-model' import Boom from '@hapi/boom' @@ -377,3 +380,28 @@ export function evaluateTemplate( export function handleLegacyRedirect(h: ResponseToolkit, targetUrl: string) { return h.redirect(targetUrl).permanent().takeover() } + +/** + * If the page doesn't have a title, set it from the title of the first form component + * @param def - the form definition + */ +export function setPageTitles(def: FormDefinition) { + def.pages.forEach((page) => { + if (!page.title) { + if (hasComponents(page)) { + // Set the page title from the first form component + const firstFormComponent = page.components.find((component) => + isFormType(component.type) + ) + + page.title = firstFormComponent?.title ?? '' + } + + if (!page.title) { + const formNameMsg = def.name ? ` in form '${def.name}'` : '' + + logger.warn(`Page '${page.path}' has no title${formNameMsg}`) + } + } + }) +} diff --git a/src/server/plugins/engine/models/FormModel.test.ts b/src/server/plugins/engine/models/FormModel.test.ts index e53299ca4..ca1f84cb8 100644 --- a/src/server/plugins/engine/models/FormModel.test.ts +++ b/src/server/plugins/engine/models/FormModel.test.ts @@ -1,5 +1,6 @@ import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { type FormContextRequest } from '~/src/server/plugins/engine/types.js' +import { V2 as definitionV2 } from '~/test/form/definitions/conditions-basic.js' import definition from '~/test/form/definitions/conditions-escaping.js' import conditionsListDefinition from '~/test/form/definitions/conditions-list.js' import fieldsRequiredDefinition from '~/test/form/definitions/fields-required.js' @@ -11,6 +12,20 @@ describe('FormModel', () => { () => new FormModel(definition, { basePath: 'test' }) ).not.toThrow() }) + + it('Sets the page title from first form component when empty (V2 only)', () => { + const noTitlesDefinition = { + ...definitionV2, + pages: definitionV2.pages.map((page) => ({ ...page, title: '' })) + } + + const model = new FormModel(noTitlesDefinition, { basePath: 'test' }) + + expect(model.def.pages.at(0)?.title).toBe( + 'Have you previously been married?' + ) + expect(model.def.pages.at(1)?.title).toBe('Date of marriage') + }) }) describe('getFormContext', () => { diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index 03d9407de..43e1c416d 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -27,7 +27,8 @@ import { import { findPage, getError, - getPage + getPage, + setPageTitles } from '~/src/server/plugins/engine/helpers.js' import { type ExecutableCondition } from '~/src/server/plugins/engine/models/types.js' import { type PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js' @@ -104,6 +105,9 @@ export class FormModel { ] }) + // Fix up page titles + setPageTitles(def) + this.engine = def.engine this.def = def this.lists = def.lists