From 3da1cf15f1777a5119a0afdb83d5c148ce00287b Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 16 Apr 2026 19:41:25 +0100 Subject: [PATCH 01/89] feat(i18n): finalise en-GB translation file with all template strings --- .../engine/i18n/translations/en-GB.json | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 src/server/plugins/engine/i18n/translations/en-GB.json diff --git a/src/server/plugins/engine/i18n/translations/en-GB.json b/src/server/plugins/engine/i18n/translations/en-GB.json new file mode 100644 index 000000000..8751bde45 --- /dev/null +++ b/src/server/plugins/engine/i18n/translations/en-GB.json @@ -0,0 +1,221 @@ +{ + "_translatorNotes": { + "joiPlaceholders": "{{...}} placeholders are processed by the validation engine — preserve exactly as-is", + "enginePlaceholders": "[[...]] placeholders are substituted by the forms engine at runtime — preserve exactly as-is", + "plurals": "_one keys apply when count = 1, _other keys apply for all other counts — both must be provided" + }, + + "validation": { + "_note": "These strings are used in validation error messages. Preserve all {{...}} placeholders exactly.", + "required": "Enter {{lowerFirst(#label)}}", + "selectRequired": "Select {{lowerFirst(#label)}}", + "selectYesNoRequired": "{{#label}} - select yes or no", + "pattern": "Enter a valid {{lowerFirst(#label)}}", + "format": "Enter {{lowerFirst(#label)}} in the correct format", + "declarationRequired": "You must confirm you understand and agree with the {{lowerFirst(#label)}} to continue", + "objectRequired": "Enter {{#label}}", + "objectMissing": "{{#title}} must include a {{lowerFirst(#label)}}", + "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", + "number": "{{#label}} must be a number", + "numberPrecision": "{{#label}} must have {{#limit}} or fewer decimal places", + "numberInteger": "{{#label}} must be a whole number", + "numberMin": "{{#label}} must be {{#limit}} or higher", + "numberMax": "{{#label}} must be {{#limit}} or lower", + "maxWords": "{{#label}} must be {{#limit}} words or fewer", + "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}}" + }, + + "errors": { + "title": "There is a problem" + }, + + "common": { + "optional": " (optional)", + "warningIconFallback": "Warning", + "back": "Back", + "continue": "Continue", + "saveAndExit": "Save and exit" + }, + + "pages": { + "question": { + "paymentCancelledBannerTitle": "Important", + "paymentCancelledTitle": "Your payment has been cancelled", + "paymentCancelledBody": "Your payment details were deleted because the form was inactive for 5 days.", + "paymentCancelledAction": "Add your payment details again.", + "backToAddAnother": "Go back to add another", + "backToCheckAnswers": "Go back to check answers", + "startNow": "Start now" + }, + + "summary": { + "title": "Check your answers before sending your form", + "change": "Change", + "notProvided": "Not provided", + "declaration": "Declaration", + "acceptAndSubmit": "Accept and submit", + "submit": "Submit", + "saveAndExit": "Save and exit", + "paymentSuccessBannerTitle": "Success", + "paymentSuccessTitle": "We have your payment details", + "paymentSuccessBody": "Your payment is on hold. We will charge you when you submit the form.", + "paymentDetailsTitle": "Payment details", + "paymentFor": "Payment for", + "totalAmount": "Total amount", + "reference": "Reference", + "dateOfPayment": "Date of payment", + "submissionFailed": "There was a problem and your form was not submitted. Try submitting the form again", + "submissionFailedContactSuffix": " or you can [[contactUsLink]] and quote your reference number to arrange a refund", + "contactUsLinkText": "contact us (opens in new tab)" + }, + + "repeater": { + "removeAnswer": "Are you sure you want to remove this answer?", + "remove": "Remove", + "cancel": "Cancel", + "continue": "Continue", + "addAnother": "Add another", + "saveAndExit": "Save and exit", + "change": "Change", + "notProvided": "Not provided", + "visuallyHiddenItem": "item [[index]]", + "pageTitle_one": "You have added [[count]] answer", + "pageTitle_other": "You have added [[count]] answers", + "tooMany_one": "You can only add up to [[count]] answer", + "tooMany_other": "You can only add up to [[count]] answers", + "tooFew_one": "You must add at least [[count]] answer", + "tooFew_other": "You must add at least [[count]] answers" + }, + + "fileUpload": { + "upload": "Upload file", + "maxFilesReached": "You have reached the maximum number of files. Please remove a file to upload more.", + "maxFilesWarningIcon": "Warning" + }, + + "exit": { + "defaultTitle": "Your progress has been saved", + "whatHappensNext": "What happens next", + "body": "Your form progress has been saved. You can return to complete your application at any time using the link provided.", + "returnToApplication": "Return to application" + }, + + "confirmation": { + "whatHappensNext": "What happens next", + "referenceNumber": "Your reference number", + "feedbackLink": "What do you think of this service? (takes 30 seconds)" + }, + + "preview": { + "warnMissingNotificationEmail": { + "body": "If you want to receive a test email, [[link]] you want form submissions to go to.", + "linkText": "enter the email address (opens in new tab)" + } + } + }, + + "components": { + "dateField": { + "day": "Day", + "month": "Month", + "year": "Year" + }, + + "monthYearField": { + "month": "Month", + "year": "Year" + }, + + "addressField": { + "uprn": "UPRN", + "line1": "Address line 1", + "line2": "Address line 2", + "town": "Town or city", + "county": "County", + "postcode": "Postcode", + "enterAddress": "Enter [[shortDescription]]", + "findAddress": "Find an address", + "selectedAddress": "Selected address:", + "useDifferentAddress": "Use a different address", + "enterManually": "enter address manually" + }, + + "latLongField": { + "latitude": "Latitude", + "longitude": "Longitude", + "latitudeRequired": "Enter latitude", + "longitudeRequired": "Enter longitude", + "precision": "{{#label}} must have no more than 7 decimal places", + "notANumber": "{{#label}} must be a valid number", + "latitudeBase": "Enter a valid latitude for [[fieldLabel]] like 51.519450", + "longitudeBase": "Enter a valid longitude for [[fieldLabel]] like -0.127758", + "latitudeRange": "Latitude for [[fieldLabel]] must be between [[min]] and [[max]]", + "longitudeRange": "Longitude for [[fieldLabel]] must be between [[min]] and [[max]]" + }, + + "eastingNorthingField": { + "easting": "Easting", + "northing": "Northing", + "eastingRequired": "Enter easting", + "northingRequired": "Enter northing", + "eastingDigits": "{{#label}} for [[fieldLabel]] must be between 1 and 6 digits", + "northingDigits": "{{#label}} for [[fieldLabel]] must be between 1 and 7 digits", + "eastingRange": "{{#label}} for [[fieldLabel]] must be between [[min]] and [[max]]", + "northingRange": "{{#label}} for [[fieldLabel]] must be between [[min]] and [[max]]" + }, + + "nationalGridField": { + "required": "Enter {{lowerFirst(#title)}}", + "pattern": "Enter a valid National Grid field number for {{lowerFirst(#title)}} like NG 1234 5678", + "howToFind": "How to find location details" + }, + + "osGridRefField": { + "required": "Enter {{lowerFirst(#title)}}", + "pattern": "Enter a valid OS grid reference for {{lowerFirst(#title)}} like TQ123456", + "howToFind": "How to find location details" + }, + + "locationFieldBase": { + "howToFind": "How to find location details" + }, + + "declarationField": { + "defaultLabel": "I understand and agree", + "defaultConfirmationLabel": "I confirm that I understand and accept this declaration", + "notProvided": "Not provided" + }, + + "geospatialField": { + "added_one": "Added [[count]] location", + "added_other": "Added [[count]] locations" + }, + + "paymentField": { + "detailsRequired": "Payment details required", + "pendingTransaction": "You may see a pending transaction in your bank account but you will only be charged when you submit the form.", + "addPaymentDetails": "Add payment details", + "cannotTakePayment": "There is a problem and we cannot take a payment. Contact us (details in the footer of this form) or save your progress and return to the form later.", + "testApiKey": "Add a valid test API key before you can preview the payment journey.", + "completePayment": "Complete the payment to continue", + "paymentExpired": "Your payment authorisation has expired. Please add your payment details again.", + "submissionFailed": "There was a problem and your form was not submitted. Try submitting the form again.", + "canSubmitAfterPayment": "You can submit the form after you have added your payment details.", + "preAuthorisedTitle": "You have already authorised a payment for this form", + "preAuthorisedBody": "Continue to submit the form. You will not be charged twice.", + "totalAmountLabel": "Total amount:" + }, + + "fileUploadField": { + "uploaded": "Uploaded", + "uploadFailed": "There was a problem with your uploaded files. Re-upload them before submitting the form again.", + "uploadedFilesHeading": "Uploaded files", + "filesCount_one": "[[count]] file uploaded", + "filesCount_other": "[[count]] files uploaded" + } + } +} From 05b01793678b3f1b5f0ee9e58312c2baee24161e Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 16 Apr 2026 20:05:35 +0100 Subject: [PATCH 02/89] feat(i18n): add i18next module with en-GB initialisation and t() wrapper --- package-lock.json | 73 ++++++++++++++++++-- package.json | 1 + src/server/plugins/engine/i18n/index.test.ts | 27 ++++++++ src/server/plugins/engine/i18n/index.ts | 28 ++++++++ 4 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 src/server/plugins/engine/i18n/index.test.ts create mode 100644 src/server/plugins/engine/i18n/index.ts diff --git a/package-lock.json b/package-lock.json index 82878829d..e0f8c78f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "highlight.js": "^11.11.1", "http-status-codes": "^2.3.0", "humanize-duration": "^3.33.1", + "i18next": "^26.0.5", "ioredis": "^5.8.2", "joi": "^17.13.3", "liquidjs": "^10.24.0", @@ -234,6 +235,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1976,9 +1978,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2091,6 +2093,7 @@ "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -2233,6 +2236,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -2272,6 +2276,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -4423,6 +4428,7 @@ "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.9.2.tgz", "integrity": "sha512-HbjwKeC+pHUFBfLMNzuSjqFE/58+rLVKmOU3lxQrpsxLBOGosYco/Q0GduBb0/jEMRiyEqjNT/01rRdOMWq5pw==", "license": "MIT", + "peer": true, "dependencies": { "@docusaurus/babel": "3.9.2", "@docusaurus/bundler": "3.9.2", @@ -4591,6 +4597,7 @@ "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.9.2.tgz", "integrity": "sha512-C5wZsGuKTY8jEYsqdxhhFOe1ZDjH0uIYJ9T/jebHwkyxqnr4wW0jTkB72OMqNjsoQRcb0JN3PcSeTwFlVgzCZg==", "license": "MIT", + "peer": true, "dependencies": { "@docusaurus/core": "3.9.2", "@docusaurus/logger": "3.9.2", @@ -4624,6 +4631,7 @@ "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.9.2.tgz", "integrity": "sha512-6c4DAbR6n6nPbnZhY2V3tzpnKnGL+6aOsLvFL26VRqhlczli9eWG0VDUNoCQEPnGwDMhPS42UhSAnz5pThm5Ag==", "license": "MIT", + "peer": true, "dependencies": { "@docusaurus/mdx-loader": "3.9.2", "@docusaurus/module-type-aliases": "3.9.2", @@ -7363,6 +7371,7 @@ "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", "license": "MIT", + "peer": true, "dependencies": { "@types/mdx": "^2.0.0" }, @@ -10945,6 +10954,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -11141,6 +11151,7 @@ "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", @@ -11180,6 +11191,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -11984,6 +11996,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -12060,6 +12073,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -13097,6 +13111,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -13455,6 +13470,7 @@ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "license": "MIT", + "peer": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -17006,6 +17022,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -17193,6 +17210,7 @@ "integrity": "sha512-vPZZsiOKaBAIATpFE2uMI4w5IRwdv/FpQ+qZZMR4E+PeOcM4OeoEbqxRMnywdxP19TyB/3h6QBB0EWon7letSQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/types": "^8.35.0", "comment-parser": "^1.4.1", @@ -19836,6 +19854,37 @@ "node": ">=10.18" } }, + "node_modules/i18next": { + "version": "26.0.5", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.5.tgz", + "integrity": "sha512-9uHb4T27TdV36phJXcbpnRPt5yzAfqHXVrdASvmHZyPuZJtrLythd+GyXhiaHV5LlpuuskbAqhwPjmfTbKbi8w==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -20995,6 +21044,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -22212,6 +22262,7 @@ "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", @@ -22278,6 +22329,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -27164,6 +27216,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -28560,6 +28613,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -29127,6 +29181,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -29136,6 +29191,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -29180,6 +29236,7 @@ "resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-6.0.0.tgz", "integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/react": "*" }, @@ -29208,6 +29265,7 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", "license": "MIT", + "peer": true, "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" @@ -30724,6 +30782,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -31982,6 +32041,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-syntax-patches-for-csstree": "^1.0.19", @@ -32731,6 +32791,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -32935,7 +32996,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.21.0", @@ -32943,6 +33005,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -33137,6 +33200,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -33813,6 +33877,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", diff --git a/package.json b/package.json index c21681b77..5551a02f7 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,7 @@ "highlight.js": "^11.11.1", "http-status-codes": "^2.3.0", "humanize-duration": "^3.33.1", + "i18next": "^26.0.5", "ioredis": "^5.8.2", "joi": "^17.13.3", "liquidjs": "^10.24.0", diff --git a/src/server/plugins/engine/i18n/index.test.ts b/src/server/plugins/engine/i18n/index.test.ts new file mode 100644 index 000000000..dad65e228 --- /dev/null +++ b/src/server/plugins/engine/i18n/index.test.ts @@ -0,0 +1,27 @@ +import { t } from '~/src/server/plugins/engine/i18n/index.js' + +describe('i18n t()', () => { + it('returns the English string for a known key', () => { + expect(t('errors.title', 'en-GB')).toBe('There is a problem') + }) + + it('falls back to en-GB for an unknown language', () => { + expect(t('errors.title', 'cy')).toBe('There is a problem') + }) + + it('returns the key string itself when the key does not exist', () => { + expect(t('does.not.exist', 'en-GB')).toBe('does.not.exist') + }) + + it('interpolates [[...]] placeholders', () => { + expect(t('pages.repeater.pageTitle', 'en-GB', { count: 3 })).toBe( + 'You have added 3 answers' + ) + }) + + it('selects _one plural variant when count is 1', () => { + expect(t('pages.repeater.pageTitle', 'en-GB', { count: 1 })).toBe( + 'You have added 1 answer' + ) + }) +}) diff --git a/src/server/plugins/engine/i18n/index.ts b/src/server/plugins/engine/i18n/index.ts new file mode 100644 index 000000000..8e43e5b58 --- /dev/null +++ b/src/server/plugins/engine/i18n/index.ts @@ -0,0 +1,28 @@ +import i18next from 'i18next' + +import enGB from '~/src/server/plugins/engine/i18n/translations/en-GB.json' + +i18next + .init({ + resources: { + 'en-GB': { translation: enGB } + }, + fallbackLng: 'en-GB', + interpolation: { + prefix: '[[', + suffix: ']]', + escapeValue: false + }, + initImmediate: false + }) + .catch(() => { + // Synchronous init (initImmediate: false) — this branch is unreachable + }) + +export function t( + key: string, + language: string, + options?: Record +): string { + return i18next.t(key, { lng: language, ...options }) +} From b6385a50b22d0e63663a58e68fd22e601a18824a Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 16 Apr 2026 22:04:19 +0100 Subject: [PATCH 03/89] feat(i18n): add buildValidationMessages factory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds buildValidationMessages(t) — a factory that accepts a language-bound translation function and returns a set of Joi message templates (JoiExpressions for lowerFirst-containing strings, plain template strings for the rest). Also adds require('@defra/forms-model') to jest.setup.cjs to align Joi's internal Symbol state across the babel-jest ESM/CJS module boundary; without this, JoiExpression objects fail Template.isTemplate() checks inside Joi's .messages() method when resetModules: true clears the registry between tests. --- jest.setup.cjs | 7 +++ .../i18n/buildValidationMessages.test.ts | 60 +++++++++++++++++++ .../engine/i18n/buildValidationMessages.ts | 59 ++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 src/server/plugins/engine/i18n/buildValidationMessages.test.ts create mode 100644 src/server/plugins/engine/i18n/buildValidationMessages.ts diff --git a/jest.setup.cjs b/jest.setup.cjs index 2686c4c7f..8ac092a83 100644 --- a/jest.setup.cjs +++ b/jest.setup.cjs @@ -1,3 +1,10 @@ +// Pre-load @defra/forms-model to ensure Joi internals are initialised before any test +// file runs. This prevents a Symbol identity mismatch between the ESM-wrapped Joi +// instance (created by babel-jest) and the CJS Joi instance used by Joi's internal +// Template.isTemplate() check, which occurs when resetModules: true clears the +// registry between tests. +require('@defra/forms-model') + process.env.REDIS_HOST = 'dummy' process.env.REDIS_KEY_PREFIX = 'forms-designer' process.env.REDIS_PASSWORD = 'dummy' diff --git a/src/server/plugins/engine/i18n/buildValidationMessages.test.ts b/src/server/plugins/engine/i18n/buildValidationMessages.test.ts new file mode 100644 index 000000000..3dc693c25 --- /dev/null +++ b/src/server/plugins/engine/i18n/buildValidationMessages.test.ts @@ -0,0 +1,60 @@ +import joi from 'joi' + +import { buildValidationMessages } from '~/src/server/plugins/engine/i18n/buildValidationMessages.js' +import { t } from '~/src/server/plugins/engine/i18n/index.js' + +describe('buildValidationMessages', () => { + const tEnGB = (key: string) => t(key, 'en-GB') + const messages = buildValidationMessages(tEnGB) + + it('required is a JoiExpression resolving to the correct template', () => { + const schema = joi + .string() + .label('Full name') + .messages({ + 'string.empty': messages.required as unknown as string + }) + const { error } = schema.validate('') + expect(error?.details[0].message).toBe('Enter full name') + }) + + it('max is a plain string template', () => { + expect(messages.max).toBe( + '{{#label}} must be {{#limit}} characters or less' + ) + }) + + it('dateFormat is a plain string template', () => { + expect(messages.dateFormat).toBe('{{#title}} must be a real date') + }) + + it('declarationRequired is a JoiExpression', () => { + expect(typeof messages.declarationRequired).toBe('object') + }) + + it('produces the same keys as the original messageTemplate', () => { + const expectedKeys = [ + 'required', + 'selectRequired', + 'selectYesNoRequired', + 'pattern', + 'format', + 'declarationRequired', + 'objectRequired', + 'objectMissing', + 'max', + 'min', + 'minMax', + 'number', + 'numberPrecision', + 'numberInteger', + 'numberMin', + 'numberMax', + 'maxWords', + 'dateFormat', + 'dateMin', + 'dateMax' + ] + expect(Object.keys(messages)).toEqual(expect.arrayContaining(expectedKeys)) + }) +}) diff --git a/src/server/plugins/engine/i18n/buildValidationMessages.ts b/src/server/plugins/engine/i18n/buildValidationMessages.ts new file mode 100644 index 000000000..c12b35233 --- /dev/null +++ b/src/server/plugins/engine/i18n/buildValidationMessages.ts @@ -0,0 +1,59 @@ +import joi, { type JoiExpression, type ReferenceOptions } from 'joi' + +import { lowerFirstPreserveProperNouns } from '~/src/server/plugins/engine/components/helpers/index.js' + +const opts = { + functions: { + lowerFirst: lowerFirstPreserveProperNouns + } +} as ReferenceOptions + +/** + * Builds a set of translated Joi message templates for a given language. + * Called once per FormModel at construction time. + * + * Strings containing {{lowerFirst(#label)}} must be wrapped in joi.expression() + * because Joi cannot resolve custom functions from a plain string. + * Plain strings with only built-in tokens ({{#label}}, {{#limit}}) work without wrapping. + */ +export function buildValidationMessages(t: (key: string) => string) { + return { + // Expressions — contain lowerFirst custom function + required: joi.expression(t('validation.required'), opts) as JoiExpression, + selectRequired: joi.expression( + t('validation.selectRequired'), + opts + ) as JoiExpression, + pattern: joi.expression(t('validation.pattern'), opts) as JoiExpression, + format: joi.expression(t('validation.format'), opts) as JoiExpression, + declarationRequired: joi.expression( + t('validation.declarationRequired'), + opts + ) as JoiExpression, + objectRequired: joi.expression( + t('validation.objectRequired'), + opts + ) as JoiExpression, + objectMissing: joi.expression( + t('validation.objectMissing'), + opts + ) as JoiExpression, + + // Plain Joi template strings — no lowerFirst + selectYesNoRequired: t('validation.selectYesNoRequired'), + max: t('validation.max'), + min: t('validation.min'), + minMax: t('validation.minMax'), + number: t('validation.number'), + numberPrecision: t('validation.numberPrecision'), + numberInteger: t('validation.numberInteger'), + numberMin: t('validation.numberMin'), + numberMax: t('validation.numberMax'), + maxWords: t('validation.maxWords'), + dateFormat: t('validation.dateFormat'), + dateMin: t('validation.dateMin'), + dateMax: t('validation.dateMax') + } +} + +export type ValidationMessages = ReturnType From 475b583675d10b362fd044df7168093ec5e9bfba Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 16 Apr 2026 22:06:43 +0100 Subject: [PATCH 04/89] feat(i18n): add language and validationMessages to FormModel --- .../plugins/engine/models/FormModel.test.ts | 23 +++++++++++++++++++ src/server/plugins/engine/models/FormModel.ts | 14 +++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/server/plugins/engine/models/FormModel.test.ts b/src/server/plugins/engine/models/FormModel.test.ts index c4899ad0d..e80555e72 100644 --- a/src/server/plugins/engine/models/FormModel.test.ts +++ b/src/server/plugins/engine/models/FormModel.test.ts @@ -156,6 +156,29 @@ describe('FormModel', () => { expect(model.versionNumber).toBeUndefined() }) + it('defaults language to en-GB when metadata.language is absent', () => { + const model = new FormModel(definition, { basePath: 'test' }) + expect(model.language).toBe('en-GB') + }) + + it('reads language from metadata.language when present', () => { + const defWithLanguage = { + ...definition, + metadata: { language: 'cy' } + } + const model = new FormModel(defWithLanguage, { basePath: 'test' }) + expect(model.language).toBe('cy') + }) + + it('builds validationMessages at construction time', () => { + const model = new FormModel(definition, { basePath: 'test' }) + expect(model.validationMessages).toBeDefined() + expect(typeof model.validationMessages.max).toBe('string') + expect(model.validationMessages.max).toBe( + '{{#label}} must be {{#limit}} characters or less' + ) + }) + it.each([ { input: undefined, diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index 6050a33b4..14d6ad448 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -42,6 +42,11 @@ import { getPage, setPageTitles } from '~/src/server/plugins/engine/helpers.js' +import { + buildValidationMessages, + type ValidationMessages +} from '~/src/server/plugins/engine/i18n/buildValidationMessages.js' +import { t } from '~/src/server/plugins/engine/i18n/index.js' import { type ExecutableCondition } from '~/src/server/plugins/engine/models/types.js' import { type PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js' import { @@ -80,6 +85,8 @@ export class FormModel { basePath: string versionNumber?: number ordnanceSurveyApiKey?: string + language: string + validationMessages: ValidationMessages conditions: Partial> pages: PageControllerClass[] services: Services @@ -150,6 +157,13 @@ export class FormModel { setPageTitles(def) this.engine = def.engine + this.language = + typeof def.metadata?.language === 'string' + ? def.metadata.language + : 'en-GB' + this.validationMessages = buildValidationMessages((key) => + t(key, this.language) + ) this.schemaVersion = def.schema ?? SchemaVersion.V1 this.def = def this.lists = def.lists From 6653669bc85d01a318265c8445a71592de648d32 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 16 Apr 2026 22:09:00 +0100 Subject: [PATCH 05/89] feat(i18n): rebuild messageTemplate from buildValidationMessages factory --- .../pageControllers/validationOptions.test.ts | 25 +++++++ .../pageControllers/validationOptions.ts | 66 ++++--------------- 2 files changed, 37 insertions(+), 54 deletions(-) create mode 100644 src/server/plugins/engine/pageControllers/validationOptions.test.ts diff --git a/src/server/plugins/engine/pageControllers/validationOptions.test.ts b/src/server/plugins/engine/pageControllers/validationOptions.test.ts new file mode 100644 index 000000000..a4bde9a7c --- /dev/null +++ b/src/server/plugins/engine/pageControllers/validationOptions.test.ts @@ -0,0 +1,25 @@ +import { + messageTemplate, + messages, + validationOptions +} from '~/src/server/plugins/engine/pageControllers/validationOptions.js' + +describe('validationOptions', () => { + it('messageTemplate.required is a JoiExpression object', () => { + expect(typeof messageTemplate.required).toBe('object') + }) + + it('messageTemplate.max is a plain string', () => { + expect(messageTemplate.max).toBe( + '{{#label}} must be {{#limit}} characters or less' + ) + }) + + it('messages maps string.base to messageTemplate.required', () => { + expect(messages['string.base']).toBe(messageTemplate.required) + }) + + it('validationOptions has abortEarly: false', () => { + expect(validationOptions.abortEarly).toBe(false) + }) +}) diff --git a/src/server/plugins/engine/pageControllers/validationOptions.ts b/src/server/plugins/engine/pageControllers/validationOptions.ts index f2e194e22..5ef566fda 100644 --- a/src/server/plugins/engine/pageControllers/validationOptions.ts +++ b/src/server/plugins/engine/pageControllers/validationOptions.ts @@ -1,67 +1,25 @@ // Declaration above is needed for: https://github.com/hapijs/joi/issues/3064 -import joi, { - type JoiExpression, +import { type LanguageMessages, type LanguageMessagesExt, - type ReferenceOptions, type ValidationOptions } from 'joi' -import { lowerFirstPreserveProperNouns } from '~/src/server/plugins/engine/components/helpers/index.js' - -const opts = { - functions: { - lowerFirst: lowerFirstPreserveProperNouns - } -} as ReferenceOptions +import { + buildValidationMessages, + type ValidationMessages +} from '~/src/server/plugins/engine/i18n/buildValidationMessages.js' +import { t } from '~/src/server/plugins/engine/i18n/index.js' /** - * see @link https://joi.dev/api/?v=17.4.2#template-syntax for template syntax + * Module-level English message templates — built via the same factory used per-form. + * Components that have not yet been migrated to use model.validationMessages still + * import messageTemplate directly and get the English version. */ -export const messageTemplate: Record = { - declarationRequired: joi.expression( - 'You must confirm you understand and agree with the {{lowerFirst(#label)}} to continue', - opts - ) as JoiExpression, - required: joi.expression( - 'Enter {{lowerFirst(#label)}}', - opts - ) as JoiExpression, - selectRequired: joi.expression( - 'Select {{lowerFirst(#label)}}', - opts - ) as JoiExpression, - 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', - pattern: joi.expression( - 'Enter a valid {{lowerFirst(#label)}}', - opts - ) as JoiExpression, - format: joi.expression( - 'Enter {{lowerFirst(#label)}} in the correct format', - 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', - numberMin: '{{#label}} must be {{#limit}} or higher', - numberMax: '{{#label}} must be {{#limit}} or lower', - maxWords: '{{#label}} must be {{#limit}} words or fewer', - - // Nested fields use component title - - objectRequired: joi.expression('Enter {{#label}}', opts) as JoiExpression, - objectMissing: joi.expression( - '{{#title}} must include a {{lowerFirst(#label)}}', - 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 messageTemplate: ValidationMessages = buildValidationMessages( + (key) => t(key, 'en-GB') +) export const messages: LanguageMessagesExt = { 'string.base': messageTemplate.required, From 524efd346f1e1b420ffbd8c6df8b72ea97154f26 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 16 Apr 2026 22:11:39 +0100 Subject: [PATCH 06/89] feat(i18n): translate DatePartsField and MonthYearField sub-labels --- .../engine/components/DatePartsField.ts | 23 +++++++++++-------- .../engine/components/MonthYearField.ts | 21 +++++++++-------- 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/src/server/plugins/engine/components/DatePartsField.ts b/src/server/plugins/engine/components/DatePartsField.ts index 9d0f0a28c..c5f5ccdaa 100644 --- a/src/server/plugins/engine/components/DatePartsField.ts +++ b/src/server/plugins/engine/components/DatePartsField.ts @@ -14,6 +14,7 @@ import { type DatePartsState } from '~/src/server/plugins/engine/components/types.js' import { parseStrictDate } from '~/src/server/plugins/engine/date-helper.js' +import { t } from '~/src/server/plugins/engine/i18n/index.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -41,14 +42,16 @@ export class DatePartsField extends FormComponent { const isRequired = options.required !== false + const { validationMessages } = props.model + const lang = props.model.language const customValidationMessages = convertToLanguageMessages({ - 'any.required': messageTemplate.objectMissing, - 'number.base': messageTemplate.objectMissing, - 'number.precision': messageTemplate.dateFormat, - 'number.integer': messageTemplate.dateFormat, - 'number.unsafe': messageTemplate.dateFormat, - 'number.min': messageTemplate.dateFormat, - 'number.max': messageTemplate.dateFormat + 'any.required': validationMessages.objectMissing, + 'number.base': validationMessages.objectMissing, + 'number.precision': validationMessages.dateFormat, + 'number.integer': validationMessages.dateFormat, + 'number.unsafe': validationMessages.dateFormat, + 'number.min': validationMessages.dateFormat, + 'number.max': validationMessages.dateFormat }) this.collection = new ComponentCollection( @@ -56,7 +59,7 @@ export class DatePartsField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__day`, - title: 'Day', + title: t('components.dateField.day', lang), schema: { min: 1, max: 31, precision: 0 }, options: { required: isRequired, @@ -68,7 +71,7 @@ export class DatePartsField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__month`, - title: 'Month', + title: t('components.dateField.month', lang), schema: { min: 1, max: 12, precision: 0 }, options: { required: isRequired, @@ -80,7 +83,7 @@ export class DatePartsField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__year`, - title: 'Year', + title: t('components.dateField.year', lang), schema: { min: 1000, max: 3000, precision: 0 }, options: { required: isRequired, diff --git a/src/server/plugins/engine/components/MonthYearField.ts b/src/server/plugins/engine/components/MonthYearField.ts index 7fb5452e2..764be7002 100644 --- a/src/server/plugins/engine/components/MonthYearField.ts +++ b/src/server/plugins/engine/components/MonthYearField.ts @@ -19,6 +19,7 @@ import { type MonthYearState } from '~/src/server/plugins/engine/components/types.js' import { parseStrictDate } from '~/src/server/plugins/engine/date-helper.js' +import { t } from '~/src/server/plugins/engine/i18n/index.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -46,15 +47,17 @@ export class MonthYearField extends FormComponent { const isRequired = options.required !== false + const { validationMessages } = props.model + const lang = props.model.language const customValidationMessages: LanguageMessages = convertToLanguageMessages({ - 'any.required': messageTemplate.objectMissing, - 'number.base': messageTemplate.objectMissing, - 'number.precision': messageTemplate.dateFormat, - 'number.integer': messageTemplate.dateFormat, - 'number.unsafe': messageTemplate.dateFormat, - 'number.min': messageTemplate.dateFormat, - 'number.max': messageTemplate.dateFormat + 'any.required': validationMessages.objectMissing, + 'number.base': validationMessages.objectMissing, + 'number.precision': validationMessages.dateFormat, + 'number.integer': validationMessages.dateFormat, + 'number.unsafe': validationMessages.dateFormat, + 'number.min': validationMessages.dateFormat, + 'number.max': validationMessages.dateFormat }) this.collection = new ComponentCollection( @@ -62,7 +65,7 @@ export class MonthYearField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__month`, - title: 'Month', + title: t('components.monthYearField.month', lang), schema: { min: 1, max: 12, precision: 0 }, options: { required: isRequired, @@ -74,7 +77,7 @@ export class MonthYearField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__year`, - title: 'Year', + title: t('components.monthYearField.year', lang), schema: { min: 1000, max: 3000, precision: 0 }, options: { required: isRequired, From 655f133da820ae4f2ebcfbb4efca192f0238319c Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 16 Apr 2026 22:12:27 +0100 Subject: [PATCH 07/89] feat(i18n): translate UkAddressField sub-labels --- .../engine/components/UkAddressField.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/server/plugins/engine/components/UkAddressField.ts b/src/server/plugins/engine/components/UkAddressField.ts index f21ea4a7e..531aa2f01 100644 --- a/src/server/plugins/engine/components/UkAddressField.ts +++ b/src/server/plugins/engine/components/UkAddressField.ts @@ -12,6 +12,7 @@ import { isFormState } from '~/src/server/plugins/engine/components/FormComponent.js' import { TextField } from '~/src/server/plugins/engine/components/TextField.js' +import { t } from '~/src/server/plugins/engine/i18n/index.js' import { type QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' import { type FormRequestPayload, @@ -47,13 +48,14 @@ export class UkAddressField extends FormComponent { const isRequired = options.required !== false const hideOptional = !!options.optionalText const hideTitle = !!options.hideTitle + const lang = props.model.language this.collection = new ComponentCollection( [ { type: ComponentType.TextField, name: `${name}__uprn`, - title: 'UPRN', + title: t('components.addressField.uprn', lang), schema: {}, options: { required: false, @@ -63,7 +65,7 @@ export class UkAddressField extends FormComponent { { type: ComponentType.TextField, name: `${name}__addressLine1`, - title: 'Address line 1', + title: t('components.addressField.line1', lang), schema: { max: 100 }, options: { autocomplete: 'address-line1', @@ -74,7 +76,7 @@ export class UkAddressField extends FormComponent { { type: ComponentType.TextField, name: `${name}__addressLine2`, - title: 'Address line 2', + title: t('components.addressField.line2', lang), schema: { max: 100 }, options: { autocomplete: 'address-line2', @@ -85,7 +87,7 @@ export class UkAddressField extends FormComponent { { type: ComponentType.TextField, name: `${name}__town`, - title: 'Town or city', + title: t('components.addressField.town', lang), schema: { max: 100 }, options: { autocomplete: 'address-level2', @@ -97,7 +99,7 @@ export class UkAddressField extends FormComponent { { type: ComponentType.TextField, name: `${name}__county`, - title: 'County', + title: t('components.addressField.county', lang), schema: { max: 100 }, options: { autocomplete: 'address-level1', @@ -108,7 +110,7 @@ export class UkAddressField extends FormComponent { { type: ComponentType.TextField, name: `${name}__postcode`, - title: 'Postcode', + title: t('components.addressField.postcode', lang), schema: { regex: '^[a-zA-Z]{1,2}\\d[a-zA-Z\\d]?\\s?\\d[a-zA-Z]{2}$' }, @@ -181,7 +183,9 @@ export class UkAddressField extends FormComponent { name, path: [name], href: `#${name}`, - text: `Enter ${lowerFirst(shortDescription)}` + text: t('components.addressField.enterAddress', this.model.language, { + shortDescription: lowerFirst(shortDescription) + }) } ] } From 4174c734939f06130ce936eb32b15a9ab1f036bd Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 16 Apr 2026 22:13:18 +0100 Subject: [PATCH 08/89] feat(i18n): translate LatLongField and EastingNorthingField strings --- .../engine/components/EastingNorthingField.ts | 65 +++++++++++++++---- .../plugins/engine/components/LatLongField.ts | 38 +++++++---- 2 files changed, 76 insertions(+), 27 deletions(-) diff --git a/src/server/plugins/engine/components/EastingNorthingField.ts b/src/server/plugins/engine/components/EastingNorthingField.ts index 45f184c49..03dd69d6c 100644 --- a/src/server/plugins/engine/components/EastingNorthingField.ts +++ b/src/server/plugins/engine/components/EastingNorthingField.ts @@ -17,6 +17,7 @@ import { import { NumberField } from '~/src/server/plugins/engine/components/NumberField.js' import { createLowerFirstExpression } from '~/src/server/plugins/engine/components/helpers/index.js' import { type EastingNorthingState } from '~/src/server/plugins/engine/components/types.js' +import { t } from '~/src/server/plugins/engine/i18n/index.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -55,18 +56,40 @@ export class EastingNorthingField extends FormComponent { const northingMin = schema?.northing?.min ?? DEFAULT_NORTHING_MIN const northingMax = schema?.northing?.max ?? DEFAULT_NORTHING_MAX - const eastingRequired = 'Enter easting' - const northingRequired = 'Enter northing' + const lang = props.model.language + const fieldLabel = lowerFirst(this.label) - const eastingDigitsMessage = `{{#label}} for ${lowerFirst(this.label)} must be between 1 and 6 digits` - const northingDigitsMessage = `{{#label}} for ${lowerFirst(this.label)} must be between 1 and 7 digits` + const eastingDigitsMessage = t( + 'components.eastingNorthingField.eastingDigits', + lang, + { fieldLabel } + ) + const northingDigitsMessage = t( + 'components.eastingNorthingField.northingDigits', + lang, + { fieldLabel } + ) const customValidationMessages: LanguageMessages = convertToLanguageMessages({ - 'any.required': eastingRequired, - 'number.base': eastingRequired, - 'number.min': `{{#label}} for ${lowerFirst(this.label)} must be between {{#limit}} and ${eastingMax}`, - 'number.max': `{{#label}} for ${lowerFirst(this.label)} must be between ${eastingMin} and {{#limit}}`, + 'any.required': t( + 'components.eastingNorthingField.eastingRequired', + lang + ), + 'number.base': t( + 'components.eastingNorthingField.eastingRequired', + lang + ), + 'number.min': t('components.eastingNorthingField.eastingRange', lang, { + fieldLabel, + min: eastingMin, + max: eastingMax + }), + 'number.max': t('components.eastingNorthingField.eastingRange', lang, { + fieldLabel, + min: eastingMin, + max: eastingMax + }), 'number.precision': eastingDigitsMessage, 'number.integer': eastingDigitsMessage, 'number.unsafe': eastingDigitsMessage @@ -74,10 +97,24 @@ export class EastingNorthingField extends FormComponent { const northingValidationMessages: LanguageMessages = convertToLanguageMessages({ - 'any.required': northingRequired, - 'number.base': northingRequired, - 'number.min': `{{#label}} for ${lowerFirst(this.label)} must be between {{#limit}} and ${northingMax}`, - 'number.max': `{{#label}} for ${lowerFirst(this.label)} must be between ${northingMin} and {{#limit}}`, + 'any.required': t( + 'components.eastingNorthingField.northingRequired', + lang + ), + 'number.base': t( + 'components.eastingNorthingField.northingRequired', + lang + ), + 'number.min': t('components.eastingNorthingField.northingRange', lang, { + fieldLabel, + min: northingMin, + max: northingMax + }), + 'number.max': t('components.eastingNorthingField.northingRange', lang, { + fieldLabel, + min: northingMin, + max: northingMax + }), 'number.precision': northingDigitsMessage, 'number.integer': northingDigitsMessage, 'number.unsafe': northingDigitsMessage @@ -88,7 +125,7 @@ export class EastingNorthingField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__easting`, - title: 'Easting', + title: t('components.eastingNorthingField.easting', lang), schema: { min: eastingMin, max: eastingMax, @@ -104,7 +141,7 @@ export class EastingNorthingField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__northing`, - title: 'Northing', + title: t('components.eastingNorthingField.northing', lang), schema: { min: northingMin, max: northingMax, diff --git a/src/server/plugins/engine/components/LatLongField.ts b/src/server/plugins/engine/components/LatLongField.ts index 0fe4c6f14..c7aee69a9 100644 --- a/src/server/plugins/engine/components/LatLongField.ts +++ b/src/server/plugins/engine/components/LatLongField.ts @@ -14,6 +14,7 @@ import { import { NumberField } from '~/src/server/plugins/engine/components/NumberField.js' import { createLowerFirstExpression } from '~/src/server/plugins/engine/components/helpers/index.js' import { type LatLongState } from '~/src/server/plugins/engine/components/types.js' +import { t } from '~/src/server/plugins/engine/i18n/index.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -51,31 +52,42 @@ export class LatLongField extends FormComponent { const longitudeMin = schema?.longitude?.min ?? -13.687 const longitudeMax = schema?.longitude?.max ?? 1.767 - const latitudeRequired = 'Enter latitude' - const longitudeRequired = 'Enter longitude' + const lang = props.model.language + const fieldLabel = lowerFirst(this.label) const customValidationMessages: LanguageMessages = convertToLanguageMessages({ - 'number.precision': - '{{#label}} must have no more than 7 decimal places', - 'number.unsafe': '{{#label}} must be a valid number' + 'number.precision': t('components.latLongField.precision', lang), + 'number.unsafe': t('components.latLongField.notANumber', lang) }) - const latitudeRangeMessage = `Latitude for ${lowerFirst(this.label)} must be between ${latitudeMin} and ${latitudeMax}` - const longitudeRangeMessage = `Longitude for ${lowerFirst(this.label)} must be between ${longitudeMin} and ${longitudeMax}` + const latitudeRangeMessage = t( + 'components.latLongField.latitudeRange', + lang, + { fieldLabel, min: latitudeMin, max: latitudeMax } + ) + const longitudeRangeMessage = t( + 'components.latLongField.longitudeRange', + lang, + { fieldLabel, min: longitudeMin, max: longitudeMax } + ) const latitudeMessages: LanguageMessages = convertToLanguageMessages({ ...customValidationMessages, - 'any.required': latitudeRequired, - 'number.base': `Enter a valid latitude for ${lowerFirst(this.label)} like 51.519450`, + 'any.required': t('components.latLongField.latitudeRequired', lang), + 'number.base': t('components.latLongField.latitudeBase', lang, { + fieldLabel + }), 'number.min': latitudeRangeMessage, 'number.max': latitudeRangeMessage }) const longitudeMessages: LanguageMessages = convertToLanguageMessages({ ...customValidationMessages, - 'any.required': longitudeRequired, - 'number.base': `Enter a valid longitude for ${lowerFirst(this.label)} like -0.127758`, + 'any.required': t('components.latLongField.longitudeRequired', lang), + 'number.base': t('components.latLongField.longitudeBase', lang, { + fieldLabel + }), 'number.min': longitudeRangeMessage, 'number.max': longitudeRangeMessage }) @@ -85,7 +97,7 @@ export class LatLongField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__latitude`, - title: 'Latitude', + title: t('components.latLongField.latitude', lang), schema: { min: latitudeMin, max: latitudeMax, @@ -102,7 +114,7 @@ export class LatLongField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__longitude`, - title: 'Longitude', + title: t('components.latLongField.longitude', lang), schema: { min: longitudeMin, max: longitudeMax, From e4811d3d44874790270acc0ddbf4083192f6bee2 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 17 Apr 2026 04:02:16 +0100 Subject: [PATCH 09/89] feat(i18n): translate LocationFieldBase, NationalGridField, OsGridRefField strings --- .../engine/components/LocationFieldBase.ts | 11 +++++------ .../components/NationalGridFieldNumberField.ts | 16 ++++++++-------- .../plugins/engine/components/OsGridRefField.ts | 16 ++++++++-------- 3 files changed, 21 insertions(+), 22 deletions(-) diff --git a/src/server/plugins/engine/components/LocationFieldBase.ts b/src/server/plugins/engine/components/LocationFieldBase.ts index ed1c943a1..4c6749d26 100644 --- a/src/server/plugins/engine/components/LocationFieldBase.ts +++ b/src/server/plugins/engine/components/LocationFieldBase.ts @@ -10,7 +10,6 @@ import { isFormValue } from '~/src/server/plugins/engine/components/FormComponent.js' import { addClassOptionIfNone } from '~/src/server/plugins/engine/components/helpers/index.js' -import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, type FormPayload, @@ -44,8 +43,8 @@ export abstract class LocationFieldBase extends FormComponent { declare stateSchema: StringSchema instructionText?: string - protected abstract getValidationConfig(): ValidationConfig - protected abstract getErrorTemplates(): { + protected abstract getValidationConfig(lang?: string): ValidationConfig + protected abstract getErrorTemplates(lang?: string): { type: string template: JoiExpression }[] @@ -62,9 +61,9 @@ export abstract class LocationFieldBase extends FormComponent { addClassOptionIfNone(locationOptions, 'govuk-input--width-10') - const config = this.getValidationConfig() + const config = this.getValidationConfig(this.model.language) const requiredMessage = - config.requiredMessage ?? (messageTemplate.required as string) + config.requiredMessage ?? this.model.validationMessages.required const messages = convertToLanguageMessages({ 'any.required': requiredMessage, @@ -141,7 +140,7 @@ export abstract class LocationFieldBase extends FormComponent { { type: 'required', template: - config.requiredMessage ?? (messageTemplate.required as string) + config.requiredMessage ?? this.model.validationMessages.required }, ...this.getErrorTemplates() ], diff --git a/src/server/plugins/engine/components/NationalGridFieldNumberField.ts b/src/server/plugins/engine/components/NationalGridFieldNumberField.ts index acf08c955..97f30c38a 100644 --- a/src/server/plugins/engine/components/NationalGridFieldNumberField.ts +++ b/src/server/plugins/engine/components/NationalGridFieldNumberField.ts @@ -2,35 +2,35 @@ import { type NationalGridFieldNumberFieldComponent } from '@defra/forms-model' import { LocationFieldBase } from '~/src/server/plugins/engine/components/LocationFieldBase.js' import { createLowerFirstExpression } from '~/src/server/plugins/engine/components/helpers/index.js' +import { t } from '~/src/server/plugins/engine/i18n/index.js' export class NationalGridFieldNumberField extends LocationFieldBase { declare options: NationalGridFieldNumberFieldComponent['options'] - protected getValidationConfig() { + protected getValidationConfig(lang = 'en-GB') { // Regex for OS national grid field references (NGFR) // Validates specific valid OS grid letter combinations with: // - 2 letters & 8 digits in 2 blocks of 4 e.g. ST 6789 6789 const pattern = /^((([sS]|[nN])[a-hA-Hj-zJ-Z])|(([tT]|[oO])[abfglmqrvwABFGLMQRVW])|([hH][l-zL-Z])|([jJ][lmqrvwLMQRVW]))\s?([0-9]{4})\s?([0-9]{4})$/ - const patternTemplate = - 'Enter a valid National Grid field number for {{lowerFirst(#title)}} like NG 1234 5678' - return { pattern, - patternErrorMessage: createLowerFirstExpression(patternTemplate), + patternErrorMessage: createLowerFirstExpression( + t('components.nationalGridField.pattern', lang) + ), requiredMessage: createLowerFirstExpression( - 'Enter {{lowerFirst(#title)}}' + t('components.nationalGridField.required', lang) ) } } - protected getErrorTemplates() { + protected getErrorTemplates(lang = 'en-GB') { return [ { type: 'pattern', template: createLowerFirstExpression( - 'Enter a valid National Grid field number for {{lowerFirst(#title)}} like NG 1234 5678' + t('components.nationalGridField.pattern', lang) ) } ] diff --git a/src/server/plugins/engine/components/OsGridRefField.ts b/src/server/plugins/engine/components/OsGridRefField.ts index f2561e7f6..d3df96aab 100644 --- a/src/server/plugins/engine/components/OsGridRefField.ts +++ b/src/server/plugins/engine/components/OsGridRefField.ts @@ -2,11 +2,12 @@ import { type OsGridRefFieldComponent } from '@defra/forms-model' import { LocationFieldBase } from '~/src/server/plugins/engine/components/LocationFieldBase.js' import { createLowerFirstExpression } from '~/src/server/plugins/engine/components/helpers/index.js' +import { t } from '~/src/server/plugins/engine/i18n/index.js' export class OsGridRefField extends LocationFieldBase { declare options: OsGridRefFieldComponent['options'] - protected getValidationConfig() { + protected getValidationConfig(lang = 'en-GB') { // Regex for OS national grid references (NGR) // Validates specific valid OS grid letter combinations with: // - 2 letters & 6 digits in 2 blocks of 3 e.g. ST 678 678 @@ -16,24 +17,23 @@ export class OsGridRefField extends LocationFieldBase { const pattern = /^((([sS]|[nN])[a-hA-Hj-zJ-Z])|(([tT]|[oO])[abfglmqrvwABFGLMQRVW])|([hH][l-zL-Z])|([jJ][lmqrvwLMQRVW]))\s?(([0-9]{3})\s?([0-9]{3})|([0-9]{4})\s?([0-9]{4})|([0-9]{5})\s?([0-9]{5}))$/ - const patternTemplate = - 'Enter a valid OS grid reference for {{lowerFirst(#title)}} like TQ123456' - return { pattern, - patternErrorMessage: createLowerFirstExpression(patternTemplate), + patternErrorMessage: createLowerFirstExpression( + t('components.osGridRefField.pattern', lang) + ), requiredMessage: createLowerFirstExpression( - 'Enter {{lowerFirst(#title)}}' + t('components.osGridRefField.required', lang) ) } } - protected getErrorTemplates() { + protected getErrorTemplates(lang = 'en-GB') { return [ { type: 'pattern', template: createLowerFirstExpression( - 'Enter a valid OS grid reference for {{lowerFirst(#title)}} like TQ123456' + t('components.osGridRefField.pattern', lang) ) } ] From a7632e96b8e0ef855ce30b41f5006d6e6145c98b Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 17 Apr 2026 04:03:59 +0100 Subject: [PATCH 10/89] feat(i18n): translate DeclarationField and GeospatialField strings --- .../engine/components/DeclarationField.ts | 16 +++++++++------- .../plugins/engine/components/GeospatialField.ts | 7 ++++--- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/server/plugins/engine/components/DeclarationField.ts b/src/server/plugins/engine/components/DeclarationField.ts index dcaa7a1cb..1fb9274fc 100644 --- a/src/server/plugins/engine/components/DeclarationField.ts +++ b/src/server/plugins/engine/components/DeclarationField.ts @@ -15,6 +15,7 @@ import { FormComponent, isFormValue } from '~/src/server/plugins/engine/components/FormComponent.js' +import { t } from '~/src/server/plugins/engine/i18n/index.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -27,8 +28,6 @@ import { } from '~/src/server/plugins/engine/types.js' export class DeclarationField extends FormComponent { - private readonly DEFAULT_DECLARATION_LABEL = 'I understand and agree' - declare options: DeclarationFieldComponent['options'] declare declarationConfirmationLabel: string @@ -58,10 +57,12 @@ export class DeclarationField extends FormComponent { .label(this.label) .single() .messages({ - 'any.required': messageTemplate.declarationRequired as string, - 'any.unknown': messageTemplate.declarationRequired as string, - 'array.includesRequiredUnknowns': - messageTemplate.declarationRequired as string + 'any.required': props.model.validationMessages + .declarationRequired as string, + 'any.unknown': props.model.validationMessages + .declarationRequired as string, + 'array.includesRequiredUnknowns': props.model.validationMessages + .declarationRequired as string }) as ArraySchema this.formSchema = formSchema @@ -70,7 +71,8 @@ export class DeclarationField extends FormComponent { this.options = options this.content = content this.declarationConfirmationLabel = - options.declarationConfirmationLabel ?? this.DEFAULT_DECLARATION_LABEL + options.declarationConfirmationLabel ?? + t('components.declarationField.defaultLabel', props.model.language) const formComponents = hasFormComponents(props.page?.pageDef) ? props.page.pageDef.components : [] diff --git a/src/server/plugins/engine/components/GeospatialField.ts b/src/server/plugins/engine/components/GeospatialField.ts index 59538c22d..e23f6846c 100644 --- a/src/server/plugins/engine/components/GeospatialField.ts +++ b/src/server/plugins/engine/components/GeospatialField.ts @@ -7,6 +7,7 @@ import { isGeospatialState } from '~/src/server/plugins/engine/components/FormComponent.js' import { geospatialSchema } from '~/src/server/plugins/engine/components/helpers/geospatial.js' +import { t } from '~/src/server/plugins/engine/i18n/index.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -58,9 +59,9 @@ export class GeospatialField extends FormComponent { return '' } - const unit = features.length === 1 ? 'location' : 'locations' - - return `Added ${features.length} ${unit}` + return t('components.geospatialField.added', this.model.language, { + count: features.length + }) } getDisplayStringFromState(state: FormSubmissionState) { From c41db714b5a61a2e5252856eb9228ebdbea231c1 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 17 Apr 2026 04:04:47 +0100 Subject: [PATCH 11/89] feat(i18n): translate PaymentField and FileUploadField runtime strings --- .../plugins/engine/components/FileUploadField.ts | 8 ++++++-- .../plugins/engine/components/PaymentField.ts | 13 ++++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/server/plugins/engine/components/FileUploadField.ts b/src/server/plugins/engine/components/FileUploadField.ts index 3a5ce729a..ee2952839 100644 --- a/src/server/plugins/engine/components/FileUploadField.ts +++ b/src/server/plugins/engine/components/FileUploadField.ts @@ -9,6 +9,7 @@ import { FormComponent, isUploadState } from '~/src/server/plugins/engine/components/FormComponent.js' +import { t } from '~/src/server/plugins/engine/i18n/index.js' import { InvalidComponentStateError } from '~/src/server/plugins/engine/pageControllers/errors.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { @@ -213,7 +214,10 @@ export class FileUploadField extends FormComponent { const { form } = status const { file } = form - const tag = { classes: 'govuk-tag--green', text: 'Uploaded' } + const tag = { + classes: 'govuk-tag--green', + text: t('components.fileUploadField.uploaded', this.model.language) + } const valueHtml = render .view('components/fileuploadfield-value.html', { @@ -347,7 +351,7 @@ export class FileUploadField extends FormComponent { // Scenarios: file missing from S3, invalid retrieval key (timing problem), etc. throw new InvalidComponentStateError( this, - 'There was a problem with your uploaded files. Re-upload them before submitting the form again.' + t('components.fileUploadField.uploadFailed', this.model.language) ) } diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts index ddac28bab..5140472e4 100644 --- a/src/server/plugins/engine/components/PaymentField.ts +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -14,6 +14,7 @@ import { createError, getPluginOptions } from '~/src/server/plugins/engine/helpers.js' +import { t } from '~/src/server/plugins/engine/i18n/index.js' import { PaymentErrorTypes, PaymentPreAuthError, @@ -239,9 +240,10 @@ export class PaymentField extends FormComponent { ) if (!payment) { + const lang = model.language const message = isLivePayment - ? 'There is a problem and we cannot take a payment. Contact us (details in the footer of this form) or save your progress and return to the form later.' - : 'Add a valid test API key before you can preview the payment journey.' + ? t('components.paymentField.cannotTakePayment', lang) + : t('components.paymentField.testApiKey', lang) const govukError = createError(componentName, message) request.yar.flash(COMPONENT_STATE_ERROR, govukError, true) return h.redirect(request.url.href).code(StatusCodes.SEE_OTHER) @@ -277,9 +279,10 @@ export class PaymentField extends FormComponent { const paymentState = this.getPaymentStateFromState(context.state) if (!paymentState) { + const lang = this.model.language throw new PaymentPreAuthError( this, - 'Complete the payment to continue', + t('components.paymentField.completePayment', lang), true, PaymentErrorTypes.PaymentIncomplete ) @@ -319,7 +322,7 @@ export class PaymentField extends FormComponent { if (status.state.status !== 'capturable') { throw new PaymentPreAuthError( this, - 'Your payment authorisation has expired. Please add your payment details again.', + t('components.paymentField.paymentExpired', this.model.language), true, PaymentErrorTypes.PaymentExpired ) @@ -333,7 +336,7 @@ export class PaymentField extends FormComponent { if (!captured) { throw new PaymentPreAuthError( this, - 'There was a problem and your form was not submitted. Try submitting the form again.', + t('components.paymentField.submissionFailed', this.model.language), false ) } From 2c216153f008320c32021107eefaffd4654ddca1 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 17 Apr 2026 04:05:12 +0100 Subject: [PATCH 12/89] feat(i18n): translate optionalText in FormComponent.getViewModel --- src/server/plugins/engine/components/FormComponent.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/plugins/engine/components/FormComponent.ts b/src/server/plugins/engine/components/FormComponent.ts index d6686fd3c..eb425d660 100644 --- a/src/server/plugins/engine/components/FormComponent.ts +++ b/src/server/plugins/engine/components/FormComponent.ts @@ -5,7 +5,7 @@ import { } 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 { t } from '~/src/server/plugins/engine/i18n/index.js' import { type FormContext, type FormRequestPayload @@ -135,7 +135,7 @@ export class FormComponent extends ComponentBase { const isRequired = !('required' in options) || options.required !== false const hideOptional = 'optionalText' in options && options.optionalText - const label = `${title}${!isRequired && !hideOptional ? optionalText : ''}` + const label = `${title}${!isRequired && !hideOptional ? t('common.optional', this.model.language) : ''}` if (hint) { viewModel.hint = { From 0fd0c4f2f4391dd92c5ec80ca2490f8d2677cea9 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 17 Apr 2026 04:06:08 +0100 Subject: [PATCH 13/89] feat(i18n): translate SummaryViewModel and SummaryPageController payment strings --- .../plugins/engine/models/SummaryViewModel.ts | 9 ++++++--- .../engine/pageControllers/SummaryPageController.ts | 13 ++++++++----- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/server/plugins/engine/models/SummaryViewModel.ts b/src/server/plugins/engine/models/SummaryViewModel.ts index 52ec20f4e..094dec852 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.ts @@ -15,6 +15,7 @@ import { getError, getPageHref } from '~/src/server/plugins/engine/helpers.js' +import { t } from '~/src/server/plugins/engine/i18n/index.js' import { type Detail, type DetailItem, @@ -66,10 +67,12 @@ export class SummaryViewModel { const { basePath, def, sections } = model const { isForceAccess } = context + const lang = model.language + this.page = page this.pageTitle = page.title if (def.schema === SchemaVersion.V2 && !page.title) { - this.pageTitle = 'Check your answers before sending your form' + this.pageTitle = t('pages.summary.title', lang) } this.serviceUrl = `/${basePath}` @@ -96,7 +99,7 @@ export class SummaryViewModel { if (!isForceAccess) { items.push({ href: item.href, - text: 'Change', + text: t('pages.summary.change', lang), classes: 'govuk-link--no-visited-state', visuallyHiddenText: item.label }) @@ -108,7 +111,7 @@ export class SummaryViewModel { }, value: { classes: 'app-prose-scope', - html: item.value || 'Not provided' + html: item.value || t('pages.summary.notProvided', lang) }, actions: { items diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index 22fdcd25b..a94273326 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -19,6 +19,7 @@ import { createError, getCacheService } from '~/src/server/plugins/engine/helpers.js' +import { t } from '~/src/server/plugins/engine/i18n/index.js' import { SummaryViewModel, type FormModel @@ -118,30 +119,32 @@ export class SummaryPageController extends QuestionPageController { ReturnType > ) { + const lang = this.model.language + const rows = [ { - key: { text: 'Payment for' }, + key: { text: t('pages.summary.paymentFor', lang) }, value: { text: paymentState.description } }, { - key: { text: 'Total amount' }, + key: { text: t('pages.summary.totalAmount', lang) }, value: { text: formatCurrency(paymentState.amount) } }, { - key: { text: 'Reference' }, + key: { text: t('pages.summary.reference', lang) }, value: { text: paymentState.reference } } ] if (paymentState.preAuth?.createdAt) { rows.push({ - key: { text: 'Date of payment' }, + key: { text: t('pages.summary.dateOfPayment', lang) }, value: { text: formatPaymentDate(paymentState.preAuth.createdAt) } }) } return { - title: { text: 'Payment details' }, + title: { text: t('pages.summary.paymentDetailsTitle', lang) }, summaryList: { rows } } } From 94e3bca7f4cbe9eb567789a1817f56dc57e8b789 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 17 Apr 2026 07:57:39 +0100 Subject: [PATCH 14/89] feat(i18n): translate all RepeatPageController hardcoded strings --- .../pageControllers/RepeatPageController.ts | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/server/plugins/engine/pageControllers/RepeatPageController.ts b/src/server/plugins/engine/pageControllers/RepeatPageController.ts index e9656249c..e60878cff 100644 --- a/src/server/plugins/engine/pageControllers/RepeatPageController.ts +++ b/src/server/plugins/engine/pageControllers/RepeatPageController.ts @@ -6,6 +6,7 @@ import Joi from 'joi' import { isRepeatState } from '~/src/server/plugins/engine/components/FormComponent.js' import { redirectPath } from '~/src/server/plugins/engine/helpers.js' +import { t } from '~/src/server/plugins/engine/i18n/index.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' import { @@ -232,7 +233,7 @@ export class RepeatPageController extends QuestionPageController { // Show error if repeat limits apply if (hasErrorMin || hasErrorMax) { const count = hasErrorMax ? schema.max : schema.min - const itemTitle = `answer${count === 1 ? '' : 's'}` + const lang = this.model.language context.errors = [ { @@ -240,8 +241,8 @@ export class RepeatPageController extends QuestionPageController { href: '', name: '', text: hasErrorMax - ? `You can only add up to ${count} ${itemTitle}` - : `You must add at least ${count} ${itemTitle}` + ? t('pages.repeater.tooMany', lang, { count }) + : t('pages.repeater.tooFew', lang, { count }) } ] @@ -290,15 +291,16 @@ export class RepeatPageController extends QuestionPageController { } const { title } = this.repeat.options + const lang = this.model.language return h.view(this.listDeleteViewName, { ...viewModel, context, backLink: this.getBackLink(request, context), - pageTitle: 'Are you sure you want to remove this answer?', + pageTitle: t('pages.repeater.removeAnswer', lang), itemTitle: `${title} ${list.indexOf(item) + 1}`, - buttonConfirm: { text: 'Remove' }, - buttonCancel: { text: 'Cancel' } + buttonConfirm: { text: t('pages.repeater.remove', lang) }, + buttonCancel: { text: t('pages.repeater.cancel', lang) } } satisfies ItemDeletePageViewModel) } } @@ -374,6 +376,7 @@ export class RepeatPageController extends QuestionPageController { const { isForceAccess, errors } = context const { title } = repeat.options + const lang = this.model.language const summaryList: SummaryList = { classes: 'govuk-summary-list--long-actions', @@ -396,9 +399,11 @@ export class RepeatPageController extends QuestionPageController { href: redirectPath(`${href}/${item.itemId}`, { returnUrl: query.returnUrl ?? this.getHref(summaryPath) }), - text: 'Change', + text: t('pages.repeater.change', lang), classes: 'govuk-link--no-visited-state', - visuallyHiddenText: `item ${index + 1}` + visuallyHiddenText: t('pages.repeater.visuallyHiddenItem', lang, { + index: index + 1 + }) }) if (count > 1) { @@ -406,9 +411,11 @@ export class RepeatPageController extends QuestionPageController { href: redirectPath(`${href}/${item.itemId}/confirm-delete`, { returnUrl: query.returnUrl }), - text: 'Remove', + text: t('pages.repeater.remove', lang), classes: 'govuk-link--no-visited-state', - visuallyHiddenText: `item ${index + 1}` + visuallyHiddenText: t('pages.repeater.visuallyHiddenItem', lang, { + index: index + 1 + }) }) } } @@ -422,7 +429,7 @@ export class RepeatPageController extends QuestionPageController { text: `${title} ${index + 1}` }, value: { - text: itemDisplayText || 'Not provided' + text: itemDisplayText || t('pages.repeater.notProvided', lang) }, actions: { items @@ -431,13 +438,11 @@ export class RepeatPageController extends QuestionPageController { }) } - const unit = count === 1 ? 'answer' : 'answers' - return { ...this.viewModel, backLink: this.getBackLink(request, context), repeatTitle: title, - pageTitle: `You have added ${count} ${unit}`, + pageTitle: t('pages.repeater.pageTitle', lang, { count }), showTitle: true, context, errors, From 1f1cb2c0e1e9e473c6e7c931baa84c29d2618c43 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 17 Apr 2026 08:00:44 +0100 Subject: [PATCH 15/89] feat(i18n): register t() as Nunjucks global with en-GB fallback --- src/server/plugins/nunjucks/enviroment.test.js | 12 ++++++++++++ src/server/plugins/nunjucks/environment.js | 14 ++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/server/plugins/nunjucks/enviroment.test.js b/src/server/plugins/nunjucks/enviroment.test.js index 4bdb62cda..bd8454945 100644 --- a/src/server/plugins/nunjucks/enviroment.test.js +++ b/src/server/plugins/nunjucks/enviroment.test.js @@ -2,6 +2,18 @@ import * as helpers from '~/src/server/plugins/engine/helpers.js' import { environment } from '~/src/server/plugins/nunjucks/environment.js' describe('Nunjucks environment', () => { + describe('t global function', () => { + it('has t as a global function', () => { + const globals = environment.globals + expect(typeof globals.t).toBe('function') + }) + + it('t global returns the English string for a known key', () => { + const globals = environment.globals + expect(globals.t('errors.title')).toBe('There is a problem') + }) + }) + describe('checkErrorTemplates function', () => { /** @type {Function} */ let checkErrorTemplates diff --git a/src/server/plugins/nunjucks/environment.js b/src/server/plugins/nunjucks/environment.js index 91cb085fc..97a6d8cfd 100644 --- a/src/server/plugins/nunjucks/environment.js +++ b/src/server/plugins/nunjucks/environment.js @@ -6,6 +6,7 @@ import resolvePkg from 'resolve' import { config } from '~/src/config/index.js' import { evaluateTemplate } from '~/src/server/plugins/engine/helpers.js' +import { t as i18nT } from '~/src/server/plugins/engine/i18n/index.js' import * as filters from '~/src/server/plugins/nunjucks/filters/index.js' const govukFrontendPath = dirname( @@ -115,6 +116,19 @@ export function govukRebrand() { environment.addGlobal('govukRebrand', govukRebrand()) +/** + * Global t() function — en-GB fallback for templates not wired to a specific language. + * Per-request page controllers override this by passing t in their view model. + * @param {string} key + * @param {Record} [opts] + * @returns {string} + */ +export function tGlobal(key, opts) { + return i18nT(key, 'en-GB', opts) +} + +environment.addGlobal('t', tGlobal) + /** * @import { NunjucksContext } from '~/src/server/plugins/nunjucks/types.js' * @import { FormSubmissionError } from '~/src/server/plugins/engine/types.js' From 74bd9138ec16d914e1b455115b1d63ee0cd0e3be Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 17 Apr 2026 08:04:15 +0100 Subject: [PATCH 16/89] feat(i18n): wire per-request t() into all page controller view models --- .../plugins/engine/models/SummaryViewModel.ts | 1 + .../engine/pageControllers/PageController.ts | 7 ++++++- .../pageControllers/QuestionPageController.ts | 13 +++++++++++-- .../pageControllers/RepeatPageController.ts | 16 +++++++++++++--- .../pageControllers/StatusPageController.ts | 5 ++++- .../pageControllers/SummaryPageController.ts | 3 +++ src/server/plugins/engine/types.ts | 1 + 7 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/server/plugins/engine/models/SummaryViewModel.ts b/src/server/plugins/engine/models/SummaryViewModel.ts index 094dec852..e56390514 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.ts @@ -57,6 +57,7 @@ export class SummaryViewModel { allowSaveAndExit = false paymentState?: PaymentState paymentDetails?: CheckAnswers + t?: (key: string, opts?: Record) => string constructor( request: FormContextRequest, diff --git a/src/server/plugins/engine/pageControllers/PageController.ts b/src/server/plugins/engine/pageControllers/PageController.ts index 426bd97d4..c41009d49 100644 --- a/src/server/plugins/engine/pageControllers/PageController.ts +++ b/src/server/plugins/engine/pageControllers/PageController.ts @@ -15,6 +15,7 @@ import { getStartPath, normalisePath } from '~/src/server/plugins/engine/helpers.js' +import { t } from '~/src/server/plugins/engine/i18n/index.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { type ExecutableCondition } from '~/src/server/plugins/engine/models/types.js' import { @@ -168,7 +169,11 @@ export class PageController { ) => ReturnType> { return (request, context, h) => { const { viewModel, viewName } = this - return h.view(viewName, viewModel) + return h.view(viewName, { + ...viewModel, + t: (key: string, opts?: Record) => + t(key, this.model.language, opts) + }) } } diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index 48ec5b9c5..5c5eb03ba 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -29,6 +29,7 @@ import { normalisePath, proceed } from '~/src/server/plugins/engine/helpers.js' +import { t } from '~/src/server/plugins/engine/i18n/index.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js' import { prefillStateFromQueryParameters } from '~/src/server/plugins/engine/pageControllers/helpers/state.js' @@ -453,7 +454,11 @@ export class QuestionPageController extends PageController { viewModel.hasMissingNotificationEmail = await this.hasMissingNotificationEmail(request, context) - return h.view(viewName, viewModel) + return h.view(viewName, { + ...viewModel, + t: (key: string, opts?: Record) => + t(key, this.model.language, opts) + }) } } @@ -551,7 +556,11 @@ export class QuestionPageController extends PageController { evaluationState ) - return h.view(viewName, viewModel) + return h.view(viewName, { + ...viewModel, + t: (key: string, opts?: Record) => + t(key, this.model.language, opts) + }) } // Save state diff --git a/src/server/plugins/engine/pageControllers/RepeatPageController.ts b/src/server/plugins/engine/pageControllers/RepeatPageController.ts index e60878cff..56d998e2e 100644 --- a/src/server/plugins/engine/pageControllers/RepeatPageController.ts +++ b/src/server/plugins/engine/pageControllers/RepeatPageController.ts @@ -196,7 +196,11 @@ export class RepeatPageController extends QuestionPageController { const viewModel = this.getListSummaryViewModel(request, context, list) - return h.view(this.listSummaryViewName, viewModel) + return h.view(this.listSummaryViewName, { + ...viewModel, + t: (key: string, opts?: Record) => + t(key, this.model.language, opts) + }) } } @@ -248,7 +252,11 @@ export class RepeatPageController extends QuestionPageController { const viewModel = this.getListSummaryViewModel(request, context, list) - return h.view(this.listSummaryViewName, viewModel) + return h.view(this.listSummaryViewName, { + ...viewModel, + t: (key: string, opts?: Record) => + t(key, this.model.language, opts) + }) } if (action === FormAction.AddAnother) { @@ -300,7 +308,9 @@ export class RepeatPageController extends QuestionPageController { pageTitle: t('pages.repeater.removeAnswer', lang), itemTitle: `${title} ${list.indexOf(item) + 1}`, buttonConfirm: { text: t('pages.repeater.remove', lang) }, - buttonCancel: { text: t('pages.repeater.cancel', lang) } + buttonCancel: { text: t('pages.repeater.cancel', lang) }, + t: (key: string, opts?: Record) => + t(key, this.model.language, opts) } satisfies ItemDeletePageViewModel) } } diff --git a/src/server/plugins/engine/pageControllers/StatusPageController.ts b/src/server/plugins/engine/pageControllers/StatusPageController.ts index fddee7f67..65738a3c2 100644 --- a/src/server/plugins/engine/pageControllers/StatusPageController.ts +++ b/src/server/plugins/engine/pageControllers/StatusPageController.ts @@ -1,6 +1,7 @@ import { type PageStatus } from '@defra/forms-model' import { getCacheService } from '~/src/server/plugins/engine/helpers.js' +import { t } from '~/src/server/plugins/engine/i18n/index.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' import { type FormContext } from '~/src/server/plugins/engine/types.js' @@ -58,7 +59,9 @@ export class StatusPageController extends QuestionPageController { submissionGuidance, formName, showReferenceNumber: this.showReferenceNumber, - referenceNumber: confirmationState.referenceNumber + referenceNumber: confirmationState.referenceNumber, + t: (key: string, opts?: Record) => + t(key, this.model.language, opts) }) } } diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index a94273326..726461c99 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -165,6 +165,9 @@ export class SummaryPageController extends QuestionPageController { viewModel.hasMissingNotificationEmail = await this.hasMissingNotificationEmail(request, context) + viewModel.t = (key: string, opts?: Record) => + t(key, this.model.language, opts) + return h.view(viewName, viewModel) } } diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 4eb4bd086..d35f178f2 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -390,6 +390,7 @@ export interface PageViewModelBase extends Partial { feedbackLink?: string serviceUrl: string phaseTag?: string + t?: (key: string, opts?: Record) => string } export interface ItemDeletePageViewModel extends PageViewModelBase { From 3fbb5ca859fd594d37d0a65115e8fca9b82d2912 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 17 Apr 2026 09:54:40 +0100 Subject: [PATCH 17/89] feat(i18n): replace all hardcoded strings in HTML templates with t() calls --- .../views/components/_location-field-base.html | 2 +- .../engine/views/components/fileuploadfield.html | 6 +++--- .../components/nationalgridfieldnumberfield.html | 2 +- .../engine/views/components/osgridreffield.html | 2 +- .../engine/views/components/paymentfield.html | 16 ++++++++-------- .../engine/views/components/ukaddressfield.html | 8 ++++---- .../plugins/engine/views/confirmation.html | 6 +++--- src/server/plugins/engine/views/exit.html | 8 ++++---- src/server/plugins/engine/views/file-upload.html | 6 +++--- src/server/plugins/engine/views/index.html | 6 +++--- src/server/plugins/engine/views/item-delete.html | 2 +- .../warn-missing-notification-email.html | 6 ++++-- .../engine/views/repeat-list-summary.html | 8 ++++---- src/server/plugins/engine/views/summary.html | 12 ++++++------ 14 files changed, 46 insertions(+), 44 deletions(-) diff --git a/src/server/plugins/engine/views/components/_location-field-base.html b/src/server/plugins/engine/views/components/_location-field-base.html index 5fdc3f4ec..aa293b61e 100644 --- a/src/server/plugins/engine/views/components/_location-field-base.html +++ b/src/server/plugins/engine/views/components/_location-field-base.html @@ -61,7 +61,7 @@ {% set detailsHtml %} {% if component.model.instructionText %} {{ govukDetails({ - summaryText: "How to find location details", + summaryText: t('components.locationFieldBase.howToFind'), html: component.model.instructionText | markdown | safe, classes: "govuk-!-margin-top-3" }) }} diff --git a/src/server/plugins/engine/views/components/fileuploadfield.html b/src/server/plugins/engine/views/components/fileuploadfield.html index 2c1187396..c2fb4f3fe 100644 --- a/src/server/plugins/engine/views/components/fileuploadfield.html +++ b/src/server/plugins/engine/views/components/fileuploadfield.html @@ -4,9 +4,9 @@ {% set upload = component.model.upload %}
-

Uploaded files

+

{{ t('components.fileUploadField.uploadedFilesHeading') }}

{% if upload.count %} - +

@@ -14,7 +14,7 @@

Uploaded files

{{ govukSummaryList(upload.summaryList) }} {% endif %} {% else %} -

{{upload.count}} files uploaded

+

{{ t('components.fileUploadField.filesCount', { count: upload.count }) }}

{% endif %}
{% endmacro %} diff --git a/src/server/plugins/engine/views/components/nationalgridfieldnumberfield.html b/src/server/plugins/engine/views/components/nationalgridfieldnumberfield.html index 6611b6337..25eceaf52 100644 --- a/src/server/plugins/engine/views/components/nationalgridfieldnumberfield.html +++ b/src/server/plugins/engine/views/components/nationalgridfieldnumberfield.html @@ -8,7 +8,7 @@ {% if component.model.instructionText %} {{ govukDetails({ - summaryText: "How to find location details", + summaryText: t('components.nationalGridField.howToFind'), html: component.model.instructionText | markdown | safe, classes: "govuk-!-margin-top-3" }) }} diff --git a/src/server/plugins/engine/views/components/osgridreffield.html b/src/server/plugins/engine/views/components/osgridreffield.html index 3f1ff7203..6c15957e6 100644 --- a/src/server/plugins/engine/views/components/osgridreffield.html +++ b/src/server/plugins/engine/views/components/osgridreffield.html @@ -10,7 +10,7 @@ {% if component.model.instructionText %} {{ govukDetails({ - summaryText: "How to find location details", + summaryText: t('components.osGridRefField.howToFind'), html: component.model.instructionText | markdown | safe, classes: "govuk-!-margin-top-3" }) }} diff --git a/src/server/plugins/engine/views/components/paymentfield.html b/src/server/plugins/engine/views/components/paymentfield.html index 7eafaf07f..7f0bf8c8f 100644 --- a/src/server/plugins/engine/views/components/paymentfield.html +++ b/src/server/plugins/engine/views/components/paymentfield.html @@ -11,27 +11,27 @@
{% if isPreAuthorised %} {# Payment already pre-authorised - show confirmation message #} -

You have already authorised a payment for this form

+

{{ t('components.paymentField.preAuthorisedTitle') }}

-

Continue to submit the form. You will not be charged twice.

+

{{ t('components.paymentField.preAuthorisedBody') }}

{% else %} {# No pre-authorisation - show payment form #} -

{{ model.label.text if model.label and model.label.text else "Payment details required" }}

+

{{ model.label.text if model.label and model.label.text else t('components.paymentField.detailsRequired') }}

{{ description }}

{{ govukWarningText({ - text: "You may see a pending transaction in your bank account but you will only be charged when you submit the form.", - iconFallbackText: "Warning" + text: t('components.paymentField.pendingTransaction'), + iconFallbackText: t('common.warningIconFallback') }) }} -

You can submit the form after you have added your payment details.

+

{{ t('components.paymentField.canSubmitAfterPayment') }}

-

Total amount:

+

{{ t('components.paymentField.totalAmountLabel') }}

{{ amount }}

{{ govukButton({ - text: "Add payment details", + text: t('components.paymentField.addPaymentDetails'), attributes: { name: "action", value: "external-" + model.name diff --git a/src/server/plugins/engine/views/components/ukaddressfield.html b/src/server/plugins/engine/views/components/ukaddressfield.html index aef9cddbf..ef2af33f5 100644 --- a/src/server/plugins/engine/views/components/ukaddressfield.html +++ b/src/server/plugins/engine/views/components/ukaddressfield.html @@ -37,13 +37,13 @@ {% if value %} {% set insetHtml %} - Selected address: + {{ t('components.addressField.selectedAddress') }}

{{ value }}

+ value="external-{{component.model.name}}">{{ t('components.addressField.useDifferentAddress') }}

{% endset %} @@ -54,14 +54,14 @@ {% else %}
{{ govukButton({ - text: "Find an address", + text: t('components.addressField.findAddress'), attributes: { name: "action", value: "external-" + component.model.name }, classes: "govuk-button--secondary govuk-!-margin-right-1 govuk-!-margin-bottom-0" }) }} -

or

+

or

{# Include a line break if this is the last component #} {% if components[components.length - 1] == component %} diff --git a/src/server/plugins/engine/views/confirmation.html b/src/server/plugins/engine/views/confirmation.html index d7df811d9..c3cf1d9b8 100644 --- a/src/server/plugins/engine/views/confirmation.html +++ b/src/server/plugins/engine/views/confirmation.html @@ -9,15 +9,15 @@
{{ govukPanel({ titleText: pageTitle, - html: "Your reference number
" + referenceNumber + "" if showReferenceNumber + html: t('pages.confirmation.referenceNumber') + "
" + referenceNumber + "" if showReferenceNumber }) }} -

What happens next

+

{{ t('pages.confirmation.whatHappensNext') }}

{{ submissionGuidance | markdown(3) | safe }}
{% if feedbackLink %}

- What do you think of this service? (takes 30 seconds) + {{ t('pages.confirmation.feedbackLink') }}

{% endif %}
diff --git a/src/server/plugins/engine/views/exit.html b/src/server/plugins/engine/views/exit.html index 20030e6d7..54c9b09ea 100644 --- a/src/server/plugins/engine/views/exit.html +++ b/src/server/plugins/engine/views/exit.html @@ -9,17 +9,17 @@
{{ govukPanel({ - titleText: pageTitle or "Your progress has been saved" + titleText: pageTitle or t('pages.exit.defaultTitle') }) }} -

What happens next

+

{{ t('pages.exit.whatHappensNext') }}

-

Your form progress has been saved. You can return to complete your application at any time using the link provided.

+

{{ t('pages.exit.body') }}

{% if returnUrl %}

{{ govukButton({ - text: "Return to application", + text: t('pages.exit.returnToApplication'), href: returnUrl, classes: "govuk-button--secondary" }) }} diff --git a/src/server/plugins/engine/views/file-upload.html b/src/server/plugins/engine/views/file-upload.html index 5da73b0d5..b2ac2e3a4 100644 --- a/src/server/plugins/engine/views/file-upload.html +++ b/src/server/plugins/engine/views/file-upload.html @@ -14,14 +14,14 @@ {% if formAction %} {{ govukButton({ - text: "Upload file", + text: t('pages.fileUpload.upload'), classes: "govuk-button--secondary upload-file-button", preventDoubleClick: true }) }} {% else %} {{ govukWarningText({ - text: "You have reached the maximum number of files. Please remove a file to upload more.", - iconFallbackText: "Warning" + text: t('pages.fileUpload.maxFilesReached'), + iconFallbackText: t('common.warningIconFallback') }) }} {% endif %} diff --git a/src/server/plugins/engine/views/index.html b/src/server/plugins/engine/views/index.html index 2c142e569..ddd2b3c93 100644 --- a/src/server/plugins/engine/views/index.html +++ b/src/server/plugins/engine/views/index.html @@ -13,14 +13,14 @@ {% if showPaymentExpiredNotification %} {{ govukNotificationBanner({ - titleText: "Important", - html: '

Your payment has been cancelled

Your payment details were deleted because the form was inactive for 5 days.

Add your payment details again.

' + titleText: t('pages.question.paymentCancelledBannerTitle'), + html: '

' + t('pages.question.paymentCancelledTitle') + '

' + t('pages.question.paymentCancelledBody') + '

' + t('pages.question.paymentCancelledAction') + '

' }) }} {% endif %} {% if errors | length and not showPaymentExpiredNotification %} {{ govukErrorSummary({ - titleText: "There is a problem", + titleText: t('errors.title'), errorList: checkErrorTemplates(errors) }) }} {% endif %} diff --git a/src/server/plugins/engine/views/item-delete.html b/src/server/plugins/engine/views/item-delete.html index e34861f78..1a1374608 100644 --- a/src/server/plugins/engine/views/item-delete.html +++ b/src/server/plugins/engine/views/item-delete.html @@ -13,7 +13,7 @@ {% if errors %} {{ govukErrorSummary({ - titleText: "There is a problem", + titleText: t('errors.title'), errorList: errors }) }} {% endif %} diff --git a/src/server/plugins/engine/views/partials/warn-missing-notification-email.html b/src/server/plugins/engine/views/partials/warn-missing-notification-email.html index b478c7ce9..18143a53b 100644 --- a/src/server/plugins/engine/views/partials/warn-missing-notification-email.html +++ b/src/server/plugins/engine/views/partials/warn-missing-notification-email.html @@ -1,10 +1,12 @@ {% from "govuk/components/warning-text/macro.njk" import govukWarningText %} +{% set linkText %}{{ t('pages.preview.warnMissingNotificationEmail.linkText') }}{% endset %} + {% set html %} - If you want to receive a test email, enter the email address (opens in new tab) you want form submissions to go to. + If you want to receive a test email, {{ linkText }} you want form submissions to go to. {% endset %} {{ govukWarningText({ html: html, - iconFallbackText: "Warning" + iconFallbackText: t('common.warningIconFallback') }) }} diff --git a/src/server/plugins/engine/views/repeat-list-summary.html b/src/server/plugins/engine/views/repeat-list-summary.html index 1befdaea7..2c201325a 100644 --- a/src/server/plugins/engine/views/repeat-list-summary.html +++ b/src/server/plugins/engine/views/repeat-list-summary.html @@ -13,7 +13,7 @@ {% if errors %} {{ govukErrorSummary({ - titleText: "There is a problem", + titleText: t('errors.title'), errorList: errors }) }} {% endif %} @@ -29,14 +29,14 @@
{{ govukButton({ - text: "Continue", + text: t('pages.repeater.continue'), name: "action", value: "continue", preventDoubleClick: true }) }} {{ govukButton({ - text: "Add another", + text: t('pages.repeater.addAnother'), name: "action", value: "add-another", classes: "govuk-button--secondary", @@ -45,7 +45,7 @@ {% if allowSaveAndExit %} {{ govukButton({ - text: "Save and exit", + text: t('pages.repeater.saveAndExit'), classes: "govuk-button--secondary", name: "action", value: "save-and-exit", diff --git a/src/server/plugins/engine/views/summary.html b/src/server/plugins/engine/views/summary.html index 100c952dc..9ec35533c 100644 --- a/src/server/plugins/engine/views/summary.html +++ b/src/server/plugins/engine/views/summary.html @@ -17,14 +17,14 @@ {% if paymentState and paymentState.preAuth and paymentState.preAuth.status == 'success' %} {{ govukNotificationBanner({ type: "success", - titleText: "Success", - html: "

We have your payment details

Your payment is on hold. We will charge you when you submit the form.

" + titleText: t('pages.summary.paymentSuccessBannerTitle'), + html: "

" + t('pages.summary.paymentSuccessTitle') + "

" + t('pages.summary.paymentSuccessBody') + "

" }) }} {% endif %} {% if errors %} {{ govukErrorSummary({ - titleText: "There is a problem", + titleText: t('errors.title'), errorList: checkErrorTemplates(errors) }) }} {% endif %} @@ -65,7 +65,7 @@

{{ componentList(components) }} {% if declaration %} -

Declaration

+

{{ t('pages.summary.declaration') }}

{{ declaration | safe }}
@@ -75,7 +75,7 @@

Declaration

{% set isDeclaration = declaration or components | length %} {{ govukButton({ - text: "Accept and submit" if isDeclaration else "Submit", + text: t('pages.summary.acceptAndSubmit') if isDeclaration else t('pages.summary.submit'), name: "action", value: "send", preventDoubleClick: true @@ -83,7 +83,7 @@

Declaration

{% if allowSaveAndExit %} {{ govukButton({ - text: "Save and exit", + text: t('pages.summary.saveAndExit'), classes: "govuk-button--secondary", name: "action", value: "save-and-exit", From 5083ad36e60889eff9f0b501d1e5d719e3e1531f Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 17 Apr 2026 09:59:24 +0100 Subject: [PATCH 18/89] =?UTF-8?q?feat(i18n):=20fix=20TypeScript=20errors?= =?UTF-8?q?=20=E2=80=94=20JSON=20import=20attribute=20and=20Nunjucks=20glo?= =?UTF-8?q?bals=20cast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/plugins/engine/i18n/index.ts | 7 +++---- src/server/plugins/nunjucks/enviroment.test.js | 6 ++++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/server/plugins/engine/i18n/index.ts b/src/server/plugins/engine/i18n/index.ts index 8e43e5b58..26df9d740 100644 --- a/src/server/plugins/engine/i18n/index.ts +++ b/src/server/plugins/engine/i18n/index.ts @@ -1,6 +1,6 @@ import i18next from 'i18next' -import enGB from '~/src/server/plugins/engine/i18n/translations/en-GB.json' +import enGB from '~/src/server/plugins/engine/i18n/translations/en-GB.json' with { type: 'json' } i18next .init({ @@ -12,11 +12,10 @@ i18next prefix: '[[', suffix: ']]', escapeValue: false - }, - initImmediate: false + } }) .catch(() => { - // Synchronous init (initImmediate: false) — this branch is unreachable + // init with inline resources completes synchronously — this branch is unreachable }) export function t( diff --git a/src/server/plugins/nunjucks/enviroment.test.js b/src/server/plugins/nunjucks/enviroment.test.js index bd8454945..49a93d161 100644 --- a/src/server/plugins/nunjucks/enviroment.test.js +++ b/src/server/plugins/nunjucks/enviroment.test.js @@ -4,12 +4,14 @@ import { environment } from '~/src/server/plugins/nunjucks/environment.js' describe('Nunjucks environment', () => { describe('t global function', () => { it('has t as a global function', () => { - const globals = environment.globals + + const globals = /** @type {any} */ (environment).globals expect(typeof globals.t).toBe('function') }) it('t global returns the English string for a known key', () => { - const globals = environment.globals + + const globals = /** @type {any} */ (environment).globals expect(globals.t('errors.title')).toBe('There is a problem') }) }) From 84d6a4d2afbdb88090991b471a00953d2cd7486c Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 17 Apr 2026 11:55:32 +0100 Subject: [PATCH 19/89] Add missing translations --- .../forms/register-as-a-unicorn-breeder.yaml | 14 -------------- .../plugins/engine/i18n/translations/en-GB.json | 5 +++++ src/server/plugins/engine/models/FormModel.ts | 14 ++++++++------ src/server/plugins/engine/views/partials/form.html | 6 +++--- .../views/postcode-lookup-details.html | 2 +- 5 files changed, 17 insertions(+), 24 deletions(-) diff --git a/src/server/forms/register-as-a-unicorn-breeder.yaml b/src/server/forms/register-as-a-unicorn-breeder.yaml index 66683758b..fa653641d 100644 --- a/src/server/forms/register-as-a-unicorn-breeder.yaml +++ b/src/server/forms/register-as-a-unicorn-breeder.yaml @@ -242,20 +242,6 @@ pages: content: 'Fill in this field' options: required: false - next: - - path: '/pay-for-your-licence' - - title: Pay for your licence - path: '/pay-for-your-licence' - section: section - components: - - name: licencePayment - title: Unicorn breeder licence fee - type: PaymentField - hint: You'll be redirected to GOV.UK Pay to complete your payment - options: - required: true - amount: 50 - description: Unicorn breeder annual licence fee next: - path: '/summary' conditions: diff --git a/src/server/plugins/engine/i18n/translations/en-GB.json b/src/server/plugins/engine/i18n/translations/en-GB.json index 8751bde45..b250cd9cc 100644 --- a/src/server/plugins/engine/i18n/translations/en-GB.json +++ b/src/server/plugins/engine/i18n/translations/en-GB.json @@ -184,6 +184,11 @@ "howToFind": "How to find location details" }, + "yesNoField": { + "yes": "Yes", + "no": "No" + }, + "declarationField": { "defaultLabel": "I understand and agree", "defaultConfirmationLabel": "I confirm that I understand and accept this declaration", diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index 14d6ad448..106491e2b 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -133,6 +133,11 @@ export class FormModel { // by joi so as not to change the source data. def = structuredClone(result.value) + const language = + typeof def.metadata?.language === 'string' + ? def.metadata.language + : 'en-GB' + // Add default lists def.lists.push({ id: def.schema === SchemaVersion.V1 ? yesNoListName : yesNoListId, @@ -142,12 +147,12 @@ export class FormModel { items: [ { id: '02900d42-83d1-4c72-a719-c4e8228952fa', - text: 'Yes', + text: t('components.yesNoField.yes', language), value: true }, { id: 'f39000eb-c51b-4019-8f82-bbda0423f04d', - text: 'No', + text: t('components.yesNoField.no', language), value: false } ] @@ -157,10 +162,7 @@ export class FormModel { setPageTitles(def) this.engine = def.engine - this.language = - typeof def.metadata?.language === 'string' - ? def.metadata.language - : 'en-GB' + this.language = language this.validationMessages = buildValidationMessages((key) => t(key, this.language) ) diff --git a/src/server/plugins/engine/views/partials/form.html b/src/server/plugins/engine/views/partials/form.html index fb19e3b0f..38460efaa 100644 --- a/src/server/plugins/engine/views/partials/form.html +++ b/src/server/plugins/engine/views/partials/form.html @@ -20,11 +20,11 @@ {{ componentList(components) }} {% if isStartPage %} - {% set buttonText = "Start now" %} + {% set buttonText = t('pages.question.startNow') %} {% elif submitButtonText %} {% set buttonText = submitButtonText %} {% else %} - {% set buttonText = "Continue" %} + {% set buttonText = t('common.continue') %} {% endif %}
@@ -38,7 +38,7 @@ {% if allowSaveAndExit %} {{ govukButton({ - text: "Save and exit", + text: t('common.saveAndExit'), classes: "govuk-button--secondary", name: "action", value: "save-and-exit", diff --git a/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html b/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html index 6e3d98482..8090445e5 100644 --- a/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html +++ b/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html @@ -13,7 +13,7 @@
{% if errors %} {{ govukErrorSummary({ - titleText: "There is a problem", + titleText: t('errors.title'), errorList: errors }) }} {% endif %} From f2362d22bab7bdfae66f2a5671df86d864d79cca Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 17 Apr 2026 12:01:54 +0100 Subject: [PATCH 20/89] wire in remaining translation keys --- .../plugins/engine/components/DeclarationField.ts | 11 +++++++---- .../plugins/engine/i18n/translations/en-GB.json | 3 +-- .../engine/pageControllers/QuestionPageController.ts | 8 +++++--- .../engine/pageControllers/SummaryPageController.ts | 12 ++++++++++-- .../partials/warn-missing-notification-email.html | 7 ++----- 5 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/server/plugins/engine/components/DeclarationField.ts b/src/server/plugins/engine/components/DeclarationField.ts index 1fb9274fc..3e6b37659 100644 --- a/src/server/plugins/engine/components/DeclarationField.ts +++ b/src/server/plugins/engine/components/DeclarationField.ts @@ -117,16 +117,19 @@ export class DeclarationField extends FormComponent { } getDisplayStringFromFormValue(value: FormValue | FormPayload): string { - return value === 'true' ? this.declarationConfirmationLabel : 'Not provided' + return value === 'true' + ? this.declarationConfirmationLabel + : t('components.declarationField.notProvided', this.model.language) } getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { - const defaultDeclarationConfirmationLabel = - 'I confirm that I understand and accept this declaration' const { hint, content, - declarationConfirmationLabel = defaultDeclarationConfirmationLabel + declarationConfirmationLabel = t( + 'components.declarationField.defaultConfirmationLabel', + this.model.language + ) } = this const viewModel = super.getViewModel(payload, errors) diff --git a/src/server/plugins/engine/i18n/translations/en-GB.json b/src/server/plugins/engine/i18n/translations/en-GB.json index b250cd9cc..3145a6fca 100644 --- a/src/server/plugins/engine/i18n/translations/en-GB.json +++ b/src/server/plugins/engine/i18n/translations/en-GB.json @@ -93,8 +93,7 @@ "fileUpload": { "upload": "Upload file", - "maxFilesReached": "You have reached the maximum number of files. Please remove a file to upload more.", - "maxFilesWarningIcon": "Warning" + "maxFilesReached": "You have reached the maximum number of files. Please remove a file to upload more." }, "exit": { diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index 5c5eb03ba..c0392b38a 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -498,13 +498,15 @@ export class QuestionPageController extends PageController { const itemId = this.getItemId(request) + const lang = this.model.language + // Check answers back link if (returnUrl) { return { text: hasRepeater(pageDef) && itemId - ? 'Go back to add another' - : 'Go back to check answers', + ? t('pages.question.backToAddAnother', lang) + : t('pages.question.backToCheckAnswers', lang), href: returnUrl } } @@ -522,7 +524,7 @@ export class QuestionPageController extends PageController { // Default back link return { - text: 'Back', + text: t('common.back', lang), href: this.getHref(backPath) } } diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index 726461c99..f215bcb1c 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -317,12 +317,20 @@ export class SummaryPageController extends QuestionPageController { request: FormRequestPayload, h: FormResponseToolkit ) { + const lang = this.model.language const helpUrl = error.helpLink ?? DEFAULT_PAYMENT_HELP_URL - const helpLinkHtml = ` or you can contact us (opens in new tab) and quote your reference number to arrange a refund` + const contactUsLink = `${t('pages.summary.contactUsLinkText', lang)}` + const helpLinkHtml = t( + 'pages.summary.submissionFailedContactSuffix', + lang, + { + contactUsLink + } + ) const govukError = createError( 'submission', - `There was a problem and your form was not submitted. Try submitting the form again${helpLinkHtml}.` + `${t('pages.summary.submissionFailed', lang)}${helpLinkHtml}.` ) request.yar.flash(COMPONENT_STATE_ERROR, govukError, true) diff --git a/src/server/plugins/engine/views/partials/warn-missing-notification-email.html b/src/server/plugins/engine/views/partials/warn-missing-notification-email.html index 18143a53b..3e3870957 100644 --- a/src/server/plugins/engine/views/partials/warn-missing-notification-email.html +++ b/src/server/plugins/engine/views/partials/warn-missing-notification-email.html @@ -1,12 +1,9 @@ {% from "govuk/components/warning-text/macro.njk" import govukWarningText %} {% set linkText %}{{ t('pages.preview.warnMissingNotificationEmail.linkText') }}{% endset %} - -{% set html %} - If you want to receive a test email, {{ linkText }} you want form submissions to go to. -{% endset %} +{% set linkHtml %}{{ linkText }}{% endset %} {{ govukWarningText({ - html: html, + html: t('pages.preview.warnMissingNotificationEmail.body', { link: linkHtml }), iconFallbackText: t('common.warningIconFallback') }) }} From 065ffc6627b1df6284143ad667a4c0bbcaa00c9c Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 17 Apr 2026 12:09:55 +0100 Subject: [PATCH 21/89] move component errors into component block --- .../engine/components/FormComponent.ts | 2 +- .../engine/i18n/buildValidationMessages.ts | 12 +++++---- .../engine/i18n/translations/en-GB.json | 26 ++++++++++++------- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/server/plugins/engine/components/FormComponent.ts b/src/server/plugins/engine/components/FormComponent.ts index eb425d660..21c077cfc 100644 --- a/src/server/plugins/engine/components/FormComponent.ts +++ b/src/server/plugins/engine/components/FormComponent.ts @@ -135,7 +135,7 @@ export class FormComponent extends ComponentBase { const isRequired = !('required' in options) || options.required !== false const hideOptional = 'optionalText' in options && options.optionalText - const label = `${title}${!isRequired && !hideOptional ? t('common.optional', this.model.language) : ''}` + const label = `${title}${!isRequired && !hideOptional ? ` ${t('common.optional', this.model.language)}` : ''}` if (hint) { viewModel.hint = { diff --git a/src/server/plugins/engine/i18n/buildValidationMessages.ts b/src/server/plugins/engine/i18n/buildValidationMessages.ts index c12b35233..39dafcf7b 100644 --- a/src/server/plugins/engine/i18n/buildValidationMessages.ts +++ b/src/server/plugins/engine/i18n/buildValidationMessages.ts @@ -27,7 +27,7 @@ export function buildValidationMessages(t: (key: string) => string) { pattern: joi.expression(t('validation.pattern'), opts) as JoiExpression, format: joi.expression(t('validation.format'), opts) as JoiExpression, declarationRequired: joi.expression( - t('validation.declarationRequired'), + t('components.declarationField.validation.declarationRequired'), opts ) as JoiExpression, objectRequired: joi.expression( @@ -40,7 +40,9 @@ export function buildValidationMessages(t: (key: string) => string) { ) as JoiExpression, // Plain Joi template strings — no lowerFirst - selectYesNoRequired: t('validation.selectYesNoRequired'), + selectYesNoRequired: t( + 'components.yesNoField.validation.selectYesNoRequired' + ), max: t('validation.max'), min: t('validation.min'), minMax: t('validation.minMax'), @@ -50,9 +52,9 @@ export function buildValidationMessages(t: (key: string) => string) { numberMin: t('validation.numberMin'), numberMax: t('validation.numberMax'), maxWords: t('validation.maxWords'), - dateFormat: t('validation.dateFormat'), - dateMin: t('validation.dateMin'), - dateMax: t('validation.dateMax') + dateFormat: t('components.dateField.validation.dateFormat'), + dateMin: t('components.dateField.validation.dateMin'), + dateMax: t('components.dateField.validation.dateMax') } } diff --git a/src/server/plugins/engine/i18n/translations/en-GB.json b/src/server/plugins/engine/i18n/translations/en-GB.json index 3145a6fca..c24d4faa7 100644 --- a/src/server/plugins/engine/i18n/translations/en-GB.json +++ b/src/server/plugins/engine/i18n/translations/en-GB.json @@ -9,10 +9,8 @@ "_note": "These strings are used in validation error messages. Preserve all {{...}} placeholders exactly.", "required": "Enter {{lowerFirst(#label)}}", "selectRequired": "Select {{lowerFirst(#label)}}", - "selectYesNoRequired": "{{#label}} - select yes or no", "pattern": "Enter a valid {{lowerFirst(#label)}}", "format": "Enter {{lowerFirst(#label)}} in the correct format", - "declarationRequired": "You must confirm you understand and agree with the {{lowerFirst(#label)}} to continue", "objectRequired": "Enter {{#label}}", "objectMissing": "{{#title}} must include a {{lowerFirst(#label)}}", "max": "{{#label}} must be {{#limit}} characters or less", @@ -23,10 +21,7 @@ "numberInteger": "{{#label}} must be a whole number", "numberMin": "{{#label}} must be {{#limit}} or higher", "numberMax": "{{#label}} must be {{#limit}} or lower", - "maxWords": "{{#label}} must be {{#limit}} words or fewer", - "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}}" + "maxWords": "{{#label}} must be {{#limit}} words or fewer" }, "errors": { @@ -34,7 +29,7 @@ }, "common": { - "optional": " (optional)", + "optional": "(optional)", "warningIconFallback": "Warning", "back": "Back", "continue": "Continue", @@ -121,7 +116,12 @@ "dateField": { "day": "Day", "month": "Month", - "year": "Year" + "year": "Year", + "validation": { + "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}}" + } }, "monthYearField": { @@ -185,13 +185,19 @@ "yesNoField": { "yes": "Yes", - "no": "No" + "no": "No", + "validation": { + "selectYesNoRequired": "{{#label}} - select yes or no" + } }, "declarationField": { "defaultLabel": "I understand and agree", "defaultConfirmationLabel": "I confirm that I understand and accept this declaration", - "notProvided": "Not provided" + "notProvided": "Not provided", + "validation": { + "declarationRequired": "You must confirm you understand and agree with the {{lowerFirst(#label)}} to continue" + } }, "geospatialField": { From 13d06d04df14b3ce7cc126d098c7cc8a9ec29ecd Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 17 Apr 2026 12:24:56 +0100 Subject: [PATCH 22/89] =?UTF-8?q?feat(i18n):=20add=20FormModel.t()=20metho?= =?UTF-8?q?d=20=E2=80=94=20language-bound=20translation=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/plugins/engine/models/FormModel.test.ts | 12 ++++++++++++ src/server/plugins/engine/models/FormModel.ts | 14 ++++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/server/plugins/engine/models/FormModel.test.ts b/src/server/plugins/engine/models/FormModel.test.ts index e80555e72..a8519a707 100644 --- a/src/server/plugins/engine/models/FormModel.test.ts +++ b/src/server/plugins/engine/models/FormModel.test.ts @@ -170,6 +170,18 @@ describe('FormModel', () => { expect(model.language).toBe('cy') }) + it('translates a key using the form language', () => { + const model = new FormModel(definition, { basePath: '/test' }) + expect(model.t('errors.title')).toBe('There is a problem') + }) + + it('passes interpolation options through', () => { + const model = new FormModel(definition, { basePath: '/test' }) + expect(model.t('pages.repeater.pageTitle', { count: 3 })).toBe( + 'You have added 3 answers' + ) + }) + it('builds validationMessages at construction time', () => { const model = new FormModel(definition, { basePath: 'test' }) expect(model.validationMessages).toBeDefined() diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index 106491e2b..2c2a3ea57 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -46,7 +46,7 @@ import { buildValidationMessages, type ValidationMessages } from '~/src/server/plugins/engine/i18n/buildValidationMessages.js' -import { t } from '~/src/server/plugins/engine/i18n/index.js' +import { t as translate } from '~/src/server/plugins/engine/i18n/index.js' import { type ExecutableCondition } from '~/src/server/plugins/engine/models/types.js' import { type PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js' import { @@ -147,12 +147,12 @@ export class FormModel { items: [ { id: '02900d42-83d1-4c72-a719-c4e8228952fa', - text: t('components.yesNoField.yes', language), + text: translate('components.yesNoField.yes', language), value: true }, { id: 'f39000eb-c51b-4019-8f82-bbda0423f04d', - text: t('components.yesNoField.no', language), + text: translate('components.yesNoField.no', language), value: false } ] @@ -163,9 +163,7 @@ export class FormModel { this.engine = def.engine this.language = language - this.validationMessages = buildValidationMessages((key) => - t(key, this.language) - ) + this.validationMessages = buildValidationMessages((key) => this.t(key)) this.schemaVersion = def.schema ?? SchemaVersion.V1 this.def = def this.lists = def.lists @@ -244,6 +242,10 @@ export class FormModel { ) } + t(key: string, opts?: Record): string { + return translate(key, this.language, opts) + } + /** * build the entire model schema from individual pages/sections and filter out answers * for pages which are no longer accessible due to an answer that has been changed From f666c5a077e4332023465b4a9206a43621983215 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 17 Apr 2026 12:25:53 +0100 Subject: [PATCH 23/89] docs(i18n): document FormModel.t() language-binding intent --- src/server/plugins/engine/models/FormModel.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index 2c2a3ea57..f2a66c7cc 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -242,6 +242,7 @@ export class FormModel { ) } + /** Translates a key using this form's configured language. */ t(key: string, opts?: Record): string { return translate(key, this.language, opts) } From 99db942bc5289c096d74eb2d15406097dae00504 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 17 Apr 2026 13:12:24 +0100 Subject: [PATCH 24/89] refactor(i18n): location field subclasses use model.t() instead of t(key, lang) Remove the lang parameter from getValidationConfig/getErrorTemplates in NationalGridFieldNumberField and OsGridRefField; fix LocationFieldBase constructor call that was passing model.language as an argument. Static getAllPossibleErrors() now constructs the result directly using the module-level t() at en-GB, matching the pattern used by EastingNorthingField. --- .../engine/components/LocationFieldBase.ts | 6 ++-- .../NationalGridFieldNumberField.ts | 34 +++++++++++++------ .../engine/components/OsGridRefField.ts | 32 ++++++++++++----- 3 files changed, 51 insertions(+), 21 deletions(-) diff --git a/src/server/plugins/engine/components/LocationFieldBase.ts b/src/server/plugins/engine/components/LocationFieldBase.ts index 4c6749d26..bdcc2d3a2 100644 --- a/src/server/plugins/engine/components/LocationFieldBase.ts +++ b/src/server/plugins/engine/components/LocationFieldBase.ts @@ -43,8 +43,8 @@ export abstract class LocationFieldBase extends FormComponent { declare stateSchema: StringSchema instructionText?: string - protected abstract getValidationConfig(lang?: string): ValidationConfig - protected abstract getErrorTemplates(lang?: string): { + protected abstract getValidationConfig(): ValidationConfig + protected abstract getErrorTemplates(): { type: string template: JoiExpression }[] @@ -61,7 +61,7 @@ export abstract class LocationFieldBase extends FormComponent { addClassOptionIfNone(locationOptions, 'govuk-input--width-10') - const config = this.getValidationConfig(this.model.language) + const config = this.getValidationConfig() const requiredMessage = config.requiredMessage ?? this.model.validationMessages.required diff --git a/src/server/plugins/engine/components/NationalGridFieldNumberField.ts b/src/server/plugins/engine/components/NationalGridFieldNumberField.ts index 97f30c38a..929728a11 100644 --- a/src/server/plugins/engine/components/NationalGridFieldNumberField.ts +++ b/src/server/plugins/engine/components/NationalGridFieldNumberField.ts @@ -3,11 +3,12 @@ import { type NationalGridFieldNumberFieldComponent } from '@defra/forms-model' import { LocationFieldBase } from '~/src/server/plugins/engine/components/LocationFieldBase.js' import { createLowerFirstExpression } from '~/src/server/plugins/engine/components/helpers/index.js' import { t } from '~/src/server/plugins/engine/i18n/index.js' +import { type ErrorMessageTemplateList } from '~/src/server/plugins/engine/types.js' export class NationalGridFieldNumberField extends LocationFieldBase { declare options: NationalGridFieldNumberFieldComponent['options'] - protected getValidationConfig(lang = 'en-GB') { + protected getValidationConfig() { // Regex for OS national grid field references (NGFR) // Validates specific valid OS grid letter combinations with: // - 2 letters & 8 digits in 2 blocks of 4 e.g. ST 6789 6789 @@ -17,20 +18,20 @@ export class NationalGridFieldNumberField extends LocationFieldBase { return { pattern, patternErrorMessage: createLowerFirstExpression( - t('components.nationalGridField.pattern', lang) + this.model.t('components.nationalGridField.pattern') ), requiredMessage: createLowerFirstExpression( - t('components.nationalGridField.required', lang) + this.model.t('components.nationalGridField.required') ) } } - protected getErrorTemplates(lang = 'en-GB') { + protected getErrorTemplates() { return [ { type: 'pattern', template: createLowerFirstExpression( - t('components.nationalGridField.pattern', lang) + this.model.t('components.nationalGridField.pattern') ) } ] @@ -39,10 +40,23 @@ export class NationalGridFieldNumberField extends LocationFieldBase { /** * Static version of getAllPossibleErrors that doesn't require a component instance. */ - static getAllPossibleErrors() { - const instance = Object.create( - NationalGridFieldNumberField.prototype - ) as NationalGridFieldNumberField - return instance.getAllPossibleErrors() + static getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { + type: 'required', + template: createLowerFirstExpression( + t('components.nationalGridField.required', 'en-GB') + ) + }, + { + type: 'pattern', + template: createLowerFirstExpression( + t('components.nationalGridField.pattern', 'en-GB') + ) + } + ], + advancedSettingsErrors: [] + } } } diff --git a/src/server/plugins/engine/components/OsGridRefField.ts b/src/server/plugins/engine/components/OsGridRefField.ts index d3df96aab..3b3e61914 100644 --- a/src/server/plugins/engine/components/OsGridRefField.ts +++ b/src/server/plugins/engine/components/OsGridRefField.ts @@ -3,11 +3,12 @@ import { type OsGridRefFieldComponent } from '@defra/forms-model' import { LocationFieldBase } from '~/src/server/plugins/engine/components/LocationFieldBase.js' import { createLowerFirstExpression } from '~/src/server/plugins/engine/components/helpers/index.js' import { t } from '~/src/server/plugins/engine/i18n/index.js' +import { type ErrorMessageTemplateList } from '~/src/server/plugins/engine/types.js' export class OsGridRefField extends LocationFieldBase { declare options: OsGridRefFieldComponent['options'] - protected getValidationConfig(lang = 'en-GB') { + protected getValidationConfig() { // Regex for OS national grid references (NGR) // Validates specific valid OS grid letter combinations with: // - 2 letters & 6 digits in 2 blocks of 3 e.g. ST 678 678 @@ -20,20 +21,20 @@ export class OsGridRefField extends LocationFieldBase { return { pattern, patternErrorMessage: createLowerFirstExpression( - t('components.osGridRefField.pattern', lang) + this.model.t('components.osGridRefField.pattern') ), requiredMessage: createLowerFirstExpression( - t('components.osGridRefField.required', lang) + this.model.t('components.osGridRefField.required') ) } } - protected getErrorTemplates(lang = 'en-GB') { + protected getErrorTemplates() { return [ { type: 'pattern', template: createLowerFirstExpression( - t('components.osGridRefField.pattern', lang) + this.model.t('components.osGridRefField.pattern') ) } ] @@ -42,8 +43,23 @@ export class OsGridRefField extends LocationFieldBase { /** * Static version of getAllPossibleErrors that doesn't require a component instance. */ - static getAllPossibleErrors() { - const instance = Object.create(OsGridRefField.prototype) as OsGridRefField - return instance.getAllPossibleErrors() + static getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { + type: 'required', + template: createLowerFirstExpression( + t('components.osGridRefField.required', 'en-GB') + ) + }, + { + type: 'pattern', + template: createLowerFirstExpression( + t('components.osGridRefField.pattern', 'en-GB') + ) + } + ], + advancedSettingsErrors: [] + } } } From 7ea790e1fa5a271882bf923a02153298a5b91778 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 17 Apr 2026 13:15:36 +0100 Subject: [PATCH 25/89] refactor(i18n): page controllers use model.t() instead of t(key, model.language) Remove free t() import from all five page controllers; replace every t(key, lang/this.model.language, opts) call with this.model.t(key, opts). Includes getBackLink lang var removal and all RepeatPageController list-summary translation calls. --- .../engine/pageControllers/PageController.ts | 3 +- .../pageControllers/QuestionPageController.ts | 13 +++--- .../pageControllers/RepeatPageController.ts | 42 +++++++++---------- .../pageControllers/StatusPageController.ts | 3 +- .../pageControllers/SummaryPageController.ts | 27 +++++------- 5 files changed, 37 insertions(+), 51 deletions(-) diff --git a/src/server/plugins/engine/pageControllers/PageController.ts b/src/server/plugins/engine/pageControllers/PageController.ts index c41009d49..03486260e 100644 --- a/src/server/plugins/engine/pageControllers/PageController.ts +++ b/src/server/plugins/engine/pageControllers/PageController.ts @@ -15,7 +15,6 @@ import { getStartPath, normalisePath } from '~/src/server/plugins/engine/helpers.js' -import { t } from '~/src/server/plugins/engine/i18n/index.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { type ExecutableCondition } from '~/src/server/plugins/engine/models/types.js' import { @@ -172,7 +171,7 @@ export class PageController { return h.view(viewName, { ...viewModel, t: (key: string, opts?: Record) => - t(key, this.model.language, opts) + this.model.t(key, opts) }) } } diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index c0392b38a..e7f610285 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -29,7 +29,6 @@ import { normalisePath, proceed } from '~/src/server/plugins/engine/helpers.js' -import { t } from '~/src/server/plugins/engine/i18n/index.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js' import { prefillStateFromQueryParameters } from '~/src/server/plugins/engine/pageControllers/helpers/state.js' @@ -457,7 +456,7 @@ export class QuestionPageController extends PageController { return h.view(viewName, { ...viewModel, t: (key: string, opts?: Record) => - t(key, this.model.language, opts) + this.model.t(key, opts) }) } } @@ -498,15 +497,13 @@ export class QuestionPageController extends PageController { const itemId = this.getItemId(request) - const lang = this.model.language - // Check answers back link if (returnUrl) { return { text: hasRepeater(pageDef) && itemId - ? t('pages.question.backToAddAnother', lang) - : t('pages.question.backToCheckAnswers', lang), + ? this.model.t('pages.question.backToAddAnother') + : this.model.t('pages.question.backToCheckAnswers'), href: returnUrl } } @@ -524,7 +521,7 @@ export class QuestionPageController extends PageController { // Default back link return { - text: t('common.back', lang), + text: this.model.t('common.back'), href: this.getHref(backPath) } } @@ -561,7 +558,7 @@ export class QuestionPageController extends PageController { return h.view(viewName, { ...viewModel, t: (key: string, opts?: Record) => - t(key, this.model.language, opts) + this.model.t(key, opts) }) } diff --git a/src/server/plugins/engine/pageControllers/RepeatPageController.ts b/src/server/plugins/engine/pageControllers/RepeatPageController.ts index 56d998e2e..6db61eb49 100644 --- a/src/server/plugins/engine/pageControllers/RepeatPageController.ts +++ b/src/server/plugins/engine/pageControllers/RepeatPageController.ts @@ -6,7 +6,6 @@ import Joi from 'joi' import { isRepeatState } from '~/src/server/plugins/engine/components/FormComponent.js' import { redirectPath } from '~/src/server/plugins/engine/helpers.js' -import { t } from '~/src/server/plugins/engine/i18n/index.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' import { @@ -199,7 +198,7 @@ export class RepeatPageController extends QuestionPageController { return h.view(this.listSummaryViewName, { ...viewModel, t: (key: string, opts?: Record) => - t(key, this.model.language, opts) + this.model.t(key, opts) }) } } @@ -237,7 +236,6 @@ export class RepeatPageController extends QuestionPageController { // Show error if repeat limits apply if (hasErrorMin || hasErrorMax) { const count = hasErrorMax ? schema.max : schema.min - const lang = this.model.language context.errors = [ { @@ -245,8 +243,8 @@ export class RepeatPageController extends QuestionPageController { href: '', name: '', text: hasErrorMax - ? t('pages.repeater.tooMany', lang, { count }) - : t('pages.repeater.tooFew', lang, { count }) + ? this.model.t('pages.repeater.tooMany', { count }) + : this.model.t('pages.repeater.tooFew', { count }) } ] @@ -255,7 +253,7 @@ export class RepeatPageController extends QuestionPageController { return h.view(this.listSummaryViewName, { ...viewModel, t: (key: string, opts?: Record) => - t(key, this.model.language, opts) + this.model.t(key, opts) }) } @@ -299,18 +297,17 @@ export class RepeatPageController extends QuestionPageController { } const { title } = this.repeat.options - const lang = this.model.language return h.view(this.listDeleteViewName, { ...viewModel, context, backLink: this.getBackLink(request, context), - pageTitle: t('pages.repeater.removeAnswer', lang), + pageTitle: this.model.t('pages.repeater.removeAnswer'), itemTitle: `${title} ${list.indexOf(item) + 1}`, - buttonConfirm: { text: t('pages.repeater.remove', lang) }, - buttonCancel: { text: t('pages.repeater.cancel', lang) }, + buttonConfirm: { text: this.model.t('pages.repeater.remove') }, + buttonCancel: { text: this.model.t('pages.repeater.cancel') }, t: (key: string, opts?: Record) => - t(key, this.model.language, opts) + this.model.t(key, opts) } satisfies ItemDeletePageViewModel) } } @@ -386,7 +383,6 @@ export class RepeatPageController extends QuestionPageController { const { isForceAccess, errors } = context const { title } = repeat.options - const lang = this.model.language const summaryList: SummaryList = { classes: 'govuk-summary-list--long-actions', @@ -409,11 +405,12 @@ export class RepeatPageController extends QuestionPageController { href: redirectPath(`${href}/${item.itemId}`, { returnUrl: query.returnUrl ?? this.getHref(summaryPath) }), - text: t('pages.repeater.change', lang), + text: this.model.t('pages.repeater.change'), classes: 'govuk-link--no-visited-state', - visuallyHiddenText: t('pages.repeater.visuallyHiddenItem', lang, { - index: index + 1 - }) + visuallyHiddenText: this.model.t( + 'pages.repeater.visuallyHiddenItem', + { index: index + 1 } + ) }) if (count > 1) { @@ -421,11 +418,12 @@ export class RepeatPageController extends QuestionPageController { href: redirectPath(`${href}/${item.itemId}/confirm-delete`, { returnUrl: query.returnUrl }), - text: t('pages.repeater.remove', lang), + text: this.model.t('pages.repeater.remove'), classes: 'govuk-link--no-visited-state', - visuallyHiddenText: t('pages.repeater.visuallyHiddenItem', lang, { - index: index + 1 - }) + visuallyHiddenText: this.model.t( + 'pages.repeater.visuallyHiddenItem', + { index: index + 1 } + ) }) } } @@ -439,7 +437,7 @@ export class RepeatPageController extends QuestionPageController { text: `${title} ${index + 1}` }, value: { - text: itemDisplayText || t('pages.repeater.notProvided', lang) + text: itemDisplayText || this.model.t('pages.repeater.notProvided') }, actions: { items @@ -452,7 +450,7 @@ export class RepeatPageController extends QuestionPageController { ...this.viewModel, backLink: this.getBackLink(request, context), repeatTitle: title, - pageTitle: t('pages.repeater.pageTitle', lang, { count }), + pageTitle: this.model.t('pages.repeater.pageTitle', { count }), showTitle: true, context, errors, diff --git a/src/server/plugins/engine/pageControllers/StatusPageController.ts b/src/server/plugins/engine/pageControllers/StatusPageController.ts index 65738a3c2..7a151d21f 100644 --- a/src/server/plugins/engine/pageControllers/StatusPageController.ts +++ b/src/server/plugins/engine/pageControllers/StatusPageController.ts @@ -1,7 +1,6 @@ import { type PageStatus } from '@defra/forms-model' import { getCacheService } from '~/src/server/plugins/engine/helpers.js' -import { t } from '~/src/server/plugins/engine/i18n/index.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' import { type FormContext } from '~/src/server/plugins/engine/types.js' @@ -61,7 +60,7 @@ export class StatusPageController extends QuestionPageController { showReferenceNumber: this.showReferenceNumber, referenceNumber: confirmationState.referenceNumber, t: (key: string, opts?: Record) => - t(key, this.model.language, opts) + this.model.t(key, opts) }) } } diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index f215bcb1c..9e26834ce 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -19,7 +19,6 @@ import { createError, getCacheService } from '~/src/server/plugins/engine/helpers.js' -import { t } from '~/src/server/plugins/engine/i18n/index.js' import { SummaryViewModel, type FormModel @@ -119,32 +118,30 @@ export class SummaryPageController extends QuestionPageController { ReturnType > ) { - const lang = this.model.language - const rows = [ { - key: { text: t('pages.summary.paymentFor', lang) }, + key: { text: this.model.t('pages.summary.paymentFor') }, value: { text: paymentState.description } }, { - key: { text: t('pages.summary.totalAmount', lang) }, + key: { text: this.model.t('pages.summary.totalAmount') }, value: { text: formatCurrency(paymentState.amount) } }, { - key: { text: t('pages.summary.reference', lang) }, + key: { text: this.model.t('pages.summary.reference') }, value: { text: paymentState.reference } } ] if (paymentState.preAuth?.createdAt) { rows.push({ - key: { text: t('pages.summary.dateOfPayment', lang) }, + key: { text: this.model.t('pages.summary.dateOfPayment') }, value: { text: formatPaymentDate(paymentState.preAuth.createdAt) } }) } return { - title: { text: t('pages.summary.paymentDetailsTitle', lang) }, + title: { text: this.model.t('pages.summary.paymentDetailsTitle') }, summaryList: { rows } } } @@ -166,7 +163,7 @@ export class SummaryPageController extends QuestionPageController { await this.hasMissingNotificationEmail(request, context) viewModel.t = (key: string, opts?: Record) => - t(key, this.model.language, opts) + this.model.t(key, opts) return h.view(viewName, viewModel) } @@ -317,20 +314,16 @@ export class SummaryPageController extends QuestionPageController { request: FormRequestPayload, h: FormResponseToolkit ) { - const lang = this.model.language const helpUrl = error.helpLink ?? DEFAULT_PAYMENT_HELP_URL - const contactUsLink = `${t('pages.summary.contactUsLinkText', lang)}` - const helpLinkHtml = t( + const contactUsLink = `${this.model.t('pages.summary.contactUsLinkText')}` + const helpLinkHtml = this.model.t( 'pages.summary.submissionFailedContactSuffix', - lang, - { - contactUsLink - } + { contactUsLink } ) const govukError = createError( 'submission', - `${t('pages.summary.submissionFailed', lang)}${helpLinkHtml}.` + `${this.model.t('pages.summary.submissionFailed')}${helpLinkHtml}.` ) request.yar.flash(COMPONENT_STATE_ERROR, govukError, true) From 0c8c1bfa371322741662bf0d582b96f990f0561a Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 17 Apr 2026 13:28:35 +0100 Subject: [PATCH 26/89] fix(test): add model.t() to PaymentField dispatcher test mocks The two inline controller.model mocks in the dispatcher error tests were missing the t() method after PaymentField.ts was updated to call model.t() instead of the free t(key, lang) function. --- .../plugins/engine/components/PaymentField.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/server/plugins/engine/components/PaymentField.test.ts b/src/server/plugins/engine/components/PaymentField.test.ts index 93df6cb3a..8080787ab 100644 --- a/src/server/plugins/engine/components/PaymentField.test.ts +++ b/src/server/plugins/engine/components/PaymentField.test.ts @@ -259,7 +259,10 @@ describe('PaymentField', () => { const collection = new ComponentCollection([def], { model }) const paymentField = collection.fields[0] as PaymentField - paymentField.model = { services: mockServices } as unknown as FormModel + paymentField.model = { + services: mockServices, + t: (key: string, opts?: Record) => model.t(key, opts) + } as unknown as FormModel describe('dispatcher', () => { it('should create payment and redirect to gov pay', async () => { @@ -416,7 +419,9 @@ describe('PaymentField', () => { formId: 'formid', basePath: 'base-path', name: 'PaymentModel', - services: mockServices + services: mockServices, + t: (key: string, opts?: Record) => + model.t(key, opts) }, getState: jest .fn() @@ -477,7 +482,9 @@ describe('PaymentField', () => { formId: 'formid', basePath: 'base-path', name: 'PaymentModel', - services: mockServices + services: mockServices, + t: (key: string, opts?: Record) => + model.t(key, opts) }, getState: jest .fn() From 77a8200fb1c5fd7ba49db3d4d694be893fe0035a Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 17 Apr 2026 14:22:30 +0100 Subject: [PATCH 27/89] Add translation helper in model to reduce repeated language prop access --- .../engine/components/DatePartsField.ts | 8 +- .../engine/components/DeclarationField.ts | 10 +-- .../engine/components/EastingNorthingField.ts | 84 ++++++++++--------- .../engine/components/FileUploadField.ts | 5 +- .../engine/components/FormComponent.ts | 3 +- .../engine/components/GeospatialField.ts | 3 +- .../plugins/engine/components/LatLongField.ts | 26 +++--- .../engine/components/MonthYearField.ts | 6 +- .../plugins/engine/components/PaymentField.ts | 13 ++- .../engine/components/UkAddressField.ts | 17 ++-- 10 files changed, 81 insertions(+), 94 deletions(-) diff --git a/src/server/plugins/engine/components/DatePartsField.ts b/src/server/plugins/engine/components/DatePartsField.ts index c5f5ccdaa..fad1f5d53 100644 --- a/src/server/plugins/engine/components/DatePartsField.ts +++ b/src/server/plugins/engine/components/DatePartsField.ts @@ -14,7 +14,6 @@ import { type DatePartsState } from '~/src/server/plugins/engine/components/types.js' import { parseStrictDate } from '~/src/server/plugins/engine/date-helper.js' -import { t } from '~/src/server/plugins/engine/i18n/index.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -43,7 +42,6 @@ export class DatePartsField extends FormComponent { const isRequired = options.required !== false const { validationMessages } = props.model - const lang = props.model.language const customValidationMessages = convertToLanguageMessages({ 'any.required': validationMessages.objectMissing, 'number.base': validationMessages.objectMissing, @@ -59,7 +57,7 @@ export class DatePartsField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__day`, - title: t('components.dateField.day', lang), + title: props.model.t('components.dateField.day'), schema: { min: 1, max: 31, precision: 0 }, options: { required: isRequired, @@ -71,7 +69,7 @@ export class DatePartsField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__month`, - title: t('components.dateField.month', lang), + title: props.model.t('components.dateField.month'), schema: { min: 1, max: 12, precision: 0 }, options: { required: isRequired, @@ -83,7 +81,7 @@ export class DatePartsField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__year`, - title: t('components.dateField.year', lang), + title: props.model.t('components.dateField.year'), schema: { min: 1000, max: 3000, precision: 0 }, options: { required: isRequired, diff --git a/src/server/plugins/engine/components/DeclarationField.ts b/src/server/plugins/engine/components/DeclarationField.ts index 3e6b37659..4278c27f1 100644 --- a/src/server/plugins/engine/components/DeclarationField.ts +++ b/src/server/plugins/engine/components/DeclarationField.ts @@ -15,7 +15,6 @@ import { FormComponent, isFormValue } from '~/src/server/plugins/engine/components/FormComponent.js' -import { t } from '~/src/server/plugins/engine/i18n/index.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -72,7 +71,7 @@ export class DeclarationField extends FormComponent { this.content = content this.declarationConfirmationLabel = options.declarationConfirmationLabel ?? - t('components.declarationField.defaultLabel', props.model.language) + props.model.t('components.declarationField.defaultLabel') const formComponents = hasFormComponents(props.page?.pageDef) ? props.page.pageDef.components : [] @@ -119,16 +118,15 @@ export class DeclarationField extends FormComponent { getDisplayStringFromFormValue(value: FormValue | FormPayload): string { return value === 'true' ? this.declarationConfirmationLabel - : t('components.declarationField.notProvided', this.model.language) + : this.model.t('components.declarationField.notProvided') } getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { const { hint, content, - declarationConfirmationLabel = t( - 'components.declarationField.defaultConfirmationLabel', - this.model.language + declarationConfirmationLabel = this.model.t( + 'components.declarationField.defaultConfirmationLabel' ) } = this diff --git a/src/server/plugins/engine/components/EastingNorthingField.ts b/src/server/plugins/engine/components/EastingNorthingField.ts index 03dd69d6c..b45002d5e 100644 --- a/src/server/plugins/engine/components/EastingNorthingField.ts +++ b/src/server/plugins/engine/components/EastingNorthingField.ts @@ -17,7 +17,6 @@ import { import { NumberField } from '~/src/server/plugins/engine/components/NumberField.js' import { createLowerFirstExpression } from '~/src/server/plugins/engine/components/helpers/index.js' import { type EastingNorthingState } from '~/src/server/plugins/engine/components/types.js' -import { t } from '~/src/server/plugins/engine/i18n/index.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -56,40 +55,41 @@ export class EastingNorthingField extends FormComponent { const northingMin = schema?.northing?.min ?? DEFAULT_NORTHING_MIN const northingMax = schema?.northing?.max ?? DEFAULT_NORTHING_MAX - const lang = props.model.language const fieldLabel = lowerFirst(this.label) - const eastingDigitsMessage = t( + const eastingDigitsMessage = props.model.t( 'components.eastingNorthingField.eastingDigits', - lang, { fieldLabel } ) - const northingDigitsMessage = t( + const northingDigitsMessage = props.model.t( 'components.eastingNorthingField.northingDigits', - lang, { fieldLabel } ) const customValidationMessages: LanguageMessages = convertToLanguageMessages({ - 'any.required': t( - 'components.eastingNorthingField.eastingRequired', - lang + 'any.required': props.model.t( + 'components.eastingNorthingField.eastingRequired' ), - 'number.base': t( - 'components.eastingNorthingField.eastingRequired', - lang + 'number.base': props.model.t( + 'components.eastingNorthingField.eastingRequired' + ), + 'number.min': props.model.t( + 'components.eastingNorthingField.eastingRange', + { + fieldLabel, + min: eastingMin, + max: eastingMax + } + ), + 'number.max': props.model.t( + 'components.eastingNorthingField.eastingRange', + { + fieldLabel, + min: eastingMin, + max: eastingMax + } ), - 'number.min': t('components.eastingNorthingField.eastingRange', lang, { - fieldLabel, - min: eastingMin, - max: eastingMax - }), - 'number.max': t('components.eastingNorthingField.eastingRange', lang, { - fieldLabel, - min: eastingMin, - max: eastingMax - }), 'number.precision': eastingDigitsMessage, 'number.integer': eastingDigitsMessage, 'number.unsafe': eastingDigitsMessage @@ -97,24 +97,28 @@ export class EastingNorthingField extends FormComponent { const northingValidationMessages: LanguageMessages = convertToLanguageMessages({ - 'any.required': t( - 'components.eastingNorthingField.northingRequired', - lang + 'any.required': props.model.t( + 'components.eastingNorthingField.northingRequired' ), - 'number.base': t( - 'components.eastingNorthingField.northingRequired', - lang + 'number.base': props.model.t( + 'components.eastingNorthingField.northingRequired' + ), + 'number.min': props.model.t( + 'components.eastingNorthingField.northingRange', + { + fieldLabel, + min: northingMin, + max: northingMax + } + ), + 'number.max': props.model.t( + 'components.eastingNorthingField.northingRange', + { + fieldLabel, + min: northingMin, + max: northingMax + } ), - 'number.min': t('components.eastingNorthingField.northingRange', lang, { - fieldLabel, - min: northingMin, - max: northingMax - }), - 'number.max': t('components.eastingNorthingField.northingRange', lang, { - fieldLabel, - min: northingMin, - max: northingMax - }), 'number.precision': northingDigitsMessage, 'number.integer': northingDigitsMessage, 'number.unsafe': northingDigitsMessage @@ -125,7 +129,7 @@ export class EastingNorthingField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__easting`, - title: t('components.eastingNorthingField.easting', lang), + title: props.model.t('components.eastingNorthingField.easting'), schema: { min: eastingMin, max: eastingMax, @@ -141,7 +145,7 @@ export class EastingNorthingField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__northing`, - title: t('components.eastingNorthingField.northing', lang), + title: props.model.t('components.eastingNorthingField.northing'), schema: { min: northingMin, max: northingMax, diff --git a/src/server/plugins/engine/components/FileUploadField.ts b/src/server/plugins/engine/components/FileUploadField.ts index ee2952839..04294a0ea 100644 --- a/src/server/plugins/engine/components/FileUploadField.ts +++ b/src/server/plugins/engine/components/FileUploadField.ts @@ -9,7 +9,6 @@ import { FormComponent, isUploadState } from '~/src/server/plugins/engine/components/FormComponent.js' -import { t } from '~/src/server/plugins/engine/i18n/index.js' import { InvalidComponentStateError } from '~/src/server/plugins/engine/pageControllers/errors.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { @@ -216,7 +215,7 @@ export class FileUploadField extends FormComponent { const tag = { classes: 'govuk-tag--green', - text: t('components.fileUploadField.uploaded', this.model.language) + text: this.model.t('components.fileUploadField.uploaded') } const valueHtml = render @@ -351,7 +350,7 @@ export class FileUploadField extends FormComponent { // Scenarios: file missing from S3, invalid retrieval key (timing problem), etc. throw new InvalidComponentStateError( this, - t('components.fileUploadField.uploadFailed', this.model.language) + this.model.t('components.fileUploadField.uploadFailed') ) } diff --git a/src/server/plugins/engine/components/FormComponent.ts b/src/server/plugins/engine/components/FormComponent.ts index 21c077cfc..6fc12abfe 100644 --- a/src/server/plugins/engine/components/FormComponent.ts +++ b/src/server/plugins/engine/components/FormComponent.ts @@ -5,7 +5,6 @@ import { } from '@defra/forms-model' import { ComponentBase } from '~/src/server/plugins/engine/components/ComponentBase.js' -import { t } from '~/src/server/plugins/engine/i18n/index.js' import { type FormContext, type FormRequestPayload @@ -135,7 +134,7 @@ export class FormComponent extends ComponentBase { const isRequired = !('required' in options) || options.required !== false const hideOptional = 'optionalText' in options && options.optionalText - const label = `${title}${!isRequired && !hideOptional ? ` ${t('common.optional', this.model.language)}` : ''}` + const label = `${title}${!isRequired && !hideOptional ? ` ${this.model.t('common.optional')}` : ''}` if (hint) { viewModel.hint = { diff --git a/src/server/plugins/engine/components/GeospatialField.ts b/src/server/plugins/engine/components/GeospatialField.ts index e23f6846c..54e410b47 100644 --- a/src/server/plugins/engine/components/GeospatialField.ts +++ b/src/server/plugins/engine/components/GeospatialField.ts @@ -7,7 +7,6 @@ import { isGeospatialState } from '~/src/server/plugins/engine/components/FormComponent.js' import { geospatialSchema } from '~/src/server/plugins/engine/components/helpers/geospatial.js' -import { t } from '~/src/server/plugins/engine/i18n/index.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -59,7 +58,7 @@ export class GeospatialField extends FormComponent { return '' } - return t('components.geospatialField.added', this.model.language, { + return this.model.t('components.geospatialField.added', { count: features.length }) } diff --git a/src/server/plugins/engine/components/LatLongField.ts b/src/server/plugins/engine/components/LatLongField.ts index c7aee69a9..8bfa3286c 100644 --- a/src/server/plugins/engine/components/LatLongField.ts +++ b/src/server/plugins/engine/components/LatLongField.ts @@ -14,7 +14,6 @@ import { import { NumberField } from '~/src/server/plugins/engine/components/NumberField.js' import { createLowerFirstExpression } from '~/src/server/plugins/engine/components/helpers/index.js' import { type LatLongState } from '~/src/server/plugins/engine/components/types.js' -import { t } from '~/src/server/plugins/engine/i18n/index.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -52,30 +51,27 @@ export class LatLongField extends FormComponent { const longitudeMin = schema?.longitude?.min ?? -13.687 const longitudeMax = schema?.longitude?.max ?? 1.767 - const lang = props.model.language const fieldLabel = lowerFirst(this.label) const customValidationMessages: LanguageMessages = convertToLanguageMessages({ - 'number.precision': t('components.latLongField.precision', lang), - 'number.unsafe': t('components.latLongField.notANumber', lang) + 'number.precision': props.model.t('components.latLongField.precision'), + 'number.unsafe': props.model.t('components.latLongField.notANumber') }) - const latitudeRangeMessage = t( + const latitudeRangeMessage = props.model.t( 'components.latLongField.latitudeRange', - lang, { fieldLabel, min: latitudeMin, max: latitudeMax } ) - const longitudeRangeMessage = t( + const longitudeRangeMessage = props.model.t( 'components.latLongField.longitudeRange', - lang, { fieldLabel, min: longitudeMin, max: longitudeMax } ) const latitudeMessages: LanguageMessages = convertToLanguageMessages({ ...customValidationMessages, - 'any.required': t('components.latLongField.latitudeRequired', lang), - 'number.base': t('components.latLongField.latitudeBase', lang, { + 'any.required': props.model.t('components.latLongField.latitudeRequired'), + 'number.base': props.model.t('components.latLongField.latitudeBase', { fieldLabel }), 'number.min': latitudeRangeMessage, @@ -84,8 +80,10 @@ export class LatLongField extends FormComponent { const longitudeMessages: LanguageMessages = convertToLanguageMessages({ ...customValidationMessages, - 'any.required': t('components.latLongField.longitudeRequired', lang), - 'number.base': t('components.latLongField.longitudeBase', lang, { + 'any.required': props.model.t( + 'components.latLongField.longitudeRequired' + ), + 'number.base': props.model.t('components.latLongField.longitudeBase', { fieldLabel }), 'number.min': longitudeRangeMessage, @@ -97,7 +95,7 @@ export class LatLongField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__latitude`, - title: t('components.latLongField.latitude', lang), + title: props.model.t('components.latLongField.latitude'), schema: { min: latitudeMin, max: latitudeMax, @@ -114,7 +112,7 @@ export class LatLongField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__longitude`, - title: t('components.latLongField.longitude', lang), + title: props.model.t('components.latLongField.longitude'), schema: { min: longitudeMin, max: longitudeMax, diff --git a/src/server/plugins/engine/components/MonthYearField.ts b/src/server/plugins/engine/components/MonthYearField.ts index 764be7002..49cfb5509 100644 --- a/src/server/plugins/engine/components/MonthYearField.ts +++ b/src/server/plugins/engine/components/MonthYearField.ts @@ -19,7 +19,6 @@ import { type MonthYearState } from '~/src/server/plugins/engine/components/types.js' import { parseStrictDate } from '~/src/server/plugins/engine/date-helper.js' -import { t } from '~/src/server/plugins/engine/i18n/index.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -48,7 +47,6 @@ export class MonthYearField extends FormComponent { const isRequired = options.required !== false const { validationMessages } = props.model - const lang = props.model.language const customValidationMessages: LanguageMessages = convertToLanguageMessages({ 'any.required': validationMessages.objectMissing, @@ -65,7 +63,7 @@ export class MonthYearField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__month`, - title: t('components.monthYearField.month', lang), + title: props.model.t('components.monthYearField.month'), schema: { min: 1, max: 12, precision: 0 }, options: { required: isRequired, @@ -77,7 +75,7 @@ export class MonthYearField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__year`, - title: t('components.monthYearField.year', lang), + title: props.model.t('components.monthYearField.year'), schema: { min: 1000, max: 3000, precision: 0 }, options: { required: isRequired, diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts index 5140472e4..c0b2e5396 100644 --- a/src/server/plugins/engine/components/PaymentField.ts +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -14,7 +14,6 @@ import { createError, getPluginOptions } from '~/src/server/plugins/engine/helpers.js' -import { t } from '~/src/server/plugins/engine/i18n/index.js' import { PaymentErrorTypes, PaymentPreAuthError, @@ -240,10 +239,9 @@ export class PaymentField extends FormComponent { ) if (!payment) { - const lang = model.language const message = isLivePayment - ? t('components.paymentField.cannotTakePayment', lang) - : t('components.paymentField.testApiKey', lang) + ? model.t('components.paymentField.cannotTakePayment') + : model.t('components.paymentField.testApiKey') const govukError = createError(componentName, message) request.yar.flash(COMPONENT_STATE_ERROR, govukError, true) return h.redirect(request.url.href).code(StatusCodes.SEE_OTHER) @@ -279,10 +277,9 @@ export class PaymentField extends FormComponent { const paymentState = this.getPaymentStateFromState(context.state) if (!paymentState) { - const lang = this.model.language throw new PaymentPreAuthError( this, - t('components.paymentField.completePayment', lang), + this.model.t('components.paymentField.completePayment'), true, PaymentErrorTypes.PaymentIncomplete ) @@ -322,7 +319,7 @@ export class PaymentField extends FormComponent { if (status.state.status !== 'capturable') { throw new PaymentPreAuthError( this, - t('components.paymentField.paymentExpired', this.model.language), + this.model.t('components.paymentField.paymentExpired'), true, PaymentErrorTypes.PaymentExpired ) @@ -336,7 +333,7 @@ export class PaymentField extends FormComponent { if (!captured) { throw new PaymentPreAuthError( this, - t('components.paymentField.submissionFailed', this.model.language), + this.model.t('components.paymentField.submissionFailed'), false ) } diff --git a/src/server/plugins/engine/components/UkAddressField.ts b/src/server/plugins/engine/components/UkAddressField.ts index 531aa2f01..cee7032f1 100644 --- a/src/server/plugins/engine/components/UkAddressField.ts +++ b/src/server/plugins/engine/components/UkAddressField.ts @@ -12,7 +12,6 @@ import { isFormState } from '~/src/server/plugins/engine/components/FormComponent.js' import { TextField } from '~/src/server/plugins/engine/components/TextField.js' -import { t } from '~/src/server/plugins/engine/i18n/index.js' import { type QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' import { type FormRequestPayload, @@ -48,14 +47,12 @@ export class UkAddressField extends FormComponent { const isRequired = options.required !== false const hideOptional = !!options.optionalText const hideTitle = !!options.hideTitle - const lang = props.model.language - this.collection = new ComponentCollection( [ { type: ComponentType.TextField, name: `${name}__uprn`, - title: t('components.addressField.uprn', lang), + title: props.model.t('components.addressField.uprn'), schema: {}, options: { required: false, @@ -65,7 +62,7 @@ export class UkAddressField extends FormComponent { { type: ComponentType.TextField, name: `${name}__addressLine1`, - title: t('components.addressField.line1', lang), + title: props.model.t('components.addressField.line1'), schema: { max: 100 }, options: { autocomplete: 'address-line1', @@ -76,7 +73,7 @@ export class UkAddressField extends FormComponent { { type: ComponentType.TextField, name: `${name}__addressLine2`, - title: t('components.addressField.line2', lang), + title: props.model.t('components.addressField.line2'), schema: { max: 100 }, options: { autocomplete: 'address-line2', @@ -87,7 +84,7 @@ export class UkAddressField extends FormComponent { { type: ComponentType.TextField, name: `${name}__town`, - title: t('components.addressField.town', lang), + title: props.model.t('components.addressField.town'), schema: { max: 100 }, options: { autocomplete: 'address-level2', @@ -99,7 +96,7 @@ export class UkAddressField extends FormComponent { { type: ComponentType.TextField, name: `${name}__county`, - title: t('components.addressField.county', lang), + title: props.model.t('components.addressField.county'), schema: { max: 100 }, options: { autocomplete: 'address-level1', @@ -110,7 +107,7 @@ export class UkAddressField extends FormComponent { { type: ComponentType.TextField, name: `${name}__postcode`, - title: t('components.addressField.postcode', lang), + title: props.model.t('components.addressField.postcode'), schema: { regex: '^[a-zA-Z]{1,2}\\d[a-zA-Z\\d]?\\s?\\d[a-zA-Z]{2}$' }, @@ -183,7 +180,7 @@ export class UkAddressField extends FormComponent { name, path: [name], href: `#${name}`, - text: t('components.addressField.enterAddress', this.model.language, { + text: this.model.t('components.addressField.enterAddress', { shortDescription: lowerFirst(shortDescription) }) } From 874f604115835f98cf00ba32740c12fe569bbbc9 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 17 Apr 2026 14:26:31 +0100 Subject: [PATCH 28/89] refactor(i18n): SummaryViewModel uses model.t() instead of t(key, lang) --- src/server/plugins/engine/models/SummaryViewModel.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/server/plugins/engine/models/SummaryViewModel.ts b/src/server/plugins/engine/models/SummaryViewModel.ts index e56390514..138a59589 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.ts @@ -15,7 +15,6 @@ import { getError, getPageHref } from '~/src/server/plugins/engine/helpers.js' -import { t } from '~/src/server/plugins/engine/i18n/index.js' import { type Detail, type DetailItem, @@ -68,12 +67,10 @@ export class SummaryViewModel { const { basePath, def, sections } = model const { isForceAccess } = context - const lang = model.language - this.page = page this.pageTitle = page.title if (def.schema === SchemaVersion.V2 && !page.title) { - this.pageTitle = t('pages.summary.title', lang) + this.pageTitle = model.t('pages.summary.title') } this.serviceUrl = `/${basePath}` @@ -100,7 +97,7 @@ export class SummaryViewModel { if (!isForceAccess) { items.push({ href: item.href, - text: t('pages.summary.change', lang), + text: model.t('pages.summary.change'), classes: 'govuk-link--no-visited-state', visuallyHiddenText: item.label }) @@ -112,7 +109,7 @@ export class SummaryViewModel { }, value: { classes: 'app-prose-scope', - html: item.value || t('pages.summary.notProvided', lang) + html: item.value || model.t('pages.summary.notProvided') }, actions: { items From 14f1ba1e1acf1f6834877173165b68a0092fd842 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 17 Apr 2026 14:32:04 +0100 Subject: [PATCH 29/89] feat(i18n): add pirate translation and simple-form-pirate demo Adds x-pirate locale (arrr), registers it in i18next, and wires up simple-form-pirate.yaml in localFormsService. Visit /simple-form-pirate to experience the high seas. --- src/server/forms/simple-form-pirate.yaml | 80 +++++++ src/server/plugins/engine/i18n/index.ts | 4 +- .../engine/i18n/translations/x-pirate.json | 224 ++++++++++++++++++ .../engine/services/localFormsService.js | 7 + 4 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 src/server/forms/simple-form-pirate.yaml create mode 100644 src/server/plugins/engine/i18n/translations/x-pirate.json diff --git a/src/server/forms/simple-form-pirate.yaml b/src/server/forms/simple-form-pirate.yaml new file mode 100644 index 000000000..d40991279 --- /dev/null +++ b/src/server/forms/simple-form-pirate.yaml @@ -0,0 +1,80 @@ +--- +name: Pirate Form +engine: V2 +schema: 2 +startPage: '/summary' +metadata: + language: x-pirate +pages: + - title: Yer name, landlubber + path: '/your-name' + components: + - type: TextField + title: What be yer first name? + name: applicantFirstName + shortDescription: Yer first name + hint: '' + options: + required: true + schema: {} + id: 1fb8e182-c709-4792-8f83-e01d8b1fee1a + - type: TextField + title: What be yer last name? + name: applicantLastName + shortDescription: Yer last name + hint: '' + options: + required: true + schema: {} + id: b68df7f1-d4f4-4c17-83c8-402f584906c9 + next: [] + id: 622a35ec-3795-418a-81f3-a45746959045 + - title: Hoist yer passport + controller: FileUploadPageController + path: '/upload-passport' + components: + - type: FileUploadField + title: Hoist a copy of yer passport, arrr + name: passportUpload + shortDescription: Hoist passport + hint: '' + options: + required: false + schema: {} + id: 987c1234-56d7-89e0-1234-56789abcdef0 + id: 23456789-0abc-def1-2345-67890abcdef1 + - title: Hoist yer ship's licence + controller: FileUploadPageController + path: '/upload-driving-licence' + components: + - type: FileUploadField + title: Hoist a copy of yer drivers licence, matey + name: driversLicenceUpload + shortDescription: Hoist drivers licence + hint: '' + options: + required: false + schema: {} + id: 987c1234-56d7-89e0-1234-56789abcdef1 + id: 23456789-0abc-def1-2345-67890abcdef2 + - title: '' + path: '/date-of-birth' + components: + - type: DatePartsField + title: When did {{ applicantFirstName }} {{ applicantLastName }} first set sail? + name: dateOfBirth + shortDescription: Yer birthday + hint: '' + options: + required: true + schema: {} + id: '00738799-3489-4ab2-a57b-542eecb31bfa' + next: [] + id: da0fbdb4-a2de-4650-be16-9ba552af135f + - id: 449a45f6-4541-4a46-91bd-8b8931b07b50 + title: '' + path: '/summary' + controller: SummaryPageController +conditions: [] +sections: [] +lists: [] diff --git a/src/server/plugins/engine/i18n/index.ts b/src/server/plugins/engine/i18n/index.ts index 26df9d740..9df00d041 100644 --- a/src/server/plugins/engine/i18n/index.ts +++ b/src/server/plugins/engine/i18n/index.ts @@ -1,11 +1,13 @@ import i18next from 'i18next' import enGB from '~/src/server/plugins/engine/i18n/translations/en-GB.json' with { type: 'json' } +import xPirate from '~/src/server/plugins/engine/i18n/translations/x-pirate.json' with { type: 'json' } i18next .init({ resources: { - 'en-GB': { translation: enGB } + 'en-GB': { translation: enGB }, + 'x-pirate': { translation: xPirate } }, fallbackLng: 'en-GB', interpolation: { diff --git a/src/server/plugins/engine/i18n/translations/x-pirate.json b/src/server/plugins/engine/i18n/translations/x-pirate.json new file mode 100644 index 000000000..0d1f0d31e --- /dev/null +++ b/src/server/plugins/engine/i18n/translations/x-pirate.json @@ -0,0 +1,224 @@ +{ + "validation": { + "required": "Ye must enter {{lowerFirst(#label)}}, ye scallywag", + "selectRequired": "Ye must select {{lowerFirst(#label)}}, ye scallywag", + "pattern": "Shiver me timbers! Enter a valid {{lowerFirst(#label)}}", + "format": "Blimey! Enter {{lowerFirst(#label)}} in the correct format", + "objectRequired": "Enter {{#label}}, or walk the plank", + "objectMissing": "{{#title}} must include a {{lowerFirst(#label)}}", + "max": "{{#label}} must be {{#limit}} characters or fewer, ye blabberin' sea dog", + "min": "{{#label}} must be {{#limit}} characters or more", + "minMax": "{{#label}} must be between {{#min}} and {{#max}} characters", + "number": "{{#label}} must be a number, ye innumerate buccaneer", + "numberPrecision": "{{#label}} must have {{#limit}} or fewer decimal places", + "numberInteger": "{{#label}} must be a whole number — no fractions on the high seas", + "numberMin": "{{#label}} must be {{#limit}} or higher", + "numberMax": "{{#label}} must be {{#limit}} or lower", + "maxWords": "{{#label}} must be {{#limit}} words or fewer, ye long-winded bilge rat" + }, + + "errors": { + "title": "Blimey! There be a problem" + }, + + "common": { + "optional": "(optional, if ye dare skip it)", + "warningIconFallback": "Warnin'!", + "back": "Turn the ship around", + "continue": "Sail on", + "saveAndExit": "Stow yer progress an' abandon ship" + }, + + "pages": { + "question": { + "paymentCancelledBannerTitle": "Avast!", + "paymentCancelledTitle": "Yer payment has walked the plank", + "paymentCancelledBody": "Yer payment details were cast into Davy Jones' locker because the form was idle for 5 days.", + "paymentCancelledAction": "Enter yer payment details again, if ye be brave enough.", + "backToAddAnother": "Go back to add another", + "backToCheckAnswers": "Go back to check yer answers", + "startNow": "Weigh anchor!" + }, + + "summary": { + "title": "Check yer answers before sendin' yer form", + "change": "Change", + "notProvided": "Not provided, ye forgetful landlubber", + "declaration": "Declaration", + "acceptAndSubmit": "Accept an' submit", + "submit": "Submit", + "saveAndExit": "Stow yer progress an' abandon ship", + "paymentSuccessBannerTitle": "Hoist the colours!", + "paymentSuccessTitle": "We have yer plunder details", + "paymentSuccessBody": "Yer payment be held in the crow's nest. We'll charge ye when ye submit the form.", + "paymentDetailsTitle": "Plunder details", + "paymentFor": "Plunder for", + "totalAmount": "Total booty", + "reference": "Reference", + "dateOfPayment": "Date of plunder", + "submissionFailed": "There be a problem and yer form was not submitted. Try submittin' the form again", + "submissionFailedContactSuffix": " or ye can [[contactUsLink]] and quote yer reference number to arrange a refund", + "contactUsLinkText": "contact the crew (opens in new tab)" + }, + + "repeater": { + "removeAnswer": "Are ye sure ye want to remove this answer, arrr?", + "remove": "Remove", + "cancel": "Cancel", + "continue": "Sail on", + "addAnother": "Add another", + "saveAndExit": "Stow yer progress an' abandon ship", + "change": "Change", + "notProvided": "Not provided, ye forgetful landlubber", + "visuallyHiddenItem": "item [[index]]", + "pageTitle_one": "Ye have added [[count]] answer, arrr", + "pageTitle_other": "Ye have added [[count]] answers, arrr", + "tooMany_one": "Ye can only add up to [[count]] answer, ye greedy pirate", + "tooMany_other": "Ye can only add up to [[count]] answers, ye greedy pirate", + "tooFew_one": "Ye must add at least [[count]] answer or walk the plank", + "tooFew_other": "Ye must add at least [[count]] answers or walk the plank" + }, + + "fileUpload": { + "upload": "Hoist yer file", + "maxFilesReached": "Ye have reached the maximum number of files. Remove a file before ye hoist any more." + }, + + "exit": { + "defaultTitle": "Yer progress has been stowed in the hold", + "whatHappensNext": "What happens next", + "body": "Yer form progress has been stowed. Ye can return to complete yer application at any time using the link provided.", + "returnToApplication": "Return to yer voyage" + }, + + "confirmation": { + "whatHappensNext": "What happens next", + "referenceNumber": "Yer reference number", + "feedbackLink": "What do ye think of this service? (takes 30 seconds)" + }, + + "preview": { + "warnMissingNotificationEmail": { + "body": "If ye want to receive a test email, [[link]] ye want form submissions to go to.", + "linkText": "enter the email address (opens in new tab)" + } + } + }, + + "components": { + "dateField": { + "day": "Day", + "month": "Month", + "year": "Year", + "validation": { + "dateFormat": "{{#title}} must be a real date, not some made-up nonsense", + "dateMin": "{{#title}} must be the same as or after {{#limit}}", + "dateMax": "{{#title}} must be the same as or before {{#limit}}" + } + }, + + "monthYearField": { + "month": "Month", + "year": "Year" + }, + + "addressField": { + "uprn": "UPRN", + "line1": "Address line 1", + "line2": "Address line 2", + "town": "Town or port", + "county": "County", + "postcode": "Postcode", + "enterAddress": "Enter [[shortDescription]]", + "findAddress": "Find a berth", + "selectedAddress": "Selected berth:", + "useDifferentAddress": "Use a different berth", + "enterManually": "enter address manually" + }, + + "latLongField": { + "latitude": "Latitude", + "longitude": "Longitude", + "latitudeRequired": "Enter latitude", + "longitudeRequired": "Enter longitude", + "precision": "{{#label}} must have no more than 7 decimal places", + "notANumber": "{{#label}} must be a valid number", + "latitudeBase": "Enter a valid latitude for [[fieldLabel]] like 51.519450", + "longitudeBase": "Enter a valid longitude for [[fieldLabel]] like -0.127758", + "latitudeRange": "Latitude for [[fieldLabel]] must be between [[min]] and [[max]]", + "longitudeRange": "Longitude for [[fieldLabel]] must be between [[min]] and [[max]]" + }, + + "eastingNorthingField": { + "easting": "Easting", + "northing": "Northing", + "eastingRequired": "Enter easting", + "northingRequired": "Enter northing", + "eastingDigits": "{{#label}} for [[fieldLabel]] must be between 1 and 6 digits", + "northingDigits": "{{#label}} for [[fieldLabel]] must be between 1 and 7 digits", + "eastingRange": "{{#label}} for [[fieldLabel]] must be between [[min]] and [[max]]", + "northingRange": "{{#label}} for [[fieldLabel]] must be between [[min]] and [[max]]" + }, + + "nationalGridField": { + "required": "Enter {{lowerFirst(#title)}}, ye scallywag", + "pattern": "Enter a valid National Grid field number for {{lowerFirst(#title)}} like NG 1234 5678", + "howToFind": "How to find yer location, landlubber" + }, + + "osGridRefField": { + "required": "Enter {{lowerFirst(#title)}}, ye scallywag", + "pattern": "Enter a valid OS grid reference for {{lowerFirst(#title)}} like TQ123456", + "howToFind": "How to find yer location, landlubber" + }, + + "locationFieldBase": { + "howToFind": "How to find yer location, landlubber" + }, + + "yesNoField": { + "yes": "Aye", + "no": "Nay", + "validation": { + "selectYesNoRequired": "{{#label}} - select aye or nay" + } + }, + + "declarationField": { + "defaultLabel": "Aye, I understand an' agree", + "defaultConfirmationLabel": "I swear on Davy Jones' locker that I understand and accept this declaration", + "notProvided": "Not provided, ye forgetful landlubber", + "validation": { + "declarationRequired": "Ye must confirm ye understand and agree with the {{lowerFirst(#label)}} to continue, or walk the plank" + } + }, + + "geospatialField": { + "added_one": "Added [[count]] location", + "added_other": "Added [[count]] locations" + }, + + "paymentField": { + "detailsRequired": "Plunder details required", + "pendingTransaction": "Ye may see a pending transaction in yer treasure chest but ye'll only be charged when ye submit the form.", + "addPaymentDetails": "Add plunder details", + "cannotTakePayment": "There be a problem and we cannot take yer plunder. Contact the crew (details in the footer of this form) or stow yer progress and return later.", + "testApiKey": "Add a valid test API key before ye can preview the payment journey, ye scallywag.", + "completePayment": "Complete the plunder to continue", + "paymentExpired": "Yer payment authorisation has expired. Add yer payment details again, ye forgetful buccaneer.", + "submissionFailed": "There be a problem and yer form was not submitted. Try submittin' the form again.", + "canSubmitAfterPayment": "Ye can submit the form after ye have added yer payment details.", + "preAuthorisedTitle": "Ye have already authorised a payment for this form", + "preAuthorisedBody": "Continue to submit the form. Ye will not be charged twice, ye have me word.", + "totalAmountLabel": "Total booty:" + }, + + "fileUploadField": { + "uploaded": "Hoisted", + "uploadFailed": "There be a problem with yer hoisted files. Re-hoist them before submittin' the form again.", + "uploadedFilesHeading": "Hoisted files", + "filesCount_one": "[[count]] file hoisted", + "filesCount_other": "[[count]] files hoisted" + } + } +} diff --git a/src/server/plugins/engine/services/localFormsService.js b/src/server/plugins/engine/services/localFormsService.js index c3fbfaef4..205255e1f 100644 --- a/src/server/plugins/engine/services/localFormsService.js +++ b/src/server/plugins/engine/services/localFormsService.js @@ -58,6 +58,13 @@ export const formsService = async () => { slug: 'simple-form' }) + await loader.addForm('src/server/forms/simple-form-pirate.yaml', { + ...metadata, + id: 'a1b2c3d4-e5f6-7890-abcd-ef0123456790', + title: 'Simple Form (Pirate)', + slug: 'simple-form-pirate' + }) + await loader.addForm('src/server/forms/payment-test.yaml', { ...metadata, id: 'b2c3d4e5-f6a7-8901-bcde-f01234567890', From 46f372e9f58ef7f2512e865901f936d690d99945 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 17 Apr 2026 14:44:09 +0100 Subject: [PATCH 30/89] feat(i18n): fully pirate all keys in x-pirate translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply pirate flavour to every remaining bland key — date field labels, lat/long errors, address fields, button labels, repeater actions, and payment strings. Comedy matters everywhere. --- .../engine/i18n/translations/x-pirate.json | 146 +++++++++--------- 1 file changed, 73 insertions(+), 73 deletions(-) diff --git a/src/server/plugins/engine/i18n/translations/x-pirate.json b/src/server/plugins/engine/i18n/translations/x-pirate.json index 0d1f0d31e..7e5a6c2b7 100644 --- a/src/server/plugins/engine/i18n/translations/x-pirate.json +++ b/src/server/plugins/engine/i18n/translations/x-pirate.json @@ -5,15 +5,15 @@ "pattern": "Shiver me timbers! Enter a valid {{lowerFirst(#label)}}", "format": "Blimey! Enter {{lowerFirst(#label)}} in the correct format", "objectRequired": "Enter {{#label}}, or walk the plank", - "objectMissing": "{{#title}} must include a {{lowerFirst(#label)}}", + "objectMissing": "Arrr! {{#title}} must include a {{lowerFirst(#label)}}", "max": "{{#label}} must be {{#limit}} characters or fewer, ye blabberin' sea dog", - "min": "{{#label}} must be {{#limit}} characters or more", - "minMax": "{{#label}} must be between {{#min}} and {{#max}} characters", + "min": "{{#label}} must be at least {{#limit}} characters, ye miserly landlubber", + "minMax": "{{#label}} must be between {{#min}} and {{#max}} characters, no more no less", "number": "{{#label}} must be a number, ye innumerate buccaneer", - "numberPrecision": "{{#label}} must have {{#limit}} or fewer decimal places", + "numberPrecision": "{{#label}} must have {{#limit}} or fewer decimal places, arrr", "numberInteger": "{{#label}} must be a whole number — no fractions on the high seas", - "numberMin": "{{#label}} must be {{#limit}} or higher", - "numberMax": "{{#label}} must be {{#limit}} or lower", + "numberMin": "{{#label}} must be {{#limit}} or higher, ye cheapskate", + "numberMax": "{{#label}} must be {{#limit}} or lower, ye greedy pirate", "maxWords": "{{#label}} must be {{#limit}} words or fewer, ye long-winded bilge rat" }, @@ -35,18 +35,18 @@ "paymentCancelledTitle": "Yer payment has walked the plank", "paymentCancelledBody": "Yer payment details were cast into Davy Jones' locker because the form was idle for 5 days.", "paymentCancelledAction": "Enter yer payment details again, if ye be brave enough.", - "backToAddAnother": "Go back to add another", - "backToCheckAnswers": "Go back to check yer answers", + "backToAddAnother": "Back to add another, arrr", + "backToCheckAnswers": "Back to check yer answers, ye forgetful swab", "startNow": "Weigh anchor!" }, "summary": { "title": "Check yer answers before sendin' yer form", - "change": "Change", + "change": "Change course", "notProvided": "Not provided, ye forgetful landlubber", - "declaration": "Declaration", - "acceptAndSubmit": "Accept an' submit", - "submit": "Submit", + "declaration": "Proclamation", + "acceptAndSubmit": "Accept an' fire the cannons", + "submit": "Submit, arrr", "saveAndExit": "Stow yer progress an' abandon ship", "paymentSuccessBannerTitle": "Hoist the colours!", "paymentSuccessTitle": "We have yer plunder details", @@ -54,21 +54,21 @@ "paymentDetailsTitle": "Plunder details", "paymentFor": "Plunder for", "totalAmount": "Total booty", - "reference": "Reference", + "reference": "Scroll number", "dateOfPayment": "Date of plunder", "submissionFailed": "There be a problem and yer form was not submitted. Try submittin' the form again", - "submissionFailedContactSuffix": " or ye can [[contactUsLink]] and quote yer reference number to arrange a refund", + "submissionFailedContactSuffix": " or ye can [[contactUsLink]] and quote yer scroll number to arrange a refund", "contactUsLinkText": "contact the crew (opens in new tab)" }, "repeater": { - "removeAnswer": "Are ye sure ye want to remove this answer, arrr?", - "remove": "Remove", - "cancel": "Cancel", + "removeAnswer": "Are ye sure ye want to cast this answer overboard, arrr?", + "remove": "Cast overboard", + "cancel": "Belay that", "continue": "Sail on", - "addAnother": "Add another", + "addAnother": "Add another, matey", "saveAndExit": "Stow yer progress an' abandon ship", - "change": "Change", + "change": "Change course", "notProvided": "Not provided, ye forgetful landlubber", "visuallyHiddenItem": "item [[index]]", "pageTitle_one": "Ye have added [[count]] answer, arrr", @@ -81,20 +81,20 @@ "fileUpload": { "upload": "Hoist yer file", - "maxFilesReached": "Ye have reached the maximum number of files. Remove a file before ye hoist any more." + "maxFilesReached": "Ye have reached the maximum number of files. Cast one overboard before ye hoist any more." }, "exit": { "defaultTitle": "Yer progress has been stowed in the hold", - "whatHappensNext": "What happens next", - "body": "Yer form progress has been stowed. Ye can return to complete yer application at any time using the link provided.", + "whatHappensNext": "What happens next on yer voyage", + "body": "Yer form progress has been stowed in the hold. Ye can return to complete yer application at any time using the link provided.", "returnToApplication": "Return to yer voyage" }, "confirmation": { - "whatHappensNext": "What happens next", - "referenceNumber": "Yer reference number", - "feedbackLink": "What do ye think of this service? (takes 30 seconds)" + "whatHappensNext": "What happens next on yer voyage", + "referenceNumber": "Yer scroll number", + "feedbackLink": "What do ye think of this service? (takes 30 seconds, even for a pirate)" }, "preview": { @@ -107,85 +107,85 @@ "components": { "dateField": { - "day": "Day", - "month": "Month", - "year": "Year", + "day": "Day (of the raid)", + "month": "Month (of the raid)", + "year": "Year (of the raid)", "validation": { "dateFormat": "{{#title}} must be a real date, not some made-up nonsense", - "dateMin": "{{#title}} must be the same as or after {{#limit}}", - "dateMax": "{{#title}} must be the same as or before {{#limit}}" + "dateMin": "{{#title}} must be the same as or after {{#limit}}, ye time-traveller", + "dateMax": "{{#title}} must be the same as or before {{#limit}}, ye time-traveller" } }, "monthYearField": { - "month": "Month", - "year": "Year" + "month": "Month (of the raid)", + "year": "Year (of the raid)" }, "addressField": { - "uprn": "UPRN", - "line1": "Address line 1", - "line2": "Address line 2", + "uprn": "Berth number (UPRN)", + "line1": "Port address line 1", + "line2": "Port address line 2", "town": "Town or port", - "county": "County", + "county": "County (or high seas region)", "postcode": "Postcode", "enterAddress": "Enter [[shortDescription]]", "findAddress": "Find a berth", "selectedAddress": "Selected berth:", "useDifferentAddress": "Use a different berth", - "enterManually": "enter address manually" + "enterManually": "enter address manually, ye stubborn sea dog" }, "latLongField": { - "latitude": "Latitude", - "longitude": "Longitude", - "latitudeRequired": "Enter latitude", - "longitudeRequired": "Enter longitude", - "precision": "{{#label}} must have no more than 7 decimal places", - "notANumber": "{{#label}} must be a valid number", - "latitudeBase": "Enter a valid latitude for [[fieldLabel]] like 51.519450", - "longitudeBase": "Enter a valid longitude for [[fieldLabel]] like -0.127758", - "latitudeRange": "Latitude for [[fieldLabel]] must be between [[min]] and [[max]]", - "longitudeRange": "Longitude for [[fieldLabel]] must be between [[min]] and [[max]]" + "latitude": "Latitude (X marks the spot)", + "longitude": "Longitude (X marks the spot)", + "latitudeRequired": "Enter latitude or ye'll never find the treasure", + "longitudeRequired": "Enter longitude or ye'll never find the treasure", + "precision": "{{#label}} must have no more than 7 decimal places, arrr", + "notANumber": "{{#label}} must be a valid number, ye innumerate buccaneer", + "latitudeBase": "Enter a valid latitude for [[fieldLabel]] like 51.519450 — X marks the spot!", + "longitudeBase": "Enter a valid longitude for [[fieldLabel]] like -0.127758 — X marks the spot!", + "latitudeRange": "Latitude for [[fieldLabel]] must be between [[min]] and [[max]] — outside that be dragons", + "longitudeRange": "Longitude for [[fieldLabel]] must be between [[min]] and [[max]] — outside that be dragons" }, "eastingNorthingField": { - "easting": "Easting", - "northing": "Northing", - "eastingRequired": "Enter easting", - "northingRequired": "Enter northing", - "eastingDigits": "{{#label}} for [[fieldLabel]] must be between 1 and 6 digits", - "northingDigits": "{{#label}} for [[fieldLabel]] must be between 1 and 7 digits", - "eastingRange": "{{#label}} for [[fieldLabel]] must be between [[min]] and [[max]]", - "northingRange": "{{#label}} for [[fieldLabel]] must be between [[min]] and [[max]]" + "easting": "Easting (how far East on the treasure map)", + "northing": "Northing (how far North on the treasure map)", + "eastingRequired": "Enter easting or ye'll be lost at sea", + "northingRequired": "Enter northing or ye'll be lost at sea", + "eastingDigits": "{{#label}} for [[fieldLabel]] must be between 1 and 6 digits, landlubber", + "northingDigits": "{{#label}} for [[fieldLabel]] must be between 1 and 7 digits, landlubber", + "eastingRange": "{{#label}} for [[fieldLabel]] must be between [[min]] and [[max]] — outside that be dragons", + "northingRange": "{{#label}} for [[fieldLabel]] must be between [[min]] and [[max]] — outside that be dragons" }, "nationalGridField": { "required": "Enter {{lowerFirst(#title)}}, ye scallywag", - "pattern": "Enter a valid National Grid field number for {{lowerFirst(#title)}} like NG 1234 5678", - "howToFind": "How to find yer location, landlubber" + "pattern": "Enter a valid National Grid reference for {{lowerFirst(#title)}} like NG 1234 5678 — even a parrot could do it", + "howToFind": "How to find yer location on the treasure map" }, "osGridRefField": { "required": "Enter {{lowerFirst(#title)}}, ye scallywag", - "pattern": "Enter a valid OS grid reference for {{lowerFirst(#title)}} like TQ123456", - "howToFind": "How to find yer location, landlubber" + "pattern": "Enter a valid OS grid reference for {{lowerFirst(#title)}} like TQ123456 — even a parrot could do it", + "howToFind": "How to find yer location on the treasure map" }, "locationFieldBase": { - "howToFind": "How to find yer location, landlubber" + "howToFind": "How to find yer location on the treasure map" }, "yesNoField": { "yes": "Aye", "no": "Nay", "validation": { - "selectYesNoRequired": "{{#label}} - select aye or nay" + "selectYesNoRequired": "{{#label}} — say aye or nay, ye indecisive landlubber" } }, "declarationField": { - "defaultLabel": "Aye, I understand an' agree", + "defaultLabel": "Aye, I understand an' agree, on me honour as a pirate", "defaultConfirmationLabel": "I swear on Davy Jones' locker that I understand and accept this declaration", "notProvided": "Not provided, ye forgetful landlubber", "validation": { @@ -194,31 +194,31 @@ }, "geospatialField": { - "added_one": "Added [[count]] location", - "added_other": "Added [[count]] locations" + "added_one": "Ye've marked [[count]] location on the treasure map", + "added_other": "Ye've marked [[count]] locations on the treasure map" }, "paymentField": { - "detailsRequired": "Plunder details required", + "detailsRequired": "Plunder details required, arrr", "pendingTransaction": "Ye may see a pending transaction in yer treasure chest but ye'll only be charged when ye submit the form.", "addPaymentDetails": "Add plunder details", "cannotTakePayment": "There be a problem and we cannot take yer plunder. Contact the crew (details in the footer of this form) or stow yer progress and return later.", "testApiKey": "Add a valid test API key before ye can preview the payment journey, ye scallywag.", "completePayment": "Complete the plunder to continue", - "paymentExpired": "Yer payment authorisation has expired. Add yer payment details again, ye forgetful buccaneer.", + "paymentExpired": "Yer payment authorisation has expired and sunk to the ocean floor. Add yer payment details again.", "submissionFailed": "There be a problem and yer form was not submitted. Try submittin' the form again.", - "canSubmitAfterPayment": "Ye can submit the form after ye have added yer payment details.", - "preAuthorisedTitle": "Ye have already authorised a payment for this form", - "preAuthorisedBody": "Continue to submit the form. Ye will not be charged twice, ye have me word.", + "canSubmitAfterPayment": "Ye can submit the form after ye have handed over yer plunder.", + "preAuthorisedTitle": "Ye have already authorised a payment for this form, ye keen pirate", + "preAuthorisedBody": "Continue to submit the form. Ye will not be charged twice, ye have me word as a pirate.", "totalAmountLabel": "Total booty:" }, "fileUploadField": { - "uploaded": "Hoisted", + "uploaded": "Hoisted aboard", "uploadFailed": "There be a problem with yer hoisted files. Re-hoist them before submittin' the form again.", - "uploadedFilesHeading": "Hoisted files", - "filesCount_one": "[[count]] file hoisted", - "filesCount_other": "[[count]] files hoisted" + "uploadedFilesHeading": "Files hoisted aboard", + "filesCount_one": "[[count]] file hoisted aboard", + "filesCount_other": "[[count]] files hoisted aboard" } } } From 38c602f555dc9486cc9fdc2b9e752a76826987dc Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 20 Apr 2026 10:05:29 +0100 Subject: [PATCH 31/89] fix: revert date part field titles to hardcoded strings --- src/server/forms/simple-form-pirate.yaml | 2 -- src/server/plugins/engine/beta/form-context.test.ts | 1 + src/server/plugins/engine/beta/form-context.ts | 8 ++++++++ .../plugins/engine/components/DatePartsField.ts | 6 +++--- src/server/plugins/engine/models/FormModel.test.ts | 11 +++++------ src/server/plugins/engine/models/FormModel.ts | 6 ++---- .../plugins/engine/services/localFormsService.js | 4 +++- 7 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/server/forms/simple-form-pirate.yaml b/src/server/forms/simple-form-pirate.yaml index d40991279..df31ec471 100644 --- a/src/server/forms/simple-form-pirate.yaml +++ b/src/server/forms/simple-form-pirate.yaml @@ -3,8 +3,6 @@ name: Pirate Form engine: V2 schema: 2 startPage: '/summary' -metadata: - language: x-pirate pages: - title: Yer name, landlubber path: '/your-name' diff --git a/src/server/plugins/engine/beta/form-context.test.ts b/src/server/plugins/engine/beta/form-context.test.ts index bda790707..c9c9fc14c 100644 --- a/src/server/plugins/engine/beta/form-context.test.ts +++ b/src/server/plugins/engine/beta/form-context.test.ts @@ -183,6 +183,7 @@ describe('getFormModel helper', () => { definition, { basePath: slug, + language: 'en-GB', versionNumber: 17, ordnanceSurveyApiKey: undefined, formId: metadata.id diff --git a/src/server/plugins/engine/beta/form-context.ts b/src/server/plugins/engine/beta/form-context.ts index fa5d8836f..0a7ffeb4d 100644 --- a/src/server/plugins/engine/beta/form-context.ts +++ b/src/server/plugins/engine/beta/form-context.ts @@ -1,3 +1,4 @@ +import { type FormMetadata } from '@defra/forms-model' import Boom from '@hapi/boom' import { type Request, type Server } from '@hapi/hapi' import { isEqual } from 'date-fns' @@ -73,6 +74,9 @@ export async function getFormModel( basePath: options.basePath ?? buildBasePath(options.routePrefix ?? '', slug, formState, isPreview), + // language not yet in FormMetadata type + language: + (metadata as FormMetadata & { language?: string }).language ?? 'en-GB', versionNumber, ordnanceSurveyApiKey: options.ordnanceSurveyApiKey, formId: options.formId ?? metadata.id @@ -190,6 +194,10 @@ export async function resolveFormModel( basePath: options.basePath ?? buildBasePath(routePrefix, slug, formState, isPreview), + // language not yet in FormMetadata type + language: + (metadata as FormMetadata & { language?: string }).language ?? + 'en-GB', versionNumber, ordnanceSurveyApiKey: options.ordnanceSurveyApiKey, formId: options.formId ?? metadata.id diff --git a/src/server/plugins/engine/components/DatePartsField.ts b/src/server/plugins/engine/components/DatePartsField.ts index fad1f5d53..ef316ef03 100644 --- a/src/server/plugins/engine/components/DatePartsField.ts +++ b/src/server/plugins/engine/components/DatePartsField.ts @@ -57,7 +57,7 @@ export class DatePartsField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__day`, - title: props.model.t('components.dateField.day'), + title: 'Day', schema: { min: 1, max: 31, precision: 0 }, options: { required: isRequired, @@ -69,7 +69,7 @@ export class DatePartsField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__month`, - title: props.model.t('components.dateField.month'), + title: 'Month', schema: { min: 1, max: 12, precision: 0 }, options: { required: isRequired, @@ -81,7 +81,7 @@ export class DatePartsField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__year`, - title: props.model.t('components.dateField.year'), + title: 'Year', schema: { min: 1000, max: 3000, precision: 0 }, options: { required: isRequired, diff --git a/src/server/plugins/engine/models/FormModel.test.ts b/src/server/plugins/engine/models/FormModel.test.ts index a8519a707..4b403bc83 100644 --- a/src/server/plugins/engine/models/FormModel.test.ts +++ b/src/server/plugins/engine/models/FormModel.test.ts @@ -161,12 +161,11 @@ describe('FormModel', () => { expect(model.language).toBe('en-GB') }) - it('reads language from metadata.language when present', () => { - const defWithLanguage = { - ...definition, - metadata: { language: 'cy' } - } - const model = new FormModel(defWithLanguage, { basePath: 'test' }) + it('reads language from options.language when present', () => { + const model = new FormModel(definition, { + basePath: 'test', + language: 'cy' + }) expect(model.language).toBe('cy') }) diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index f2a66c7cc..aaf192a07 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -107,6 +107,7 @@ export class FormModel { def: typeof this.def, options: { basePath: string + language?: string versionNumber?: number ordnanceSurveyApiKey?: string formId?: string @@ -133,10 +134,7 @@ export class FormModel { // by joi so as not to change the source data. def = structuredClone(result.value) - const language = - typeof def.metadata?.language === 'string' - ? def.metadata.language - : 'en-GB' + const language = options.language ?? 'en-GB' // Add default lists def.lists.push({ diff --git a/src/server/plugins/engine/services/localFormsService.js b/src/server/plugins/engine/services/localFormsService.js index 205255e1f..4d2ae867c 100644 --- a/src/server/plugins/engine/services/localFormsService.js +++ b/src/server/plugins/engine/services/localFormsService.js @@ -62,7 +62,9 @@ export const formsService = async () => { ...metadata, id: 'a1b2c3d4-e5f6-7890-abcd-ef0123456790', title: 'Simple Form (Pirate)', - slug: 'simple-form-pirate' + slug: 'simple-form-pirate', + // @ts-expect-error - language not yet in FormMetadata type + language: 'x-pirate' }) await loader.addForm('src/server/forms/payment-test.yaml', { From a35bf67da4e862ec768094bb65e81bdb4abab551 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 20 Apr 2026 10:05:57 +0100 Subject: [PATCH 32/89] fix: extract payment cancelled banner html to a variable --- src/server/plugins/engine/views/index.html | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/server/plugins/engine/views/index.html b/src/server/plugins/engine/views/index.html index ddd2b3c93..5227ab608 100644 --- a/src/server/plugins/engine/views/index.html +++ b/src/server/plugins/engine/views/index.html @@ -12,9 +12,14 @@ {% endif %} {% if showPaymentExpiredNotification %} + {% set paymentCancelledHtml %} +

{{ t('pages.question.paymentCancelledTitle') }}

+

{{ t('pages.question.paymentCancelledBody') }}

+

{{ t('pages.question.paymentCancelledAction') }}

+ {% endset %} {{ govukNotificationBanner({ titleText: t('pages.question.paymentCancelledBannerTitle'), - html: '

' + t('pages.question.paymentCancelledTitle') + '

' + t('pages.question.paymentCancelledBody') + '

' + t('pages.question.paymentCancelledAction') + '

' + html: paymentCancelledHtml }) }} {% endif %} From c02f7bf1a67c8b43f7d7c461fa6f6d388961456f Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 20 Apr 2026 11:31:20 +0100 Subject: [PATCH 33/89] sample --- src/server/forms/simple-form.yaml | 4 ++-- src/server/plugins/engine/i18n/translations/en-GB.json | 7 ------- src/server/plugins/engine/index.ts | 6 ++++-- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/server/forms/simple-form.yaml b/src/server/forms/simple-form.yaml index efa8f8215..59c6c0fe0 100644 --- a/src/server/forms/simple-form.yaml +++ b/src/server/forms/simple-form.yaml @@ -37,7 +37,7 @@ pages: shortDescription: Upload passport hint: '' options: - required: true + required: false schema: {} id: 987c1234-56d7-89e0-1234-56789abcdef0 id: 23456789-0abc-def1-2345-67890abcdef1 @@ -51,7 +51,7 @@ pages: shortDescription: Upload drivers licence hint: '' options: - required: true + required: false schema: {} id: 987c1234-56d7-89e0-1234-56789abcdef1 id: 23456789-0abc-def1-2345-67890abcdef2 diff --git a/src/server/plugins/engine/i18n/translations/en-GB.json b/src/server/plugins/engine/i18n/translations/en-GB.json index c24d4faa7..1b194ad7a 100644 --- a/src/server/plugins/engine/i18n/translations/en-GB.json +++ b/src/server/plugins/engine/i18n/translations/en-GB.json @@ -1,12 +1,5 @@ { - "_translatorNotes": { - "joiPlaceholders": "{{...}} placeholders are processed by the validation engine — preserve exactly as-is", - "enginePlaceholders": "[[...]] placeholders are substituted by the forms engine at runtime — preserve exactly as-is", - "plurals": "_one keys apply when count = 1, _other keys apply for all other counts — both must be provided" - }, - "validation": { - "_note": "These strings are used in validation error messages. Preserve all {{...}} placeholders exactly.", "required": "Enter {{lowerFirst(#label)}}", "selectRequired": "Select {{lowerFirst(#label)}}", "pattern": "Enter a valid {{lowerFirst(#label)}}", diff --git a/src/server/plugins/engine/index.ts b/src/server/plugins/engine/index.ts index 0911dc17f..d14e0c694 100644 --- a/src/server/plugins/engine/index.ts +++ b/src/server/plugins/engine/index.ts @@ -8,7 +8,8 @@ import { checkComponentTemplates, checkErrorTemplates, evaluate, - govukRebrand + govukRebrand, + tGlobal } from '~/src/server/plugins/nunjucks/environment.js' import * as filters from '~/src/server/plugins/nunjucks/filters/index.js' @@ -25,7 +26,8 @@ const globals = { checkComponentTemplates, checkErrorTemplates, evaluate, - govukRebrand + govukRebrand, + t: tGlobal } export const VIEW_PATH = 'src/server/plugins/engine/views' From 22bf3b32b1bae2fa25a47353cf880d72c8ae73ce Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 20 Apr 2026 15:23:29 +0100 Subject: [PATCH 34/89] Revert "fix: revert date part field titles to hardcoded strings" This reverts commit 38c602f555dc9486cc9fdc2b9e752a76826987dc. --- src/server/forms/simple-form-pirate.yaml | 2 ++ src/server/plugins/engine/beta/form-context.test.ts | 1 - src/server/plugins/engine/beta/form-context.ts | 8 -------- .../plugins/engine/components/DatePartsField.ts | 6 +++--- src/server/plugins/engine/models/FormModel.test.ts | 11 ++++++----- src/server/plugins/engine/models/FormModel.ts | 6 ++++-- .../plugins/engine/services/localFormsService.js | 4 +--- 7 files changed, 16 insertions(+), 22 deletions(-) diff --git a/src/server/forms/simple-form-pirate.yaml b/src/server/forms/simple-form-pirate.yaml index df31ec471..d40991279 100644 --- a/src/server/forms/simple-form-pirate.yaml +++ b/src/server/forms/simple-form-pirate.yaml @@ -3,6 +3,8 @@ name: Pirate Form engine: V2 schema: 2 startPage: '/summary' +metadata: + language: x-pirate pages: - title: Yer name, landlubber path: '/your-name' diff --git a/src/server/plugins/engine/beta/form-context.test.ts b/src/server/plugins/engine/beta/form-context.test.ts index c9c9fc14c..bda790707 100644 --- a/src/server/plugins/engine/beta/form-context.test.ts +++ b/src/server/plugins/engine/beta/form-context.test.ts @@ -183,7 +183,6 @@ describe('getFormModel helper', () => { definition, { basePath: slug, - language: 'en-GB', versionNumber: 17, ordnanceSurveyApiKey: undefined, formId: metadata.id diff --git a/src/server/plugins/engine/beta/form-context.ts b/src/server/plugins/engine/beta/form-context.ts index 0a7ffeb4d..fa5d8836f 100644 --- a/src/server/plugins/engine/beta/form-context.ts +++ b/src/server/plugins/engine/beta/form-context.ts @@ -1,4 +1,3 @@ -import { type FormMetadata } from '@defra/forms-model' import Boom from '@hapi/boom' import { type Request, type Server } from '@hapi/hapi' import { isEqual } from 'date-fns' @@ -74,9 +73,6 @@ export async function getFormModel( basePath: options.basePath ?? buildBasePath(options.routePrefix ?? '', slug, formState, isPreview), - // language not yet in FormMetadata type - language: - (metadata as FormMetadata & { language?: string }).language ?? 'en-GB', versionNumber, ordnanceSurveyApiKey: options.ordnanceSurveyApiKey, formId: options.formId ?? metadata.id @@ -194,10 +190,6 @@ export async function resolveFormModel( basePath: options.basePath ?? buildBasePath(routePrefix, slug, formState, isPreview), - // language not yet in FormMetadata type - language: - (metadata as FormMetadata & { language?: string }).language ?? - 'en-GB', versionNumber, ordnanceSurveyApiKey: options.ordnanceSurveyApiKey, formId: options.formId ?? metadata.id diff --git a/src/server/plugins/engine/components/DatePartsField.ts b/src/server/plugins/engine/components/DatePartsField.ts index ef316ef03..fad1f5d53 100644 --- a/src/server/plugins/engine/components/DatePartsField.ts +++ b/src/server/plugins/engine/components/DatePartsField.ts @@ -57,7 +57,7 @@ export class DatePartsField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__day`, - title: 'Day', + title: props.model.t('components.dateField.day'), schema: { min: 1, max: 31, precision: 0 }, options: { required: isRequired, @@ -69,7 +69,7 @@ export class DatePartsField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__month`, - title: 'Month', + title: props.model.t('components.dateField.month'), schema: { min: 1, max: 12, precision: 0 }, options: { required: isRequired, @@ -81,7 +81,7 @@ export class DatePartsField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__year`, - title: 'Year', + title: props.model.t('components.dateField.year'), schema: { min: 1000, max: 3000, precision: 0 }, options: { required: isRequired, diff --git a/src/server/plugins/engine/models/FormModel.test.ts b/src/server/plugins/engine/models/FormModel.test.ts index 4b403bc83..a8519a707 100644 --- a/src/server/plugins/engine/models/FormModel.test.ts +++ b/src/server/plugins/engine/models/FormModel.test.ts @@ -161,11 +161,12 @@ describe('FormModel', () => { expect(model.language).toBe('en-GB') }) - it('reads language from options.language when present', () => { - const model = new FormModel(definition, { - basePath: 'test', - language: 'cy' - }) + it('reads language from metadata.language when present', () => { + const defWithLanguage = { + ...definition, + metadata: { language: 'cy' } + } + const model = new FormModel(defWithLanguage, { basePath: 'test' }) expect(model.language).toBe('cy') }) diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index aaf192a07..f2a66c7cc 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -107,7 +107,6 @@ export class FormModel { def: typeof this.def, options: { basePath: string - language?: string versionNumber?: number ordnanceSurveyApiKey?: string formId?: string @@ -134,7 +133,10 @@ export class FormModel { // by joi so as not to change the source data. def = structuredClone(result.value) - const language = options.language ?? 'en-GB' + const language = + typeof def.metadata?.language === 'string' + ? def.metadata.language + : 'en-GB' // Add default lists def.lists.push({ diff --git a/src/server/plugins/engine/services/localFormsService.js b/src/server/plugins/engine/services/localFormsService.js index 4d2ae867c..205255e1f 100644 --- a/src/server/plugins/engine/services/localFormsService.js +++ b/src/server/plugins/engine/services/localFormsService.js @@ -62,9 +62,7 @@ export const formsService = async () => { ...metadata, id: 'a1b2c3d4-e5f6-7890-abcd-ef0123456790', title: 'Simple Form (Pirate)', - slug: 'simple-form-pirate', - // @ts-expect-error - language not yet in FormMetadata type - language: 'x-pirate' + slug: 'simple-form-pirate' }) await loader.addForm('src/server/forms/payment-test.yaml', { From a5d40e589d3a88a1e0ba0ab5732e268e5b612f21 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 20 Apr 2026 15:23:39 +0100 Subject: [PATCH 35/89] Revert "fix: extract payment cancelled banner html to a variable" This reverts commit a35bf67da4e862ec768094bb65e81bdb4abab551. --- src/server/plugins/engine/views/index.html | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/server/plugins/engine/views/index.html b/src/server/plugins/engine/views/index.html index 5227ab608..ddd2b3c93 100644 --- a/src/server/plugins/engine/views/index.html +++ b/src/server/plugins/engine/views/index.html @@ -12,14 +12,9 @@ {% endif %} {% if showPaymentExpiredNotification %} - {% set paymentCancelledHtml %} -

{{ t('pages.question.paymentCancelledTitle') }}

-

{{ t('pages.question.paymentCancelledBody') }}

-

{{ t('pages.question.paymentCancelledAction') }}

- {% endset %} {{ govukNotificationBanner({ titleText: t('pages.question.paymentCancelledBannerTitle'), - html: paymentCancelledHtml + html: '

' + t('pages.question.paymentCancelledTitle') + '

' + t('pages.question.paymentCancelledBody') + '

' + t('pages.question.paymentCancelledAction') + '

' }) }} {% endif %} From 84404be1f12e3f6b63a21dc8537a853ffc88db36 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 24 Apr 2026 22:56:55 +0100 Subject: [PATCH 36/89] feat(i18n): add FormDefinitionTranslations, TContentFunction and Translator types --- src/server/plugins/engine/i18n/types.ts | 61 +++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/server/plugins/engine/i18n/types.ts diff --git a/src/server/plugins/engine/i18n/types.ts b/src/server/plugins/engine/i18n/types.ts new file mode 100644 index 000000000..c977fe8b2 --- /dev/null +++ b/src/server/plugins/engine/i18n/types.ts @@ -0,0 +1,61 @@ +import { + type ComponentDef, + type Item, + type Page, + type Section +} from '@defra/forms-model' + +/** + * Structure of the `translations` block in a form definition. + * Also used to derive the valid property keys for tContent overloads. + */ +export type FormDefinitionTranslations = Record< + string, + { + pages: Record>> + // ComponentDef is a discriminated union; Pick across the full union is not feasible. + // The four keys below are the complete set of translatable component properties. + components: Record< + string, + Partial<{ + title: string + hint: string + content: string + shortDescription: string + }> + > + sections: Record>> + listItems: Record>> + } +> + +type EntityTranslations = FormDefinitionTranslations[string] + +/** + * Translates form-authored content (titles, hints, etc.) by passing the + * source entity object and property name. Internally constructs the i18next + * key from the entity's GUID. Falls back to entity[prop] when no GUID or + * no translation found. + * + * Valid property names are derived from FormDefinitionTranslations — one + * source of truth; the compiler enforces correctness. + */ +export interface TContentFunction { + ( + entity: ComponentDef, + prop: keyof EntityTranslations['components'][string] + ): string + (entity: Page, prop: keyof EntityTranslations['pages'][string]): string + (entity: Section, prop: keyof EntityTranslations['sections'][string]): string + (entity: Item, prop: keyof EntityTranslations['listItems'][string]): string +} + +/** + * Scoped translator pair returned by model.createTranslator(language). + * t — plugin + host namespace (UI strings, buttons, errors, sub-field labels) + * tContent — form namespace only (question titles, hints, page titles, list item text) + */ +export interface Translator { + t: (key: string, opts?: Record) => string + tContent: TContentFunction +} From a62af1b16ac6c65760f682021128f7d9c0c09c3d Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 24 Apr 2026 23:04:44 +0100 Subject: [PATCH 37/89] feat(i18n): add extractBaseTranslations to seed en-GB form namespace --- .../i18n/extractBaseTranslations.test.ts | 108 ++++++++++++++++++ .../engine/i18n/extractBaseTranslations.ts | 58 ++++++++++ 2 files changed, 166 insertions(+) create mode 100644 src/server/plugins/engine/i18n/extractBaseTranslations.test.ts create mode 100644 src/server/plugins/engine/i18n/extractBaseTranslations.ts diff --git a/src/server/plugins/engine/i18n/extractBaseTranslations.test.ts b/src/server/plugins/engine/i18n/extractBaseTranslations.test.ts new file mode 100644 index 000000000..0e4759851 --- /dev/null +++ b/src/server/plugins/engine/i18n/extractBaseTranslations.test.ts @@ -0,0 +1,108 @@ +import { + ComponentType, + SchemaVersion, + type FormDefinition +} from '@defra/forms-model' + +import { extractBaseTranslations } from '~/src/server/plugins/engine/i18n/extractBaseTranslations.js' + +const pageId = 'b2c3d4e5-0001-0000-0000-000000000000' +const componentId = 'c3d4e5f6-0001-0000-0000-000000000000' +const sectionId = 'a1b2c3d4-0001-0000-0000-000000000000' +const listItemId = 'e5f6a7b8-0001-0000-0000-000000000000' + +const def = { + schema: SchemaVersion.V2, + engine: 'V2' as const, + name: 'Test form', + pages: [ + { + id: pageId, + path: '/personal-details', + title: 'Your personal details', + section: sectionId, + components: [ + { + id: componentId, + type: ComponentType.TextField, + name: 'firstName', + title: 'First name', + hint: 'As it appears on your licence', + shortDescription: 'Name', + schema: {}, + options: {} + } + ] + } + ], + lists: [ + { + id: 'listId', + name: 'duration', + title: 'Duration', + type: 'string' as const, + items: [{ id: listItemId, text: '1 day', value: '1' }] + } + ], + sections: [ + { + id: sectionId, + name: 'personal', + title: 'Personal details', + hideTitle: false + } + ], + conditions: [], + startPage: '/personal-details' +} as unknown as FormDefinition + +describe('extractBaseTranslations', () => { + it('extracts page titles', () => { + const result = extractBaseTranslations(def) + expect(result.pages[pageId]).toEqual({ title: 'Your personal details' }) + }) + + it('extracts component title, hint and shortDescription', () => { + const result = extractBaseTranslations(def) + expect(result.components[componentId]).toEqual({ + title: 'First name', + hint: 'As it appears on your licence', + shortDescription: 'Name' + }) + }) + + it('extracts section titles', () => { + const result = extractBaseTranslations(def) + expect(result.sections[sectionId]).toEqual({ title: 'Personal details' }) + }) + + it('extracts list item text', () => { + const result = extractBaseTranslations(def) + expect(result.listItems[listItemId]).toEqual({ text: '1 day' }) + }) + + it('skips pages without an id', () => { + const defNoId = { + ...def, + pages: [{ ...def.pages[0], id: undefined }] + } as unknown as FormDefinition + const result = extractBaseTranslations(defNoId) + expect(Object.keys(result.pages)).toHaveLength(0) + }) + + it('skips components without an id', () => { + interface PageWithComponents { components: Record[] } + const firstPage = def.pages[0] as unknown as PageWithComponents + const defNoId = { + ...def, + pages: [ + { + ...def.pages[0], + components: [{ ...firstPage.components[0], id: undefined }] + } + ] + } as unknown as FormDefinition + const result = extractBaseTranslations(defNoId) + expect(Object.keys(result.components)).toHaveLength(0) + }) +}) diff --git a/src/server/plugins/engine/i18n/extractBaseTranslations.ts b/src/server/plugins/engine/i18n/extractBaseTranslations.ts new file mode 100644 index 000000000..529348409 --- /dev/null +++ b/src/server/plugins/engine/i18n/extractBaseTranslations.ts @@ -0,0 +1,58 @@ +import { + hasComponentsEvenIfNoNext, + type FormDefinition +} from '@defra/forms-model' + +import { type FormDefinitionTranslations } from '~/src/server/plugins/engine/i18n/types.js' + +type BaseTranslations = FormDefinitionTranslations[string] + +export function extractBaseTranslations(def: FormDefinition): BaseTranslations { + const pages: BaseTranslations['pages'] = {} + const components: BaseTranslations['components'] = {} + const sections: BaseTranslations['sections'] = {} + const listItems: BaseTranslations['listItems'] = {} + + for (const page of def.pages) { + if (page.id && page.title) { + pages[page.id] = { title: page.title } + } + + if (hasComponentsEvenIfNoNext(page)) { + for (const component of page.components) { + if (!component.id) continue + + const entry: BaseTranslations['components'][string] = {} + + if (component.title) entry.title = component.title + if ('hint' in component && component.hint) entry.hint = component.hint + if ('content' in component && component.content) { + entry.content = component.content + } + if ('shortDescription' in component && component.shortDescription) { + entry.shortDescription = component.shortDescription + } + + if (Object.keys(entry).length) { + components[component.id] = entry + } + } + } + } + + for (const section of def.sections) { + if (section.id && section.title) { + sections[section.id] = { title: section.title } + } + } + + for (const list of def.lists) { + for (const item of list.items) { + if (item.id && item.text) { + listItems[item.id] = { text: item.text } + } + } + } + + return { pages, components, sections, listItems } +} From 1f7c64beb060b83a35c5a56a58aebc74b81b10a0 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 24 Apr 2026 23:10:52 +0100 Subject: [PATCH 38/89] feat(i18n): add createFormI18nInstance factory for per-FormModel i18next instances --- src/server/plugins/engine/i18n/index.test.ts | 47 +++++++++++++++++++- src/server/plugins/engine/i18n/index.ts | 35 ++++++++++++++- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/server/plugins/engine/i18n/index.test.ts b/src/server/plugins/engine/i18n/index.test.ts index dad65e228..7ffadc3f8 100644 --- a/src/server/plugins/engine/i18n/index.test.ts +++ b/src/server/plugins/engine/i18n/index.test.ts @@ -1,4 +1,7 @@ -import { t } from '~/src/server/plugins/engine/i18n/index.js' +import { + createFormI18nInstance, + t +} from '~/src/server/plugins/engine/i18n/index.js' describe('i18n t()', () => { it('returns the English string for a known key', () => { @@ -25,3 +28,45 @@ describe('i18n t()', () => { ) }) }) + +describe('createFormI18nInstance', () => { + const formNamespace = { + pages: { 'page-id': { title: 'Your personal details' } }, + components: { + 'comp-id': { title: 'First name', hint: 'As shown on licence' } + }, + sections: {}, + listItems: {} + } + + it('resolves plugin strings for en-GB', () => { + const instance = createFormI18nInstance(formNamespace) + const t = instance.getFixedT('en-GB', 'plugin') + expect(t('common.continue')).toBe('Continue') + }) + + it('resolves form content from the form namespace for en-GB', () => { + const instance = createFormI18nInstance(formNamespace) + const t = instance.getFixedT('en-GB', 'form') + expect(t('pages.page-id.title')).toBe('Your personal details') + }) + + it('falls back to en-GB form strings for an unknown language', () => { + const instance = createFormI18nInstance(formNamespace) + const t = instance.getFixedT('cy', 'form') + expect(t('pages.page-id.title')).toBe('Your personal details') + }) + + it('resolves a Welsh override when registered', () => { + const instance = createFormI18nInstance(formNamespace) + instance.addResourceBundle( + 'cy', + 'form', + { pages: { 'page-id': { title: 'Eich manylion personol' } } }, + true, + true + ) + const t = instance.getFixedT('cy', 'form') + expect(t('pages.page-id.title')).toBe('Eich manylion personol') + }) +}) diff --git a/src/server/plugins/engine/i18n/index.ts b/src/server/plugins/engine/i18n/index.ts index 9df00d041..8af122c8f 100644 --- a/src/server/plugins/engine/i18n/index.ts +++ b/src/server/plugins/engine/i18n/index.ts @@ -1,7 +1,10 @@ -import i18next from 'i18next' +import i18next, { createInstance, type i18n } from 'i18next' import enGB from '~/src/server/plugins/engine/i18n/translations/en-GB.json' with { type: 'json' } import xPirate from '~/src/server/plugins/engine/i18n/translations/x-pirate.json' with { type: 'json' } +import { type FormDefinitionTranslations } from '~/src/server/plugins/engine/i18n/types.js' + +type BaseTranslations = FormDefinitionTranslations[string] i18next .init({ @@ -27,3 +30,33 @@ export function t( ): string { return i18next.t(key, { lng: language, ...options }) } + +export function createFormI18nInstance(formEnGb: BaseTranslations): i18n { + const instance = createInstance() + + instance + .init({ + resources: { + 'en-GB': { + plugin: enGB, + form: formEnGb + }, + 'x-pirate': { + plugin: xPirate + } + }, + fallbackLng: 'en-GB', + ns: ['plugin', 'form'], + defaultNS: 'plugin', + interpolation: { + prefix: '[[', + suffix: ']]', + escapeValue: false + } + }) + .catch(() => { + // init with inline resources completes synchronously — unreachable + }) + + return instance +} From 75c35726e1823659932a42faeb35a567766abe65 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 24 Apr 2026 23:13:38 +0100 Subject: [PATCH 39/89] feat(i18n): add getLanguage callback to PluginOptions --- src/server/plugins/engine/types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index d35f178f2..f0233e1f2 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -507,6 +507,7 @@ export interface PluginOptions { viewContext: PluginProperties['forms-engine-plugin']['viewContext'] preparePageEventRequestOptions?: PreparePageEventRequestOptions onRequest?: OnRequestCallback + getLanguage?: (request: AnyFormRequest) => string | undefined baseUrl: string // base URL of the application, protocol and hostname e.g. "https://myapp.com" ordnanceSurveyApiKey?: string ordnanceSurveyApiSecret?: string @@ -523,6 +524,7 @@ export interface FormAdapterSubmissionMessageMeta { isPreview: boolean notificationEmail: string versionMetadata?: FormVersionMetadata + language?: string custom?: Record } From d8fee97e166c227366438bcb99ed1f48562e11d4 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Sat, 25 Apr 2026 07:58:13 +0100 Subject: [PATCH 40/89] feat(i18n): add createTranslator to FormModel with per-form i18next instance --- .../plugins/engine/models/FormModel.test.ts | 44 ++++++++++++ src/server/plugins/engine/models/FormModel.ts | 70 ++++++++++++++++++- 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/src/server/plugins/engine/models/FormModel.test.ts b/src/server/plugins/engine/models/FormModel.test.ts index a8519a707..0715b9c85 100644 --- a/src/server/plugins/engine/models/FormModel.test.ts +++ b/src/server/plugins/engine/models/FormModel.test.ts @@ -821,4 +821,48 @@ describe('FormModel - Joined Conditions', () => { expect(model.getSection('nonexistent')).toBeUndefined() }) }) + + describe('FormModel.createTranslator', () => { + beforeEach(() => { + jest.spyOn(formDefinitionV2Schema, 'validate').mockReturnValue({ + value: definitionV2, + error: undefined + }) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('returns "Continue" for t("common.continue") with en-GB language', () => { + const model = new FormModel(definitionV2, { basePath: 'test' }) + const { t } = model.createTranslator('en-GB') + expect(t('common.continue')).toBe('Continue') + }) + + it('does not return "Continue" for t("common.continue") with x-pirate language', () => { + const model = new FormModel(definitionV2, { basePath: 'test' }) + const { t } = model.createTranslator('x-pirate') + expect(t('common.continue')).not.toBe('Continue') + }) + + it('returns the component title for tContent with en-GB language (falls back to base en-GB form string)', () => { + const model = new FormModel(definitionV2, { basePath: 'test' }) + const { tContent } = model.createTranslator('en-GB') + // pages[0].components[0] has id '717eb213-4e4b-4a2d-9cfd-2780f5e1e3e5' + // and title 'Have you previously been married?' + expect(tContent(definitionV2.pages[0].components[0], 'title')).toBe( + 'Have you previously been married?' + ) + }) + + it('returns the component title for tContent with cy language (no Welsh translation registered → en-GB fallback)', () => { + const model = new FormModel(definitionV2, { basePath: 'test' }) + const { tContent } = model.createTranslator('cy') + // No Welsh translations registered → falls back to en-GB base form string + expect(tContent(definitionV2.pages[0].components[0], 'title')).toBe( + 'Have you previously been married?' + ) + }) + }) }) diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index f2a66c7cc..f0321089e 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -26,6 +26,7 @@ import { } from '@defra/forms-model' import { add, format } from 'date-fns' import { Parser, type Value } from 'expr-eval-fork' +import { type i18n } from 'i18next' import joi from 'joi' import { createLogger } from '~/src/server/common/helpers/logging/logger.js' @@ -46,7 +47,16 @@ import { buildValidationMessages, type ValidationMessages } from '~/src/server/plugins/engine/i18n/buildValidationMessages.js' -import { t as translate } from '~/src/server/plugins/engine/i18n/index.js' +import { extractBaseTranslations } from '~/src/server/plugins/engine/i18n/extractBaseTranslations.js' +import { + createFormI18nInstance, + t as translate +} from '~/src/server/plugins/engine/i18n/index.js' +import { + type FormDefinitionTranslations, + type TContentFunction, + type Translator +} from '~/src/server/plugins/engine/i18n/types.js' import { type ExecutableCondition } from '~/src/server/plugins/engine/models/types.js' import { type PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js' import { @@ -90,6 +100,7 @@ export class FormModel { conditions: Partial> pages: PageControllerClass[] services: Services + private i18nInstance: i18n controllers?: Record pageDefMap: Map @@ -133,6 +144,17 @@ export class FormModel { // by joi so as not to change the source data. def = structuredClone(result.value) + const baseTranslations = extractBaseTranslations(def) + this.i18nInstance = createFormI18nInstance(baseTranslations) + const formTranslations = def.metadata?.translations as + | FormDefinitionTranslations + | undefined + if (formTranslations) { + for (const [lng, resources] of Object.entries(formTranslations)) { + this.i18nInstance.addResourceBundle(lng, 'form', resources, true, true) + } + } + const language = typeof def.metadata?.language === 'string' ? def.metadata.language @@ -247,6 +269,49 @@ export class FormModel { return translate(key, this.language, opts) } + /** Returns a scoped translator pair for the given language. */ + createTranslator(language: string): Translator { + const { i18nInstance } = this + + const t = (key: string, opts?: Record): string => + i18nInstance.t(key, { lng: language, ns: 'plugin', ...opts }) + + const resolveContent = ( + entity: { id?: string }, + entityType: string, + prop: string + ): string => { + if (!entity.id) { + const raw = (entity as Record)[prop] + return typeof raw === 'string' ? raw : '' + } + const key = `${entityType}.${entity.id}.${prop}` + const result = i18nInstance.t(key, { + lng: language, + ns: 'form', + fallbackLng: 'en-GB' + }) + if (result === key) { + const raw = (entity as Record)[prop] + return typeof raw === 'string' ? raw : '' + } + return result + } + + const tContent = ((entity: unknown, prop: string): string => { + const e = entity as Record + if ('path' in e) + return resolveContent(e as { id?: string }, 'pages', prop) + if ('value' in e && 'text' in e) + return resolveContent(e as { id?: string }, 'listItems', prop) + if ('type' in e) + return resolveContent(e as { id?: string }, 'components', prop) + return resolveContent(e as { id?: string }, 'sections', prop) + }) as TContentFunction + + return { t, tContent } + } + /** * build the entire model schema from individual pages/sections and filter out answers * for pages which are no longer accessible due to an answer that has been changed @@ -520,8 +585,7 @@ export class FormModel { if (isInvalid) { context.errors ??= [] - const text = - 'Options are different because you changed a previous answer' + const text = this.t('errors.optionsMismatch') context.errors.push({ text, From fb04bb1d7ca468f8e18fd6b118a0da67487a7d4a Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Sat, 25 Apr 2026 08:01:09 +0100 Subject: [PATCH 41/89] fix(i18n): remove dead fallbackLng per-call option; assert exact x-pirate string in test --- src/server/plugins/engine/models/FormModel.test.ts | 4 ++-- src/server/plugins/engine/models/FormModel.ts | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/server/plugins/engine/models/FormModel.test.ts b/src/server/plugins/engine/models/FormModel.test.ts index 0715b9c85..572130401 100644 --- a/src/server/plugins/engine/models/FormModel.test.ts +++ b/src/server/plugins/engine/models/FormModel.test.ts @@ -840,10 +840,10 @@ describe('FormModel - Joined Conditions', () => { expect(t('common.continue')).toBe('Continue') }) - it('does not return "Continue" for t("common.continue") with x-pirate language', () => { + it('returns x-pirate string for t("common.continue") with x-pirate language', () => { const model = new FormModel(definitionV2, { basePath: 'test' }) const { t } = model.createTranslator('x-pirate') - expect(t('common.continue')).not.toBe('Continue') + expect(t('common.continue')).toBe('Sail on') }) it('returns the component title for tContent with en-GB language (falls back to base en-GB form string)', () => { diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index f0321089e..dea0d5778 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -288,8 +288,7 @@ export class FormModel { const key = `${entityType}.${entity.id}.${prop}` const result = i18nInstance.t(key, { lng: language, - ns: 'form', - fallbackLng: 'en-GB' + ns: 'form' }) if (result === key) { const raw = (entity as Record)[prop] From 7b632cb213084e73b22b19399546b154f9b8bfcf Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Sat, 25 Apr 2026 08:07:21 +0100 Subject: [PATCH 42/89] feat(i18n): store date/month-year sub-field titles as i18next key constants --- .../engine/components/DatePartsField.test.ts | 60 +++++++++++++++---- .../engine/components/DatePartsField.ts | 6 +- .../engine/components/MonthYearField.test.ts | 50 +++++++++++++--- .../engine/components/MonthYearField.ts | 4 +- 4 files changed, 96 insertions(+), 24 deletions(-) diff --git a/src/server/plugins/engine/components/DatePartsField.test.ts b/src/server/plugins/engine/components/DatePartsField.test.ts index 0b885cada..a81d07c48 100644 --- a/src/server/plugins/engine/components/DatePartsField.test.ts +++ b/src/server/plugins/engine/components/DatePartsField.test.ts @@ -2,6 +2,7 @@ import { ComponentType, type DatePartsFieldComponent } from '@defra/forms-model' import { addDays, format, startOfDay } from 'date-fns' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' +import { type DatePartsField } from '~/src/server/plugins/engine/components/DatePartsField.js' import { getAnswer, type Field @@ -50,21 +51,27 @@ describe('DatePartsField', () => { expect(keys).toHaveProperty( 'myComponent__day', expect.objectContaining({ - flags: expect.objectContaining({ label: 'Day' }) + flags: expect.objectContaining({ + label: 'components.dateField.day' + }) }) ) expect(keys).toHaveProperty( 'myComponent__month', expect.objectContaining({ - flags: expect.objectContaining({ label: 'Month' }) + flags: expect.objectContaining({ + label: 'components.dateField.month' + }) }) ) expect(keys).toHaveProperty( 'myComponent__year', expect.objectContaining({ - flags: expect.objectContaining({ label: 'Year' }) + flags: expect.objectContaining({ + label: 'components.dateField.year' + }) }) ) }) @@ -165,7 +172,7 @@ describe('DatePartsField', () => { expect(result1.errors).toBeUndefined() expect(result2.errors).toEqual([ expect.objectContaining({ - text: 'Example date parts field must include a month' + text: 'Example date parts field must include a components.dateField.month' }) ]) }) @@ -212,13 +219,13 @@ describe('DatePartsField', () => { expect(result.errors).toEqual([ expect.objectContaining({ - text: 'Example date parts must include a day' + text: 'Example date parts must include a components.dateField.day' }), expect.objectContaining({ - text: 'Example date parts must include a month' + text: 'Example date parts must include a components.dateField.month' }), expect.objectContaining({ - text: 'Example date parts must include a year' + text: 'Example date parts must include a components.dateField.year' }) ]) }) @@ -362,7 +369,7 @@ describe('DatePartsField', () => { items: [ expect.objectContaining( getViewModel(date, 'day', { - label: { text: 'Day' }, + label: { text: 'components.dateField.day' }, classes: 'govuk-input--width-2', value: 31 }) @@ -370,7 +377,7 @@ describe('DatePartsField', () => { expect.objectContaining( getViewModel(date, 'month', { - label: { text: 'Month' }, + label: { text: 'components.dateField.month' }, classes: 'govuk-input--width-2', value: 12 }) @@ -378,7 +385,7 @@ describe('DatePartsField', () => { expect.objectContaining( getViewModel(date, 'year', { - label: { text: 'Year' }, + label: { text: 'components.dateField.year' }, classes: 'govuk-input--width-4', value: 2024 }) @@ -865,6 +872,39 @@ describe('DatePartsField', () => { ) }) }) + + describe('sub-field title key constants', () => { + let dateParts: DatePartsField + + beforeEach(() => { + const def: DatePartsFieldComponent = { + title: 'Date of birth', + name: 'dob', + type: ComponentType.DatePartsField, + options: {} + } + const coll = new ComponentCollection([def], { model }) + dateParts = coll.fields[0] as DatePartsField + }) + + it('stores day sub-field title as i18next key constant', () => { + expect(dateParts.collection.fields[0].title).toBe( + 'components.dateField.day' + ) + }) + + it('stores month sub-field title as i18next key constant', () => { + expect(dateParts.collection.fields[1].title).toBe( + 'components.dateField.month' + ) + }) + + it('stores year sub-field title as i18next key constant', () => { + expect(dateParts.collection.fields[2].title).toBe( + 'components.dateField.year' + ) + }) + }) }) /** diff --git a/src/server/plugins/engine/components/DatePartsField.ts b/src/server/plugins/engine/components/DatePartsField.ts index fad1f5d53..e8b7bf2c3 100644 --- a/src/server/plugins/engine/components/DatePartsField.ts +++ b/src/server/plugins/engine/components/DatePartsField.ts @@ -57,7 +57,7 @@ export class DatePartsField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__day`, - title: props.model.t('components.dateField.day'), + title: 'components.dateField.day', schema: { min: 1, max: 31, precision: 0 }, options: { required: isRequired, @@ -69,7 +69,7 @@ export class DatePartsField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__month`, - title: props.model.t('components.dateField.month'), + title: 'components.dateField.month', schema: { min: 1, max: 12, precision: 0 }, options: { required: isRequired, @@ -81,7 +81,7 @@ export class DatePartsField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__year`, - title: props.model.t('components.dateField.year'), + title: 'components.dateField.year', schema: { min: 1000, max: 3000, precision: 0 }, options: { required: isRequired, diff --git a/src/server/plugins/engine/components/MonthYearField.test.ts b/src/server/plugins/engine/components/MonthYearField.test.ts index 7781fcdc8..ff70c4580 100644 --- a/src/server/plugins/engine/components/MonthYearField.test.ts +++ b/src/server/plugins/engine/components/MonthYearField.test.ts @@ -2,6 +2,7 @@ import { ComponentType, type MonthYearFieldComponent } from '@defra/forms-model' import { startOfDay } from 'date-fns' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' +import { type MonthYearField } from '~/src/server/plugins/engine/components/MonthYearField.js' import { getAnswer, type Field @@ -49,10 +50,14 @@ describe('MonthYearField', () => { expect(keys).toEqual( expect.objectContaining({ myComponent__month: expect.objectContaining({ - flags: expect.objectContaining({ label: 'Month' }) + flags: expect.objectContaining({ + label: 'components.monthYearField.month' + }) }), myComponent__year: expect.objectContaining({ - flags: expect.objectContaining({ label: 'Year' }) + flags: expect.objectContaining({ + label: 'components.monthYearField.year' + }) }) }) ) @@ -137,7 +142,7 @@ describe('MonthYearField', () => { expect(result1.errors).toBeUndefined() expect(result2.errors).toEqual([ expect.objectContaining({ - text: 'Example month/year field must include a year' + text: 'Example month/year field must include a components.monthYearField.year' }) ]) }) @@ -171,10 +176,10 @@ describe('MonthYearField', () => { expect(result.errors).toEqual([ expect.objectContaining({ - text: 'Example month/year must include a month' + text: 'Example month/year must include a components.monthYearField.month' }), expect.objectContaining({ - text: 'Example month/year must include a year' + text: 'Example month/year must include a components.monthYearField.year' }) ]) }) @@ -197,10 +202,10 @@ describe('MonthYearField', () => { expect(result.errors).toEqual([ expect.objectContaining({ - text: 'Example month/year field must include a month' + text: 'Example month/year field must include a components.monthYearField.month' }), expect.objectContaining({ - text: 'Example month/year field must include a year' + text: 'Example month/year field must include a components.monthYearField.year' }) ]) }) @@ -336,7 +341,7 @@ describe('MonthYearField', () => { items: [ expect.objectContaining( getViewModel(date, 'month', { - label: { text: 'Month' }, + label: { text: 'components.monthYearField.month' }, classes: 'govuk-input--width-2', value: 12 }) @@ -344,7 +349,7 @@ describe('MonthYearField', () => { expect.objectContaining( getViewModel(date, 'year', { - label: { text: 'Year' }, + label: { text: 'components.monthYearField.year' }, classes: 'govuk-input--width-4', value: 2024 }) @@ -558,6 +563,33 @@ describe('MonthYearField', () => { ) }) }) + + describe('sub-field title key constants', () => { + let monthYear: MonthYearField + + beforeEach(() => { + const def: MonthYearFieldComponent = { + title: 'Date of issue', + name: 'issueDate', + type: ComponentType.MonthYearField, + options: {} + } + const coll = new ComponentCollection([def], { model }) + monthYear = coll.fields[0] as MonthYearField + }) + + it('stores month sub-field title as i18next key constant', () => { + expect(monthYear.collection.fields[0].title).toBe( + 'components.monthYearField.month' + ) + }) + + it('stores year sub-field title as i18next key constant', () => { + expect(monthYear.collection.fields[1].title).toBe( + 'components.monthYearField.year' + ) + }) + }) }) /** diff --git a/src/server/plugins/engine/components/MonthYearField.ts b/src/server/plugins/engine/components/MonthYearField.ts index 49cfb5509..a663d7db4 100644 --- a/src/server/plugins/engine/components/MonthYearField.ts +++ b/src/server/plugins/engine/components/MonthYearField.ts @@ -63,7 +63,7 @@ export class MonthYearField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__month`, - title: props.model.t('components.monthYearField.month'), + title: 'components.monthYearField.month', schema: { min: 1, max: 12, precision: 0 }, options: { required: isRequired, @@ -75,7 +75,7 @@ export class MonthYearField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__year`, - title: props.model.t('components.monthYearField.year'), + title: 'components.monthYearField.year', schema: { min: 1000, max: 3000, precision: 0 }, options: { required: isRequired, From 3be964a3d1a736658da646795bdb52886cd01566 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Sat, 25 Apr 2026 08:10:22 +0100 Subject: [PATCH 43/89] test(i18n): annotate key-constant-in-error-message assertions as temporary state --- src/server/plugins/engine/components/DatePartsField.test.ts | 3 +++ src/server/plugins/engine/components/MonthYearField.test.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/server/plugins/engine/components/DatePartsField.test.ts b/src/server/plugins/engine/components/DatePartsField.test.ts index a81d07c48..69be71d53 100644 --- a/src/server/plugins/engine/components/DatePartsField.test.ts +++ b/src/server/plugins/engine/components/DatePartsField.test.ts @@ -170,6 +170,8 @@ describe('DatePartsField', () => { ) expect(result1.errors).toBeUndefined() + // Sub-field title is a key constant; error text uses the raw key until + // request-time resolution is wired in (Task 8 / Task 9). expect(result2.errors).toEqual([ expect.objectContaining({ text: 'Example date parts field must include a components.dateField.month' @@ -217,6 +219,7 @@ describe('DatePartsField', () => { }) ) + // Sub-field titles are key constants; error text uses raw keys until Task 9. expect(result.errors).toEqual([ expect.objectContaining({ text: 'Example date parts must include a components.dateField.day' diff --git a/src/server/plugins/engine/components/MonthYearField.test.ts b/src/server/plugins/engine/components/MonthYearField.test.ts index ff70c4580..3407a46f5 100644 --- a/src/server/plugins/engine/components/MonthYearField.test.ts +++ b/src/server/plugins/engine/components/MonthYearField.test.ts @@ -140,6 +140,7 @@ describe('MonthYearField', () => { ) expect(result1.errors).toBeUndefined() + // Sub-field title is a key constant; error text uses raw key until Task 9. expect(result2.errors).toEqual([ expect.objectContaining({ text: 'Example month/year field must include a components.monthYearField.year' @@ -174,6 +175,7 @@ describe('MonthYearField', () => { }) ) + // Sub-field titles are key constants; error text uses raw keys until Task 9. expect(result.errors).toEqual([ expect.objectContaining({ text: 'Example month/year must include a components.monthYearField.month' @@ -200,6 +202,7 @@ describe('MonthYearField', () => { }) ) + // Sub-field titles are key constants; error text uses raw keys until Task 9. expect(result.errors).toEqual([ expect.objectContaining({ text: 'Example month/year field must include a components.monthYearField.month' From 104e0b1e69c176f1009a88930e779deca2b19aaf Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Sat, 25 Apr 2026 08:21:51 +0100 Subject: [PATCH 44/89] feat(i18n): store address/location sub-field titles as i18next key constants Replace props.model.t(...) calls with raw i18next key strings in sub-field title initializations for UkAddressField, EastingNorthingField, and LatLongField components. Titles are now stored as constants to be resolved at request time during Task 8/9 (view model generation). Changes: - UkAddressField: 6 sub-field titles (uprn, line1, line2, town, county, postcode) - EastingNorthingField: 2 sub-field titles (easting, northing) - LatLongField: 2 sub-field titles (latitude, longitude) Updated all tests to expect key constants in validation messages and view models. --- .../components/EastingNorthingField.test.ts | 52 ++++++++--- .../engine/components/EastingNorthingField.ts | 4 +- .../engine/components/LatLongField.test.ts | 42 +++++++-- .../plugins/engine/components/LatLongField.ts | 4 +- .../components/LocationFieldHelpers.test.ts | 6 +- .../engine/components/UkAddressField.test.ts | 87 ++++++++++++++----- .../engine/components/UkAddressField.ts | 15 ++-- 7 files changed, 158 insertions(+), 52 deletions(-) diff --git a/src/server/plugins/engine/components/EastingNorthingField.test.ts b/src/server/plugins/engine/components/EastingNorthingField.test.ts index 3f43699a4..138221fe6 100644 --- a/src/server/plugins/engine/components/EastingNorthingField.test.ts +++ b/src/server/plugins/engine/components/EastingNorthingField.test.ts @@ -49,14 +49,20 @@ describe('EastingNorthingField', () => { expect(keys).toHaveProperty( 'myComponent__easting', expect.objectContaining({ - flags: expect.objectContaining({ label: 'Easting' }) + flags: expect.objectContaining({ + label: 'components.eastingNorthingField.easting' + }) + // Sub-field title is a key constant; resolved at request time (Task 8/9). }) ) expect(keys).toHaveProperty( 'myComponent__northing', expect.objectContaining({ - flags: expect.objectContaining({ label: 'Northing' }) + flags: expect.objectContaining({ + label: 'components.eastingNorthingField.northing' + }) + // Sub-field title is a key constant; resolved at request time (Task 8/9). }) ) }) @@ -298,13 +304,19 @@ describe('EastingNorthingField', () => { }, items: [ expect.objectContaining({ - label: expect.objectContaining({ text: 'Easting' }), + label: expect.objectContaining({ + text: 'components.eastingNorthingField.easting' + }), + // Sub-field title is a key constant; resolved at request time (Task 8/9). name: 'myComponent__easting', id: 'myComponent__easting', value: 12345 }), expect.objectContaining({ - label: expect.objectContaining({ text: 'Northing' }), + label: expect.objectContaining({ + text: 'components.eastingNorthingField.northing' + }), + // Sub-field title is a key constant; resolved at request time (Task 8/9). name: 'myComponent__northing', id: 'myComponent__northing', value: 1234567 @@ -448,6 +460,20 @@ describe('EastingNorthingField', () => { ) }) }) + + describe('sub-field title key constants', () => { + it('stores sub-field titles as i18next key constants', () => { + const locationField = collection.fields[0] as EastingNorthingField + const subFields = locationField.collection.fields + + expect(subFields[0].title).toBe( + 'components.eastingNorthingField.easting' + ) + expect(subFields[1].title).toBe( + 'components.eastingNorthingField.northing' + ) + }) + }) }) describe('Validation', () => { @@ -516,7 +542,8 @@ describe('EastingNorthingField', () => { errors: [ expect.objectContaining({ text: expect.stringMatching( - /Easting for .* must be between 1000 and 60000/ + /components\.eastingNorthingField\.easting for .* must be between 1000 and 60000/ + // Sub-field title is a key constant; resolved at request time (Task 8/9). ) }) ] @@ -535,7 +562,8 @@ describe('EastingNorthingField', () => { errors: [ expect.objectContaining({ text: expect.stringMatching( - /Easting for .* must be between 1000 and 60000/ + /components\.eastingNorthingField\.easting for .* must be between 1000 and 60000/ + // Sub-field title is a key constant; resolved at request time (Task 8/9). ) }) ] @@ -571,7 +599,8 @@ describe('EastingNorthingField', () => { errors: [ expect.objectContaining({ text: expect.stringMatching( - /Northing for .* must be between 1000 and 1200000/ + /components\.eastingNorthingField\.northing for .* must be between 1000 and 1200000/ + // Sub-field title is a key constant; resolved at request time (Task 8/9). ) }) ] @@ -590,7 +619,8 @@ describe('EastingNorthingField', () => { errors: [ expect.objectContaining({ text: expect.stringMatching( - /Northing for .* must be between 1000 and 1200000/ + /components\.eastingNorthingField\.northing for .* must be between 1000 and 1200000/ + // Sub-field title is a key constant; resolved at request time (Task 8/9). ) }) ] @@ -621,7 +651,8 @@ describe('EastingNorthingField', () => { errors: [ expect.objectContaining({ text: expect.stringMatching( - /Easting for .* must be between 1 and 6 digits/ + /components\.eastingNorthingField\.easting for .* must be between 1 and 6 digits/ + // Sub-field title is a key constant; resolved at request time (Task 8/9). ) }) ] @@ -640,7 +671,8 @@ describe('EastingNorthingField', () => { errors: [ expect.objectContaining({ text: expect.stringMatching( - /Northing for .* must be between 1 and 7 digits/ + /components\.eastingNorthingField\.northing for .* must be between 1 and 7 digits/ + // Sub-field title is a key constant; resolved at request time (Task 8/9). ) }) ] diff --git a/src/server/plugins/engine/components/EastingNorthingField.ts b/src/server/plugins/engine/components/EastingNorthingField.ts index b45002d5e..0e79ff303 100644 --- a/src/server/plugins/engine/components/EastingNorthingField.ts +++ b/src/server/plugins/engine/components/EastingNorthingField.ts @@ -129,7 +129,7 @@ export class EastingNorthingField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__easting`, - title: props.model.t('components.eastingNorthingField.easting'), + title: 'components.eastingNorthingField.easting', schema: { min: eastingMin, max: eastingMax, @@ -145,7 +145,7 @@ export class EastingNorthingField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__northing`, - title: props.model.t('components.eastingNorthingField.northing'), + title: 'components.eastingNorthingField.northing', schema: { min: northingMin, max: northingMax, diff --git a/src/server/plugins/engine/components/LatLongField.test.ts b/src/server/plugins/engine/components/LatLongField.test.ts index 70bb2e875..894c488a3 100644 --- a/src/server/plugins/engine/components/LatLongField.test.ts +++ b/src/server/plugins/engine/components/LatLongField.test.ts @@ -46,14 +46,20 @@ describe('LatLongField', () => { expect(keys).toHaveProperty( 'myComponent__latitude', expect.objectContaining({ - flags: expect.objectContaining({ label: 'Latitude' }) + flags: expect.objectContaining({ + label: 'components.latLongField.latitude' + }) + // Sub-field title is a key constant; resolved at request time (Task 8/9). }) ) expect(keys).toHaveProperty( 'myComponent__longitude', expect.objectContaining({ - flags: expect.objectContaining({ label: 'Longitude' }) + flags: expect.objectContaining({ + label: 'components.latLongField.longitude' + }) + // Sub-field title is a key constant; resolved at request time (Task 8/9). }) ) }) @@ -287,13 +293,19 @@ describe('LatLongField', () => { }, items: [ expect.objectContaining({ - label: expect.objectContaining({ text: 'Latitude' }), + label: expect.objectContaining({ + text: 'components.latLongField.latitude' + }), + // Sub-field title is a key constant; resolved at request time (Task 8/9). name: 'myComponent__latitude', id: 'myComponent__latitude', value: 51.51945 }), expect.objectContaining({ - label: expect.objectContaining({ text: 'Longitude' }), + label: expect.objectContaining({ + text: 'components.latLongField.longitude' + }), + // Sub-field title is a key constant; resolved at request time (Task 8/9). name: 'myComponent__longitude', id: 'myComponent__longitude', value: -0.127758 @@ -436,6 +448,16 @@ describe('LatLongField', () => { ) }) }) + + describe('sub-field title key constants', () => { + it('stores sub-field titles as i18next key constants', () => { + const locationField = collection.fields[0] as LatLongField + const subFields = locationField.collection.fields + + expect(subFields[0].title).toBe('components.latLongField.latitude') + expect(subFields[1].title).toBe('components.latLongField.longitude') + }) + }) }) describe('Validation', () => { @@ -602,7 +624,8 @@ describe('LatLongField', () => { }), errors: [ expect.objectContaining({ - text: 'Latitude must have no more than 7 decimal places' + text: 'components.latLongField.latitude must have no more than 7 decimal places' + // Sub-field title is a key constant; resolved at request time (Task 8/9). }) ] } @@ -619,7 +642,8 @@ describe('LatLongField', () => { }), errors: [ expect.objectContaining({ - text: 'Longitude must have no more than 7 decimal places' + text: 'components.latLongField.longitude must have no more than 7 decimal places' + // Sub-field title is a key constant; resolved at request time (Task 8/9). }) ] } @@ -697,7 +721,8 @@ describe('LatLongField', () => { }), errors: [ expect.objectContaining({ - text: 'Latitude must have no more than 7 decimal places' + text: 'components.latLongField.latitude must have no more than 7 decimal places' + // Sub-field title is a key constant; resolved at request time (Task 8/9). }) ] } @@ -727,7 +752,8 @@ describe('LatLongField', () => { }), errors: [ expect.objectContaining({ - text: 'Longitude must have no more than 7 decimal places' + text: 'components.latLongField.longitude must have no more than 7 decimal places' + // Sub-field title is a key constant; resolved at request time (Task 8/9). }) ] } diff --git a/src/server/plugins/engine/components/LatLongField.ts b/src/server/plugins/engine/components/LatLongField.ts index 8bfa3286c..1c22c0cbc 100644 --- a/src/server/plugins/engine/components/LatLongField.ts +++ b/src/server/plugins/engine/components/LatLongField.ts @@ -95,7 +95,7 @@ export class LatLongField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__latitude`, - title: props.model.t('components.latLongField.latitude'), + title: 'components.latLongField.latitude', schema: { min: latitudeMin, max: latitudeMax, @@ -112,7 +112,7 @@ export class LatLongField extends FormComponent { { type: ComponentType.NumberField, name: `${name}__longitude`, - title: props.model.t('components.latLongField.longitude'), + title: 'components.latLongField.longitude', schema: { min: longitudeMin, max: longitudeMax, diff --git a/src/server/plugins/engine/components/LocationFieldHelpers.test.ts b/src/server/plugins/engine/components/LocationFieldHelpers.test.ts index 92360edfe..b9cf6adea 100644 --- a/src/server/plugins/engine/components/LocationFieldHelpers.test.ts +++ b/src/server/plugins/engine/components/LocationFieldHelpers.test.ts @@ -573,13 +573,15 @@ describe('LocationFieldHelpers', () => { const label = viewModel.items[0].label expect(label).toBeDefined() - expect(label?.text).toBe('Latitude') + expect(label?.text).toBe('components.latLongField.latitude') + // Sub-field title is a key constant; resolved at request time (Task 8/9). const labelString = label && 'toString' in label && typeof label.toString === 'function' ? (label as { toString: () => string }).toString() : '' - expect(labelString).toBe('Latitude') + expect(labelString).toBe('components.latLongField.latitude') + // Sub-field title is a key constant; resolved at request time (Task 8/9). }) it('should use existing fieldset if provided', () => { diff --git a/src/server/plugins/engine/components/UkAddressField.test.ts b/src/server/plugins/engine/components/UkAddressField.test.ts index aa5853958..98e6f2f7e 100644 --- a/src/server/plugins/engine/components/UkAddressField.test.ts +++ b/src/server/plugins/engine/components/UkAddressField.test.ts @@ -52,35 +52,50 @@ describe('UkAddressField', () => { expect(keys).toHaveProperty( 'myComponent__addressLine1', expect.objectContaining({ - flags: expect.objectContaining({ label: 'Address line 1' }) + flags: expect.objectContaining({ + label: 'components.addressField.line1' + }) + // Sub-field title is a key constant; resolved at request time (Task 8/9). }) ) expect(keys).toHaveProperty( 'myComponent__addressLine2', expect.objectContaining({ - flags: expect.objectContaining({ label: 'Address line 2' }) + flags: expect.objectContaining({ + label: 'components.addressField.line2' + }) + // Sub-field title is a key constant; resolved at request time (Task 8/9). }) ) expect(keys).toHaveProperty( 'myComponent__town', expect.objectContaining({ - flags: expect.objectContaining({ label: 'Town or city' }) + flags: expect.objectContaining({ + label: 'components.addressField.town' + }) + // Sub-field title is a key constant; resolved at request time (Task 8/9). }) ) expect(keys).toHaveProperty( 'myComponent__county', expect.objectContaining({ - flags: expect.objectContaining({ label: 'County' }) + flags: expect.objectContaining({ + label: 'components.addressField.county' + }) + // Sub-field title is a key constant; resolved at request time (Task 8/9). }) ) expect(keys).toHaveProperty( `myComponent__postcode`, expect.objectContaining({ - flags: expect.objectContaining({ label: 'Postcode' }) + flags: expect.objectContaining({ + label: 'components.addressField.postcode' + }) + // Sub-field title is a key constant; resolved at request time (Task 8/9). }) ) }) @@ -244,13 +259,16 @@ describe('UkAddressField', () => { expect(result.errors).toEqual([ expect.objectContaining({ - text: 'Enter address line 1' + text: 'Enter components.addressField.line1' + // Sub-field title is a key constant; resolved at request time (Task 8/9). }), expect.objectContaining({ - text: 'Enter town or city' + text: 'Enter components.addressField.town' + // Sub-field title is a key constant; resolved at request time (Task 8/9). }), expect.objectContaining({ - text: 'Enter postcode' + text: 'Enter components.addressField.postcode' + // Sub-field title is a key constant; resolved at request time (Task 8/9). }) ]) }) @@ -416,14 +434,16 @@ describe('UkAddressField', () => { components: expect.arrayContaining([ expect.objectContaining({ model: getViewModel(address, 'addressLine1', { - label: { text: 'Address line 1' }, + label: { text: 'components.addressField.line1' }, + // Sub-field title is a key constant; resolved at request time (Task 8/9). attributes: { autocomplete: 'address-line1' } }) }), expect.objectContaining({ model: getViewModel(address, 'addressLine2', { - label: { text: 'Address line 2 (optional)' }, + label: { text: 'components.addressField.line2 (optional)' }, + // Sub-field title is a key constant; resolved at request time (Task 8/9). attributes: { autocomplete: 'address-line2' }, value: address.addressLine2 }) @@ -431,7 +451,8 @@ describe('UkAddressField', () => { expect.objectContaining({ model: getViewModel(address, 'town', { - label: { text: 'Town or city' }, + label: { text: 'components.addressField.town' }, + // Sub-field title is a key constant; resolved at request time (Task 8/9). classes: 'govuk-!-width-two-thirds', attributes: { autocomplete: 'address-level2' }, value: address.town @@ -440,7 +461,8 @@ describe('UkAddressField', () => { expect.objectContaining({ model: getViewModel(address, 'county', { - label: { text: 'County (optional)' }, + label: { text: 'components.addressField.county (optional)' }, + // Sub-field title is a key constant; resolved at request time (Task 8/9). attributes: { autocomplete: 'address-level1' }, value: address.county }) @@ -448,7 +470,8 @@ describe('UkAddressField', () => { expect.objectContaining({ model: getViewModel(address, 'postcode', { - label: { text: 'Postcode' }, + label: { text: 'components.addressField.postcode' }, + // Sub-field title is a key constant; resolved at request time (Task 8/9). classes: 'govuk-input--width-10', attributes: { autocomplete: 'postal-code' }, value: address.postcode @@ -479,6 +502,20 @@ describe('UkAddressField', () => { expect(errors.advancedSettingsErrors).toBeEmpty() }) }) + + describe('sub-field title key constants', () => { + it('stores sub-field titles as i18next key constants', () => { + const addrField = collection.fields[0] as UkAddressField + const subFields = addrField.collection.fields + + expect(subFields[0].title).toBe('components.addressField.uprn') + expect(subFields[1].title).toBe('components.addressField.line1') + expect(subFields[2].title).toBe('components.addressField.line2') + expect(subFields[3].title).toBe('components.addressField.town') + expect(subFields[4].title).toBe('components.addressField.county') + expect(subFields[5].title).toBe('components.addressField.postcode') + }) + }) }) describe('Validation', () => { @@ -588,7 +625,8 @@ describe('UkAddressField', () => { }), errors: [ expect.objectContaining({ - text: 'Address line 1 must be 100 characters or less' + text: 'components.addressField.line1 must be 100 characters or less' + // Sub-field title is a key constant; resolved at request time (Task 8/9). }) ] } @@ -613,7 +651,8 @@ describe('UkAddressField', () => { }), errors: [ expect.objectContaining({ - text: 'Address line 2 must be 100 characters or less' + text: 'components.addressField.line2 must be 100 characters or less' + // Sub-field title is a key constant; resolved at request time (Task 8/9). }) ] } @@ -638,7 +677,8 @@ describe('UkAddressField', () => { }), errors: [ expect.objectContaining({ - text: 'Town or city must be 100 characters or less' + text: 'components.addressField.town must be 100 characters or less' + // Sub-field title is a key constant; resolved at request time (Task 8/9). }) ] } @@ -663,7 +703,8 @@ describe('UkAddressField', () => { }), errors: [ expect.objectContaining({ - text: 'County must be 100 characters or less' + text: 'components.addressField.county must be 100 characters or less' + // Sub-field title is a key constant; resolved at request time (Task 8/9). }) ] } @@ -688,7 +729,8 @@ describe('UkAddressField', () => { }), errors: [ expect.objectContaining({ - text: 'Enter a valid postcode' + text: 'Enter a valid components.addressField.postcode' + // Sub-field title is a key constant; resolved at request time (Task 8/9). }) ] } @@ -713,13 +755,16 @@ describe('UkAddressField', () => { }), errors: [ expect.objectContaining({ - text: 'Enter address line 1' + text: 'Enter components.addressField.line1' + // Sub-field title is a key constant; resolved at request time (Task 8/9). }), expect.objectContaining({ - text: 'Enter town or city' + text: 'Enter components.addressField.town' + // Sub-field title is a key constant; resolved at request time (Task 8/9). }), expect.objectContaining({ - text: 'Enter a valid postcode' + text: 'Enter a valid components.addressField.postcode' + // Sub-field title is a key constant; resolved at request time (Task 8/9). }) ] } diff --git a/src/server/plugins/engine/components/UkAddressField.ts b/src/server/plugins/engine/components/UkAddressField.ts index cee7032f1..95c22fc30 100644 --- a/src/server/plugins/engine/components/UkAddressField.ts +++ b/src/server/plugins/engine/components/UkAddressField.ts @@ -52,7 +52,7 @@ export class UkAddressField extends FormComponent { { type: ComponentType.TextField, name: `${name}__uprn`, - title: props.model.t('components.addressField.uprn'), + title: 'components.addressField.uprn', schema: {}, options: { required: false, @@ -62,7 +62,7 @@ export class UkAddressField extends FormComponent { { type: ComponentType.TextField, name: `${name}__addressLine1`, - title: props.model.t('components.addressField.line1'), + title: 'components.addressField.line1', schema: { max: 100 }, options: { autocomplete: 'address-line1', @@ -73,7 +73,7 @@ export class UkAddressField extends FormComponent { { type: ComponentType.TextField, name: `${name}__addressLine2`, - title: props.model.t('components.addressField.line2'), + title: 'components.addressField.line2', schema: { max: 100 }, options: { autocomplete: 'address-line2', @@ -84,7 +84,7 @@ export class UkAddressField extends FormComponent { { type: ComponentType.TextField, name: `${name}__town`, - title: props.model.t('components.addressField.town'), + title: 'components.addressField.town', schema: { max: 100 }, options: { autocomplete: 'address-level2', @@ -96,7 +96,7 @@ export class UkAddressField extends FormComponent { { type: ComponentType.TextField, name: `${name}__county`, - title: props.model.t('components.addressField.county'), + title: 'components.addressField.county', schema: { max: 100 }, options: { autocomplete: 'address-level1', @@ -107,7 +107,7 @@ export class UkAddressField extends FormComponent { { type: ComponentType.TextField, name: `${name}__postcode`, - title: props.model.t('components.addressField.postcode'), + title: 'components.addressField.postcode', schema: { regex: '^[a-zA-Z]{1,2}\\d[a-zA-Z\\d]?\\s?\\d[a-zA-Z]{2}$' }, @@ -299,7 +299,8 @@ export class UkAddressField extends FormComponent { componentHint: component.hint, componentTitle: component.title || controller.title, step: args.actionArgs.step, - sourceUrl: args.sourceUrl + sourceUrl: args.sourceUrl, + language: controller.model.language }) } } From cc3325832f3d7f0558ddcdc8a98fc93c0d5fe021 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Sat, 25 Apr 2026 08:24:16 +0100 Subject: [PATCH 45/89] fix(i18n): remove out-of-scope language field from UkAddressField dispatch call --- src/server/plugins/engine/components/UkAddressField.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/server/plugins/engine/components/UkAddressField.ts b/src/server/plugins/engine/components/UkAddressField.ts index 95c22fc30..d2c09ee80 100644 --- a/src/server/plugins/engine/components/UkAddressField.ts +++ b/src/server/plugins/engine/components/UkAddressField.ts @@ -299,8 +299,7 @@ export class UkAddressField extends FormComponent { componentHint: component.hint, componentTitle: component.title || controller.title, step: args.actionArgs.step, - sourceUrl: args.sourceUrl, - language: controller.model.language + sourceUrl: args.sourceUrl }) } } From e5fb12f1eaf1ff60314a54f31ab194a31f1c75c9 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Sat, 25 Apr 2026 16:00:27 +0100 Subject: [PATCH 46/89] feat(i18n): add language field to adapter meta payload --- .../outputFormatters/adapter/v1.test.ts | 29 +++++++++++++++++++ .../engine/outputFormatters/adapter/v1.ts | 5 ++++ .../plugins/engine/types/schema.test.ts | 22 ++++++++++++++ src/server/plugins/engine/types/schema.ts | 1 + 4 files changed, 57 insertions(+) diff --git a/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts b/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts index 11f0be182..0c1a22938 100644 --- a/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts +++ b/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts @@ -1220,4 +1220,33 @@ describe('Adapter v1 formatter', () => { }) }) }) + + describe('meta.language', () => { + const formStatus = { isPreview: false, state: FormStatus.Live } + + it('includes meta.language when the form definition specifies a language', () => { + const welshModel = new FormModel( + { ...definition, metadata: { language: 'cy' } }, + { basePath: 'test' } + ) + + const body = format( + context, + items, + welshModel, + submitResponse, + formStatus + ) + const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload + + expect(parsedBody.meta.language).toBe('cy') + }) + + it('omits meta.language when the form definition has no language set', () => { + const body = format(context, items, model, submitResponse, formStatus) + const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload + + expect(parsedBody.meta.language).toBeUndefined() + }) + }) }) diff --git a/src/server/plugins/engine/outputFormatters/adapter/v1.ts b/src/server/plugins/engine/outputFormatters/adapter/v1.ts index f5728c320..45e0f952d 100644 --- a/src/server/plugins/engine/outputFormatters/adapter/v1.ts +++ b/src/server/plugins/engine/outputFormatters/adapter/v1.ts @@ -51,6 +51,11 @@ export function format( meta.versionMetadata = versionMetadata } + const definitionLanguage = model.def.metadata?.language + if (typeof definitionLanguage === 'string') { + meta.language = definitionLanguage + } + const main = Object.fromEntries( Object.entries(v2Main).map(([key, value]) => { if (value === undefined) { diff --git a/src/server/plugins/engine/types/schema.test.ts b/src/server/plugins/engine/types/schema.test.ts index a77c9257a..65c4988e4 100644 --- a/src/server/plugins/engine/types/schema.test.ts +++ b/src/server/plugins/engine/types/schema.test.ts @@ -126,6 +126,28 @@ describe('Schema validation', () => { ) expect(error).toBeDefined() }) + + it('should validate meta with optional language', () => { + const { error } = formAdapterSubmissionMessageMetaSchema.validate({ + ...validMeta, + language: 'cy' + }) + expect(error).toBeUndefined() + }) + + it('should validate meta without language', () => { + const { error } = + formAdapterSubmissionMessageMetaSchema.validate(validMeta) + expect(error).toBeUndefined() + }) + + it('should reject non-string language', () => { + const { error } = formAdapterSubmissionMessageMetaSchema.validate({ + ...validMeta, + language: 123 + }) + expect(error).toBeDefined() + }) }) describe('formAdapterSubmissionMessageDataSchema', () => { diff --git a/src/server/plugins/engine/types/schema.ts b/src/server/plugins/engine/types/schema.ts index b7d18f600..af4c53dc2 100644 --- a/src/server/plugins/engine/types/schema.ts +++ b/src/server/plugins/engine/types/schema.ts @@ -32,6 +32,7 @@ export const formAdapterSubmissionMessageMetaSchema = isPreview: Joi.boolean().required(), notificationEmail: notificationEmailAddressSchema.required(), versionMetadata: formVersionMetadataSchema.optional(), + language: Joi.string().optional(), custom: Joi.object() .pattern(/^/, Joi.any()) .unknown() From 41cc07593258e74fa89f4cc9c82c4fd546855e52 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Sat, 25 Apr 2026 16:00:37 +0100 Subject: [PATCH 47/89] feat(i18n): FormComponent.getViewModel accepts Translator; fix tContent for no-GUID entities --- .../engine/components/FormComponent.ts | 39 +++++++++++++++++-- .../engine/components/TextField.test.ts | 36 +++++++++++++++++ src/server/plugins/engine/models/FormModel.ts | 4 +- 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/server/plugins/engine/components/FormComponent.ts b/src/server/plugins/engine/components/FormComponent.ts index 6fc12abfe..fc6936e2b 100644 --- a/src/server/plugins/engine/components/FormComponent.ts +++ b/src/server/plugins/engine/components/FormComponent.ts @@ -1,10 +1,12 @@ import { + type ComponentDef, type FormComponentsDef, type FormMetadata, type Item } from '@defra/forms-model' import { ComponentBase } from '~/src/server/plugins/engine/components/ComponentBase.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type FormContext, type FormRequestPayload @@ -24,6 +26,7 @@ import { type RepeatListState, type UploadState } from '~/src/server/plugins/engine/types.js' +import { type FormQuery } from '~/src/server/routes/types.js' export class FormComponent extends ComponentBase { type: FormComponentsDef['type'] @@ -129,16 +132,33 @@ export class FormComponent extends ComponentBase { return firstError && [firstError] } - getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { + getViewModel( + payload: FormPayload, + errors?: FormSubmissionError[], + translatorOrQuery?: Translator | FormQuery + ) { const { hint, name, options = {}, title, viewModel } = this + const translator = isTranslator(translatorOrQuery) + ? translatorOrQuery + : undefined + + const t = translator?.t ?? ((key: string) => this.model.t(key)) + const tContent = translator?.tContent + const isRequired = !('required' in options) || options.required !== false const hideOptional = 'optionalText' in options && options.optionalText - const label = `${title}${!isRequired && !hideOptional ? ` ${this.model.t('common.optional')}` : ''}` + + const resolvedTitle = tContent + ? tContent(this as unknown as ComponentDef, 'title') + : title + const label = `${resolvedTitle}${!isRequired && !hideOptional ? ` ${t('common.optional')}` : ''}` if (hint) { viewModel.hint = { - text: hint + text: tContent + ? tContent(this as unknown as ComponentDef, 'hint') + : hint } } @@ -239,6 +259,19 @@ export class FormComponent extends ComponentBase { } } +/** + * Type guard — returns true when the value is a Translator (has t and tContent functions), + * as opposed to a FormQuery (plain string-keyed record). + */ +export function isTranslator(value: unknown): value is Translator { + return ( + typeof value === 'object' && + value !== null && + typeof (value as Record).t === 'function' && + typeof (value as Record).tContent === 'function' + ) +} + /** * Check for form value */ diff --git a/src/server/plugins/engine/components/TextField.test.ts b/src/server/plugins/engine/components/TextField.test.ts index 492e0fff5..724491ab1 100644 --- a/src/server/plugins/engine/components/TextField.test.ts +++ b/src/server/plugins/engine/components/TextField.test.ts @@ -197,6 +197,42 @@ describe('TextField', () => { }) }) + describe('getViewModel with Translator', () => { + it('calls tContent for the field title', () => { + const tContent = jest.fn().mockReturnValue('Translated title') + const t = jest.fn().mockReturnValue('(optional)') + const viewModel = field.getViewModel({}, undefined, { t, tContent }) + expect(tContent).toHaveBeenCalledWith(field, 'title') + expect(viewModel.label.text).toBe('Translated title') + }) + + it('calls tContent for the hint when hint is set', () => { + const hintDef = { ...def, hint: 'Enter your name' } + const hintCollection = new ComponentCollection([hintDef], { model }) + const hintField = hintCollection.fields[0] + const tContent = jest.fn().mockReturnValue('Translated hint') + const t = jest.fn().mockReturnValue('') + const viewModel = hintField.getViewModel({}, undefined, { t, tContent }) + expect(tContent).toHaveBeenCalledWith(hintField, 'hint') + expect(viewModel.hint?.text).toBe('Translated hint') + }) + + it('calls t for common.optional when field is optional', () => { + const optDef = { ...def, options: { required: false } } + const optCollection = new ComponentCollection([optDef], { model }) + const optField = optCollection.fields[0] + const tContent = jest.fn().mockReturnValue('Title') + const t = jest.fn().mockReturnValue('(optional)') + optField.getViewModel({}, undefined, { t, tContent }) + expect(t).toHaveBeenCalledWith('common.optional') + }) + + it('falls back to English when no translator supplied', () => { + const viewModel = field.getViewModel({}) + expect(viewModel.label.text).toBe('Example text field') + }) + }) + describe('AllPossibleErrors', () => { it('should return errors', () => { const errors = field.getAllPossibleErrors() diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index dea0d5778..7c00a222f 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -283,7 +283,9 @@ export class FormModel { ): string => { if (!entity.id) { const raw = (entity as Record)[prop] - return typeof raw === 'string' ? raw : '' + if (typeof raw !== 'string') return '' + // t() resolves i18next key constants (sub-field labels); returns raw string unchanged if not a key + return t(raw) } const key = `${entityType}.${entity.id}.${prop}` const result = i18nInstance.t(key, { From 659bc8debc0e1ab7f377ba03c9f21de12d63a5bd Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Sat, 25 Apr 2026 16:01:11 +0100 Subject: [PATCH 48/89] feat(i18n): wire Translator through ComponentCollection, FileUploadField; replace hardcoded strings with i18n keys --- .../engine/components/ComponentCollection.ts | 5 ++- .../engine/components/FileUploadField.ts | 9 ++++- .../engine/i18n/translations/en-GB.json | 38 ++++++++++++++++++- .../pageControllers/QuestionPageController.ts | 3 +- .../pageControllers/RepeatPageController.ts | 8 ++-- .../plugins/engine/pageControllers/errors.ts | 2 +- 6 files changed, 52 insertions(+), 13 deletions(-) diff --git a/src/server/plugins/engine/components/ComponentCollection.ts b/src/server/plugins/engine/components/ComponentCollection.ts index df0ae3f48..a788f2760 100644 --- a/src/server/plugins/engine/components/ComponentCollection.ts +++ b/src/server/plugins/engine/components/ComponentCollection.ts @@ -18,6 +18,7 @@ import { } from '~/src/server/plugins/engine/components/helpers/components.js' import { type ComponentViewModel } from '~/src/server/plugins/engine/components/types.js' import { getErrors } from '~/src/server/plugins/engine/helpers.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js' import { validationOptions as opts } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' @@ -233,7 +234,7 @@ export class ComponentCollection { getViewModel( payload: FormPayload, errors?: FormSubmissionError[], - query: FormQuery = {} + translatorOrQuery: Translator | FormQuery = {} ) { const { components } = this @@ -242,7 +243,7 @@ export class ComponentCollection { const model = component instanceof FormComponent - ? component.getViewModel(payload, errors, query) + ? component.getViewModel(payload, errors, translatorOrQuery) : component.getViewModel() return { type, isFormComponent, model } diff --git a/src/server/plugins/engine/components/FileUploadField.ts b/src/server/plugins/engine/components/FileUploadField.ts index 04294a0ea..fc04b13b5 100644 --- a/src/server/plugins/engine/components/FileUploadField.ts +++ b/src/server/plugins/engine/components/FileUploadField.ts @@ -7,8 +7,10 @@ import joi, { type ArraySchema } from 'joi' import { FormComponent, + isTranslator, isUploadState } from '~/src/server/plugins/engine/components/FormComponent.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { InvalidComponentStateError } from '~/src/server/plugins/engine/pageControllers/errors.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { @@ -192,11 +194,14 @@ export class FileUploadField extends FormComponent { getViewModel( payload: FormPayload, errors?: FormSubmissionError[], - query: FormQuery = {} + translatorOrQuery?: Translator | FormQuery ) { const { options, page, schema } = this - // Allow preview URL direct access + // Allow preview URL direct access (query is passed when called via ComponentCollection) + const query = !isTranslator(translatorOrQuery) + ? (translatorOrQuery ?? {}) + : {} const isForceAccess = 'force' in query const viewModel = super.getViewModel(payload, errors) diff --git a/src/server/plugins/engine/i18n/translations/en-GB.json b/src/server/plugins/engine/i18n/translations/en-GB.json index 1b194ad7a..68e74a064 100644 --- a/src/server/plugins/engine/i18n/translations/en-GB.json +++ b/src/server/plugins/engine/i18n/translations/en-GB.json @@ -18,7 +18,8 @@ }, "errors": { - "title": "There is a problem" + "title": "There is a problem", + "optionsMismatch": "Options are different because you changed a previous answer" }, "common": { @@ -65,6 +66,8 @@ "removeAnswer": "Are you sure you want to remove this answer?", "remove": "Remove", "cancel": "Cancel", + "lastItemCannotBeRemoved": "Last list item cannot be removed", + "itemToRemoveNotFound": "List item to remove not found", "continue": "Continue", "addAnother": "Add another", "saveAndExit": "Save and exit", @@ -105,6 +108,36 @@ } }, + "postcodeLookup": { + "postcodeLabel": "Postcode", + "postcodeHint": "For example, AA3 1AB", + "buildingNameLabel": "Building name or number (optional)", + "buildingNameHint": "For example, 15 or Prospect Cottage", + "selectAddress": "Select an address", + "addressLine1Label": "Address line 1", + "addressLine2Label": "Address line 2 (optional)", + "townLabel": "Town or city", + "countyLabel": "County (optional)", + "findAddress": "Find address", + "enterManually": "enter address manually", + "searchAgain": "Search again", + "useThisAddress": "Use this address", + "findAnAddressInstead": "find an address instead", + "noAddressFoundTitle": "No address found", + "noAddressFoundBody": "We could not find an address that matches", + "addressFound_one": "[[count]] address found for", + "addressFound_other": "[[count]] addresses found for", + "validation": { + "invalidPostcode": "Enter a valid postcode or enter an address manually", + "requiredPostcode": "Enter a postcode", + "selectAddress": "Select an address", + "requiredAddressLine1": "Enter address line 1", + "requiredTown": "Enter town or city", + "invalidManualPostcode": "Enter a valid postcode", + "requiredManualPostcode": "Enter postcode" + } + }, + "components": { "dateField": { "day": "Day", @@ -210,7 +243,8 @@ "canSubmitAfterPayment": "You can submit the form after you have added your payment details.", "preAuthorisedTitle": "You have already authorised a payment for this form", "preAuthorisedBody": "Continue to submit the form. You will not be charged twice.", - "totalAmountLabel": "Total amount:" + "totalAmountLabel": "Total amount:", + "paymentAmountMismatch": "The pre-authorised payment amount is somehow different from that requested. Try adding payment details again." }, "fileUploadField": { diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index e7f610285..9576cebdd 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -19,7 +19,6 @@ import { PAYMENT_EXPIRED_NOTIFICATION } from '~/src/server/constants.js' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' -import { optionalText } from '~/src/server/plugins/engine/components/constants.js' import { type BackLink } from '~/src/server/plugins/engine/components/types.js' import { checkFormStatus, @@ -160,7 +159,7 @@ export class QuestionPageController extends PageController { if (pageTitle) { labelOrLegend.text = isOptional - ? `${pageTitle}${optionalText}` + ? `${pageTitle} ${this.model.t('common.optional')}` : pageTitle } diff --git a/src/server/plugins/engine/pageControllers/RepeatPageController.ts b/src/server/plugins/engine/pageControllers/RepeatPageController.ts index 6db61eb49..e50a565ad 100644 --- a/src/server/plugins/engine/pageControllers/RepeatPageController.ts +++ b/src/server/plugins/engine/pageControllers/RepeatPageController.ts @@ -291,8 +291,8 @@ export class RepeatPageController extends QuestionPageController { if (!item || list.length === 1) { throw Boom.notFound( item - ? 'Last list item cannot be removed' - : 'List item to remove not found' + ? this.model.t('pages.repeater.lastItemCannotBeRemoved') + : this.model.t('pages.repeater.itemToRemoveNotFound') ) } @@ -330,8 +330,8 @@ export class RepeatPageController extends QuestionPageController { if (!item || list.length === 1) { throw Boom.notFound( item - ? 'Last list item cannot be removed' - : 'List item to remove not found' + ? this.model.t('pages.repeater.lastItemCannotBeRemoved') + : this.model.t('pages.repeater.itemToRemoveNotFound') ) } diff --git a/src/server/plugins/engine/pageControllers/errors.ts b/src/server/plugins/engine/pageControllers/errors.ts index 5e796eec7..c8c769985 100644 --- a/src/server/plugins/engine/pageControllers/errors.ts +++ b/src/server/plugins/engine/pageControllers/errors.ts @@ -70,7 +70,7 @@ export class PaymentSubmissionError extends Error { if (stateAmount / 100 !== definitionAmount) { throw new PaymentPreAuthError( component, - 'The pre-authorised payment amount is somehow different from that requested. Try adding payment details again.', + component.model.t('components.paymentField.paymentAmountMismatch'), true, PaymentErrorTypes.PaymentIncomplete ) From 247d801f4c57a235917396b892259777ebf3723f Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Sat, 25 Apr 2026 16:01:25 +0100 Subject: [PATCH 49/89] feat(i18n): postcode-lookup language support and i18n text extraction --- .../plugins/engine/components/constants.ts | 1 - .../plugins/postcode-lookup/models/index.js | 231 +++++++++++------- .../plugins/postcode-lookup/routes/index.js | 24 +- src/server/plugins/postcode-lookup/types.js | 1 + .../views/postcode-lookup-details.html | 4 +- 5 files changed, 163 insertions(+), 98 deletions(-) delete mode 100644 src/server/plugins/engine/components/constants.ts diff --git a/src/server/plugins/engine/components/constants.ts b/src/server/plugins/engine/components/constants.ts deleted file mode 100644 index f7b9ac7c7..000000000 --- a/src/server/plugins/engine/components/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const optionalText = ' (optional)' diff --git a/src/server/plugins/postcode-lookup/models/index.js b/src/server/plugins/postcode-lookup/models/index.js index fefbd0a4d..8c7e953af 100644 --- a/src/server/plugins/postcode-lookup/models/index.js +++ b/src/server/plugins/postcode-lookup/models/index.js @@ -1,5 +1,6 @@ import Joi from 'joi' +import { t as i18nT } from '~/src/server/plugins/engine/i18n/index.js' import * as service from '~/src/server/plugins/postcode-lookup/service.js' import { crumbSchema } from '~/src/server/schemas/index.js' @@ -14,8 +15,6 @@ const townFieldName = 'town' const countyFieldName = 'county' const postcodeFieldName = 'postcode' -const selectLabelText = 'Select an address' - const GOVUK_MARGIN_RIGHT_1 = 'govuk-!-margin-right-1' export const steps = { @@ -129,17 +128,23 @@ async function getAddresses(postcodeQuery, buildingNameQuery, apiKey) { * @param {PostcodeLookupDetailsData | undefined} details * @param {OptionalValidationErrorItem} postcodeQueryError * @param {OptionalValidationErrorItem} buildingNameQueryError + * @param {string} language */ -function getDetailsFields(details, postcodeQueryError, buildingNameQueryError) { +function getDetailsFields( + details, + postcodeQueryError, + buildingNameQueryError, + language +) { return { [postcodeQueryFieldName]: { id: postcodeQueryFieldName, name: postcodeQueryFieldName, label: { - text: 'Postcode' + text: i18nT('postcodeLookup.postcodeLabel', language) }, hint: { - text: 'For example, AA3 1AB' + text: i18nT('postcodeLookup.postcodeHint', language) }, value: details?.postcodeQuery, errorMessage: postcodeQueryError && { text: postcodeQueryError.message } @@ -148,10 +153,10 @@ function getDetailsFields(details, postcodeQueryError, buildingNameQueryError) { id: buildingNameQueryFieldName, name: buildingNameQueryFieldName, label: { - text: 'Building name or number (optional)' + text: i18nT('postcodeLookup.buildingNameLabel', language) }, hint: { - text: 'For example, 15 or Prospect Cottage' + text: i18nT('postcodeLookup.buildingNameHint', language) }, value: details?.buildingNameQuery, errorMessage: buildingNameQueryError && { @@ -169,6 +174,7 @@ function getDetailsFields(details, postcodeQueryError, buildingNameQueryError) { * @param {PostcodeLookupSelectPayload | undefined} payload * @param {OptionalValidationErrorItem} uprnError * @param {Address[]} addresses + * @param {string} language */ function getSelectFields( details, @@ -176,8 +182,11 @@ function getSelectFields( singleAddress, payload, uprnError, - addresses + addresses, + language ) { + const selectAddressText = i18nT('postcodeLookup.selectAddress', language) + return { [postcodeQueryFieldName]: { id: postcodeQueryFieldName, @@ -196,13 +205,13 @@ function getSelectFields( name: uprnFieldName, label: hasMultipleAddresses ? { - text: selectLabelText + text: selectAddressText } : undefined, value: singleAddress ? singleAddress.uprn : payload?.uprn, errorMessage: uprnError && { text: uprnError.message }, items: hasMultipleAddresses - ? [{ text: selectLabelText, value: '' }].concat( + ? [{ text: selectAddressText, value: '' }].concat( addresses.map((item) => ({ text: item.formatted, value: item.uprn @@ -222,6 +231,7 @@ function getSelectFields( * @param {OptionalValidationErrorItem} townError * @param {OptionalValidationErrorItem} countyError * @param {OptionalValidationErrorItem} postcodeError + * @param {string} language */ function getManualFields( payload, @@ -229,14 +239,15 @@ function getManualFields( line2Error, townError, countyError, - postcodeError + postcodeError, + language ) { return { [line1FieldName]: { id: line1FieldName, name: line1FieldName, label: { - text: 'Address line 1' + text: i18nT('postcodeLookup.addressLine1Label', language) }, value: payload?.addressLine1, errorMessage: line1Error && { text: line1Error.message } @@ -245,7 +256,7 @@ function getManualFields( id: line2FieldName, name: line2FieldName, label: { - text: 'Address line 2 (optional)' + text: i18nT('postcodeLookup.addressLine2Label', language) }, value: payload?.addressLine2, errorMessage: line2Error && { text: line2Error.message } @@ -254,7 +265,7 @@ function getManualFields( id: townFieldName, name: townFieldName, label: { - text: 'Town or city' + text: i18nT('postcodeLookup.townLabel', language) }, classes: 'govuk-!-width-two-thirds', value: payload?.town, @@ -264,7 +275,7 @@ function getManualFields( id: countyFieldName, name: countyFieldName, label: { - text: 'County (optional)' + text: i18nT('postcodeLookup.countyLabel', language) }, value: payload?.county, errorMessage: countyError && { text: countyError.message } @@ -273,7 +284,7 @@ function getManualFields( id: postcodeFieldName, name: postcodeFieldName, label: { - text: 'Postcode' + text: i18nT('postcodeLookup.postcodeLabel', language) }, classes: 'govuk-input--width-10', value: payload?.postcode, @@ -293,66 +304,91 @@ const sharedPayloadSchemaKeys = { /** * Postcode lookup details form payload schema - * @type {ObjectSchema} + * @param {string} [language] + * @returns {ObjectSchema} */ -export const detailsPayloadSchema = Joi.object() - .keys({ - ...sharedPayloadSchemaKeys, - [postcodeQueryFieldName]: Joi.string() - .pattern(/^[a-zA-Z]{1,2}\d[a-zA-Z\d]?\s?\d[a-zA-Z]{2}$/) - .trim() - .required() - .messages({ - 'string.pattern.base': - 'Enter a valid postcode or enter an address manually', - '*': 'Enter a postcode' - }), - [buildingNameQueryFieldName]: Joi.string() - .trim() - .required() - .allow('') - .trim() - }) - .required() +export function createDetailsPayloadSchema(language = 'en-GB') { + return Joi.object() + .keys({ + ...sharedPayloadSchemaKeys, + [postcodeQueryFieldName]: Joi.string() + .pattern(/^[a-zA-Z]{1,2}\d[a-zA-Z\d]?\s?\d[a-zA-Z]{2}$/) + .trim() + .required() + .messages({ + 'string.pattern.base': i18nT( + 'postcodeLookup.validation.invalidPostcode', + language + ), + '*': i18nT('postcodeLookup.validation.requiredPostcode', language) + }), + [buildingNameQueryFieldName]: Joi.string() + .trim() + .required() + .allow('') + .trim() + }) + .required() +} /** * Postcode lookup select form payload schema - * @type {ObjectSchema} + * @param {string} [language] + * @returns {ObjectSchema} */ -export const selectPayloadSchema = Joi.object() - .keys({ - ...sharedPayloadSchemaKeys, - [uprnFieldName]: Joi.string().required().messages({ - '*': selectLabelText +export function createSelectPayloadSchema(language = 'en-GB') { + return Joi.object() + .keys({ + ...sharedPayloadSchemaKeys, + [uprnFieldName]: Joi.string() + .required() + .messages({ + '*': i18nT('postcodeLookup.validation.selectAddress', language) + }) }) - }) - .required() + .required() +} /** * Postcode lookup manual form payload schema - * @type {ObjectSchema} + * @param {string} [language] + * @returns {ObjectSchema} */ -export const manualPayloadSchema = Joi.object() - .keys({ - ...sharedPayloadSchemaKeys, - [line1FieldName]: Joi.string().trim().required().messages({ - '*': 'Enter address line 1' - }), - [line2FieldName]: Joi.string().trim().allow('').required(), - [townFieldName]: Joi.string().trim().required().messages({ - '*': 'Enter town or city' - }), - [countyFieldName]: Joi.string().trim().allow('').required(), - [postcodeFieldName]: Joi.string() - .pattern(/^[a-zA-Z]{1,2}\d[a-zA-Z\d]?\s?\d[a-zA-Z]{2}$/) - .trim() - .required() - .messages({ - 'string.pattern.base': 'Enter a valid postcode', - '*': 'Enter postcode' - }) - }) - .required() +export function createManualPayloadSchema(language = 'en-GB') { + return Joi.object() + .keys({ + ...sharedPayloadSchemaKeys, + [line1FieldName]: Joi.string() + .trim() + .required() + .messages({ + '*': i18nT('postcodeLookup.validation.requiredAddressLine1', language) + }), + [line2FieldName]: Joi.string().trim().allow('').required(), + [townFieldName]: Joi.string() + .trim() + .required() + .messages({ + '*': i18nT('postcodeLookup.validation.requiredTown', language) + }), + [countyFieldName]: Joi.string().trim().allow('').required(), + [postcodeFieldName]: Joi.string() + .pattern(/^[a-zA-Z]{1,2}\d[a-zA-Z\d]?\s?\d[a-zA-Z]{2}$/) + .trim() + .required() + .messages({ + 'string.pattern.base': i18nT( + 'postcodeLookup.validation.invalidManualPostcode', + language + ), + '*': i18nT( + 'postcodeLookup.validation.requiredManualPostcode', + language + ) + }) + }) + .required() +} /** * Get the postcode lookup href @@ -371,7 +407,12 @@ function getHref(step) { * @param {Error} [err] */ export function detailsViewModel(data, payload, err) { - const { componentTitle: pageTitle, formName, sourceUrl } = data.initial + const { + componentTitle: pageTitle, + formName, + sourceUrl, + language = 'en-GB' + } = data.initial const backLink = { href: sourceUrl @@ -380,20 +421,19 @@ export function detailsViewModel(data, payload, err) { const { errors, postcodeQueryError, buildingNameQueryError } = buildErrors(err) - // Model fields const fields = getDetailsFields( payload ?? data.details, postcodeQueryError, - buildingNameQueryError + buildingNameQueryError, + language ) - // Model buttons const continueButton = { - text: 'Find address', + text: i18nT('postcodeLookup.findAddress', language), classes: GOVUK_MARGIN_RIGHT_1 } const manualLink = { - text: 'enter address manually', + text: i18nT('postcodeLookup.enterManually', language), href: getHref(steps.manual) } @@ -420,8 +460,8 @@ export async function selectViewModel(data, payload, err) { const { session, apiKey } = data const { details, initial } = session const { postcodeQuery, buildingNameQuery } = details + const language = initial.language ?? 'en-GB' - // Search for addresses const { hasAddresses, hasMultipleAddresses, @@ -430,7 +470,9 @@ export async function selectViewModel(data, payload, err) { addressCount } = await getAddresses(postcodeQuery, buildingNameQuery, apiKey) - const title = hasAddresses ? initial.componentTitle : 'No address found' + const title = hasAddresses + ? initial.componentTitle + : i18nT('postcodeLookup.noAddressFoundTitle', language) const formPath = initial.sourceUrl const href = getHref() @@ -438,32 +480,42 @@ export async function selectViewModel(data, payload, err) { const { errors, uprnError } = buildErrors(err) - // Model fields const fields = getSelectFields( details, hasMultipleAddresses, singleAddress, payload, uprnError, - addresses + addresses, + language ) + const searchAgainText = i18nT('postcodeLookup.searchAgain', language) + const searchAgainLink = { - text: 'Search again', + text: searchAgainText, href } - // Model buttons const continueButton = { href: hasAddresses ? undefined : href, - text: hasAddresses ? 'Use this address' : 'Search again', + text: hasAddresses + ? i18nT('postcodeLookup.useThisAddress', language) + : searchAgainText, classes: GOVUK_MARGIN_RIGHT_1 } const manualLink = { - text: 'enter address manually', + text: i18nT('postcodeLookup.enterManually', language), href: `${href}?step=${steps.manual}` } + const addressesFoundText = hasAddresses + ? i18nT('postcodeLookup.addressFound', language, { count: addressCount }) + : undefined + const noAddressFoundText = !hasAddresses + ? i18nT('postcodeLookup.noAddressFoundBody', language) + : undefined + return { step: steps.select, showTitle: true, @@ -479,6 +531,8 @@ export async function selectViewModel(data, payload, err) { singleAddress, hasAddresses, hasMultipleAddresses, + addressesFoundText, + noAddressFoundText, buttons: { continueButton, manualLink } } } @@ -490,7 +544,12 @@ export async function selectViewModel(data, payload, err) { * @param {Error} [err] */ export function manualViewModel(data, payload, err) { - const { componentTitle, sourceUrl, componentHint } = data.initial + const { + componentTitle, + sourceUrl, + componentHint, + language = 'en-GB' + } = data.initial const formPath = sourceUrl const href = getHref() @@ -507,28 +566,26 @@ export function manualViewModel(data, payload, err) { postcodeError } = buildErrors(err) - // Model hint const hint = componentHint && { text: componentHint } - // Model fields const fields = getManualFields( payload, line1Error, line2Error, townError, countyError, - postcodeError + postcodeError, + language ) - // Model buttons const continueButton = { - text: 'Use this address', + text: i18nT('postcodeLookup.useThisAddress', language), classes: GOVUK_MARGIN_RIGHT_1 } const detailsLink = { - text: 'find an address instead', + text: i18nT('postcodeLookup.findAnAddressInstead', language), href } diff --git a/src/server/plugins/postcode-lookup/routes/index.js b/src/server/plugins/postcode-lookup/routes/index.js index 34feba53a..2e7c27d4c 100644 --- a/src/server/plugins/postcode-lookup/routes/index.js +++ b/src/server/plugins/postcode-lookup/routes/index.js @@ -5,11 +5,11 @@ import Joi from 'joi' import { EXTERNAL_STATE_APPENDAGE } from '~/src/server/constants.js' import { JOURNEY_BASE_URL, - detailsPayloadSchema, + createDetailsPayloadSchema, + createManualPayloadSchema, + createSelectPayloadSchema, detailsViewModel, - manualPayloadSchema, manualViewModel, - selectPayloadSchema, selectViewModel, stepSchema, steps @@ -173,7 +173,9 @@ async function detailsPostHandler(request, h, options) { const { payload } = request const session = getSessionState(request) const { ordnanceSurveyApiKey: apiKey } = options - const { value: details, error } = detailsPayloadSchema.validate(payload) + const language = session.initial.language ?? 'en-GB' + const { value: details, error } = + createDetailsPayloadSchema(language).validate(payload) let model @@ -204,7 +206,9 @@ async function selectPostHandler(request, h, options) { const { payload } = request const session = getSessionState(request) const { ordnanceSurveyApiKey: apiKey } = options - const { value: select, error } = selectPayloadSchema.validate(payload) + const language = session.initial.language ?? 'en-GB' + const { value: select, error } = + createSelectPayloadSchema(language).validate(payload) if (error) { const model = await selectViewModel({ session, apiKey }, select, error) @@ -234,10 +238,14 @@ async function selectPostHandler(request, h, options) { function manualPostHandler(request, h) { const { payload } = request const session = getSessionState(request) + const language = session.initial.language ?? 'en-GB' - const { value: manual, error } = manualPayloadSchema.validate(payload, { - abortEarly: false - }) + const { value: manual, error } = createManualPayloadSchema(language).validate( + payload, + { + abortEarly: false + } + ) if (error) { const model = manualViewModel(session, manual, error) diff --git a/src/server/plugins/postcode-lookup/types.js b/src/server/plugins/postcode-lookup/types.js index 49ca24a11..4d6d1065f 100644 --- a/src/server/plugins/postcode-lookup/types.js +++ b/src/server/plugins/postcode-lookup/types.js @@ -19,6 +19,7 @@ * componentTitle: string, * componentHint?: string * step?: string + * language?: string * }} PostcodeLookupDispatchData */ diff --git a/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html b/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html index 8090445e5..70c83f3fe 100644 --- a/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html +++ b/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html @@ -38,7 +38,7 @@ {% if hasAddresses %}

- {{addressCount}} address{{ "es" if hasMultipleAddresses }} found for {{ detailsHtml | safe }}. {{ searchAgainLink.text }} + {{ addressesFoundText }} {{ detailsHtml | safe }}. {{ searchAgainLink.text }}

{% endif %} @@ -53,7 +53,7 @@ }) }} {% else %} -

We could not find an address that matches {{ detailsHtml | safe }}.

+

{{ noAddressFoundText }} {{ detailsHtml | safe }}.

{% endif %} {% case "manual" %} From dc6fa120ec738381e42ea59aaa4d91d3a4bcb018 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Sat, 25 Apr 2026 16:05:05 +0100 Subject: [PATCH 50/89] feat(i18n): ComponentCollection.validate accepts Translator for per-request message templates --- .../engine/components/ComponentCollection.ts | 19 ++++++++++-- .../pageControllers/validationOptions.ts | 31 +++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/server/plugins/engine/components/ComponentCollection.ts b/src/server/plugins/engine/components/ComponentCollection.ts index a788f2760..23e2423d0 100644 --- a/src/server/plugins/engine/components/ComponentCollection.ts +++ b/src/server/plugins/engine/components/ComponentCollection.ts @@ -21,7 +21,10 @@ import { getErrors } from '~/src/server/plugins/engine/helpers.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js' -import { validationOptions as opts } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' +import { + buildLanguageMessages, + validationOptions as opts +} from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type FormPayload, type FormState, @@ -255,8 +258,18 @@ export class ComponentCollection { /** * Validate form payload */ - validate(value: FormPayload = {}): FormValidationResult { - const result = this.formSchema.validate(value, opts) + validate( + value: FormPayload = {}, + translator?: Translator + ): FormValidationResult { + const messages = translator + ? buildLanguageMessages(translator.t) + : undefined + + const result = this.formSchema.validate(value, { + ...opts, + ...(messages && { messages }) + }) const details = result.error?.details diff --git a/src/server/plugins/engine/pageControllers/validationOptions.ts b/src/server/plugins/engine/pageControllers/validationOptions.ts index 5ef566fda..b16f8a301 100644 --- a/src/server/plugins/engine/pageControllers/validationOptions.ts +++ b/src/server/plugins/engine/pageControllers/validationOptions.ts @@ -55,6 +55,37 @@ export const messages: LanguageMessagesExt = { export const messagesPre: LanguageMessages = messages as unknown as LanguageMessages +export function buildLanguageMessages( + t: (key: string) => string +): LanguageMessages { + const vm = buildValidationMessages(t) + return { + 'string.base': vm.required, + 'string.min': vm.min, + 'string.empty': vm.required, + 'string.max': vm.max, + 'string.email': vm.format, + 'string.pattern.base': vm.pattern, + 'string.maxWords': vm.maxWords, + 'number.base': vm.number, + 'number.precision': vm.numberPrecision, + 'number.integer': vm.numberInteger, + 'number.unsafe': vm.format, + 'number.min': vm.numberMin, + 'number.max': vm.numberMax, + 'object.required': vm.objectRequired, + 'object.and': vm.objectMissing, + 'any.only': vm.selectRequired, + 'any.required': vm.selectRequired, + 'any.empty': vm.required, + 'date.base': vm.dateFormat, + 'date.format': vm.dateFormat, + 'date.min': vm.dateMin, + 'date.max': vm.dateMax, + 'object.invalidjson': vm.format + } as unknown as LanguageMessages +} + export const validationOptions: ValidationOptions = { abortEarly: false, messages: messagesPre, From 08e3368b8941d0ca3d294670a6b495940fa6d377 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Sat, 25 Apr 2026 16:23:34 +0100 Subject: [PATCH 51/89] feat(i18n): PageController and QuestionPageController use per-request translator Task 10: PageController.makeGetRouteHandler now creates a per-request translator via model.createTranslator(language), resolving language from getPluginOptions.getLanguage or falling back to 'en-GB'. Task 11: QuestionPageController.getViewModel accepts an optional Translator parameter. When supplied, translator.t replaces model.t for plugin strings and translator.tContent is used for page/section title resolution. makeGetRouteHandler and makePostRouteHandler create per-request translators and pass them through to getViewModel. Also exposes getLanguage via server.expose in plugin.ts and adds it to the Hapi PluginProperties type augmentation. Tests updated to use buildFormRequest stub (which provides a server object) and new translator-aware tests added. --- .../pageControllers/PageController.test.ts | 9 +- .../engine/pageControllers/PageController.ts | 7 +- .../QuestionPageController.test.ts | 125 ++++++++++++++++++ .../pageControllers/QuestionPageController.ts | 62 +++++++-- src/server/plugins/engine/plugin.ts | 2 + src/typings/hapi/index.d.ts | 1 + 6 files changed, 186 insertions(+), 20 deletions(-) diff --git a/src/server/plugins/engine/pageControllers/PageController.test.ts b/src/server/plugins/engine/pageControllers/PageController.test.ts index 48093dc44..06c03567a 100644 --- a/src/server/plugins/engine/pageControllers/PageController.test.ts +++ b/src/server/plugins/engine/pageControllers/PageController.test.ts @@ -3,6 +3,7 @@ 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 { buildFormRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js' import { serverWithSaveAndExit } from '~/src/server/plugins/engine/pageControllers/__stubs__/server.js' import { type FormRequest, @@ -139,7 +140,7 @@ describe('PageController', () => { describe('Route handlers', () => { const page1Url = new URL('http://example.com/test/licence') - const request = { + const request = buildFormRequest({ method: 'get', url: page1Url, path: page1Url.pathname, @@ -149,7 +150,7 @@ describe('PageController', () => { }, query: {}, app: { model } - } as FormRequest + } as FormRequest) const h: FormResponseToolkit = { redirect: jest.fn(), @@ -204,10 +205,10 @@ describe('PageController', () => { }) it('supports save and exit functionality', async () => { - const mockRequest = { + const mockRequest = buildFormRequest({ ...request, payload: { saveAndExit: true } - } as FormRequest + } as FormRequest) const mockResponse = { redirect: jest.fn(), diff --git a/src/server/plugins/engine/pageControllers/PageController.ts b/src/server/plugins/engine/pageControllers/PageController.ts index 03486260e..668ad1ab4 100644 --- a/src/server/plugins/engine/pageControllers/PageController.ts +++ b/src/server/plugins/engine/pageControllers/PageController.ts @@ -11,6 +11,7 @@ import { type Lifecycle, type RouteOptions, type Server } from '@hapi/hapi' import { type ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { type FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' import { + getPluginOptions, getSaveAndExitHelpers, getStartPath, normalisePath @@ -168,10 +169,12 @@ export class PageController { ) => ReturnType> { return (request, context, h) => { const { viewModel, viewName } = this + const { getLanguage } = getPluginOptions(request.server) + const language = getLanguage?.(request) ?? 'en-GB' + const { t } = this.model.createTranslator(language) return h.view(viewName, { ...viewModel, - t: (key: string, opts?: Record) => - this.model.t(key, opts) + t }) } } diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts index f42fe847d..1504747a4 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts @@ -1,6 +1,7 @@ import { type PageQuestion } from '@defra/forms-model' import { getForm } from '~/src/server/plugins/engine/configureEnginePlugin.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' import { @@ -1684,3 +1685,127 @@ describe('Save and Exit functionality', () => { }) }) }) + +describe('QuestionPageController translator support', () => { + let page1: PageQuestion + let page1Url: URL + + let model: FormModel + let controller1: QuestionPageController + let requestPage1: FormRequest + + beforeEach(() => { + const { pages } = definitionConditionsBasic + + page1 = pages[0] + page1Url = new URL('http://example.com/test/first-page') + + model = new FormModel(definitionConditionsBasic, { + basePath: 'test' + }) + + controller1 = new QuestionPageController(model, page1) + + requestPage1 = buildFormRequest({ + method: 'get', + url: page1Url, + path: page1Url.pathname, + params: { + path: 'first-page', + slug: 'test' + }, + query: {}, + app: { model } + } as FormRequest) + }) + + describe('getViewModel with translator', () => { + it('uses mock t from translator for plugin strings', () => { + const mockT = jest.fn((key: string) => `translated:${key}`) + const mockTContent = jest.fn( + (entity: unknown, prop: string) => `content:${prop}` + ) + + const translator: Translator = { + t: mockT, + tContent: mockTContent as Translator['tContent'] + } + + // Use a request with returnUrl so that getBackLink calls t() for back link text + const requestWithReturn = buildFormRequest({ + method: 'get', + url: page1Url, + path: page1Url.pathname, + params: { + path: 'first-page', + slug: 'test' + }, + query: { returnUrl: '/test/summary' }, + app: { model } + } as FormRequest) + + const context = model.getFormContext(requestWithReturn, { + $$__referenceNumber: 'foobar' + }) + + controller1.getViewModel(requestWithReturn, context, translator) + + // The translator's t should have been called for back link text + expect(mockT).toHaveBeenCalledWith('pages.question.backToCheckAnswers') + }) + + it('uses mock tContent from translator for page entity', () => { + const mockT = jest.fn((key: string) => key) + const mockTContent = jest.fn( + (entity: unknown, prop: string) => `content:${prop}` + ) + + const translator: Translator = { + t: mockT, + tContent: mockTContent as Translator['tContent'] + } + + const context = model.getFormContext(requestPage1, { + $$__referenceNumber: 'foobar' + }) + + controller1.getViewModel(requestPage1, context, translator) + + // tContent should have been called with the page entity + expect(mockTContent).toHaveBeenCalledWith( + expect.objectContaining({ path: page1.path }), + 'title' + ) + }) + + it('falls back to model.t when no translator is supplied', () => { + jest.spyOn(model, 't') + + // Use a request with returnUrl so that getBackLink calls model.t() for back link text + const requestWithReturn = buildFormRequest({ + method: 'get', + url: page1Url, + path: page1Url.pathname, + params: { + path: 'first-page', + slug: 'test' + }, + query: { returnUrl: '/test/summary' }, + app: { model } + } as FormRequest) + + const context = model.getFormContext(requestWithReturn, { + $$__referenceNumber: 'foobar' + }) + + // Call without translator (backward compat) + controller1.getViewModel(requestWithReturn, context) + + // model.t should have been called for back link text (opts may be undefined) + expect(model.t).toHaveBeenCalledWith( + 'pages.question.backToCheckAnswers', + undefined + ) + }) + }) +}) diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index 9576cebdd..e040da8b7 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -24,10 +24,12 @@ import { checkFormStatus, getCacheService, getErrors, + getPluginOptions, getSaveAndExitHelpers, normalisePath, proceed } from '~/src/server/plugins/engine/helpers.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js' import { prefillStateFromQueryParameters } from '~/src/server/plugins/engine/pageControllers/helpers/state.js' @@ -114,16 +116,36 @@ export class QuestionPageController extends PageController { * Used for mapping form payloads and errors to govuk-frontend's template api, so a page can be rendered * @param request - the hapi request * @param context - the form context + * @param translator - optional per-request translator pair */ getViewModel( request: FormContextRequest, - context: FormContext + context: FormContext, + translator?: Translator ): FormPageViewModel { const { collection, viewModel } = this const { query } = request const { payload, errors } = context - let { pageTitle, showTitle } = viewModel + const t = + translator?.t ?? + ((key: string, opts?: Record) => this.model.t(key, opts)) + const tContent = translator?.tContent + + let { showTitle } = viewModel + + // Resolve page title via tContent if available, otherwise fall back to viewModel.pageTitle + let pageTitle = tContent + ? tContent(this.pageDef, 'title') + : viewModel.pageTitle + + // Resolve section title via tContent if available, otherwise fall back to viewModel.sectionTitle + const sectionTitle = + this.section && tContent + ? this.section.hideTitle !== true + ? tContent(this.section, 'title') + : '' + : viewModel.sectionTitle const components = collection.getViewModel(payload, errors, query) const formComponents = components.filter( @@ -159,7 +181,7 @@ export class QuestionPageController extends PageController { if (pageTitle) { labelOrLegend.text = isOptional - ? `${pageTitle} ${this.model.t('common.optional')}` + ? `${pageTitle} ${t('common.optional')}` : pageTitle } @@ -193,8 +215,10 @@ export class QuestionPageController extends PageController { return { ...viewModel, - backLink: this.getBackLink(request, context), + backLink: this.getBackLink(request, context, t), context, + pageTitle, + sectionTitle, showTitle, components, errors, @@ -418,13 +442,18 @@ export class QuestionPageController extends PageController { const { collection, model, viewName } = this const { evaluationState } = context + const { getLanguage } = getPluginOptions(request.server) + const language = getLanguage?.(request) ?? 'en-GB' + const translator = this.model.createTranslator(language) + const { t } = translator + // Copy any URL params into the form state (if not already done so) if (await prefillStateFromQueryParameters(request, this)) { // Forward to same page without query string return h.redirect(`${request.url.origin}${request.url.pathname}`) } - const viewModel = this.getViewModel(request, context) + const viewModel = this.getViewModel(request, context, translator) viewModel.errors = collection.getViewErrors(viewModel.errors) const flashedError = request.yar.flash(COMPONENT_STATE_ERROR) @@ -454,8 +483,7 @@ export class QuestionPageController extends PageController { return h.view(viewName, { ...viewModel, - t: (key: string, opts?: Record) => - this.model.t(key, opts) + t }) } } @@ -487,7 +515,9 @@ export class QuestionPageController extends PageController { */ protected getBackLink( request: FormContextRequest, - context: FormContext + context: FormContext, + t: (key: string, opts?: Record) => string = (key, opts) => + this.model.t(key, opts) ): BackLink | undefined { const { pageDef } = this const { path, query } = request @@ -501,8 +531,8 @@ export class QuestionPageController extends PageController { return { text: hasRepeater(pageDef) && itemId - ? this.model.t('pages.question.backToAddAnother') - : this.model.t('pages.question.backToCheckAnswers'), + ? t('pages.question.backToAddAnother') + : t('pages.question.backToCheckAnswers'), href: returnUrl } } @@ -520,7 +550,7 @@ export class QuestionPageController extends PageController { // Default back link return { - text: this.model.t('common.back'), + text: t('common.back'), href: this.getHref(backPath) } } @@ -535,6 +565,11 @@ export class QuestionPageController extends PageController { const { isForceAccess, state, evaluationState } = context const action = request.payload.action + const { getLanguage } = getPluginOptions(request.server) + const language = getLanguage?.(request) ?? 'en-GB' + const translator = this.model.createTranslator(language) + const { t } = translator + if (action?.startsWith(FormAction.External)) { return await this.dispatchExternal(request, h, context) } @@ -544,7 +579,7 @@ export class QuestionPageController extends PageController { * @todo Refactor to match POST REDIRECT GET pattern */ if (context.errors || isForceAccess) { - const viewModel = this.getViewModel(request, context) + const viewModel = this.getViewModel(request, context, translator) viewModel.errors = collection.getViewErrors(viewModel.errors) // Filter our components based on their conditions using our evaluated state @@ -556,8 +591,7 @@ export class QuestionPageController extends PageController { return h.view(viewName, { ...viewModel, - t: (key: string, opts?: Record) => - this.model.t(key, opts) + t }) } diff --git a/src/server/plugins/engine/plugin.ts b/src/server/plugins/engine/plugin.ts index 2fec65366..707d2aaaf 100644 --- a/src/server/plugins/engine/plugin.ts +++ b/src/server/plugins/engine/plugin.ts @@ -35,6 +35,7 @@ export const plugin = { model, cache, saveAndExit, + getLanguage, nunjucks: nunjucksOptions, viewContext, preparePageEventRequestOptions, @@ -77,6 +78,7 @@ export const plugin = { server.expose('viewContext', viewContext) server.expose('cacheService', cacheService) server.expose('saveAndExit', saveAndExit) + server.expose('getLanguage', getLanguage) server.expose('baseUrl', baseUrl) server.expose('services', services) diff --git a/src/typings/hapi/index.d.ts b/src/typings/hapi/index.d.ts index fa7577016..ef7bb15aa 100644 --- a/src/typings/hapi/index.d.ts +++ b/src/typings/hapi/index.d.ts @@ -38,6 +38,7 @@ declare module '@hapi/hapi' { request: AnyFormRequest | null ) => Record | Promise> saveAndExit?: PluginOptions['saveAndExit'] + getLanguage?: PluginOptions['getLanguage'] baseUrl: string services: PluginOptions['services'] } From 0788997d52055bfea29a93b4b1a85741a9484183 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Sat, 25 Apr 2026 17:31:41 +0100 Subject: [PATCH 52/89] feat(i18n): RepeatPageController uses per-request translator instead of model.t Migrates all this.model.t(...) calls in RepeatPageController to use a per-request translator created via model.createTranslator(language). getViewModel and getListSummaryViewModel accept an optional Translator, route handlers (makeGetListSummaryRouteHandler, makePostListSummaryRouteHandler, makeGetItemDeleteRouteHandler, makePostItemDeleteRouteHandler) resolve the language via getPluginOptions and thread the translator through. Falls back to model.t when no translator is supplied for backward compatibility. --- .../RepeatPageController.test.ts | 93 ++++++++++++++++ .../pageControllers/RepeatPageController.ts | 100 ++++++++++++------ 2 files changed, 160 insertions(+), 33 deletions(-) diff --git a/src/server/plugins/engine/pageControllers/RepeatPageController.test.ts b/src/server/plugins/engine/pageControllers/RepeatPageController.test.ts index b66f91e5f..98135fadc 100644 --- a/src/server/plugins/engine/pageControllers/RepeatPageController.test.ts +++ b/src/server/plugins/engine/pageControllers/RepeatPageController.test.ts @@ -1,3 +1,4 @@ +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { RepeatPageController } from '~/src/server/plugins/engine/pageControllers/RepeatPageController.js' import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js' @@ -276,4 +277,96 @@ describe('RepeatPageController', () => { expect(controller.shouldShowSaveAndExit(serverWithSaveAndExit)).toBe(true) }) }) + + describe('getViewModel with Translator', () => { + it('passes translator through to super.getViewModel without throwing', () => { + const mockT = jest.fn((key: string) => `translated:${key}`) + const mockTContent = jest.fn((_entity: object, _prop: string) => '') + const translator: Translator = { + t: mockT, + tContent: mockTContent as Translator['tContent'] + } + + const context = model.getFormContext(requestPageItem, { + $$__referenceNumber: 'foobar' + }) + + const viewModel = controller.getViewModel( + requestPageItem, + context, + translator + ) + + // RepeatPageController.getViewModel extends super — result must still have sectionTitle + expect(viewModel).toHaveProperty('sectionTitle') + }) + + it('does not call model.t when translator is supplied', () => { + const spy = jest.spyOn(model, 't') + const mockT = jest.fn((key: string) => `translated:${key}`) + const mockTContent = jest.fn((_entity: object, _prop: string) => '') + const translator: Translator = { + t: mockT, + tContent: mockTContent as Translator['tContent'] + } + + const context = model.getFormContext(requestPageItem, { + $$__referenceNumber: 'foobar' + }) + + controller.getViewModel(requestPageItem, context, translator) + + // model.t should NOT have been called — translator.t was used instead + expect(spy).not.toHaveBeenCalled() + spy.mockRestore() + }) + }) + + describe('getListSummaryViewModel with Translator', () => { + const list: RepeatListState = [ + { itemId: 'abc-123', toppings: 'Ham', quantity: 2 } + ] + + it('calls the supplied translator t for plugin strings', () => { + const mockT = jest.fn((key: string) => `translated:${key}`) + const mockTContent = jest.fn((_entity: object, _prop: string) => '') + const translator: Translator = { + t: mockT, + tContent: mockTContent as Translator['tContent'] + } + + const context = model.getFormContext(requestPageSummary, { + $$__referenceNumber: 'foobar' + }) + + const viewModel = controller.getListSummaryViewModel( + requestPageSummary, + context, + list, + translator + ) + + expect(mockT).toHaveBeenCalledWith( + 'pages.repeater.pageTitle', + expect.anything() + ) + expect(viewModel.pageTitle).toBe('translated:pages.repeater.pageTitle') + }) + + it('falls back to model.t when no translator is supplied', () => { + const spy = jest.spyOn(model, 't') + + const context = model.getFormContext(requestPageSummary, { + $$__referenceNumber: 'foobar' + }) + + controller.getListSummaryViewModel(requestPageSummary, context, list) + + expect(spy).toHaveBeenCalledWith( + 'pages.repeater.pageTitle', + expect.anything() + ) + spy.mockRestore() + }) + }) }) diff --git a/src/server/plugins/engine/pageControllers/RepeatPageController.ts b/src/server/plugins/engine/pageControllers/RepeatPageController.ts index e50a565ad..c10bf0dff 100644 --- a/src/server/plugins/engine/pageControllers/RepeatPageController.ts +++ b/src/server/plugins/engine/pageControllers/RepeatPageController.ts @@ -5,7 +5,11 @@ import Boom from '@hapi/boom' import Joi from 'joi' import { isRepeatState } from '~/src/server/plugins/engine/components/FormComponent.js' -import { redirectPath } from '~/src/server/plugins/engine/helpers.js' +import { + getPluginOptions, + redirectPath +} from '~/src/server/plugins/engine/helpers.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' import { @@ -193,12 +197,21 @@ export class RepeatPageController extends QuestionPageController { return super.proceed(request, h, nextPath) } - const viewModel = this.getListSummaryViewModel(request, context, list) + const { getLanguage } = getPluginOptions(request.server) + const language = getLanguage?.(request) ?? 'en-GB' + const translator = this.model.createTranslator(language) + const { t } = translator + + const viewModel = this.getListSummaryViewModel( + request, + context, + list, + translator + ) return h.view(this.listSummaryViewName, { ...viewModel, - t: (key: string, opts?: Record) => - this.model.t(key, opts) + t }) } } @@ -224,6 +237,11 @@ export class RepeatPageController extends QuestionPageController { return super.proceed(request, h, nextPath) } + const { getLanguage } = getPluginOptions(request.server) + const language = getLanguage?.(request) ?? 'en-GB' + const translator = this.model.createTranslator(language) + const { t } = translator + const { action } = this.getFormParams(request) const hasErrorMin = @@ -243,17 +261,21 @@ export class RepeatPageController extends QuestionPageController { href: '', name: '', text: hasErrorMax - ? this.model.t('pages.repeater.tooMany', { count }) - : this.model.t('pages.repeater.tooFew', { count }) + ? t('pages.repeater.tooMany', { count }) + : t('pages.repeater.tooFew', { count }) } ] - const viewModel = this.getListSummaryViewModel(request, context, list) + const viewModel = this.getListSummaryViewModel( + request, + context, + list, + translator + ) return h.view(this.listSummaryViewName, { ...viewModel, - t: (key: string, opts?: Record) => - this.model.t(key, opts) + t }) } @@ -288,11 +310,16 @@ export class RepeatPageController extends QuestionPageController { const itemId = this.getItemId(request) const item = this.getItemFromList(list, itemId) + const { getLanguage } = getPluginOptions(request.server) + const language = getLanguage?.(request) ?? 'en-GB' + const translator = this.model.createTranslator(language) + const { t } = translator + if (!item || list.length === 1) { throw Boom.notFound( item - ? this.model.t('pages.repeater.lastItemCannotBeRemoved') - : this.model.t('pages.repeater.itemToRemoveNotFound') + ? t('pages.repeater.lastItemCannotBeRemoved') + : t('pages.repeater.itemToRemoveNotFound') ) } @@ -302,12 +329,11 @@ export class RepeatPageController extends QuestionPageController { ...viewModel, context, backLink: this.getBackLink(request, context), - pageTitle: this.model.t('pages.repeater.removeAnswer'), + pageTitle: t('pages.repeater.removeAnswer'), itemTitle: `${title} ${list.indexOf(item) + 1}`, - buttonConfirm: { text: this.model.t('pages.repeater.remove') }, - buttonCancel: { text: this.model.t('pages.repeater.cancel') }, - t: (key: string, opts?: Record) => - this.model.t(key, opts) + buttonConfirm: { text: t('pages.repeater.remove') }, + buttonCancel: { text: t('pages.repeater.cancel') }, + t } satisfies ItemDeletePageViewModel) } } @@ -327,11 +353,15 @@ export class RepeatPageController extends QuestionPageController { const itemId = this.getItemId(request) const item = this.getItemFromList(list, itemId) + const { getLanguage } = getPluginOptions(request.server) + const language = getLanguage?.(request) ?? 'en-GB' + const { t } = this.model.createTranslator(language) + if (!item || list.length === 1) { throw Boom.notFound( item - ? this.model.t('pages.repeater.lastItemCannotBeRemoved') - : this.model.t('pages.repeater.itemToRemoveNotFound') + ? t('pages.repeater.lastItemCannotBeRemoved') + : t('pages.repeater.itemToRemoveNotFound') ) } @@ -352,7 +382,8 @@ export class RepeatPageController extends QuestionPageController { getViewModel( request: FormContextRequest, - context: FormContext + context: FormContext, + translator?: Translator ): FormPageViewModel { const { state } = context @@ -360,7 +391,7 @@ export class RepeatPageController extends QuestionPageController { const itemId = this.getItemId(request) const item = this.getItemFromList(list, itemId) - const viewModel = super.getViewModel(request, context) + const viewModel = super.getViewModel(request, context, translator) const itemNumber = item ? list.indexOf(item) + 1 : list.length + 1 const repeatCaption = `${this.repeat.options.title} ${itemNumber}` @@ -376,12 +407,17 @@ export class RepeatPageController extends QuestionPageController { getListSummaryViewModel( request: FormContextRequest, context: FormContext, - list: RepeatListState + list: RepeatListState, + translator?: Translator ): RepeaterSummaryPageViewModel { const { collection, href, repeat } = this const { query } = request const { isForceAccess, errors } = context + const t = + translator?.t ?? + ((key: string, opts?: Record) => this.model.t(key, opts)) + const { title } = repeat.options const summaryList: SummaryList = { @@ -405,12 +441,11 @@ export class RepeatPageController extends QuestionPageController { href: redirectPath(`${href}/${item.itemId}`, { returnUrl: query.returnUrl ?? this.getHref(summaryPath) }), - text: this.model.t('pages.repeater.change'), + text: t('pages.repeater.change'), classes: 'govuk-link--no-visited-state', - visuallyHiddenText: this.model.t( - 'pages.repeater.visuallyHiddenItem', - { index: index + 1 } - ) + visuallyHiddenText: t('pages.repeater.visuallyHiddenItem', { + index: index + 1 + }) }) if (count > 1) { @@ -418,12 +453,11 @@ export class RepeatPageController extends QuestionPageController { href: redirectPath(`${href}/${item.itemId}/confirm-delete`, { returnUrl: query.returnUrl }), - text: this.model.t('pages.repeater.remove'), + text: t('pages.repeater.remove'), classes: 'govuk-link--no-visited-state', - visuallyHiddenText: this.model.t( - 'pages.repeater.visuallyHiddenItem', - { index: index + 1 } - ) + visuallyHiddenText: t('pages.repeater.visuallyHiddenItem', { + index: index + 1 + }) }) } } @@ -437,7 +471,7 @@ export class RepeatPageController extends QuestionPageController { text: `${title} ${index + 1}` }, value: { - text: itemDisplayText || this.model.t('pages.repeater.notProvided') + text: itemDisplayText || t('pages.repeater.notProvided') }, actions: { items @@ -450,7 +484,7 @@ export class RepeatPageController extends QuestionPageController { ...this.viewModel, backLink: this.getBackLink(request, context), repeatTitle: title, - pageTitle: this.model.t('pages.repeater.pageTitle', { count }), + pageTitle: t('pages.repeater.pageTitle', { count }), showTitle: true, context, errors, From 37e148c6dc668187ec891c03b737a9e80762c134 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 27 Apr 2026 11:33:10 +0100 Subject: [PATCH 53/89] feat(i18n): SummaryViewModel and SummaryPageController use per-request translator SummaryViewModel constructor accepts optional Translator; falls back to model.t for backward compat. SummaryPageController route handlers create per-request translator from getLanguage plugin option and thread it through getSummaryViewModel, buildPaymentDetails, getBackLink, and the handlePaymentSubmissionError flow. --- .../engine/models/SummaryViewModel.test.ts | 82 +++++++++++++++++++ .../plugins/engine/models/SummaryViewModel.ts | 14 +++- .../SummaryPageController.test.ts | 36 ++++++++ .../pageControllers/SummaryPageController.ts | 58 ++++++++----- 4 files changed, 165 insertions(+), 25 deletions(-) diff --git a/src/server/plugins/engine/models/SummaryViewModel.test.ts b/src/server/plugins/engine/models/SummaryViewModel.test.ts index 2fef463da..7e4eeaffa 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.test.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.test.ts @@ -5,6 +5,7 @@ import { } from '@defra/forms-model' import { FORM_PREFIX } from '~/src/server/constants.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { FormModel, SummaryViewModel @@ -340,6 +341,87 @@ describe('SummaryViewModel', () => { }) }) +describe('SummaryViewModel with per-request Translator', () => { + let model: FormModel + let page: PageControllerClass + let request: FormContextRequest + let context: FormContext + let mockTranslator: Translator + + beforeEach(() => { + model = new FormModel(definition, { + basePath: `${FORM_PREFIX}/test` + }) + + page = createPage(model, definition.pages[2]) + const pageUrl = new URL('http://example.com/repeat/pizza-order/summary') + + request = buildFormContextRequest({ + method: 'get', + url: pageUrl, + path: pageUrl.pathname, + params: { + path: 'pizza-order', + slug: 'repeat' + }, + query: {}, + app: { model } + }) + + mockTranslator = { + t: jest.fn().mockReturnValue('mocked'), + tContent: jest + .fn() + .mockReturnValue('mocked-content') as Translator['tContent'] + } + }) + + it('should use the provided translator t function for plugin keys like pages.summary.change', () => { + const state: FormState = { + $$__referenceNumber: 'foobar', + orderType: 'collection', + pizza: [] + } + + context = model.getFormContext(request, state) + expect( + new SummaryViewModel(request, page, context, mockTranslator) + ).toBeDefined() + + expect(mockTranslator.t).toHaveBeenCalledWith('pages.summary.change') + }) + + it('should use the provided translator t function for pages.summary.notProvided', () => { + const state: FormState = { + $$__referenceNumber: 'foobar', + orderType: 'collection', + pizza: [] + } + + context = model.getFormContext(request, state) + expect( + new SummaryViewModel(request, page, context, mockTranslator) + ).toBeDefined() + + expect(mockTranslator.t).toHaveBeenCalledWith('pages.summary.notProvided') + }) + + it('should fall back to model.t when no translator is provided', () => { + const tSpy = jest.spyOn(model, 't') + const state: FormState = { + $$__referenceNumber: 'foobar', + orderType: 'collection', + pizza: [] + } + + context = model.getFormContext(request, state) + expect(new SummaryViewModel(request, page, context)).toBeDefined() + + const calledKeys = tSpy.mock.calls.map(([key]) => key) + expect(calledKeys).toContain('pages.summary.change') + }) +}) + describe('SummaryPageController', () => { let model: FormModel let controller: SummaryPageController diff --git a/src/server/plugins/engine/models/SummaryViewModel.ts b/src/server/plugins/engine/models/SummaryViewModel.ts index 138a59589..46f8cdaa8 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.ts @@ -15,6 +15,7 @@ import { getError, getPageHref } from '~/src/server/plugins/engine/helpers.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type Detail, type DetailItem, @@ -61,16 +62,21 @@ export class SummaryViewModel { constructor( request: FormContextRequest, page: PageControllerClass, - context: FormContext + context: FormContext, + translator?: Translator ) { const { model } = page const { basePath, def, sections } = model const { isForceAccess } = context + const t = + translator?.t ?? + ((key: string, opts?: Record) => model.t(key, opts)) + this.page = page this.pageTitle = page.title if (def.schema === SchemaVersion.V2 && !page.title) { - this.pageTitle = model.t('pages.summary.title') + this.pageTitle = t('pages.summary.title') } this.serviceUrl = `/${basePath}` @@ -97,7 +103,7 @@ export class SummaryViewModel { if (!isForceAccess) { items.push({ href: item.href, - text: model.t('pages.summary.change'), + text: t('pages.summary.change'), classes: 'govuk-link--no-visited-state', visuallyHiddenText: item.label }) @@ -109,7 +115,7 @@ export class SummaryViewModel { }, value: { classes: 'app-prose-scope', - html: item.value || model.t('pages.summary.notProvided') + html: item.value || t('pages.summary.notProvided') }, actions: { items diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts index d9f05baea..0aa0f08e0 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts @@ -1,3 +1,4 @@ +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { SummaryPageController } from '~/src/server/plugins/engine/pageControllers/SummaryPageController.js' import { buildFormRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js' @@ -86,4 +87,39 @@ describe('SummaryPageController', () => { // Note: InvalidComponentStateError handling is comprehensively tested // in the integration test: test/form/component-state-errors.test.js + + describe('per-request translator in makeGetRouteHandler', () => { + it('should create a per-request translator and pass t to the view', async () => { + const mockTranslator: Translator = { + t: jest.fn().mockReturnValue('translated'), + tContent: jest.fn().mockReturnValue('content') as Translator['tContent'] + } + + const createTranslatorSpy = jest + .spyOn(model, 'createTranslator') + .mockReturnValue(mockTranslator) + + // Mock model.services.formsService so hasMissingNotificationEmail does not throw + jest + .spyOn(model.services.formsService, 'getFormMetadata') + .mockResolvedValue({ notificationEmail: 'test@test.com' } as never) + + const state: FormSubmissionState = { + $$__referenceNumber: 'foobar', + licenceLength: 365, + fullName: 'John Smith' + } + + const context = model.getFormContext(requestPage, state) + + const getHandler = controller.makeGetRouteHandler() + await getHandler(requestPage, context, h) + + expect(createTranslatorSpy).toHaveBeenCalledWith('en-GB') + expect(h.view).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ t: mockTranslator.t }) + ) + }) + }) }) diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index 9e26834ce..3b9b7821c 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -17,8 +17,10 @@ import { checkEmailAddressForLiveFormSubmission, checkFormStatus, createError, - getCacheService + getCacheService, + getPluginOptions } from '~/src/server/plugins/engine/helpers.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { SummaryViewModel, type FormModel @@ -78,9 +80,14 @@ export class SummaryPageController extends QuestionPageController { getSummaryViewModel( request: FormContextRequest, - context: FormContext + context: FormContext, + translator?: Translator ): SummaryViewModel { - const viewModel = new SummaryViewModel(request, this, context) + const t = + translator?.t ?? + ((key: string, opts?: Record) => this.model.t(key, opts)) + + const viewModel = new SummaryViewModel(request, this, context, translator) const { query } = request const { payload, errors, state } = context @@ -95,14 +102,15 @@ export class SummaryPageController extends QuestionPageController { viewModel.paymentState = paymentState viewModel.paymentDetails = this.buildPaymentDetails( paymentField, - paymentState + paymentState, + t ) } } const components = this.collection.getViewModel(payload, errors, query) - viewModel.backLink = this.getBackLink(request, context) + viewModel.backLink = this.getBackLink(request, context, t) viewModel.feedbackLink = this.feedbackLink viewModel.phaseTag = this.phaseTag viewModel.components = components @@ -116,32 +124,34 @@ export class SummaryPageController extends QuestionPageController { paymentField: PaymentField, paymentState: NonNullable< ReturnType - > + >, + t: (key: string, opts?: Record) => string = (key, opts) => + this.model.t(key, opts) ) { const rows = [ { - key: { text: this.model.t('pages.summary.paymentFor') }, + key: { text: t('pages.summary.paymentFor') }, value: { text: paymentState.description } }, { - key: { text: this.model.t('pages.summary.totalAmount') }, + key: { text: t('pages.summary.totalAmount') }, value: { text: formatCurrency(paymentState.amount) } }, { - key: { text: this.model.t('pages.summary.reference') }, + key: { text: t('pages.summary.reference') }, value: { text: paymentState.reference } } ] if (paymentState.preAuth?.createdAt) { rows.push({ - key: { text: this.model.t('pages.summary.dateOfPayment') }, + key: { text: t('pages.summary.dateOfPayment') }, value: { text: formatPaymentDate(paymentState.preAuth.createdAt) } }) } return { - title: { text: this.model.t('pages.summary.paymentDetailsTitle') }, + title: { text: t('pages.summary.paymentDetailsTitle') }, summaryList: { rows } } } @@ -157,14 +167,17 @@ export class SummaryPageController extends QuestionPageController { ) => { const { viewName } = this - const viewModel = this.getSummaryViewModel(request, context) + const { getLanguage } = getPluginOptions(request.server) + const language = getLanguage?.(request) ?? 'en-GB' + const translator = this.model.createTranslator(language) + const { t } = translator + + const viewModel = this.getSummaryViewModel(request, context, translator) viewModel.hasMissingNotificationEmail = await this.hasMissingNotificationEmail(request, context) - viewModel.t = (key: string, opts?: Record) => - this.model.t(key, opts) - + viewModel.t = t return h.view(viewName, viewModel) } } @@ -314,16 +327,19 @@ export class SummaryPageController extends QuestionPageController { request: FormRequestPayload, h: FormResponseToolkit ) { + const { getLanguage } = getPluginOptions(request.server) + const language = getLanguage?.(request) ?? 'en-GB' + const { t } = this.model.createTranslator(language) + const helpUrl = error.helpLink ?? DEFAULT_PAYMENT_HELP_URL - const contactUsLink = `${this.model.t('pages.summary.contactUsLinkText')}` - const helpLinkHtml = this.model.t( - 'pages.summary.submissionFailedContactSuffix', - { contactUsLink } - ) + const contactUsLink = `${t('pages.summary.contactUsLinkText')}` + const helpLinkHtml = t('pages.summary.submissionFailedContactSuffix', { + contactUsLink + }) const govukError = createError( 'submission', - `${this.model.t('pages.summary.submissionFailed')}${helpLinkHtml}.` + `${t('pages.summary.submissionFailed')}${helpLinkHtml}.` ) request.yar.flash(COMPONENT_STATE_ERROR, govukError, true) From f95eb6dad3cecdbeb307d052eb4c3b1859a58cd9 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 27 Apr 2026 11:33:59 +0100 Subject: [PATCH 54/89] feat(i18n): wire per-request Translator through DeclarationField, FileUploadField, UkAddressField, StatusPageController Each component's getViewModel/getViewErrors now accepts Translator|FormQuery and uses translator?.t with model.t fallback. StatusPageController makeGetRouteHandler creates a per-request translator instead of binding model.t at call site. --- .../engine/components/DeclarationField.ts | 22 +++++++++++++--- .../engine/components/FileUploadField.ts | 11 ++++++-- .../engine/components/UkAddressField.ts | 25 +++++++++++++++---- .../pageControllers/StatusPageController.ts | 13 +++++++--- 4 files changed, 57 insertions(+), 14 deletions(-) diff --git a/src/server/plugins/engine/components/DeclarationField.ts b/src/server/plugins/engine/components/DeclarationField.ts index 4278c27f1..46645db8e 100644 --- a/src/server/plugins/engine/components/DeclarationField.ts +++ b/src/server/plugins/engine/components/DeclarationField.ts @@ -13,8 +13,10 @@ import joi, { import { FormComponent, - isFormValue + isFormValue, + isTranslator } from '~/src/server/plugins/engine/components/FormComponent.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -25,6 +27,7 @@ import { type FormSubmissionState, type FormValue } from '~/src/server/plugins/engine/types.js' +import { type FormQuery } from '~/src/server/routes/types.js' export class DeclarationField extends FormComponent { declare options: DeclarationFieldComponent['options'] @@ -121,16 +124,27 @@ export class DeclarationField extends FormComponent { : this.model.t('components.declarationField.notProvided') } - getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { + getViewModel( + payload: FormPayload, + errors?: FormSubmissionError[], + translatorOrQuery?: Translator | FormQuery + ) { + const translator = isTranslator(translatorOrQuery) + ? translatorOrQuery + : undefined + const t = + translator?.t ?? + ((key: string, opts?: Record) => this.model.t(key, opts)) + const { hint, content, - declarationConfirmationLabel = this.model.t( + declarationConfirmationLabel = t( 'components.declarationField.defaultConfirmationLabel' ) } = this - const viewModel = super.getViewModel(payload, errors) + const viewModel = super.getViewModel(payload, errors, translator) let { fieldset, label } = viewModel fieldset ??= { diff --git a/src/server/plugins/engine/components/FileUploadField.ts b/src/server/plugins/engine/components/FileUploadField.ts index fc04b13b5..8fe66e2ae 100644 --- a/src/server/plugins/engine/components/FileUploadField.ts +++ b/src/server/plugins/engine/components/FileUploadField.ts @@ -204,7 +204,14 @@ export class FileUploadField extends FormComponent { : {} const isForceAccess = 'force' in query - const viewModel = super.getViewModel(payload, errors) + const translator = isTranslator(translatorOrQuery) + ? translatorOrQuery + : undefined + const t = + translator?.t ?? + ((key: string, opts?: Record) => this.model.t(key, opts)) + + const viewModel = super.getViewModel(payload, errors, translator) const { attributes, id, value } = viewModel const files = this.getFormValue(value) ?? [] @@ -220,7 +227,7 @@ export class FileUploadField extends FormComponent { const tag = { classes: 'govuk-tag--green', - text: this.model.t('components.fileUploadField.uploaded') + text: t('components.fileUploadField.uploaded') } const valueHtml = render diff --git a/src/server/plugins/engine/components/UkAddressField.ts b/src/server/plugins/engine/components/UkAddressField.ts index d2c09ee80..3addd0d48 100644 --- a/src/server/plugins/engine/components/UkAddressField.ts +++ b/src/server/plugins/engine/components/UkAddressField.ts @@ -12,6 +12,7 @@ import { isFormState } from '~/src/server/plugins/engine/components/FormComponent.js' import { TextField } from '~/src/server/plugins/engine/components/TextField.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' import { type FormRequestPayload, @@ -27,6 +28,7 @@ import { type PostcodeLookupExternalArgs } from '~/src/server/plugins/engine/types.js' import { dispatch } from '~/src/server/plugins/postcode-lookup/routes/index.js' +import { type FormQuery } from '~/src/server/routes/types.js' export class UkAddressField extends FormComponent { declare options: UkAddressFieldComponent['options'] @@ -163,8 +165,13 @@ export class UkAddressField extends FormComponent { * Returns one error per child field */ getViewErrors( - errors?: FormSubmissionError[] + errors?: FormSubmissionError[], + translator?: Translator ): FormSubmissionError[] | undefined { + const t = + translator?.t ?? + ((key: string, opts?: Record) => this.model.t(key, opts)) + const uniqueErrors = this.getErrors(errors)?.filter( (error, index, self) => index === self.findIndex((err) => err.name === error.name) @@ -180,7 +187,7 @@ export class UkAddressField extends FormComponent { name, path: [name], href: `#${name}`, - text: this.model.t('components.addressField.enterAddress', { + text: t('components.addressField.enterAddress', { shortDescription: lowerFirst(shortDescription) }) } @@ -190,10 +197,14 @@ export class UkAddressField extends FormComponent { return uniqueErrors } - getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { + getViewModel( + payload: FormPayload, + errors?: FormSubmissionError[], + translatorOrQuery?: Translator | FormQuery + ) { const { collection, name, options } = this - const viewModel = super.getViewModel(payload, errors) + const viewModel = super.getViewModel(payload, errors, translatorOrQuery) let { fieldset, hint, label } = viewModel fieldset ??= { @@ -217,7 +228,11 @@ export class UkAddressField extends FormComponent { } } - const components = collection.getViewModel(payload, errors) + const components = collection.getViewModel( + payload, + errors, + translatorOrQuery + ) // Hide UPRN const uprn = components.at(0) diff --git a/src/server/plugins/engine/pageControllers/StatusPageController.ts b/src/server/plugins/engine/pageControllers/StatusPageController.ts index 7a151d21f..9407a8c4b 100644 --- a/src/server/plugins/engine/pageControllers/StatusPageController.ts +++ b/src/server/plugins/engine/pageControllers/StatusPageController.ts @@ -1,6 +1,9 @@ import { type PageStatus } from '@defra/forms-model' -import { getCacheService } from '~/src/server/plugins/engine/helpers.js' +import { + getCacheService, + getPluginOptions +} from '~/src/server/plugins/engine/helpers.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' import { type FormContext } from '~/src/server/plugins/engine/types.js' @@ -32,6 +35,11 @@ export class StatusPageController extends QuestionPageController { ) => { const { viewModel, viewName } = this + const { getLanguage } = getPluginOptions(request.server) + const language = getLanguage?.(request) ?? 'en-GB' + const translator = this.model.createTranslator(language) + const { t } = translator + const cacheService = getCacheService(request.server) const confirmationState = await cacheService.getConfirmationState(request) @@ -59,8 +67,7 @@ export class StatusPageController extends QuestionPageController { formName, showReferenceNumber: this.showReferenceNumber, referenceNumber: confirmationState.referenceNumber, - t: (key: string, opts?: Record) => - this.model.t(key, opts) + t }) } } From a341d1f91446c0e2d2233662ac30bd442b2d9fac Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 27 Apr 2026 11:37:23 +0100 Subject: [PATCH 55/89] feat(i18n): PaymentField.dispatcher and onSubmit use per-request translator; checkPaymentAmount accepts t PaymentField.dispatcher and onSubmit create a per-request translator from getLanguage plugin option and use t() for all error messages. PaymentSubmissionError.checkPaymentAmount accepts an optional t function and falls back to component.model.t when not provided. --- .../engine/components/PaymentField.test.ts | 43 +++++++++++++------ .../plugins/engine/components/PaymentField.ts | 20 ++++++--- .../plugins/engine/pageControllers/errors.ts | 8 +++- 3 files changed, 51 insertions(+), 20 deletions(-) diff --git a/src/server/plugins/engine/components/PaymentField.test.ts b/src/server/plugins/engine/components/PaymentField.test.ts index 8080787ab..2d9b7f5a0 100644 --- a/src/server/plugins/engine/components/PaymentField.test.ts +++ b/src/server/plugins/engine/components/PaymentField.test.ts @@ -261,7 +261,8 @@ describe('PaymentField', () => { const paymentField = collection.fields[0] as PaymentField paymentField.model = { services: mockServices, - t: (key: string, opts?: Record) => model.t(key, opts) + t: (key: string, opts?: Record) => model.t(key, opts), + createTranslator: (lang: string) => model.createTranslator(lang) } as unknown as FormModel describe('dispatcher', () => { @@ -291,7 +292,8 @@ describe('PaymentField', () => { formId: 'formid', basePath: 'base-path', name: 'PaymentModel', - services: mockServices + services: mockServices, + createTranslator: (lang: string) => model.createTranslator(lang) }, getState: jest .fn() @@ -357,7 +359,8 @@ describe('PaymentField', () => { formId: 'formid', basePath: 'base-path', name: 'PaymentModel', - services: mockServices + services: mockServices, + createTranslator: (lang: string) => model.createTranslator(lang) }, getState: jest.fn().mockResolvedValueOnce({ $$__referenceNumber: 'pay-ref-123', @@ -421,7 +424,8 @@ describe('PaymentField', () => { name: 'PaymentModel', services: mockServices, t: (key: string, opts?: Record) => - model.t(key, opts) + model.t(key, opts), + createTranslator: (lang: string) => model.createTranslator(lang) }, getState: jest .fn() @@ -484,7 +488,8 @@ describe('PaymentField', () => { name: 'PaymentModel', services: mockServices, t: (key: string, opts?: Record) => - model.t(key, opts) + model.t(key, opts), + createTranslator: (lang: string) => model.createTranslator(lang) }, getState: jest .fn() @@ -517,7 +522,9 @@ describe('PaymentField', () => { describe('onSubmit', () => { it('should throw if missing state', async () => { - const mockRequest = {} as unknown as FormRequestPayload + const mockRequest = { + server: { plugins: { 'forms-engine-plugin': {} } } + } as unknown as FormRequestPayload const error = await paymentField .onSubmit( @@ -535,7 +542,9 @@ describe('PaymentField', () => { }) it('should ignore if our state says payment already captured', async () => { - const mockRequest = {} as unknown as FormRequestPayload + const mockRequest = { + server: { plugins: { 'forms-engine-plugin': {} } } + } as unknown as FormRequestPayload await paymentField.onSubmit( mockRequest, @@ -558,7 +567,9 @@ describe('PaymentField', () => { }) it('should mark payment already captured according to gov pay', async () => { - const mockRequest = {} as unknown as FormRequestPayload + const mockRequest = { + server: { plugins: { 'forms-engine-plugin': {} } } + } as unknown as FormRequestPayload jest .mocked(get) // @ts-expect-error - partial mock @@ -585,7 +596,9 @@ describe('PaymentField', () => { }) it('should throw if bad status', async () => { - const mockRequest = {} as unknown as FormRequestPayload + const mockRequest = { + server: { plugins: { 'forms-engine-plugin': {} } } + } as unknown as FormRequestPayload jest .mocked(get) // @ts-expect-error - partial mock @@ -618,7 +631,9 @@ describe('PaymentField', () => { }) it('should throw if error during capture', async () => { - const mockRequest = {} as unknown as FormRequestPayload + const mockRequest = { + server: { plugins: { 'forms-engine-plugin': {} } } + } as unknown as FormRequestPayload jest .mocked(get) // @ts-expect-error - partial mock @@ -653,7 +668,9 @@ describe('PaymentField', () => { }) it('should throw if amount mismatch', async () => { - const mockRequest = {} as unknown as FormRequestPayload + const mockRequest = { + server: { plugins: { 'forms-engine-plugin': {} } } + } as unknown as FormRequestPayload jest .mocked(get) // @ts-expect-error - partial mock @@ -688,7 +705,9 @@ describe('PaymentField', () => { }) it('should capture payment if no errors', async () => { - const mockRequest = {} as unknown as FormRequestPayload + const mockRequest = { + server: { plugins: { 'forms-engine-plugin': {} } } + } as unknown as FormRequestPayload jest .mocked(get) // @ts-expect-error - partial mock diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts index c0b2e5396..e9a2aea21 100644 --- a/src/server/plugins/engine/components/PaymentField.ts +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -194,6 +194,9 @@ export class PaymentField extends FormComponent { ): Promise { const { options, name: componentName } = args.component const { model } = args.controller + const { getLanguage } = getPluginOptions(request.server) + const language = getLanguage?.(request) ?? 'en-GB' + const { t } = model.createTranslator(language) const state = await args.controller.getState(request) const { baseUrl } = getPluginOptions(request.server) @@ -240,8 +243,8 @@ export class PaymentField extends FormComponent { if (!payment) { const message = isLivePayment - ? model.t('components.paymentField.cannotTakePayment') - : model.t('components.paymentField.testApiKey') + ? t('components.paymentField.cannotTakePayment') + : t('components.paymentField.testApiKey') const govukError = createError(componentName, message) request.yar.flash(COMPONENT_STATE_ERROR, govukError, true) return h.redirect(request.url.href).code(StatusCodes.SEE_OTHER) @@ -274,12 +277,16 @@ export class PaymentField extends FormComponent { _metadata: FormMetadata, context: FormContext ): Promise { + const { getLanguage } = getPluginOptions(request.server) + const language = getLanguage?.(request) ?? 'en-GB' + const { t } = this.model.createTranslator(language) + const paymentState = this.getPaymentStateFromState(context.state) if (!paymentState) { throw new PaymentPreAuthError( this, - this.model.t('components.paymentField.completePayment'), + t('components.paymentField.completePayment'), true, PaymentErrorTypes.PaymentIncomplete ) @@ -308,7 +315,8 @@ export class PaymentField extends FormComponent { PaymentSubmissionError.checkPaymentAmount( status.amount, this.options.amount, - this + this, + t ) if (status.state.status === 'success') { @@ -319,7 +327,7 @@ export class PaymentField extends FormComponent { if (status.state.status !== 'capturable') { throw new PaymentPreAuthError( this, - this.model.t('components.paymentField.paymentExpired'), + t('components.paymentField.paymentExpired'), true, PaymentErrorTypes.PaymentExpired ) @@ -333,7 +341,7 @@ export class PaymentField extends FormComponent { if (!captured) { throw new PaymentPreAuthError( this, - this.model.t('components.paymentField.submissionFailed'), + t('components.paymentField.submissionFailed'), false ) } diff --git a/src/server/plugins/engine/pageControllers/errors.ts b/src/server/plugins/engine/pageControllers/errors.ts index c8c769985..2140a9c19 100644 --- a/src/server/plugins/engine/pageControllers/errors.ts +++ b/src/server/plugins/engine/pageControllers/errors.ts @@ -65,12 +65,16 @@ export class PaymentSubmissionError extends Error { static checkPaymentAmount( stateAmount: number, definitionAmount: number | undefined, - component: FormComponent + component: FormComponent, + t?: (key: string) => string ) { if (stateAmount / 100 !== definitionAmount) { + const message = + t?.('components.paymentField.paymentAmountMismatch') ?? + component.model.t('components.paymentField.paymentAmountMismatch') throw new PaymentPreAuthError( component, - component.model.t('components.paymentField.paymentAmountMismatch'), + message, true, PaymentErrorTypes.PaymentIncomplete ) From 2ef58a29f07697b144a23a3abbc9f85c85a44fc7 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 27 Apr 2026 11:40:49 +0100 Subject: [PATCH 56/89] feat(i18n): thread Translator through getFormContext, validateFormPayload, fieldStateIsInvalid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FormModel.getFormContext accepts optional Translator; passes it to validateFormPayload (so ComponentCollection.validate gets per-request messages) and to pageStateIsInvalid → fieldStateIsInvalid (so optionsMismatch errors use translator?.t). routes/index.ts creates per-request translator before calling getFormContext. --- src/server/plugins/engine/models/FormModel.ts | 33 ++++++++++++------- .../plugins/engine/routes/index.test.ts | 11 +++++-- src/server/plugins/engine/routes/index.ts | 11 ++++++- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index 7c00a222f..cbdff3e3e 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -419,7 +419,8 @@ export class FormModel { getFormContext( request: FormContextRequest, state: FormSubmissionState, - errors?: FormSubmissionError[] + errors?: FormSubmissionError[], + translator?: Translator ): FormContext { const { query } = request @@ -452,7 +453,7 @@ export class FormModel { } // Validate current page - context = validateFormPayload(request, page, context) + context = validateFormPayload(request, page, context, translator) // Find start page let nextPage = findPage(this, startPath) @@ -470,7 +471,7 @@ export class FormModel { // Stop at current page if ( - this.pageStateIsInvalid(context, nextPage) || + this.pageStateIsInvalid(context, nextPage, translator) || nextPage.path === currentPath ) { break @@ -531,7 +532,11 @@ export class FormModel { } } - private pageStateIsInvalid(context: FormContext, page: PageControllerClass) { + private pageStateIsInvalid( + context: FormContext, + page: PageControllerClass, + translator?: Translator + ) { // Get any list-bound fields on the page const listFields = page.collection.fields.filter(hasListFormField) @@ -547,7 +552,7 @@ export class FormModel { list.items.filter((item) => item.condition).length > 0 if (hasOptionalItems) { - return this.fieldStateIsInvalid(context, field, list) + return this.fieldStateIsInvalid(context, field, list, translator) } } } @@ -556,7 +561,8 @@ export class FormModel { private fieldStateIsInvalid( context: FormContext, field: ListFormComponent, - list: List + list: List, + translator?: Translator ) { const { evaluationState, state } = context @@ -586,7 +592,9 @@ export class FormModel { if (isInvalid) { context.errors ??= [] - const text = this.t('errors.optionsMismatch') + const text = + translator?.t('errors.optionsMismatch') ?? + this.t('errors.optionsMismatch') context.errors.push({ text, @@ -641,7 +649,8 @@ export class FormModel { function validateFormPayload( request: FormContextRequest, page: PageControllerClass, - context: FormContext + context: FormContext, + translator?: Translator ): FormContext { const { collection } = page const { payload, state } = context @@ -671,10 +680,10 @@ function validateFormPayload( } }) - const { value, errors } = collection.validate({ - ...payload, - ...update - }) + const { value, errors } = collection.validate( + { ...payload, ...update }, + translator + ) // Add sanitised payload (ready to save) const formState = page.getStateFromValidForm(request, state, value) diff --git a/src/server/plugins/engine/routes/index.test.ts b/src/server/plugins/engine/routes/index.test.ts index fedbfeb83..3628eed91 100644 --- a/src/server/plugins/engine/routes/index.test.ts +++ b/src/server/plugins/engine/routes/index.test.ts @@ -6,6 +6,7 @@ import { findPage, getCacheService, getPage, + getPluginOptions, proceed } from '~/src/server/plugins/engine/helpers.js' import { type FormModel } from '~/src/server/plugins/engine/models/FormModel.js' @@ -36,15 +37,18 @@ function buildMockModel( isForceAccess: false, data: {} }), + createTranslator: jest + .fn() + .mockReturnValue({ t: jest.fn(), tContent: jest.fn() }), pages: pagesControllerOverride, services: servicesOverride } as unknown as FormModel } describe('redirectOrMakeHandler', () => { - const mockServer = {} as unknown as Parameters< - typeof redirectOrMakeHandler - >[0]['server'] + const mockServer = { + plugins: { 'forms-engine-plugin': {} } + } as unknown as Parameters[0]['server'] const mockRequest: AnyFormRequest = { server: mockServer, app: {}, @@ -94,6 +98,7 @@ describe('redirectOrMakeHandler', () => { ;(getCacheService as jest.Mock).mockReturnValue({ getFlash: jest.fn().mockReturnValue({ errors: [] }) }) + ;(getPluginOptions as jest.Mock).mockReturnValue({}) ;(getPage as jest.Mock).mockReturnValue(mockPage) ;(findPage as jest.Mock).mockReturnValue({ next: [] }) ;(proceed as jest.Mock).mockReturnValue({ statusCode: 302 }) diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index 56f908468..71528db99 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -19,6 +19,7 @@ import { findPage, getCacheService, getPage, + getPluginOptions, getStartPath, proceed } from '~/src/server/plugins/engine/helpers.js' @@ -81,7 +82,15 @@ export async function redirectOrMakeHandler( state = await importExternalComponentState(request, page, state) const flash = cacheService.getFlash(request) - const context = model.getFormContext(request, state, flash?.errors) + const { getLanguage } = getPluginOptions(request.server) + const language = getLanguage?.(request) ?? 'en-GB' + const translator = model.createTranslator(language) + const context = model.getFormContext( + request, + state, + flash?.errors, + translator + ) await copyNotYetValidatedState(request, context) From d891f7d0bd794af2a0227470f52362f37f581a73 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 27 Apr 2026 11:41:46 +0100 Subject: [PATCH 57/89] feat(i18n): replace model.validationMessages with static messageTemplate in DeclarationField and LocationFieldBase Constructor-time validation messages now use the module-level English messageTemplate constant instead of model.validationMessages, removing the per-model dependency on runtime validation messages at schema construction time. --- .../plugins/engine/components/DeclarationField.ts | 10 ++++------ .../plugins/engine/components/LocationFieldBase.ts | 7 +++---- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/server/plugins/engine/components/DeclarationField.ts b/src/server/plugins/engine/components/DeclarationField.ts index 46645db8e..5f2037ea9 100644 --- a/src/server/plugins/engine/components/DeclarationField.ts +++ b/src/server/plugins/engine/components/DeclarationField.ts @@ -59,12 +59,10 @@ export class DeclarationField extends FormComponent { .label(this.label) .single() .messages({ - 'any.required': props.model.validationMessages - .declarationRequired as string, - 'any.unknown': props.model.validationMessages - .declarationRequired as string, - 'array.includesRequiredUnknowns': props.model.validationMessages - .declarationRequired as string + 'any.required': messageTemplate.declarationRequired as string, + 'any.unknown': messageTemplate.declarationRequired as string, + 'array.includesRequiredUnknowns': + messageTemplate.declarationRequired as string }) as ArraySchema this.formSchema = formSchema diff --git a/src/server/plugins/engine/components/LocationFieldBase.ts b/src/server/plugins/engine/components/LocationFieldBase.ts index bdcc2d3a2..339743f01 100644 --- a/src/server/plugins/engine/components/LocationFieldBase.ts +++ b/src/server/plugins/engine/components/LocationFieldBase.ts @@ -10,6 +10,7 @@ import { isFormValue } from '~/src/server/plugins/engine/components/FormComponent.js' import { addClassOptionIfNone } from '~/src/server/plugins/engine/components/helpers/index.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, type FormPayload, @@ -62,8 +63,7 @@ export abstract class LocationFieldBase extends FormComponent { addClassOptionIfNone(locationOptions, 'govuk-input--width-10') const config = this.getValidationConfig() - const requiredMessage = - config.requiredMessage ?? this.model.validationMessages.required + const requiredMessage = config.requiredMessage ?? messageTemplate.required const messages = convertToLanguageMessages({ 'any.required': requiredMessage, @@ -139,8 +139,7 @@ export abstract class LocationFieldBase extends FormComponent { baseErrors: [ { type: 'required', - template: - config.requiredMessage ?? this.model.validationMessages.required + template: config.requiredMessage ?? messageTemplate.required }, ...this.getErrorTemplates() ], From 9e62b11bb47d61721b0c739b412da49d7ab0e9e4 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 27 Apr 2026 22:20:15 +0100 Subject: [PATCH 58/89] feat(i18n): remove model.t/language/validationMessages; require Translator throughout - FormModel.t(), FormModel.language, FormModel.validationMessages removed - All remaining model.t() calls replaced with tPlugin('en-GB') or translator.t - Translator required (not optional) in getViewModel, getSummaryViewModel, getBackLink, getListSummaryViewModel, SummaryViewModel constructor - Union-type params (Translator|FormQuery) use clean ternary with tPlugin fallback - stubTranslator fixture added for tests where translation content is not the focus - Tests updated to pass stubTranslator; backward-compat fallback tests removed - DatePartsField, MonthYearField, EastingNorthingField, LatLongField, NationalGridFieldNumberField, OsGridRefField, DeclarationField, GeospatialField use messageTemplate/tPlugin instead of model.validationMessages/model.t --- .../engine/components/DatePartsField.ts | 15 +++-- .../engine/components/DeclarationField.ts | 18 +++--- .../engine/components/EastingNorthingField.ts | 39 +++++++----- .../engine/components/FileUploadField.ts | 21 +++---- .../engine/components/FormComponent.ts | 13 ++-- .../engine/components/GeospatialField.ts | 3 +- .../plugins/engine/components/LatLongField.ts | 28 ++++++--- .../engine/components/MonthYearField.ts | 15 +++-- .../NationalGridFieldNumberField.ts | 6 +- .../engine/components/OsGridRefField.ts | 6 +- .../engine/components/PaymentField.test.ts | 5 -- .../engine/components/UkAddressField.ts | 8 ++- .../plugins/engine/models/FormModel.test.ts | 33 ++++------ src/server/plugins/engine/models/FormModel.ts | 19 +----- .../engine/models/SummaryViewModel.test.ts | 63 ++++++++++++++----- .../plugins/engine/models/SummaryViewModel.ts | 9 ++- .../outputFormatters/human/v1.payment.test.ts | 4 +- .../engine/outputFormatters/human/v1.test.ts | 7 ++- .../machine/v2.payment.test.ts | 7 ++- .../outputFormatters/machine/v2.test.ts | 7 ++- .../FileUploadPageController.test.ts | 4 +- .../FileUploadPageController.ts | 15 +++-- .../QuestionPageController.test.ts | 44 ++++++++----- .../pageControllers/QuestionPageController.ts | 32 ++++------ .../RepeatPageController.test.ts | 44 +++++++------ .../pageControllers/RepeatPageController.ts | 12 ++-- .../pageControllers/StartPageController.ts | 9 ++- .../pageControllers/SummaryPageController.ts | 17 +++-- .../pageControllers/__stubs__/translator.ts | 17 +++++ .../plugins/engine/pageControllers/errors.ts | 6 +- .../plugins/engine/routes/questions.test.ts | 63 +++++++++++++------ src/server/plugins/engine/routes/questions.ts | 3 +- 32 files changed, 352 insertions(+), 240 deletions(-) create mode 100644 src/server/plugins/engine/pageControllers/__stubs__/translator.ts diff --git a/src/server/plugins/engine/components/DatePartsField.ts b/src/server/plugins/engine/components/DatePartsField.ts index e8b7bf2c3..4a7acc637 100644 --- a/src/server/plugins/engine/components/DatePartsField.ts +++ b/src/server/plugins/engine/components/DatePartsField.ts @@ -41,15 +41,14 @@ export class DatePartsField extends FormComponent { const isRequired = options.required !== false - const { validationMessages } = props.model const customValidationMessages = convertToLanguageMessages({ - 'any.required': validationMessages.objectMissing, - 'number.base': validationMessages.objectMissing, - 'number.precision': validationMessages.dateFormat, - 'number.integer': validationMessages.dateFormat, - 'number.unsafe': validationMessages.dateFormat, - 'number.min': validationMessages.dateFormat, - 'number.max': validationMessages.dateFormat + 'any.required': messageTemplate.objectMissing, + '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( diff --git a/src/server/plugins/engine/components/DeclarationField.ts b/src/server/plugins/engine/components/DeclarationField.ts index 5f2037ea9..baecaee0e 100644 --- a/src/server/plugins/engine/components/DeclarationField.ts +++ b/src/server/plugins/engine/components/DeclarationField.ts @@ -16,6 +16,7 @@ import { isFormValue, isTranslator } from '~/src/server/plugins/engine/components/FormComponent.js' +import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { @@ -72,7 +73,7 @@ export class DeclarationField extends FormComponent { this.content = content this.declarationConfirmationLabel = options.declarationConfirmationLabel ?? - props.model.t('components.declarationField.defaultLabel') + tPlugin('components.declarationField.defaultLabel', 'en-GB') const formComponents = hasFormComponents(props.page?.pageDef) ? props.page.pageDef.components : [] @@ -119,7 +120,7 @@ export class DeclarationField extends FormComponent { getDisplayStringFromFormValue(value: FormValue | FormPayload): string { return value === 'true' ? this.declarationConfirmationLabel - : this.model.t('components.declarationField.notProvided') + : tPlugin('components.declarationField.notProvided', 'en-GB') } getViewModel( @@ -127,12 +128,11 @@ export class DeclarationField extends FormComponent { errors?: FormSubmissionError[], translatorOrQuery?: Translator | FormQuery ) { - const translator = isTranslator(translatorOrQuery) - ? translatorOrQuery - : undefined - const t = - translator?.t ?? - ((key: string, opts?: Record) => this.model.t(key, opts)) + const isT = isTranslator(translatorOrQuery) + const t = isT + ? translatorOrQuery.t + : (key: string, opts?: Record) => + tPlugin(key, 'en-GB', opts) const { hint, @@ -142,7 +142,7 @@ export class DeclarationField extends FormComponent { ) } = this - const viewModel = super.getViewModel(payload, errors, translator) + const viewModel = super.getViewModel(payload, errors, translatorOrQuery) let { fieldset, label } = viewModel fieldset ??= { diff --git a/src/server/plugins/engine/components/EastingNorthingField.ts b/src/server/plugins/engine/components/EastingNorthingField.ts index 0e79ff303..d4f1e5214 100644 --- a/src/server/plugins/engine/components/EastingNorthingField.ts +++ b/src/server/plugins/engine/components/EastingNorthingField.ts @@ -17,6 +17,7 @@ import { import { NumberField } from '~/src/server/plugins/engine/components/NumberField.js' import { createLowerFirstExpression } from '~/src/server/plugins/engine/components/helpers/index.js' import { type EastingNorthingState } from '~/src/server/plugins/engine/components/types.js' +import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -57,33 +58,39 @@ export class EastingNorthingField extends FormComponent { const fieldLabel = lowerFirst(this.label) - const eastingDigitsMessage = props.model.t( + const eastingDigitsMessage = tPlugin( 'components.eastingNorthingField.eastingDigits', + 'en-GB', { fieldLabel } ) - const northingDigitsMessage = props.model.t( + const northingDigitsMessage = tPlugin( 'components.eastingNorthingField.northingDigits', + 'en-GB', { fieldLabel } ) const customValidationMessages: LanguageMessages = convertToLanguageMessages({ - 'any.required': props.model.t( - 'components.eastingNorthingField.eastingRequired' + 'any.required': tPlugin( + 'components.eastingNorthingField.eastingRequired', + 'en-GB' ), - 'number.base': props.model.t( - 'components.eastingNorthingField.eastingRequired' + 'number.base': tPlugin( + 'components.eastingNorthingField.eastingRequired', + 'en-GB' ), - 'number.min': props.model.t( + 'number.min': tPlugin( 'components.eastingNorthingField.eastingRange', + 'en-GB', { fieldLabel, min: eastingMin, max: eastingMax } ), - 'number.max': props.model.t( + 'number.max': tPlugin( 'components.eastingNorthingField.eastingRange', + 'en-GB', { fieldLabel, min: eastingMin, @@ -97,22 +104,26 @@ export class EastingNorthingField extends FormComponent { const northingValidationMessages: LanguageMessages = convertToLanguageMessages({ - 'any.required': props.model.t( - 'components.eastingNorthingField.northingRequired' + 'any.required': tPlugin( + 'components.eastingNorthingField.northingRequired', + 'en-GB' ), - 'number.base': props.model.t( - 'components.eastingNorthingField.northingRequired' + 'number.base': tPlugin( + 'components.eastingNorthingField.northingRequired', + 'en-GB' ), - 'number.min': props.model.t( + 'number.min': tPlugin( 'components.eastingNorthingField.northingRange', + 'en-GB', { fieldLabel, min: northingMin, max: northingMax } ), - 'number.max': props.model.t( + 'number.max': tPlugin( 'components.eastingNorthingField.northingRange', + 'en-GB', { fieldLabel, min: northingMin, diff --git a/src/server/plugins/engine/components/FileUploadField.ts b/src/server/plugins/engine/components/FileUploadField.ts index 8fe66e2ae..bb9cf6a03 100644 --- a/src/server/plugins/engine/components/FileUploadField.ts +++ b/src/server/plugins/engine/components/FileUploadField.ts @@ -10,6 +10,7 @@ import { isTranslator, isUploadState } from '~/src/server/plugins/engine/components/FormComponent.js' +import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { InvalidComponentStateError } from '~/src/server/plugins/engine/pageControllers/errors.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' @@ -198,20 +199,18 @@ export class FileUploadField extends FormComponent { ) { const { options, page, schema } = this + const isT = isTranslator(translatorOrQuery) + // Allow preview URL direct access (query is passed when called via ComponentCollection) - const query = !isTranslator(translatorOrQuery) - ? (translatorOrQuery ?? {}) - : {} + const query = isT ? {} : (translatorOrQuery ?? {}) const isForceAccess = 'force' in query - const translator = isTranslator(translatorOrQuery) - ? translatorOrQuery - : undefined - const t = - translator?.t ?? - ((key: string, opts?: Record) => this.model.t(key, opts)) + const t = isT + ? translatorOrQuery.t + : (key: string, opts?: Record) => + tPlugin(key, 'en-GB', opts) - const viewModel = super.getViewModel(payload, errors, translator) + const viewModel = super.getViewModel(payload, errors, translatorOrQuery) const { attributes, id, value } = viewModel const files = this.getFormValue(value) ?? [] @@ -362,7 +361,7 @@ export class FileUploadField extends FormComponent { // Scenarios: file missing from S3, invalid retrieval key (timing problem), etc. throw new InvalidComponentStateError( this, - this.model.t('components.fileUploadField.uploadFailed') + tPlugin('components.fileUploadField.uploadFailed', 'en-GB') ) } diff --git a/src/server/plugins/engine/components/FormComponent.ts b/src/server/plugins/engine/components/FormComponent.ts index fc6936e2b..9388c04cc 100644 --- a/src/server/plugins/engine/components/FormComponent.ts +++ b/src/server/plugins/engine/components/FormComponent.ts @@ -6,6 +6,7 @@ import { } from '@defra/forms-model' import { ComponentBase } from '~/src/server/plugins/engine/components/ComponentBase.js' +import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type FormContext, @@ -139,12 +140,12 @@ export class FormComponent extends ComponentBase { ) { const { hint, name, options = {}, title, viewModel } = this - const translator = isTranslator(translatorOrQuery) - ? translatorOrQuery - : undefined - - const t = translator?.t ?? ((key: string) => this.model.t(key)) - const tContent = translator?.tContent + const isT = isTranslator(translatorOrQuery) + const t = isT + ? translatorOrQuery.t + : (key: string, opts?: Record) => + tPlugin(key, 'en-GB', opts) + const tContent = isT ? translatorOrQuery.tContent : undefined const isRequired = !('required' in options) || options.required !== false const hideOptional = 'optionalText' in options && options.optionalText diff --git a/src/server/plugins/engine/components/GeospatialField.ts b/src/server/plugins/engine/components/GeospatialField.ts index 54e410b47..ffd142b16 100644 --- a/src/server/plugins/engine/components/GeospatialField.ts +++ b/src/server/plugins/engine/components/GeospatialField.ts @@ -7,6 +7,7 @@ import { isGeospatialState } from '~/src/server/plugins/engine/components/FormComponent.js' import { geospatialSchema } from '~/src/server/plugins/engine/components/helpers/geospatial.js' +import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -58,7 +59,7 @@ export class GeospatialField extends FormComponent { return '' } - return this.model.t('components.geospatialField.added', { + return tPlugin('components.geospatialField.added', 'en-GB', { count: features.length }) } diff --git a/src/server/plugins/engine/components/LatLongField.ts b/src/server/plugins/engine/components/LatLongField.ts index 1c22c0cbc..5bb47e9d8 100644 --- a/src/server/plugins/engine/components/LatLongField.ts +++ b/src/server/plugins/engine/components/LatLongField.ts @@ -14,6 +14,7 @@ import { import { NumberField } from '~/src/server/plugins/engine/components/NumberField.js' import { createLowerFirstExpression } from '~/src/server/plugins/engine/components/helpers/index.js' import { type LatLongState } from '~/src/server/plugins/engine/components/types.js' +import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -55,23 +56,31 @@ export class LatLongField extends FormComponent { const customValidationMessages: LanguageMessages = convertToLanguageMessages({ - 'number.precision': props.model.t('components.latLongField.precision'), - 'number.unsafe': props.model.t('components.latLongField.notANumber') + 'number.precision': tPlugin( + 'components.latLongField.precision', + 'en-GB' + ), + 'number.unsafe': tPlugin('components.latLongField.notANumber', 'en-GB') }) - const latitudeRangeMessage = props.model.t( + const latitudeRangeMessage = tPlugin( 'components.latLongField.latitudeRange', + 'en-GB', { fieldLabel, min: latitudeMin, max: latitudeMax } ) - const longitudeRangeMessage = props.model.t( + const longitudeRangeMessage = tPlugin( 'components.latLongField.longitudeRange', + 'en-GB', { fieldLabel, min: longitudeMin, max: longitudeMax } ) const latitudeMessages: LanguageMessages = convertToLanguageMessages({ ...customValidationMessages, - 'any.required': props.model.t('components.latLongField.latitudeRequired'), - 'number.base': props.model.t('components.latLongField.latitudeBase', { + 'any.required': tPlugin( + 'components.latLongField.latitudeRequired', + 'en-GB' + ), + 'number.base': tPlugin('components.latLongField.latitudeBase', 'en-GB', { fieldLabel }), 'number.min': latitudeRangeMessage, @@ -80,10 +89,11 @@ export class LatLongField extends FormComponent { const longitudeMessages: LanguageMessages = convertToLanguageMessages({ ...customValidationMessages, - 'any.required': props.model.t( - 'components.latLongField.longitudeRequired' + 'any.required': tPlugin( + 'components.latLongField.longitudeRequired', + 'en-GB' ), - 'number.base': props.model.t('components.latLongField.longitudeBase', { + 'number.base': tPlugin('components.latLongField.longitudeBase', 'en-GB', { fieldLabel }), 'number.min': longitudeRangeMessage, diff --git a/src/server/plugins/engine/components/MonthYearField.ts b/src/server/plugins/engine/components/MonthYearField.ts index a663d7db4..4f69fd85b 100644 --- a/src/server/plugins/engine/components/MonthYearField.ts +++ b/src/server/plugins/engine/components/MonthYearField.ts @@ -46,16 +46,15 @@ export class MonthYearField extends FormComponent { const isRequired = options.required !== false - const { validationMessages } = props.model const customValidationMessages: LanguageMessages = convertToLanguageMessages({ - 'any.required': validationMessages.objectMissing, - 'number.base': validationMessages.objectMissing, - 'number.precision': validationMessages.dateFormat, - 'number.integer': validationMessages.dateFormat, - 'number.unsafe': validationMessages.dateFormat, - 'number.min': validationMessages.dateFormat, - 'number.max': validationMessages.dateFormat + 'any.required': messageTemplate.objectMissing, + '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( diff --git a/src/server/plugins/engine/components/NationalGridFieldNumberField.ts b/src/server/plugins/engine/components/NationalGridFieldNumberField.ts index 929728a11..e3186c68e 100644 --- a/src/server/plugins/engine/components/NationalGridFieldNumberField.ts +++ b/src/server/plugins/engine/components/NationalGridFieldNumberField.ts @@ -18,10 +18,10 @@ export class NationalGridFieldNumberField extends LocationFieldBase { return { pattern, patternErrorMessage: createLowerFirstExpression( - this.model.t('components.nationalGridField.pattern') + t('components.nationalGridField.pattern', 'en-GB') ), requiredMessage: createLowerFirstExpression( - this.model.t('components.nationalGridField.required') + t('components.nationalGridField.required', 'en-GB') ) } } @@ -31,7 +31,7 @@ export class NationalGridFieldNumberField extends LocationFieldBase { { type: 'pattern', template: createLowerFirstExpression( - this.model.t('components.nationalGridField.pattern') + t('components.nationalGridField.pattern', 'en-GB') ) } ] diff --git a/src/server/plugins/engine/components/OsGridRefField.ts b/src/server/plugins/engine/components/OsGridRefField.ts index 3b3e61914..dc61fc6dd 100644 --- a/src/server/plugins/engine/components/OsGridRefField.ts +++ b/src/server/plugins/engine/components/OsGridRefField.ts @@ -21,10 +21,10 @@ export class OsGridRefField extends LocationFieldBase { return { pattern, patternErrorMessage: createLowerFirstExpression( - this.model.t('components.osGridRefField.pattern') + t('components.osGridRefField.pattern', 'en-GB') ), requiredMessage: createLowerFirstExpression( - this.model.t('components.osGridRefField.required') + t('components.osGridRefField.required', 'en-GB') ) } } @@ -34,7 +34,7 @@ export class OsGridRefField extends LocationFieldBase { { type: 'pattern', template: createLowerFirstExpression( - this.model.t('components.osGridRefField.pattern') + t('components.osGridRefField.pattern', 'en-GB') ) } ] diff --git a/src/server/plugins/engine/components/PaymentField.test.ts b/src/server/plugins/engine/components/PaymentField.test.ts index 2d9b7f5a0..64e71df1c 100644 --- a/src/server/plugins/engine/components/PaymentField.test.ts +++ b/src/server/plugins/engine/components/PaymentField.test.ts @@ -261,7 +261,6 @@ describe('PaymentField', () => { const paymentField = collection.fields[0] as PaymentField paymentField.model = { services: mockServices, - t: (key: string, opts?: Record) => model.t(key, opts), createTranslator: (lang: string) => model.createTranslator(lang) } as unknown as FormModel @@ -423,8 +422,6 @@ describe('PaymentField', () => { basePath: 'base-path', name: 'PaymentModel', services: mockServices, - t: (key: string, opts?: Record) => - model.t(key, opts), createTranslator: (lang: string) => model.createTranslator(lang) }, getState: jest @@ -487,8 +484,6 @@ describe('PaymentField', () => { basePath: 'base-path', name: 'PaymentModel', services: mockServices, - t: (key: string, opts?: Record) => - model.t(key, opts), createTranslator: (lang: string) => model.createTranslator(lang) }, getState: jest diff --git a/src/server/plugins/engine/components/UkAddressField.ts b/src/server/plugins/engine/components/UkAddressField.ts index 3addd0d48..3bfd8a143 100644 --- a/src/server/plugins/engine/components/UkAddressField.ts +++ b/src/server/plugins/engine/components/UkAddressField.ts @@ -12,6 +12,7 @@ import { isFormState } from '~/src/server/plugins/engine/components/FormComponent.js' import { TextField } from '~/src/server/plugins/engine/components/TextField.js' +import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' import { @@ -168,9 +169,10 @@ export class UkAddressField extends FormComponent { errors?: FormSubmissionError[], translator?: Translator ): FormSubmissionError[] | undefined { - const t = - translator?.t ?? - ((key: string, opts?: Record) => this.model.t(key, opts)) + const t = translator + ? translator.t + : (key: string, opts?: Record) => + tPlugin(key, 'en-GB', opts) const uniqueErrors = this.getErrors(errors)?.filter( (error, index, self) => diff --git a/src/server/plugins/engine/models/FormModel.test.ts b/src/server/plugins/engine/models/FormModel.test.ts index 572130401..cff0c29e6 100644 --- a/src/server/plugins/engine/models/FormModel.test.ts +++ b/src/server/plugins/engine/models/FormModel.test.ts @@ -156,39 +156,30 @@ describe('FormModel', () => { expect(model.versionNumber).toBeUndefined() }) - it('defaults language to en-GB when metadata.language is absent', () => { + it('defaults translator to en-GB when metadata.language is absent', () => { const model = new FormModel(definition, { basePath: 'test' }) - expect(model.language).toBe('en-GB') - }) - - it('reads language from metadata.language when present', () => { - const defWithLanguage = { - ...definition, - metadata: { language: 'cy' } - } - const model = new FormModel(defWithLanguage, { basePath: 'test' }) - expect(model.language).toBe('cy') + const { t } = model.createTranslator('en-GB') + expect(t('errors.title')).toBe('There is a problem') }) - it('translates a key using the form language', () => { + it('translates a key using createTranslator', () => { const model = new FormModel(definition, { basePath: '/test' }) - expect(model.t('errors.title')).toBe('There is a problem') + const { t } = model.createTranslator('en-GB') + expect(t('errors.title')).toBe('There is a problem') }) - it('passes interpolation options through', () => { + it('passes interpolation options through createTranslator', () => { const model = new FormModel(definition, { basePath: '/test' }) - expect(model.t('pages.repeater.pageTitle', { count: 3 })).toBe( + const { t } = model.createTranslator('en-GB') + expect(t('pages.repeater.pageTitle', { count: 3 })).toBe( 'You have added 3 answers' ) }) - it('builds validationMessages at construction time', () => { + it('creates translator that resolves plugin namespace keys', () => { const model = new FormModel(definition, { basePath: 'test' }) - expect(model.validationMessages).toBeDefined() - expect(typeof model.validationMessages.max).toBe('string') - expect(model.validationMessages.max).toBe( - '{{#label}} must be {{#limit}} characters or less' - ) + const { t } = model.createTranslator('en-GB') + expect(t('common.continue')).toBe('Continue') }) it.each([ diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index cbdff3e3e..d157b7d37 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -43,10 +43,6 @@ import { getPage, setPageTitles } from '~/src/server/plugins/engine/helpers.js' -import { - buildValidationMessages, - type ValidationMessages -} from '~/src/server/plugins/engine/i18n/buildValidationMessages.js' import { extractBaseTranslations } from '~/src/server/plugins/engine/i18n/extractBaseTranslations.js' import { createFormI18nInstance, @@ -95,8 +91,6 @@ export class FormModel { basePath: string versionNumber?: number ordnanceSurveyApiKey?: string - language: string - validationMessages: ValidationMessages conditions: Partial> pages: PageControllerClass[] services: Services @@ -184,8 +178,6 @@ export class FormModel { setPageTitles(def) this.engine = def.engine - this.language = language - this.validationMessages = buildValidationMessages((key) => this.t(key)) this.schemaVersion = def.schema ?? SchemaVersion.V1 this.def = def this.lists = def.lists @@ -264,11 +256,6 @@ export class FormModel { ) } - /** Translates a key using this form's configured language. */ - t(key: string, opts?: Record): string { - return translate(key, this.language, opts) - } - /** Returns a scoped translator pair for the given language. */ createTranslator(language: string): Translator { const { i18nInstance } = this @@ -592,9 +579,9 @@ export class FormModel { if (isInvalid) { context.errors ??= [] - const text = - translator?.t('errors.optionsMismatch') ?? - this.t('errors.optionsMismatch') + const text = translator + ? translator.t('errors.optionsMismatch') + : translate('errors.optionsMismatch', 'en-GB') context.errors.push({ text, diff --git a/src/server/plugins/engine/models/SummaryViewModel.test.ts b/src/server/plugins/engine/models/SummaryViewModel.test.ts index 7e4eeaffa..4460d94a8 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.test.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.test.ts @@ -13,6 +13,7 @@ import { import { SummaryPageController } from '~/src/server/plugins/engine/pageControllers/SummaryPageController.js' import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js' import { serverWithSaveAndExit } from '~/src/server/plugins/engine/pageControllers/__stubs__/server.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import { createPage, type PageControllerClass @@ -135,7 +136,12 @@ describe('SummaryViewModel', () => { ({ state, keys, values, names, answers }) => { beforeEach(() => { context = model.getFormContext(request, state) - summaryViewModel = new SummaryViewModel(request, page, context) + summaryViewModel = new SummaryViewModel( + request, + page, + context, + stubTranslator + ) }) it('should add title for each section', () => { @@ -208,7 +214,12 @@ describe('SummaryViewModel', () => { it('should add summary list for each section (preview URL direct access)', () => { request.query.force = '' // Preview URL '?force' context = model.getFormContext(request, state) - summaryViewModel = new SummaryViewModel(request, page, context) + summaryViewModel = new SummaryViewModel( + request, + page, + context, + stubTranslator + ) expect(summaryViewModel.checkAnswers).toHaveLength(2) @@ -253,7 +264,12 @@ 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) + summaryViewModel = new SummaryViewModel( + request, + page, + context, + stubTranslator + ) expect(summaryViewModel.details).toHaveLength(2) @@ -317,7 +333,12 @@ describe('SummaryViewModel', () => { const page = createPage(model, definition.pages[2]) - summaryViewModel = new SummaryViewModel(request, page, context) + summaryViewModel = new SummaryViewModel( + request, + page, + context, + stubTranslator + ) expect(summaryViewModel.details).toHaveLength(2) @@ -406,8 +427,7 @@ describe('SummaryViewModel with per-request Translator', () => { expect(mockTranslator.t).toHaveBeenCalledWith('pages.summary.notProvided') }) - it('should fall back to model.t when no translator is provided', () => { - const tSpy = jest.spyOn(model, 't') + it('should use stubTranslator when no specific translation assertions needed', () => { const state: FormState = { $$__referenceNumber: 'foobar', orderType: 'collection', @@ -415,10 +435,9 @@ describe('SummaryViewModel with per-request Translator', () => { } context = model.getFormContext(request, state) - expect(new SummaryViewModel(request, page, context)).toBeDefined() - - const calledKeys = tSpy.mock.calls.map(([key]) => key) - expect(calledKeys).toContain('pages.summary.change') + expect( + new SummaryViewModel(request, page, context, stubTranslator) + ).toBeDefined() }) }) @@ -461,7 +480,11 @@ describe('SummaryPageController', () => { } const context = model.getFormContext(request, state) - const viewModel = controller.getViewModel(request, context) + const viewModel = controller.getViewModel( + request, + context, + stubTranslator + ) expect(viewModel).toHaveProperty('allowSaveAndExit', true) }) @@ -474,7 +497,11 @@ describe('SummaryPageController', () => { } const context = model.getFormContext(request, state) - const viewModel = controller.getSummaryViewModel(request, context) + const viewModel = controller.getSummaryViewModel( + request, + context, + stubTranslator + ) expect(viewModel.pageTitle).toBe( 'Check your answers before sending your form' @@ -508,7 +535,11 @@ describe('SummaryPageController', () => { } const context = titleModel.getFormContext(request, state) - const viewModel = controller.getSummaryViewModel(request, context) + const viewModel = controller.getSummaryViewModel( + request, + context, + stubTranslator + ) expect(viewModel.pageTitle).toBe( 'Check your answers before sending your form' @@ -546,7 +577,11 @@ describe('SummaryPageController', () => { } const context = titleModel.getFormContext(request, state) - const viewModel = controller.getSummaryViewModel(request, context) + const viewModel = controller.getSummaryViewModel( + request, + context, + stubTranslator + ) expect(viewModel.pageTitle).toBe('Override summary title') }) diff --git a/src/server/plugins/engine/models/SummaryViewModel.ts b/src/server/plugins/engine/models/SummaryViewModel.ts index 46f8cdaa8..837d99d22 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.ts @@ -57,21 +57,20 @@ export class SummaryViewModel { allowSaveAndExit = false paymentState?: PaymentState paymentDetails?: CheckAnswers - t?: (key: string, opts?: Record) => string + t: (key: string, opts?: Record) => string constructor( request: FormContextRequest, page: PageControllerClass, context: FormContext, - translator?: Translator + translator: Translator ) { const { model } = page const { basePath, def, sections } = model const { isForceAccess } = context - const t = - translator?.t ?? - ((key: string, opts?: Record) => model.t(key, opts)) + const { t } = translator + this.t = t this.page = page this.pageTitle = page.title diff --git a/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts b/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts index d4f8c5129..4fec29228 100644 --- a/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts +++ b/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts @@ -9,6 +9,7 @@ import { getFormSubmissionData } from '~/src/server/plugins/engine/pageControllers/SummaryPageController.js' import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import { type FormSubmissionState } from '~/src/server/plugins/engine/types.js' import { FormStatus } from '~/src/server/routes/types.js' import definitionPayment from '~/test/form/definitions/payment.js' @@ -77,7 +78,8 @@ describe('v1 human formatter', () => { ) const summaryViewModelPayment = controllerPayment.getSummaryViewModel( requestPayment, - contextPayment + contextPayment, + stubTranslator ) const itemsPayment = getFormSubmissionData( diff --git a/src/server/plugins/engine/outputFormatters/human/v1.test.ts b/src/server/plugins/engine/outputFormatters/human/v1.test.ts index d4ebb36d8..1f08ab71e 100644 --- a/src/server/plugins/engine/outputFormatters/human/v1.test.ts +++ b/src/server/plugins/engine/outputFormatters/human/v1.test.ts @@ -8,6 +8,7 @@ import { getFormSubmissionData } from '~/src/server/plugins/engine/pageControllers/SummaryPageController.js' import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import { FormStatus } from '~/src/server/routes/types.js' import definition from '~/test/form/definitions/repeat-mixed.js' @@ -66,7 +67,11 @@ describe('v1 human formatter', () => { }) const context = model.getFormContext(request, state) - const summaryViewModel = controller.getSummaryViewModel(request, context) + const summaryViewModel = controller.getSummaryViewModel( + request, + context, + stubTranslator + ) const items = getFormSubmissionData( summaryViewModel.context, diff --git a/src/server/plugins/engine/outputFormatters/machine/v2.payment.test.ts b/src/server/plugins/engine/outputFormatters/machine/v2.payment.test.ts index c16a81288..2ed3c09a9 100644 --- a/src/server/plugins/engine/outputFormatters/machine/v2.payment.test.ts +++ b/src/server/plugins/engine/outputFormatters/machine/v2.payment.test.ts @@ -8,6 +8,7 @@ import { getFormSubmissionData } from '~/src/server/plugins/engine/pageControllers/SummaryPageController.js' import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import { type FormSubmissionState } from '~/src/server/plugins/engine/types.js' import { FormStatus } from '~/src/server/routes/types.js' import definition from '~/test/form/definitions/payment.js' @@ -75,7 +76,11 @@ const pageDef = definition.pages[2] const controller = new SummaryPageController(model, pageDef) -const summaryViewModel = controller.getSummaryViewModel(request, context) +const summaryViewModel = controller.getSummaryViewModel( + request, + context, + stubTranslator +) const items = getFormSubmissionData( summaryViewModel.context, diff --git a/src/server/plugins/engine/outputFormatters/machine/v2.test.ts b/src/server/plugins/engine/outputFormatters/machine/v2.test.ts index 78703fc3d..211199655 100644 --- a/src/server/plugins/engine/outputFormatters/machine/v2.test.ts +++ b/src/server/plugins/engine/outputFormatters/machine/v2.test.ts @@ -14,6 +14,7 @@ import { getFormSubmissionData } from '~/src/server/plugins/engine/pageControllers/SummaryPageController.js' import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import { FileStatus, UploadStatus, @@ -276,7 +277,11 @@ describe('getPersonalisation', () => { const pageDef = definition.pages[2] const controller = new SummaryPageController(model, pageDef) - const summaryViewModel = controller.getSummaryViewModel(request, context) + const summaryViewModel = controller.getSummaryViewModel( + request, + context, + stubTranslator + ) const items = getFormSubmissionData( summaryViewModel.context, diff --git a/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts b/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts index 096565ab2..95a66d11e 100644 --- a/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts +++ b/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts @@ -19,6 +19,7 @@ import { } from '~/src/server/plugins/engine/pageControllers/FileUploadPageController.js' import { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' import { serverWithSaveAndExit } from '~/src/server/plugins/engine/pageControllers/__stubs__/server.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import * as pageHelpers from '~/src/server/plugins/engine/pageControllers/helpers/index.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' import * as uploadService from '~/src/server/plugins/engine/services/uploadService.js' @@ -1309,7 +1310,8 @@ describe('FileUploadPageController', () => { const viewModel = controller.getViewModel( request as FormContextRequest, - context + context, + stubTranslator ) expect(viewModel.uploadId).toBe('some-upload-id') diff --git a/src/server/plugins/engine/pageControllers/FileUploadPageController.ts b/src/server/plugins/engine/pageControllers/FileUploadPageController.ts index 184b4954e..6aec0efb2 100644 --- a/src/server/plugins/engine/pageControllers/FileUploadPageController.ts +++ b/src/server/plugins/engine/pageControllers/FileUploadPageController.ts @@ -12,8 +12,10 @@ import { type FormComponent } from '~/src/server/plugins/engine/components/FormC import { getCacheService, getError, - getExponentialBackoffDelay + getExponentialBackoffDelay, + getPluginOptions } from '~/src/server/plugins/engine/helpers.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' import { getProxyUrlForLocalDevelopment } from '~/src/server/plugins/engine/pageControllers/helpers/index.js' @@ -187,10 +189,14 @@ export class FileUploadPageController extends QuestionPageController { const { filename } = fileToRemove.status.form.file + const { getLanguage } = getPluginOptions(request.server) + const language = getLanguage?.(request) ?? 'en-GB' + const { t } = this.model.createTranslator(language) + return h.view(this.fileDeleteViewName, { ...viewModel, context, - backLink: this.getBackLink(request, context), + backLink: this.getBackLink(request, context, t), pageTitle: `Are you sure you want to remove this file?`, itemTitle: filename, confirmation: { text: 'You cannot recover removed files.' }, @@ -258,14 +264,15 @@ export class FileUploadPageController extends QuestionPageController { getViewModel( request: FormContextRequest, - context: FormContext + context: FormContext, + translator: Translator ): FeaturedFormPageViewModel { const { fileUpload } = this const { state } = context const upload = this.getUploadFromState(state) - const viewModel = super.getViewModel(request, context) + const viewModel = super.getViewModel(request, context, translator) const { components } = viewModel // Featured form component diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts index 1504747a4..1f2972737 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts @@ -9,6 +9,7 @@ import { buildFormRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js' import { serverWithSaveAndExit } from '~/src/server/plugins/engine/pageControllers/__stubs__/server.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import { FileStatus, UploadStatus, @@ -189,12 +190,14 @@ describe('QuestionPageController', () => { beforeEach(() => { viewModel1 = controller1.getViewModel( requestPage1, - model.getFormContext(requestPage1, { $$__referenceNumber: 'foobar' }) + model.getFormContext(requestPage1, { $$__referenceNumber: 'foobar' }), + stubTranslator ) viewModel2 = controller2.getViewModel( requestPage2, - model.getFormContext(requestPage2, { $$__referenceNumber: 'foobar' }) + model.getFormContext(requestPage2, { $$__referenceNumber: 'foobar' }), + stubTranslator ) }) @@ -448,7 +451,11 @@ describe('QuestionPageController', () => { const context = controller.model.getFormContext(request, state) const evaluationState = { animalType: 'Barn owl' } - const viewModel = controller.getViewModel(request, context) + const viewModel = controller.getViewModel( + request, + context, + stubTranslator + ) const filtered = controller.filterConditionalComponents( viewModel, @@ -504,7 +511,11 @@ describe('QuestionPageController', () => { const context = controller.model.getFormContext(request, state) const evaluationState = { animalType: 'Swan' } - const viewModel = controller.getViewModel(request, context) + const viewModel = controller.getViewModel( + request, + context, + stubTranslator + ) const filtered = controller.filterConditionalComponents( viewModel, @@ -1088,12 +1099,14 @@ describe('QuestionPageController V2', () => { beforeEach(() => { viewModel1 = controller1.getViewModel( requestPage1, - model.getFormContext(requestPage1, { $$__referenceNumber: 'foobar' }) + model.getFormContext(requestPage1, { $$__referenceNumber: 'foobar' }), + stubTranslator ) viewModel2 = controller2.getViewModel( requestPage2, - model.getFormContext(requestPage2, { $$__referenceNumber: 'foobar' }) + model.getFormContext(requestPage2, { $$__referenceNumber: 'foobar' }), + stubTranslator ) }) @@ -1778,10 +1791,14 @@ describe('QuestionPageController translator support', () => { ) }) - it('falls back to model.t when no translator is supplied', () => { - jest.spyOn(model, 't') + it('uses translator.t for back link text when translator is supplied', () => { + const mockT = jest.fn().mockReturnValue('Back to check answers') + const mockTranslatorLocal: Translator = { + t: mockT, + tContent: stubTranslator.tContent + } - // Use a request with returnUrl so that getBackLink calls model.t() for back link text + // Use a request with returnUrl so that getBackLink calls translator.t for back link text const requestWithReturn = buildFormRequest({ method: 'get', url: page1Url, @@ -1798,14 +1815,9 @@ describe('QuestionPageController translator support', () => { $$__referenceNumber: 'foobar' }) - // Call without translator (backward compat) - controller1.getViewModel(requestWithReturn, context) + controller1.getViewModel(requestWithReturn, context, mockTranslatorLocal) - // model.t should have been called for back link text (opts may be undefined) - expect(model.t).toHaveBeenCalledWith( - 'pages.question.backToCheckAnswers', - undefined - ) + expect(mockT).toHaveBeenCalledWith('pages.question.backToCheckAnswers') }) }) }) diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index e040da8b7..e1acc9617 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -121,33 +121,26 @@ export class QuestionPageController extends PageController { getViewModel( request: FormContextRequest, context: FormContext, - translator?: Translator + translator: Translator ): FormPageViewModel { const { collection, viewModel } = this - const { query } = request const { payload, errors } = context - const t = - translator?.t ?? - ((key: string, opts?: Record) => this.model.t(key, opts)) - const tContent = translator?.tContent + const { t, tContent } = translator let { showTitle } = viewModel - // Resolve page title via tContent if available, otherwise fall back to viewModel.pageTitle - let pageTitle = tContent - ? tContent(this.pageDef, 'title') - : viewModel.pageTitle + // Resolve page title via tContent + let pageTitle = tContent(this.pageDef, 'title') || viewModel.pageTitle - // Resolve section title via tContent if available, otherwise fall back to viewModel.sectionTitle - const sectionTitle = - this.section && tContent - ? this.section.hideTitle !== true - ? tContent(this.section, 'title') - : '' - : viewModel.sectionTitle + // Resolve section title via tContent + const sectionTitle = this.section + ? this.section.hideTitle !== true + ? tContent(this.section, 'title') + : '' + : viewModel.sectionTitle - const components = collection.getViewModel(payload, errors, query) + const components = collection.getViewModel(payload, errors, translator) const formComponents = components.filter( ({ isFormComponent }) => isFormComponent ) @@ -516,8 +509,7 @@ export class QuestionPageController extends PageController { protected getBackLink( request: FormContextRequest, context: FormContext, - t: (key: string, opts?: Record) => string = (key, opts) => - this.model.t(key, opts) + t: (key: string, opts?: Record) => string ): BackLink | undefined { const { pageDef } = this const { path, query } = request diff --git a/src/server/plugins/engine/pageControllers/RepeatPageController.test.ts b/src/server/plugins/engine/pageControllers/RepeatPageController.test.ts index 98135fadc..4c8636178 100644 --- a/src/server/plugins/engine/pageControllers/RepeatPageController.test.ts +++ b/src/server/plugins/engine/pageControllers/RepeatPageController.test.ts @@ -6,6 +6,7 @@ import { server, serverWithSaveAndExit } from '~/src/server/plugins/engine/pageControllers/__stubs__/server.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import { type FormContextRequest, type FormPageViewModel, @@ -136,7 +137,10 @@ describe('RepeatPageController', () => { beforeEach(() => { viewModel = controller.getViewModel( requestPageItem, - model.getFormContext(requestPageItem, { $$__referenceNumber: 'foobar' }) + model.getFormContext(requestPageItem, { + $$__referenceNumber: 'foobar' + }), + stubTranslator ) }) @@ -201,7 +205,8 @@ describe('RepeatPageController', () => { model.getFormContext(requestPageSummary, { $$__referenceNumber: 'foobar' }), - list + list, + stubTranslator ) }) @@ -301,10 +306,11 @@ describe('RepeatPageController', () => { expect(viewModel).toHaveProperty('sectionTitle') }) - it('does not call model.t when translator is supplied', () => { - const spy = jest.spyOn(model, 't') + it('uses translator.tContent for page title when translator is supplied', () => { const mockT = jest.fn((key: string) => `translated:${key}`) - const mockTContent = jest.fn((_entity: object, _prop: string) => '') + const mockTContent = jest.fn( + (_entity: object, _prop: string) => 'translated-title' + ) const translator: Translator = { t: mockT, tContent: mockTContent as Translator['tContent'] @@ -314,11 +320,14 @@ describe('RepeatPageController', () => { $$__referenceNumber: 'foobar' }) - controller.getViewModel(requestPageItem, context, translator) + const viewModel = controller.getViewModel( + requestPageItem, + context, + translator + ) - // model.t should NOT have been called — translator.t was used instead - expect(spy).not.toHaveBeenCalled() - spy.mockRestore() + expect(mockTContent).toHaveBeenCalled() + expect(viewModel).toHaveProperty('pageTitle', 'translated-title') }) }) @@ -353,20 +362,19 @@ describe('RepeatPageController', () => { expect(viewModel.pageTitle).toBe('translated:pages.repeater.pageTitle') }) - it('falls back to model.t when no translator is supplied', () => { - const spy = jest.spyOn(model, 't') - + it('uses stubTranslator when no specific translation assertions needed', () => { const context = model.getFormContext(requestPageSummary, { $$__referenceNumber: 'foobar' }) - controller.getListSummaryViewModel(requestPageSummary, context, list) - - expect(spy).toHaveBeenCalledWith( - 'pages.repeater.pageTitle', - expect.anything() + const viewModel = controller.getListSummaryViewModel( + requestPageSummary, + context, + list, + stubTranslator ) - spy.mockRestore() + + expect(viewModel).toHaveProperty('pageTitle') }) }) }) diff --git a/src/server/plugins/engine/pageControllers/RepeatPageController.ts b/src/server/plugins/engine/pageControllers/RepeatPageController.ts index c10bf0dff..e9e855d85 100644 --- a/src/server/plugins/engine/pageControllers/RepeatPageController.ts +++ b/src/server/plugins/engine/pageControllers/RepeatPageController.ts @@ -328,7 +328,7 @@ export class RepeatPageController extends QuestionPageController { return h.view(this.listDeleteViewName, { ...viewModel, context, - backLink: this.getBackLink(request, context), + backLink: this.getBackLink(request, context, t), pageTitle: t('pages.repeater.removeAnswer'), itemTitle: `${title} ${list.indexOf(item) + 1}`, buttonConfirm: { text: t('pages.repeater.remove') }, @@ -383,7 +383,7 @@ export class RepeatPageController extends QuestionPageController { getViewModel( request: FormContextRequest, context: FormContext, - translator?: Translator + translator: Translator ): FormPageViewModel { const { state } = context @@ -408,15 +408,13 @@ export class RepeatPageController extends QuestionPageController { request: FormContextRequest, context: FormContext, list: RepeatListState, - translator?: Translator + translator: Translator ): RepeaterSummaryPageViewModel { const { collection, href, repeat } = this const { query } = request const { isForceAccess, errors } = context - const t = - translator?.t ?? - ((key: string, opts?: Record) => this.model.t(key, opts)) + const { t } = translator const { title } = repeat.options @@ -482,7 +480,7 @@ export class RepeatPageController extends QuestionPageController { return { ...this.viewModel, - backLink: this.getBackLink(request, context), + backLink: this.getBackLink(request, context, t), repeatTitle: title, pageTitle: t('pages.repeater.pageTitle', { count }), showTitle: true, diff --git a/src/server/plugins/engine/pageControllers/StartPageController.ts b/src/server/plugins/engine/pageControllers/StartPageController.ts index db4403916..4365ec939 100644 --- a/src/server/plugins/engine/pageControllers/StartPageController.ts +++ b/src/server/plugins/engine/pageControllers/StartPageController.ts @@ -1,3 +1,4 @@ +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' import { type FormContext } from '~/src/server/plugins/engine/types.js' import { type FormRequest } from '~/src/server/routes/types.js' @@ -11,9 +12,13 @@ export class StartPageController extends QuestionPageController { allowSaveAndExit = false - getViewModel(request: FormRequest, context: FormContext) { + getViewModel( + request: FormRequest, + context: FormContext, + translator: Translator + ) { return { - ...super.getViewModel(request, context), + ...super.getViewModel(request, context, translator), isStartPage: true } } diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index 3b9b7821c..4b6da749a 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -81,15 +81,12 @@ export class SummaryPageController extends QuestionPageController { getSummaryViewModel( request: FormContextRequest, context: FormContext, - translator?: Translator + translator: Translator ): SummaryViewModel { - const t = - translator?.t ?? - ((key: string, opts?: Record) => this.model.t(key, opts)) + const { t } = translator const viewModel = new SummaryViewModel(request, this, context, translator) - const { query } = request const { payload, errors, state } = context const paymentField = context.relevantPages @@ -108,7 +105,7 @@ export class SummaryPageController extends QuestionPageController { } } - const components = this.collection.getViewModel(payload, errors, query) + const components = this.collection.getViewModel(payload, errors, translator) viewModel.backLink = this.getBackLink(request, context, t) viewModel.feedbackLink = this.feedbackLink @@ -125,8 +122,7 @@ export class SummaryPageController extends QuestionPageController { paymentState: NonNullable< ReturnType >, - t: (key: string, opts?: Record) => string = (key, opts) => - this.model.t(key, opts) + t: (key: string, opts?: Record) => string ) { const rows = [ { @@ -221,7 +217,10 @@ export class SummaryPageController extends QuestionPageController { checkEmailAddressForLiveFormSubmission(notificationEmail, isPreview) if (notificationEmail) { - const viewModel = this.getSummaryViewModel(request, context) + const { getLanguage } = getPluginOptions(request.server) + const language = getLanguage?.(request) ?? 'en-GB' + const translator = this.model.createTranslator(language) + const viewModel = this.getSummaryViewModel(request, context, translator) try { await submitForm( diff --git a/src/server/plugins/engine/pageControllers/__stubs__/translator.ts b/src/server/plugins/engine/pageControllers/__stubs__/translator.ts new file mode 100644 index 000000000..63a222085 --- /dev/null +++ b/src/server/plugins/engine/pageControllers/__stubs__/translator.ts @@ -0,0 +1,17 @@ +import { t as resolveKey } from '~/src/server/plugins/engine/i18n/index.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' + +/** + * Shared Translator stub for tests where translation content is not the focus. + * Uses the real English i18n keys so tests that assert on English text still pass. + * tContent returns the entity's raw property value (title, hint, etc.). + */ +export const stubTranslator: Translator = { + t: (key: string, opts?: Record) => + resolveKey(key, 'en-GB', opts), + tContent: ((entity: unknown, prop: string) => + typeof entity === 'object' && entity !== null + ? (((entity as Record)[prop] as string | undefined) ?? + '') + : '') as unknown as Translator['tContent'] +} diff --git a/src/server/plugins/engine/pageControllers/errors.ts b/src/server/plugins/engine/pageControllers/errors.ts index 2140a9c19..d8d327ca4 100644 --- a/src/server/plugins/engine/pageControllers/errors.ts +++ b/src/server/plugins/engine/pageControllers/errors.ts @@ -66,12 +66,10 @@ export class PaymentSubmissionError extends Error { stateAmount: number, definitionAmount: number | undefined, component: FormComponent, - t?: (key: string) => string + t: (key: string) => string ) { if (stateAmount / 100 !== definitionAmount) { - const message = - t?.('components.paymentField.paymentAmountMismatch') ?? - component.model.t('components.paymentField.paymentAmountMismatch') + const message = t('components.paymentField.paymentAmountMismatch') throw new PaymentPreAuthError( component, message, diff --git a/src/server/plugins/engine/routes/questions.test.ts b/src/server/plugins/engine/routes/questions.test.ts index 53737db67..23bf504b6 100644 --- a/src/server/plugins/engine/routes/questions.test.ts +++ b/src/server/plugins/engine/routes/questions.test.ts @@ -65,8 +65,11 @@ describe('makeGetHandler', () => { const modelMock = { basePath: 'some-base-path', - def: { name: 'Hello world' } - } as FormModel + def: { name: 'Hello world' }, + createTranslator: jest + .fn() + .mockReturnValue({ t: jest.fn(), tContent: jest.fn() }) + } as unknown as FormModel const pageMock = createMockPageController( modelMock, @@ -106,8 +109,11 @@ describe('makeGetHandler', () => { const modelMock = { basePath: 'some-base-path', - def: { name: 'Hello world' } - } as FormModel + def: { name: 'Hello world' }, + createTranslator: jest + .fn() + .mockReturnValue({ t: jest.fn(), tContent: jest.fn() }) + } as unknown as FormModel const pageMock = createMockPageController( modelMock, @@ -147,8 +153,11 @@ describe('makeGetHandler', () => { const modelMock = { basePath: 'some-base-path', - def: { name: 'Hello world' } - } as FormModel + def: { name: 'Hello world' }, + createTranslator: jest + .fn() + .mockReturnValue({ t: jest.fn(), tContent: jest.fn() }) + } as unknown as FormModel const pageMock = createMockPageController( modelMock, @@ -196,8 +205,11 @@ describe('makeGetHandler', () => { const onRequestCallback = jest.fn().mockResolvedValue(undefined) const modelMock = { basePath: 'some-base-path', - def: { name: 'Hello world' } - } as FormModel + def: { name: 'Hello world' }, + createTranslator: jest + .fn() + .mockReturnValue({ t: jest.fn(), tContent: jest.fn() }) + } as unknown as FormModel const pageMock = createMockPageController( modelMock, @@ -262,8 +274,11 @@ describe('makePostHandler', () => { const modelMock = { basePath: 'some-base-path', - def: { name: 'Hello world' } - } as FormModel + def: { name: 'Hello world' }, + createTranslator: jest + .fn() + .mockReturnValue({ t: jest.fn(), tContent: jest.fn() }) + } as unknown as FormModel const pageMock = createMockPageController( modelMock, @@ -301,8 +316,11 @@ describe('makePostHandler', () => { it('does not call the callback when the events.onSave.type is not http', async () => { const modelMock = { basePath: 'some-base-path', - def: { name: 'Hello world' } - } as FormModel + def: { name: 'Hello world' }, + createTranslator: jest + .fn() + .mockReturnValue({ t: jest.fn(), tContent: jest.fn() }) + } as unknown as FormModel const pageMock = createMockPageController( modelMock, @@ -344,8 +362,11 @@ describe('makePostHandler', () => { const modelMock = { basePath: 'some-base-path', - def: { name: 'Hello world' } - } as FormModel + def: { name: 'Hello world' }, + createTranslator: jest + .fn() + .mockReturnValue({ t: jest.fn(), tContent: jest.fn() }) + } as unknown as FormModel const pageMock = createMockPageController( modelMock, @@ -384,8 +405,11 @@ describe('makePostHandler', () => { const modelMock = { basePath: 'some-base-path', - def: { name: 'Hello world' } - } as FormModel + def: { name: 'Hello world' }, + createTranslator: jest + .fn() + .mockReturnValue({ t: jest.fn(), tContent: jest.fn() }) + } as unknown as FormModel const pageMock = createMockPageController( modelMock, @@ -434,8 +458,11 @@ describe('makePostHandler', () => { const onRequestCallback = jest.fn().mockResolvedValue(undefined) const modelMock = { basePath: 'some-base-path', - def: { name: 'Hello world' } - } as FormModel + def: { name: 'Hello world' }, + createTranslator: jest + .fn() + .mockReturnValue({ t: jest.fn(), tContent: jest.fn() }) + } as unknown as FormModel const pageMock = createMockPageController( modelMock, diff --git a/src/server/plugins/engine/routes/questions.ts b/src/server/plugins/engine/routes/questions.ts index e69c3e2d6..d2d9473ac 100644 --- a/src/server/plugins/engine/routes/questions.ts +++ b/src/server/plugins/engine/routes/questions.ts @@ -58,7 +58,8 @@ async function handleHttpEvent( // TODO: Update structured data POST payload with when helper // is updated to removing the dependency on `SummaryViewModel` etc. - const viewModel = new SummaryViewModel(request, page, context) + const translator = model.createTranslator('en-GB') + const viewModel = new SummaryViewModel(request, page, context, translator) const items = getFormSubmissionData(viewModel.context, viewModel.details) // @ts-expect-error - function signature will be refactored in the next iteration of the formatter From 8d92f3c8d267770c64212e224927c57aefc399c6 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 27 Apr 2026 22:40:28 +0100 Subject: [PATCH 59/89] add welsh devtool simple-form --- src/server/forms/simple-form.yaml | 31 +++++++++++++++++++ .../plugins/engine/configureEnginePlugin.ts | 5 ++- src/server/plugins/engine/options.js | 3 +- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/server/forms/simple-form.yaml b/src/server/forms/simple-form.yaml index 59c6c0fe0..182aad736 100644 --- a/src/server/forms/simple-form.yaml +++ b/src/server/forms/simple-form.yaml @@ -3,6 +3,37 @@ name: Page events engine: V2 schema: 2 startPage: '/summary' +metadata: + language: en-GB + translations: + cy: + components: + 1fb8e182-c709-4792-8f83-e01d8b1fee1a: + title: Beth yw eich enw cyntaf? + shortDescription: Eich enw cyntaf + b68df7f1-d4f4-4c17-83c8-402f584906c9: + title: Beth yw eich enw olaf? + shortDescription: Eich enw olaf + 987c1234-56d7-89e0-1234-56789abcdef0: + title: Llwytho copi o'ch pasbort + shortDescription: Llwytho pasbort + 987c1234-56d7-89e0-1234-56789abcdef1: + title: Llwytho copi o'ch trwydded yrru + shortDescription: Llwytho trwydded yrru + 00738799-3489-4ab2-a57b-542eecb31bfa: + title: Pryd yw pen-blwydd {{ applicantFirstName }} {{ applicantLastName }}? + shortDescription: Eich pen-blwydd + pages: + 622a35ec-3795-418a-81f3-a45746959045: + title: Eich enw + 23456789-0abc-def1-2345-67890abcdef1: + title: Llwytho copi o'ch pasbort + 23456789-0abc-def1-2345-67890abcdef2: + title: Llwytho copi o'ch trwydded yrru + da0fbdb4-a2de-4650-be16-9ba552af135f: + title: Dyddiad geni + 449a45f6-4541-4a46-91bd-8b8931b07b50: + title: Crynodeb pages: - title: Your name path: '/your-name' diff --git a/src/server/plugins/engine/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts index 127c57258..1d40bd774 100644 --- a/src/server/plugins/engine/configureEnginePlugin.ts +++ b/src/server/plugins/engine/configureEnginePlugin.ts @@ -67,7 +67,10 @@ export const configureEnginePlugin = async ( baseUrl: 'http://localhost:3009', // always runs locally saveAndExit, ordnanceSurveyApiKey, - ordnanceSurveyApiSecret + ordnanceSurveyApiSecret, + getLanguage: (_request) => { + return 'cy' + } } } } diff --git a/src/server/plugins/engine/options.js b/src/server/plugins/engine/options.js index 04774b47b..9c0c747ac 100644 --- a/src/server/plugins/engine/options.js +++ b/src/server/plugins/engine/options.js @@ -27,7 +27,8 @@ const pluginRegistrationOptionsSchema = Joi.object({ baseUrl: Joi.string().uri().required(), saveAndExit: Joi.function().optional(), ordnanceSurveyApiKey: Joi.string().optional(), - ordnanceSurveyApiSecret: Joi.string().optional() + ordnanceSurveyApiSecret: Joi.string().optional(), + getLanguage: Joi.function().optional() }) /** From ba14b07e04041c85b56858cc842b82fc37cf3d88 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 27 Apr 2026 23:29:19 +0100 Subject: [PATCH 60/89] feat(i18n): eliminate Translator|FormQuery union; thread translator through all getViewModel, getViewErrors, getDisplayStringFromState - Translator required (not union) in FormComponent.getViewModel and all subclass overrides - isForceAccess boolean param replaces FormQuery in ComponentCollection.getViewModel and FileUploadField - ComponentCollection.getViewErrors passes translator to field.getViewErrors - QuestionPageController passes translator to collection.getViewErrors calls - getDisplayStringFromFormValue/getDisplayStringFromState accept optional Translator - GeospatialField and DeclarationField use translator.t when provided, tPlugin fallback otherwise - getAnswer() and ItemField() accept optional Translator, thread from SummaryViewModel - FileUploadField.onSubmit and routes/questions.ts handleHttpEvent use per-request language from getPluginOptions - FormModel.fieldStateIsInvalid uses form default language as fallback when no translator --- .../components/AutocompleteField.test.ts | 13 +++++- .../engine/components/AutocompleteField.ts | 15 ++++++- .../engine/components/CheckboxesField.test.ts | 13 +++++- .../engine/components/ComponentCollection.ts | 16 ++++--- .../engine/components/DatePartsField.test.ts | 7 +-- .../engine/components/DatePartsField.ts | 17 ++++++-- .../components/DeclarationField.test.ts | 43 ++++++++++++++++--- .../engine/components/DeclarationField.ts | 33 ++++++++------ .../components/EastingNorthingField.test.ts | 10 +++-- .../engine/components/EastingNorthingField.ts | 24 +++++++++-- .../components/EmailAddressField.test.ts | 5 ++- .../engine/components/EmailAddressField.ts | 15 ++++++- .../engine/components/FileUploadField.test.ts | 31 ++++++++++--- .../engine/components/FileUploadField.ts | 32 ++++++-------- .../engine/components/FormComponent.ts | 35 +++++++-------- .../engine/components/GeospatialField.test.ts | 7 ++- .../engine/components/GeospatialField.ts | 35 +++++++++++---- .../engine/components/HiddenField.test.ts | 7 ++- .../engine/components/LatLongField.test.ts | 10 +++-- .../plugins/engine/components/LatLongField.ts | 24 +++++++++-- .../engine/components/ListFormComponent.ts | 15 ++++++- .../components/LocationFieldBase.test.ts | 13 +++++- .../engine/components/LocationFieldBase.ts | 15 ++++++- .../components/LocationFieldHelpers.test.ts | 22 +++++----- .../engine/components/LocationFieldHelpers.ts | 12 +++++- .../engine/components/MonthYearField.test.ts | 7 +-- .../engine/components/MonthYearField.ts | 17 ++++++-- .../components/MultilineTextField.test.ts | 21 +++++++-- .../engine/components/MultilineTextField.ts | 17 ++++++-- .../NationalGridFieldNumberField.test.ts | 11 ++++- .../engine/components/NumberField.test.ts | 37 +++++++++++++--- .../plugins/engine/components/NumberField.ts | 15 ++++++- .../engine/components/OsGridRefField.test.ts | 11 ++++- .../engine/components/PaymentField.test.ts | 13 +++++- .../plugins/engine/components/PaymentField.ts | 15 ++++++- .../engine/components/RadiosField.test.ts | 13 +++++- .../engine/components/SelectField.test.ts | 13 +++++- .../plugins/engine/components/SelectField.ts | 15 ++++++- .../components/SelectionControlField.ts | 15 ++++++- .../components/TelephoneNumberField.test.ts | 5 ++- .../engine/components/TelephoneNumberField.ts | 15 ++++++- .../engine/components/TextField.test.ts | 10 ++++- .../engine/components/UkAddressField.test.ts | 6 ++- .../engine/components/UkAddressField.ts | 19 ++++---- .../engine/components/YesNoField.test.ts | 13 +++++- .../engine/components/helpers/components.ts | 6 ++- .../plugins/engine/configureEnginePlugin.ts | 4 +- src/server/plugins/engine/models/FormModel.ts | 7 ++- .../plugins/engine/models/SummaryViewModel.ts | 19 +++++--- .../pageControllers/QuestionPageController.ts | 7 ++- .../plugins/engine/routes/questions.test.ts | 9 ++++ src/server/plugins/engine/routes/questions.ts | 5 ++- 52 files changed, 619 insertions(+), 195 deletions(-) diff --git a/src/server/plugins/engine/components/AutocompleteField.test.ts b/src/server/plugins/engine/components/AutocompleteField.test.ts index a94f0574d..0b04cb8e7 100644 --- a/src/server/plugins/engine/components/AutocompleteField.test.ts +++ b/src/server/plugins/engine/components/AutocompleteField.test.ts @@ -11,6 +11,7 @@ import { type Field } from '~/src/server/plugins/engine/components/helpers/components.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import { listNumber, listNumberExamples, @@ -265,7 +266,11 @@ describe.each([ it('sets Nunjucks component defaults', () => { const item = options.examples[0] - const viewModel = field.getViewModel(getFormData(item.value)) + const viewModel = field.getViewModel( + getFormData(item.value), + undefined, + stubTranslator + ) expect(viewModel).toEqual( expect.objectContaining({ @@ -280,7 +285,11 @@ describe.each([ it.each([...options.examples])( 'sets Nunjucks component autocomplete suggestions', (item) => { - const viewModel = field.getViewModel(getFormData(item.value)) + const viewModel = field.getViewModel( + getFormData(item.value), + undefined, + stubTranslator + ) expect(viewModel.items?.[0]).toMatchObject({ value: '' // First item is always empty diff --git a/src/server/plugins/engine/components/AutocompleteField.ts b/src/server/plugins/engine/components/AutocompleteField.ts index 2a9067ff6..4e83d3f9a 100644 --- a/src/server/plugins/engine/components/AutocompleteField.ts +++ b/src/server/plugins/engine/components/AutocompleteField.ts @@ -1,6 +1,7 @@ import { type AutocompleteFieldComponent } from '@defra/forms-model' import { SelectField } from '~/src/server/plugins/engine/components/SelectField.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type FormPayload, @@ -34,8 +35,18 @@ export class AutocompleteField extends SelectField { this.formSchema = formSchema } - getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { - const viewModel = super.getViewModel(payload, errors) + getViewModel( + payload: FormPayload, + errors: FormSubmissionError[] | undefined, + translator: Translator, + isForceAccess = false + ) { + const viewModel = super.getViewModel( + payload, + errors, + translator, + isForceAccess + ) let { formGroup } = viewModel formGroup ??= {} diff --git a/src/server/plugins/engine/components/CheckboxesField.test.ts b/src/server/plugins/engine/components/CheckboxesField.test.ts index 08db675eb..163bd951c 100644 --- a/src/server/plugins/engine/components/CheckboxesField.test.ts +++ b/src/server/plugins/engine/components/CheckboxesField.test.ts @@ -13,6 +13,7 @@ import { type Field } from '~/src/server/plugins/engine/components/helpers/components.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import { listNumber, listNumberExamples, @@ -329,7 +330,11 @@ describe.each([ it('sets Nunjucks component defaults', () => { const item = options.examples[0] - const viewModel = field.getViewModel(getFormData([item.value])) + const viewModel = field.getViewModel( + getFormData([item.value]), + undefined, + stubTranslator + ) expect(viewModel).toEqual( expect.objectContaining({ @@ -344,7 +349,11 @@ describe.each([ it.each([...options.examples])( 'sets Nunjucks component checkbox items', (item) => { - const viewModel = field.getViewModel(getFormData([item.value])) + const viewModel = field.getViewModel( + getFormData([item.value]), + undefined, + stubTranslator + ) expect(viewModel.items?.[0]).not.toMatchObject({ value: '' // First item is never empty diff --git a/src/server/plugins/engine/components/ComponentCollection.ts b/src/server/plugins/engine/components/ComponentCollection.ts index 23e2423d0..64e59d550 100644 --- a/src/server/plugins/engine/components/ComponentCollection.ts +++ b/src/server/plugins/engine/components/ComponentCollection.ts @@ -32,7 +32,6 @@ import { type FormSubmissionState, type FormValidationResult } from '~/src/server/plugins/engine/types.js' -import { type FormQuery } from '~/src/server/routes/types.js' export class ComponentCollection { page?: PageControllerClass @@ -229,15 +228,20 @@ export class ComponentCollection { * Composite fields like UKAddress can choose to return more than one error. */ getViewErrors( - errors?: FormSubmissionError[] + errors?: FormSubmissionError[], + translator?: Translator ): FormSubmissionError[] | undefined { - return this.getFieldErrors((field) => field.getViewErrors(errors), errors) + return this.getFieldErrors( + (field) => field.getViewErrors(errors, translator), + errors + ) } getViewModel( payload: FormPayload, - errors?: FormSubmissionError[], - translatorOrQuery: Translator | FormQuery = {} + errors: FormSubmissionError[] | undefined, + translator: Translator, + isForceAccess = false ) { const { components } = this @@ -246,7 +250,7 @@ export class ComponentCollection { const model = component instanceof FormComponent - ? component.getViewModel(payload, errors, translatorOrQuery) + ? component.getViewModel(payload, errors, translator, isForceAccess) : component.getViewModel() return { type, isFormComponent, model } diff --git a/src/server/plugins/engine/components/DatePartsField.test.ts b/src/server/plugins/engine/components/DatePartsField.test.ts index 69be71d53..32ab88ac5 100644 --- a/src/server/plugins/engine/components/DatePartsField.test.ts +++ b/src/server/plugins/engine/components/DatePartsField.test.ts @@ -9,6 +9,7 @@ import { } from '~/src/server/plugins/engine/components/helpers/components.js' import { type DateInputItem } from '~/src/server/plugins/engine/components/types.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import { type ErrorMessageTemplateList, type FormPayload, @@ -361,7 +362,7 @@ describe('DatePartsField', () => { it('sets Nunjucks component defaults', () => { const payload = getFormData(date) - const viewModel = field.getViewModel(payload) + const viewModel = field.getViewModel(payload, undefined, stubTranslator) expect(viewModel).toEqual( expect.objectContaining({ @@ -405,7 +406,7 @@ describe('DatePartsField', () => { year: 'YYYY' }) - const viewModel = field.getViewModel(payload) + const viewModel = field.getViewModel(payload, undefined, stubTranslator) expect(viewModel).toEqual( expect.objectContaining({ @@ -428,7 +429,7 @@ describe('DatePartsField', () => { it('sets Nunjucks component fieldset', () => { const payload = getFormData(date) - const viewModel = field.getViewModel(payload) + const viewModel = field.getViewModel(payload, undefined, stubTranslator) expect(viewModel.fieldset).toEqual({ legend: { diff --git a/src/server/plugins/engine/components/DatePartsField.ts b/src/server/plugins/engine/components/DatePartsField.ts index 4a7acc637..5aeed8271 100644 --- a/src/server/plugins/engine/components/DatePartsField.ts +++ b/src/server/plugins/engine/components/DatePartsField.ts @@ -14,6 +14,7 @@ import { type DatePartsState } from '~/src/server/plugins/engine/components/types.js' import { parseStrictDate } from '~/src/server/plugins/engine/date-helper.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -148,10 +149,20 @@ export class DatePartsField extends FormComponent { return this.getContextValueFromFormValue(value) } - getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { + getViewModel( + payload: FormPayload, + errors: FormSubmissionError[] | undefined, + translator: Translator, + isForceAccess = false + ) { const { collection, name } = this - const viewModel = super.getViewModel(payload, errors) + const viewModel = super.getViewModel( + payload, + errors, + translator, + isForceAccess + ) let { fieldset, label } = viewModel // Check for component errors only @@ -159,7 +170,7 @@ export class DatePartsField extends FormComponent { // Use the component collection to generate the subitems const items: DateInputItem[] = collection - .getViewModel(payload, errors) + .getViewModel(payload, errors, translator, isForceAccess) .map(({ model }) => { let { label, type, value, classes, errorMessage } = model diff --git a/src/server/plugins/engine/components/DeclarationField.test.ts b/src/server/plugins/engine/components/DeclarationField.test.ts index c81a4ab79..948bba66e 100644 --- a/src/server/plugins/engine/components/DeclarationField.test.ts +++ b/src/server/plugins/engine/components/DeclarationField.test.ts @@ -10,6 +10,7 @@ import { type Field } from '~/src/server/plugins/engine/components/helpers/components.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import definition from '~/test/form/definitions/blank.js' import declarationWithGuidance from '~/test/form/definitions/declaration-with-guidance.js' import declarationWithoutGuidance from '~/test/form/definitions/declaration-without-guidance.js' @@ -241,7 +242,11 @@ describe('DeclarationField', () => { describe('View model', () => { it('sets Nunjucks component defaults', () => { - const viewModel = field.getViewModel(getFormData(undefined)) + const viewModel = field.getViewModel( + getFormData(undefined), + undefined, + stubTranslator + ) expect(viewModel).toEqual( expect.objectContaining({ @@ -275,7 +280,11 @@ describe('DeclarationField', () => { collection = new ComponentCollection([def], { model }) field = collection.fields[0] - const viewModel = field.getViewModel(getFormData('unchecked')) + const viewModel = field.getViewModel( + getFormData('unchecked'), + undefined, + stubTranslator + ) expect(viewModel).toEqual( expect.objectContaining({ @@ -301,7 +310,11 @@ describe('DeclarationField', () => { collection = new ComponentCollection([def], { model }) field = collection.fields[0] - const viewModel = field.getViewModel(getFormData(['true', 'unchecked'])) + const viewModel = field.getViewModel( + getFormData(['true', 'unchecked']), + undefined, + stubTranslator + ) expect(viewModel).toEqual( expect.objectContaining({ @@ -327,7 +340,11 @@ describe('DeclarationField', () => { collection = new ComponentCollection([def], { model }) field = collection.fields[0] - const viewModel = field.getViewModel(getFormData(['unchecked'])) + const viewModel = field.getViewModel( + getFormData(['unchecked']), + undefined, + stubTranslator + ) expect(viewModel).toEqual( expect.objectContaining({ @@ -353,7 +370,11 @@ describe('DeclarationField', () => { collection = new ComponentCollection([def], { model }) field = collection.fields[0] - const viewModel = field.getViewModel(getFormData('true')) + const viewModel = field.getViewModel( + getFormData('true'), + undefined, + stubTranslator + ) expect(viewModel).toEqual( expect.objectContaining({ @@ -388,7 +409,11 @@ describe('DeclarationField', () => { collection = new ComponentCollection([def], { model }) field = collection.fields[0] - const viewModel = field.getViewModel(getFormData('true')) + const viewModel = field.getViewModel( + getFormData('true'), + undefined, + stubTranslator + ) expect(viewModel).toEqual( expect.objectContaining({ @@ -412,7 +437,11 @@ describe('DeclarationField', () => { collection = new ComponentCollection([def], { model }) field = collection.fields[0] - const viewModel = field.getViewModel(getFormData(undefined)) + const viewModel = field.getViewModel( + getFormData(undefined), + undefined, + stubTranslator + ) expect(viewModel).toEqual( expect.objectContaining({ diff --git a/src/server/plugins/engine/components/DeclarationField.ts b/src/server/plugins/engine/components/DeclarationField.ts index baecaee0e..581971179 100644 --- a/src/server/plugins/engine/components/DeclarationField.ts +++ b/src/server/plugins/engine/components/DeclarationField.ts @@ -13,8 +13,7 @@ import joi, { import { FormComponent, - isFormValue, - isTranslator + isFormValue } from '~/src/server/plugins/engine/components/FormComponent.js' import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' @@ -28,7 +27,6 @@ import { type FormSubmissionState, type FormValue } from '~/src/server/plugins/engine/types.js' -import { type FormQuery } from '~/src/server/routes/types.js' export class DeclarationField extends FormComponent { declare options: DeclarationFieldComponent['options'] @@ -117,22 +115,26 @@ export class DeclarationField extends FormComponent { return this.isValue(value) ? value : undefined } - getDisplayStringFromFormValue(value: FormValue | FormPayload): string { + getDisplayStringFromFormValue( + value: FormValue | FormPayload, + translator?: Translator + ): string { + const t = + translator?.t ?? + ((key: string, opts?: Record) => + tPlugin(key, 'en-GB', opts)) return value === 'true' ? this.declarationConfirmationLabel - : tPlugin('components.declarationField.notProvided', 'en-GB') + : t('components.declarationField.notProvided') } getViewModel( payload: FormPayload, - errors?: FormSubmissionError[], - translatorOrQuery?: Translator | FormQuery + errors: FormSubmissionError[] | undefined, + translator: Translator, + isForceAccess = false ) { - const isT = isTranslator(translatorOrQuery) - const t = isT - ? translatorOrQuery.t - : (key: string, opts?: Record) => - tPlugin(key, 'en-GB', opts) + const { t } = translator const { hint, @@ -142,7 +144,12 @@ export class DeclarationField extends FormComponent { ) } = this - const viewModel = super.getViewModel(payload, errors, translatorOrQuery) + const viewModel = super.getViewModel( + payload, + errors, + translator, + isForceAccess + ) let { fieldset, label } = viewModel fieldset ??= { diff --git a/src/server/plugins/engine/components/EastingNorthingField.test.ts b/src/server/plugins/engine/components/EastingNorthingField.test.ts index 138221fe6..4fd94e84d 100644 --- a/src/server/plugins/engine/components/EastingNorthingField.test.ts +++ b/src/server/plugins/engine/components/EastingNorthingField.test.ts @@ -3,6 +3,7 @@ import { type EastingNorthingFieldComponent } from '@defra/forms-model' + import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { EastingNorthingField } from '~/src/server/plugins/engine/components/EastingNorthingField.js' import { @@ -10,6 +11,7 @@ import { type Field } from '~/src/server/plugins/engine/components/helpers/components.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import { type FormSubmissionError } from '~/src/server/plugins/engine/types.js' import definition from '~/test/form/definitions/blank.js' @@ -292,7 +294,7 @@ describe('EastingNorthingField', () => { easting: 12345, northing: 1234567 }) - const viewModel = field.getViewModel(payload) + const viewModel = field.getViewModel(payload, undefined, stubTranslator) expect(viewModel).toEqual( expect.objectContaining({ @@ -339,7 +341,9 @@ describe('EastingNorthingField', () => { getFormData({ easting: 12345, northing: 1234567 - }) + }), + undefined, + stubTranslator ) const instructionText = @@ -363,7 +367,7 @@ describe('EastingNorthingField', () => { } ] - const viewModel = field.getViewModel(payload, errors) + const viewModel = field.getViewModel(payload, errors, stubTranslator) // Check that error is passed to the viewModel expect(viewModel.errors).toEqual(errors) diff --git a/src/server/plugins/engine/components/EastingNorthingField.ts b/src/server/plugins/engine/components/EastingNorthingField.ts index d4f1e5214..36f9702a7 100644 --- a/src/server/plugins/engine/components/EastingNorthingField.ts +++ b/src/server/plugins/engine/components/EastingNorthingField.ts @@ -18,6 +18,7 @@ import { NumberField } from '~/src/server/plugins/engine/components/NumberField. import { createLowerFirstExpression } from '~/src/server/plugins/engine/components/helpers/index.js' import { type EastingNorthingState } from '~/src/server/plugins/engine/components/types.js' import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -219,9 +220,26 @@ export class EastingNorthingField extends FormComponent { return this.getContextValueFromFormValue(value) } - getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { - const viewModel = super.getViewModel(payload, errors) - return getLocationFieldViewModel(this, viewModel, payload, errors) + getViewModel( + payload: FormPayload, + errors: FormSubmissionError[] | undefined, + translator: Translator, + isForceAccess = false + ) { + const viewModel = super.getViewModel( + payload, + errors, + translator, + isForceAccess + ) + return getLocationFieldViewModel( + this, + viewModel, + payload, + errors, + translator, + isForceAccess + ) } getViewErrors( diff --git a/src/server/plugins/engine/components/EmailAddressField.test.ts b/src/server/plugins/engine/components/EmailAddressField.test.ts index 580c1bac9..83ce253d3 100644 --- a/src/server/plugins/engine/components/EmailAddressField.test.ts +++ b/src/server/plugins/engine/components/EmailAddressField.test.ts @@ -9,6 +9,7 @@ import { type Field } from '~/src/server/plugins/engine/components/helpers/components.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import definition from '~/test/form/definitions/blank.js' import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' @@ -209,7 +210,9 @@ describe('EmailAddressField', () => { describe('View model', () => { it('sets Nunjucks component defaults', () => { const viewModel = field.getViewModel( - getFormData('defra.helpline@defra.gov.uk') + getFormData('defra.helpline@defra.gov.uk'), + undefined, + stubTranslator ) expect(viewModel).toEqual( diff --git a/src/server/plugins/engine/components/EmailAddressField.ts b/src/server/plugins/engine/components/EmailAddressField.ts index 30a3f3595..f3c44d4c3 100644 --- a/src/server/plugins/engine/components/EmailAddressField.ts +++ b/src/server/plugins/engine/components/EmailAddressField.ts @@ -2,6 +2,7 @@ import { type EmailAddressFieldComponent } from '@defra/forms-model' import joi from 'joi' import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -43,8 +44,18 @@ export class EmailAddressField extends FormComponent { this.options = options } - getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { - const viewModel = super.getViewModel(payload, errors) + getViewModel( + payload: FormPayload, + errors: FormSubmissionError[] | undefined, + translator: Translator, + isForceAccess = false + ) { + const viewModel = super.getViewModel( + payload, + errors, + translator, + isForceAccess + ) const { attributes } = viewModel attributes.autocomplete = 'email' diff --git a/src/server/plugins/engine/components/FileUploadField.test.ts b/src/server/plugins/engine/components/FileUploadField.test.ts index 8d36772f3..01355efce 100644 --- a/src/server/plugins/engine/components/FileUploadField.test.ts +++ b/src/server/plugins/engine/components/FileUploadField.test.ts @@ -15,6 +15,7 @@ import { type Field } from '~/src/server/plugins/engine/components/helpers/components.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import { InvalidComponentStateError } from '~/src/server/plugins/engine/pageControllers/errors.js' import { createPage, @@ -382,7 +383,11 @@ describe('FileUploadField', () => { describe('View model', () => { it('sets Nunjucks component defaults', () => { - const viewModel = field.getViewModel(getFormData(validState)) + const viewModel = field.getViewModel( + getFormData(validState), + undefined, + stubTranslator + ) expect(viewModel).toEqual( expect.objectContaining({ @@ -464,9 +469,8 @@ describe('FileUploadField', () => { const viewModel = field.getViewModel( getFormData(validState), undefined, - - // Preview URL '?force' - { force: '' } + stubTranslator, + true ) expect(viewModel).toEqual( @@ -521,7 +525,11 @@ describe('FileUploadField', () => { }) it('sets Nunjucks component defaults with temp valid state', () => { - const viewModel = field.getViewModel(getFormData(validTempState)) + const viewModel = field.getViewModel( + getFormData(validTempState), + undefined, + stubTranslator + ) expect(viewModel).toEqual( expect.objectContaining({ @@ -562,7 +570,11 @@ describe('FileUploadField', () => { }) it('sets Nunjucks component defaults with temp valid state with errors (on POST)', () => { - const viewModel = field.getViewModel(getFormData(validTempState), []) + const viewModel = field.getViewModel( + getFormData(validTempState), + [], + stubTranslator + ) expect(viewModel).toEqual( expect.objectContaining({ @@ -892,13 +904,18 @@ describe('FileUploadField', () => { // Mock request mockRequest = { + server: { plugins: { 'forms-engine-plugin': {} } }, app: { model: { services: { formSubmissionService: { persistFiles: mockPersistFiles } - } + }, + createTranslator: () => ({ + t: jest.fn((k: string) => k), + tContent: jest.fn() + }) } } } as unknown as FormRequestPayload diff --git a/src/server/plugins/engine/components/FileUploadField.ts b/src/server/plugins/engine/components/FileUploadField.ts index bb9cf6a03..b7eb9da64 100644 --- a/src/server/plugins/engine/components/FileUploadField.ts +++ b/src/server/plugins/engine/components/FileUploadField.ts @@ -7,10 +7,9 @@ import joi, { type ArraySchema } from 'joi' import { FormComponent, - isTranslator, isUploadState } from '~/src/server/plugins/engine/components/FormComponent.js' -import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' +import { getPluginOptions } from '~/src/server/plugins/engine/helpers.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { InvalidComponentStateError } from '~/src/server/plugins/engine/pageControllers/errors.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' @@ -35,10 +34,7 @@ import { type UploadStatusResponse } from '~/src/server/plugins/engine/types.js' import { render } from '~/src/server/plugins/nunjucks/index.js' -import { - type FormQuery, - type FormRequestPayload -} from '~/src/server/routes/types.js' +import { type FormRequestPayload } from '~/src/server/routes/types.js' export const uploadIdSchema = joi.string().uuid().required() @@ -194,23 +190,15 @@ export class FileUploadField extends FormComponent { getViewModel( payload: FormPayload, - errors?: FormSubmissionError[], - translatorOrQuery?: Translator | FormQuery + errors: FormSubmissionError[] | undefined, + translator: Translator, + isForceAccess = false ) { const { options, page, schema } = this - const isT = isTranslator(translatorOrQuery) - - // Allow preview URL direct access (query is passed when called via ComponentCollection) - const query = isT ? {} : (translatorOrQuery ?? {}) - const isForceAccess = 'force' in query + const { t } = translator - const t = isT - ? translatorOrQuery.t - : (key: string, opts?: Record) => - tPlugin(key, 'en-GB', opts) - - const viewModel = super.getViewModel(payload, errors, translatorOrQuery) + const viewModel = super.getViewModel(payload, errors, translator) const { attributes, id, value } = viewModel const files = this.getFormValue(value) ?? [] @@ -323,6 +311,10 @@ export class FileUploadField extends FormComponent { metadata: FormMetadata, context: FormContext ) { + const { getLanguage } = getPluginOptions(request.server) + const language = getLanguage?.(request) ?? 'en-GB' + const { t } = this.model.createTranslator(language) + const notificationEmail = metadata.notificationEmail if (!notificationEmail) { @@ -361,7 +353,7 @@ export class FileUploadField extends FormComponent { // Scenarios: file missing from S3, invalid retrieval key (timing problem), etc. throw new InvalidComponentStateError( this, - tPlugin('components.fileUploadField.uploadFailed', 'en-GB') + t('components.fileUploadField.uploadFailed') ) } diff --git a/src/server/plugins/engine/components/FormComponent.ts b/src/server/plugins/engine/components/FormComponent.ts index 9388c04cc..f7cd42205 100644 --- a/src/server/plugins/engine/components/FormComponent.ts +++ b/src/server/plugins/engine/components/FormComponent.ts @@ -6,7 +6,6 @@ import { } from '@defra/forms-model' import { ComponentBase } from '~/src/server/plugins/engine/components/ComponentBase.js' -import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type FormContext, @@ -27,7 +26,6 @@ import { type RepeatListState, type UploadState } from '~/src/server/plugins/engine/types.js' -import { type FormQuery } from '~/src/server/routes/types.js' export class FormComponent extends ComponentBase { type: FormComponentsDef['type'] @@ -135,31 +133,24 @@ export class FormComponent extends ComponentBase { getViewModel( payload: FormPayload, - errors?: FormSubmissionError[], - translatorOrQuery?: Translator | FormQuery + errors: FormSubmissionError[] | undefined, + translator: Translator, + _isForceAccess = false ) { const { hint, name, options = {}, title, viewModel } = this - const isT = isTranslator(translatorOrQuery) - const t = isT - ? translatorOrQuery.t - : (key: string, opts?: Record) => - tPlugin(key, 'en-GB', opts) - const tContent = isT ? translatorOrQuery.tContent : undefined + const { t, tContent } = translator const isRequired = !('required' in options) || options.required !== false const hideOptional = 'optionalText' in options && options.optionalText - const resolvedTitle = tContent - ? tContent(this as unknown as ComponentDef, 'title') - : title + const resolvedTitle = + tContent(this as unknown as ComponentDef, 'title') || title const label = `${resolvedTitle}${!isRequired && !hideOptional ? ` ${t('common.optional')}` : ''}` if (hint) { viewModel.hint = { - text: tContent - ? tContent(this as unknown as ComponentDef, 'hint') - : hint + text: tContent(this as unknown as ComponentDef, 'hint') || hint } } @@ -188,15 +179,21 @@ export class FormComponent extends ComponentBase { } } - getDisplayStringFromFormValue(value: FormValue | FormPayload): string { + getDisplayStringFromFormValue( + value: FormValue | FormPayload, + _translator?: Translator + ): string { // Map selected values to text // eslint-disable-next-line @typescript-eslint/no-base-to-string return this.isValue(value) ? value.toString() : '' } - getDisplayStringFromState(state: FormSubmissionState): string { + getDisplayStringFromState( + state: FormSubmissionState, + translator?: Translator + ): string { const value = this.getFormValueFromState(state) - return this.getDisplayStringFromFormValue(value) + return this.getDisplayStringFromFormValue(value, translator) } getContextValueFromFormValue( diff --git a/src/server/plugins/engine/components/GeospatialField.test.ts b/src/server/plugins/engine/components/GeospatialField.test.ts index 5950cab5f..088715085 100644 --- a/src/server/plugins/engine/components/GeospatialField.test.ts +++ b/src/server/plugins/engine/components/GeospatialField.test.ts @@ -14,6 +14,7 @@ import { type Field } from '~/src/server/plugins/engine/components/helpers/components.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import { type GeospatialState } from '~/src/server/plugins/engine/types.js' import definition from '~/test/form/definitions/blank.js' import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' @@ -220,7 +221,11 @@ describe('GeospatialField', () => { describe('View model', () => { it('sets Nunjucks component defaults', () => { - const viewModel = field.getViewModel(getFormData('Geospatial')) + const viewModel = field.getViewModel( + getFormData('Geospatial'), + undefined, + stubTranslator + ) expect(viewModel).toEqual( expect.objectContaining({ diff --git a/src/server/plugins/engine/components/GeospatialField.ts b/src/server/plugins/engine/components/GeospatialField.ts index ffd142b16..9cec29a76 100644 --- a/src/server/plugins/engine/components/GeospatialField.ts +++ b/src/server/plugins/engine/components/GeospatialField.ts @@ -8,6 +8,7 @@ import { } from '~/src/server/plugins/engine/components/FormComponent.js' import { geospatialSchema } from '~/src/server/plugins/engine/components/helpers/geospatial.js' import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -54,20 +55,28 @@ export class GeospatialField extends FormComponent { return this.isValue(value) ? value : undefined } - getDisplayStringFromFormValue(features: GeospatialState | undefined): string { + getDisplayStringFromFormValue( + features: GeospatialState | undefined, + translator?: Translator + ): string { if (!features?.length) { return '' } - return tPlugin('components.geospatialField.added', 'en-GB', { - count: features.length - }) + const t = + translator?.t ?? + ((key: string, opts?: Record) => + tPlugin(key, 'en-GB', opts)) + return t('components.geospatialField.added', { count: features.length }) } - getDisplayStringFromState(state: FormSubmissionState) { + getDisplayStringFromState( + state: FormSubmissionState, + translator?: Translator + ) { const features = this.getFormValueFromState(state) - return this.getDisplayStringFromFormValue(features) + return this.getDisplayStringFromFormValue(features, translator) } getContextValueFromFormValue( @@ -82,8 +91,18 @@ export class GeospatialField extends FormComponent { return this.getContextValueFromFormValue(features) } - getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { - const viewModel = super.getViewModel(payload, errors) + getViewModel( + payload: FormPayload, + errors: FormSubmissionError[] | undefined, + translator: Translator, + isForceAccess = false + ) { + const viewModel = super.getViewModel( + payload, + errors, + translator, + isForceAccess + ) const value = typeof viewModel.value === 'string' ? viewModel.value diff --git a/src/server/plugins/engine/components/HiddenField.test.ts b/src/server/plugins/engine/components/HiddenField.test.ts index bf02c3802..d157aad0c 100644 --- a/src/server/plugins/engine/components/HiddenField.test.ts +++ b/src/server/plugins/engine/components/HiddenField.test.ts @@ -6,6 +6,7 @@ import { type Field } from '~/src/server/plugins/engine/components/helpers/components.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import definition from '~/test/form/definitions/blank.js' import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' @@ -164,7 +165,11 @@ describe('HiddenField', () => { describe('View model', () => { it('sets Nunjucks component defaults', () => { - const viewModel = field.getViewModel(getFormData('Hidden field')) + const viewModel = field.getViewModel( + getFormData('Hidden field'), + undefined, + stubTranslator + ) expect(viewModel).toEqual( expect.objectContaining({ diff --git a/src/server/plugins/engine/components/LatLongField.test.ts b/src/server/plugins/engine/components/LatLongField.test.ts index 894c488a3..517022022 100644 --- a/src/server/plugins/engine/components/LatLongField.test.ts +++ b/src/server/plugins/engine/components/LatLongField.test.ts @@ -1,5 +1,6 @@ import { ComponentType, type LatLongFieldComponent } from '@defra/forms-model' + import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { LatLongField } from '~/src/server/plugins/engine/components/LatLongField.js' import { @@ -7,6 +8,7 @@ import { type Field } from '~/src/server/plugins/engine/components/helpers/components.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import { type FormSubmissionError } from '~/src/server/plugins/engine/types.js' import definition from '~/test/form/definitions/blank.js' @@ -281,7 +283,7 @@ describe('LatLongField', () => { latitude: 51.51945, longitude: -0.127758 }) - const viewModel = field.getViewModel(payload) + const viewModel = field.getViewModel(payload, undefined, stubTranslator) expect(viewModel).toEqual( expect.objectContaining({ @@ -328,7 +330,9 @@ describe('LatLongField', () => { getFormData({ latitude: 51.51945, longitude: -0.127758 - }) + }), + undefined, + stubTranslator ) const instructionText = @@ -352,7 +356,7 @@ describe('LatLongField', () => { } ] - const viewModel = field.getViewModel(payload, errors) + const viewModel = field.getViewModel(payload, errors, stubTranslator) // Check that error is passed to the viewModel expect(viewModel.errors).toEqual(errors) diff --git a/src/server/plugins/engine/components/LatLongField.ts b/src/server/plugins/engine/components/LatLongField.ts index 5bb47e9d8..d4957b725 100644 --- a/src/server/plugins/engine/components/LatLongField.ts +++ b/src/server/plugins/engine/components/LatLongField.ts @@ -15,6 +15,7 @@ import { NumberField } from '~/src/server/plugins/engine/components/NumberField. import { createLowerFirstExpression } from '~/src/server/plugins/engine/components/helpers/index.js' import { type LatLongState } from '~/src/server/plugins/engine/components/types.js' import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -183,9 +184,26 @@ export class LatLongField extends FormComponent { return this.getContextValueFromFormValue(value) } - getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { - const viewModel = super.getViewModel(payload, errors) - return getLocationFieldViewModel(this, viewModel, payload, errors) + getViewModel( + payload: FormPayload, + errors: FormSubmissionError[] | undefined, + translator: Translator, + isForceAccess = false + ) { + const viewModel = super.getViewModel( + payload, + errors, + translator, + isForceAccess + ) + return getLocationFieldViewModel( + this, + viewModel, + payload, + errors, + translator, + isForceAccess + ) } getViewErrors( diff --git a/src/server/plugins/engine/components/ListFormComponent.ts b/src/server/plugins/engine/components/ListFormComponent.ts index 3d8b91db1..db300bf4a 100644 --- a/src/server/plugins/engine/components/ListFormComponent.ts +++ b/src/server/plugins/engine/components/ListFormComponent.ts @@ -14,6 +14,7 @@ import joi, { import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' import { type ListItem } from '~/src/server/plugins/engine/components/types.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -119,10 +120,20 @@ export class ListFormComponent extends FormComponent { return this.getDisplayStringFromFormValue(value) } - getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { + getViewModel( + payload: FormPayload, + errors: FormSubmissionError[] | undefined, + translator: Translator, + isForceAccess = false + ) { const { items: listItems } = this - const viewModel = super.getViewModel(payload, errors) + const viewModel = super.getViewModel( + payload, + errors, + translator, + isForceAccess + ) const { value } = viewModel // Support multiple values for checkboxes diff --git a/src/server/plugins/engine/components/LocationFieldBase.test.ts b/src/server/plugins/engine/components/LocationFieldBase.test.ts index 08a4d302f..8bb4243b9 100644 --- a/src/server/plugins/engine/components/LocationFieldBase.test.ts +++ b/src/server/plugins/engine/components/LocationFieldBase.test.ts @@ -4,6 +4,7 @@ import { type LanguageMessages } from 'joi' import { LocationFieldBase } from '~/src/server/plugins/engine/components/LocationFieldBase.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import definition from '~/test/form/definitions/blank.js' import { getFormData } from '~/test/helpers/component-helpers.js' @@ -83,7 +84,11 @@ describe('LocationFieldBase', () => { } as ConstructorParameters[0] const field = new TestLocationField(def, { model }) - const viewModel = field.getViewModel(getFormData('TEST1234')) + const viewModel = field.getViewModel( + getFormData('TEST1234'), + undefined, + stubTranslator + ) const instructionText = 'instructionText' in viewModel ? viewModel.instructionText : undefined @@ -101,7 +106,11 @@ describe('LocationFieldBase', () => { } as ConstructorParameters[0] const field = new TestLocationField(def, { model }) - const viewModel = field.getViewModel(getFormData('TEST1234')) + const viewModel = field.getViewModel( + getFormData('TEST1234'), + undefined, + stubTranslator + ) expect( 'instructionText' in viewModel ? viewModel.instructionText : undefined diff --git a/src/server/plugins/engine/components/LocationFieldBase.ts b/src/server/plugins/engine/components/LocationFieldBase.ts index 339743f01..37c439e2e 100644 --- a/src/server/plugins/engine/components/LocationFieldBase.ts +++ b/src/server/plugins/engine/components/LocationFieldBase.ts @@ -10,6 +10,7 @@ import { isFormValue } from '~/src/server/plugins/engine/components/FormComponent.js' import { addClassOptionIfNone } from '~/src/server/plugins/engine/components/helpers/index.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -119,8 +120,18 @@ export abstract class LocationFieldBase extends FormComponent { return LocationFieldBase.isText(value) } - getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { - const viewModel = super.getViewModel(payload, errors) + getViewModel( + payload: FormPayload, + errors: FormSubmissionError[] | undefined, + translator: Translator, + isForceAccess = false + ) { + const viewModel = super.getViewModel( + payload, + errors, + translator, + isForceAccess + ) if (this.instructionText) { return { diff --git a/src/server/plugins/engine/components/LocationFieldHelpers.test.ts b/src/server/plugins/engine/components/LocationFieldHelpers.test.ts index b9cf6adea..3fa899f51 100644 --- a/src/server/plugins/engine/components/LocationFieldHelpers.test.ts +++ b/src/server/plugins/engine/components/LocationFieldHelpers.test.ts @@ -1,5 +1,6 @@ import { ComponentType, type LatLongFieldComponent } from '@defra/forms-model' + import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { type LatLongField } from '~/src/server/plugins/engine/components/LatLongField.js' import { @@ -9,6 +10,7 @@ import { mergeCssClasses } from '~/src/server/plugins/engine/components/LocationFieldHelpers.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import { type FormSubmissionError } from '~/src/server/plugins/engine/types.js' import definition from '~/test/form/definitions/blank.js' @@ -276,7 +278,7 @@ describe('LocationFieldHelpers', () => { myComponent__longitude: -0.1 } - const viewModel = field.getViewModel(payload) + const viewModel = field.getViewModel(payload, undefined, stubTranslator) expect(viewModel.fieldset).toEqual({ legend: { @@ -307,7 +309,7 @@ describe('LocationFieldHelpers', () => { myComponent__longitude: -0.1 } - const viewModel = field.getViewModel(payload) + const viewModel = field.getViewModel(payload, undefined, stubTranslator) const instructionText = 'instructionText' in viewModel ? viewModel.instructionText : undefined @@ -341,7 +343,7 @@ describe('LocationFieldHelpers', () => { } ] - const viewModel = field.getViewModel(payload, errors) + const viewModel = field.getViewModel(payload, errors, stubTranslator) // Check that errors are passed to the viewModel expect(viewModel.errors).toEqual(errors) @@ -389,7 +391,7 @@ describe('LocationFieldHelpers', () => { } ] - const viewModel = field.getViewModel(payload, errors) + const viewModel = field.getViewModel(payload, errors, stubTranslator) // Single errors should be displayed at fieldset level expect(viewModel.items[0].errorMessage).toBeUndefined() @@ -437,7 +439,7 @@ describe('LocationFieldHelpers', () => { } ] - const viewModel = field.getViewModel(payload, errors) + const viewModel = field.getViewModel(payload, errors, stubTranslator) expect(viewModel.items[0].errorMessage).toBeUndefined() expect(viewModel.items[1].errorMessage).toBeUndefined() @@ -469,7 +471,7 @@ describe('LocationFieldHelpers', () => { } // No errors passed in, but the subViewModels might have errors from elsewhere - const viewModel = field.getViewModel(payload, []) + const viewModel = field.getViewModel(payload, [], stubTranslator) // When no field errors, items should not have error messages expect(viewModel.items[0].errorMessage).toBeUndefined() @@ -511,7 +513,7 @@ describe('LocationFieldHelpers', () => { } ] - const viewModel = field.getViewModel(payload, errors) + const viewModel = field.getViewModel(payload, errors, stubTranslator) // No individual field errors expect(viewModel.items[0].errorMessage).toBeUndefined() @@ -545,7 +547,7 @@ describe('LocationFieldHelpers', () => { myComponent__longitude: '-0.1' } - const viewModel = field.getViewModel(payload) + const viewModel = field.getViewModel(payload, undefined, stubTranslator) // When no errors and no additional classes, classes should not include govuk-input--error expect(viewModel.items[0].classes).not.toContain('govuk-input--error') @@ -569,7 +571,7 @@ describe('LocationFieldHelpers', () => { myComponent__longitude: '-0.1' } - const viewModel = field.getViewModel(payload) + const viewModel = field.getViewModel(payload, undefined, stubTranslator) const label = viewModel.items[0].label expect(label).toBeDefined() @@ -601,7 +603,7 @@ describe('LocationFieldHelpers', () => { myComponent__longitude: -0.1 } - const viewModel = field.getViewModel(payload) + const viewModel = field.getViewModel(payload, undefined, stubTranslator) expect(viewModel.fieldset).toBeDefined() }) diff --git a/src/server/plugins/engine/components/LocationFieldHelpers.ts b/src/server/plugins/engine/components/LocationFieldHelpers.ts index 50a891d03..466548442 100644 --- a/src/server/plugins/engine/components/LocationFieldHelpers.ts +++ b/src/server/plugins/engine/components/LocationFieldHelpers.ts @@ -8,6 +8,7 @@ import { type Label, type ViewModel } from '~/src/server/plugins/engine/components/types.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type FormPayload, type FormSubmissionError, @@ -95,12 +96,19 @@ export function getLocationFieldViewModel( value: FormValue }, payload: FormPayload, - errors?: FormSubmissionError[] + errors: FormSubmissionError[] | undefined, + translator: Translator, + isForceAccess = false ) { const { collection } = component const { fieldset: existingFieldset, label } = viewModel - const subViewModels = collection.getViewModel(payload, errors) + const subViewModels = collection.getViewModel( + payload, + errors, + translator, + isForceAccess + ) const fieldErrors: string[] = [] subViewModels.forEach(({ model }) => { diff --git a/src/server/plugins/engine/components/MonthYearField.test.ts b/src/server/plugins/engine/components/MonthYearField.test.ts index 3407a46f5..e95d4ae59 100644 --- a/src/server/plugins/engine/components/MonthYearField.test.ts +++ b/src/server/plugins/engine/components/MonthYearField.test.ts @@ -9,6 +9,7 @@ import { } from '~/src/server/plugins/engine/components/helpers/components.js' import { type DateInputItem } from '~/src/server/plugins/engine/components/types.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import { type FormPayload, type FormState @@ -333,7 +334,7 @@ describe('MonthYearField', () => { it('sets Nunjucks component defaults', () => { const payload = getFormData(date) - const viewModel = field.getViewModel(payload) + const viewModel = field.getViewModel(payload, undefined, stubTranslator) expect(viewModel).toEqual( expect.objectContaining({ @@ -368,7 +369,7 @@ describe('MonthYearField', () => { year: 'YYYY' }) - const viewModel = field.getViewModel(payload) + const viewModel = field.getViewModel(payload, undefined, stubTranslator) expect(viewModel).toEqual( expect.objectContaining({ @@ -387,7 +388,7 @@ describe('MonthYearField', () => { it('sets Nunjucks component fieldset', () => { const payload = getFormData(date) - const viewModel = field.getViewModel(payload) + const viewModel = field.getViewModel(payload, undefined, stubTranslator) expect(viewModel.fieldset).toEqual({ legend: { diff --git a/src/server/plugins/engine/components/MonthYearField.ts b/src/server/plugins/engine/components/MonthYearField.ts index 4f69fd85b..41d27092f 100644 --- a/src/server/plugins/engine/components/MonthYearField.ts +++ b/src/server/plugins/engine/components/MonthYearField.ts @@ -19,6 +19,7 @@ import { type MonthYearState } from '~/src/server/plugins/engine/components/types.js' import { parseStrictDate } from '~/src/server/plugins/engine/date-helper.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -145,10 +146,20 @@ export class MonthYearField extends FormComponent { return this.getContextValueFromFormValue(value) } - getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { + getViewModel( + payload: FormPayload, + errors: FormSubmissionError[] | undefined, + translator: Translator, + isForceAccess = false + ) { const { collection, name } = this - const viewModel = super.getViewModel(payload, errors) + const viewModel = super.getViewModel( + payload, + errors, + translator, + isForceAccess + ) let { fieldset, label } = viewModel // Check for component errors only @@ -156,7 +167,7 @@ export class MonthYearField extends FormComponent { // Use the component collection to generate the subitems const items: DateInputItem[] = collection - .getViewModel(payload, errors) + .getViewModel(payload, errors, translator, isForceAccess) .map(({ model }) => { let { label, type, value, classes, errorMessage } = model diff --git a/src/server/plugins/engine/components/MultilineTextField.test.ts b/src/server/plugins/engine/components/MultilineTextField.test.ts index d903e404d..2c040f552 100644 --- a/src/server/plugins/engine/components/MultilineTextField.test.ts +++ b/src/server/plugins/engine/components/MultilineTextField.test.ts @@ -10,6 +10,7 @@ import { type Field } from '~/src/server/plugins/engine/components/helpers/components.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import definition from '~/test/form/definitions/blank.js' import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' @@ -219,7 +220,11 @@ describe('MultilineTextField', () => { describe('View model', () => { it('sets Nunjucks component defaults', () => { - const viewModel = field.getViewModel(getFormData('Textarea')) + const viewModel = field.getViewModel( + getFormData('Textarea'), + undefined, + stubTranslator + ) expect(viewModel).toEqual( expect.objectContaining({ @@ -242,14 +247,22 @@ describe('MultilineTextField', () => { { model } ) - const viewModel = field.getViewModel(getFormData('Textarea')) + const viewModel = field.getViewModel( + getFormData('Textarea'), + undefined, + stubTranslator + ) const viewModel1 = componentCustom1.getViewModel( - getFormData('Textarea custom #1') + getFormData('Textarea custom #1'), + undefined, + stubTranslator ) const viewModel2 = componentCustom2.getViewModel( - getFormData('Textarea custom #2') + getFormData('Textarea custom #2'), + undefined, + stubTranslator ) expect(viewModel).toEqual( diff --git a/src/server/plugins/engine/components/MultilineTextField.ts b/src/server/plugins/engine/components/MultilineTextField.ts index 68bf908db..26680b0e2 100644 --- a/src/server/plugins/engine/components/MultilineTextField.ts +++ b/src/server/plugins/engine/components/MultilineTextField.ts @@ -3,6 +3,7 @@ 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 { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -90,10 +91,20 @@ export class MultilineTextField extends FormComponent { this.schema = schema } - getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { + getViewModel( + payload: FormPayload, + errors: FormSubmissionError[] | undefined, + translator: Translator, + isForceAccess = false + ) { const { schema, options, isCharacterOrWordCount } = this - const viewModel = super.getViewModel(payload, errors) + const viewModel = super.getViewModel( + payload, + errors, + translator, + isForceAccess + ) let { maxlength, maxwords, rows } = viewModel if (schema.max) { @@ -118,7 +129,7 @@ export class MultilineTextField extends FormComponent { } buildMinMaxText(min?: number, max?: number): string { - const minMaxError = messageTemplate.minMax as string + const minMaxError = messageTemplate.minMax return minMaxError .replace('{{#min}}', min ? min.toString() : '[min length]') .replace('{{#max}}', max ? max.toString() : '[max length]') diff --git a/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts b/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts index 00a67d634..12dd7923e 100644 --- a/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts +++ b/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts @@ -10,6 +10,7 @@ import { type Field } from '~/src/server/plugins/engine/components/helpers/components.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import definition from '~/test/form/definitions/blank.js' import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' @@ -204,7 +205,11 @@ describe('NationalGridFieldNumberField', () => { describe('View model', () => { it('sets Nunjucks component defaults', () => { - const viewModel = field.getViewModel(getFormData('NG12345678')) + const viewModel = field.getViewModel( + getFormData('NG12345678'), + undefined, + stubTranslator + ) expect(viewModel).toEqual( expect.objectContaining({ @@ -226,7 +231,9 @@ describe('NationalGridFieldNumberField', () => { ) const viewModel = componentWithInstruction.getViewModel( - getFormData('NG12345678') + getFormData('NG12345678'), + undefined, + stubTranslator ) const instructionText = diff --git a/src/server/plugins/engine/components/NumberField.test.ts b/src/server/plugins/engine/components/NumberField.test.ts index 28b266df0..c629f6102 100644 --- a/src/server/plugins/engine/components/NumberField.test.ts +++ b/src/server/plugins/engine/components/NumberField.test.ts @@ -7,6 +7,7 @@ import { type Field } from '~/src/server/plugins/engine/components/helpers/components.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import definition from '~/test/form/definitions/blank.js' import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' @@ -209,7 +210,11 @@ describe('NumberField', () => { describe('View model', () => { it('sets Nunjucks component defaults', () => { - const viewModel = field.getViewModel(getFormData(2024)) + const viewModel = field.getViewModel( + getFormData(2024), + undefined, + stubTranslator + ) expect(viewModel).toEqual( expect.objectContaining({ @@ -227,7 +232,11 @@ describe('NumberField', () => { { model } ) - const viewModel = componentCustom.getViewModel(getFormData(99.99)) + const viewModel = componentCustom.getViewModel( + getFormData(99.99), + undefined, + stubTranslator + ) expect(viewModel.prefix).toEqual({ text: '£' }) expect(viewModel.suffix).toEqual({ text: 'per item' }) @@ -239,7 +248,11 @@ describe('NumberField', () => { { model } ) - const viewModel = componentCustom.getViewModel(getFormData(99)) + const viewModel = componentCustom.getViewModel( + getFormData(99), + undefined, + stubTranslator + ) expect(viewModel.attributes).toHaveProperty('inputmode', 'numeric') }) @@ -250,7 +263,11 @@ describe('NumberField', () => { { model } ) - const viewModel = componentCustom.getViewModel(getFormData(99)) + const viewModel = componentCustom.getViewModel( + getFormData(99), + undefined, + stubTranslator + ) expect(viewModel.attributes).toHaveProperty('inputmode', 'numeric') }) @@ -261,14 +278,22 @@ describe('NumberField', () => { { model } ) - const viewModel = componentCustom.getViewModel(getFormData(99.99)) + const viewModel = componentCustom.getViewModel( + getFormData(99.99), + undefined, + stubTranslator + ) expect(viewModel.attributes).not.toHaveProperty('inputmode', 'numeric') }) }) it('sets Nunjucks component value when invalid', () => { - const viewModel = field.getViewModel(getFormData('AA')) + const viewModel = field.getViewModel( + getFormData('AA'), + undefined, + stubTranslator + ) expect(viewModel).toHaveProperty('value', 'AA') }) diff --git a/src/server/plugins/engine/components/NumberField.ts b/src/server/plugins/engine/components/NumberField.ts index ec138d3dd..6139ffa26 100644 --- a/src/server/plugins/engine/components/NumberField.ts +++ b/src/server/plugins/engine/components/NumberField.ts @@ -5,6 +5,7 @@ import { FormComponent, isFormValue } from '~/src/server/plugins/engine/components/FormComponent.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -88,10 +89,20 @@ export class NumberField extends FormComponent { return this.isValue(value) ? value : undefined } - getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { + getViewModel( + payload: FormPayload, + errors: FormSubmissionError[] | undefined, + translator: Translator, + isForceAccess = false + ) { const { options, schema } = this - const viewModel = super.getViewModel(payload, errors) + const viewModel = super.getViewModel( + payload, + errors, + translator, + isForceAccess + ) let { attributes, prefix, suffix, value } = viewModel if (typeof schema.precision === 'undefined' || schema.precision <= 0) { diff --git a/src/server/plugins/engine/components/OsGridRefField.test.ts b/src/server/plugins/engine/components/OsGridRefField.test.ts index 9183fb081..36d3144f9 100644 --- a/src/server/plugins/engine/components/OsGridRefField.test.ts +++ b/src/server/plugins/engine/components/OsGridRefField.test.ts @@ -7,6 +7,7 @@ import { type Field } from '~/src/server/plugins/engine/components/helpers/components.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import definition from '~/test/form/definitions/blank.js' import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' @@ -220,7 +221,11 @@ describe('OsGridRefField', () => { describe('View model', () => { it('sets Nunjucks component defaults', () => { - const viewModel = field.getViewModel(getFormData('TQ12345678')) + const viewModel = field.getViewModel( + getFormData('TQ12345678'), + undefined, + stubTranslator + ) expect(viewModel).toEqual( expect.objectContaining({ @@ -242,7 +247,9 @@ describe('OsGridRefField', () => { ) const viewModel = componentWithInstruction.getViewModel( - getFormData('TQ12345678') + getFormData('TQ12345678'), + undefined, + stubTranslator ) const instructionText = diff --git a/src/server/plugins/engine/components/PaymentField.test.ts b/src/server/plugins/engine/components/PaymentField.test.ts index 64e71df1c..668aa6ee6 100644 --- a/src/server/plugins/engine/components/PaymentField.test.ts +++ b/src/server/plugins/engine/components/PaymentField.test.ts @@ -12,6 +12,7 @@ import { type Field } from '~/src/server/plugins/engine/components/helpers/components.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import { PaymentPreAuthError } from '~/src/server/plugins/engine/pageControllers/errors.js' import { type FormContext, @@ -198,7 +199,11 @@ describe('PaymentField', () => { describe('View model', () => { it('sets Nunjucks component defaults', () => { - const viewModel = field.getViewModel(getFormData(undefined)) + const viewModel = field.getViewModel( + getFormData(undefined), + undefined, + stubTranslator + ) expect(viewModel).toEqual( expect.objectContaining({ @@ -222,7 +227,11 @@ describe('PaymentField', () => { description: 'Test payment description', isLivePayment: false } as unknown as FormValue - const viewModel = field.getViewModel(getFormData(paymentForViewModel)) + const viewModel = field.getViewModel( + getFormData(paymentForViewModel), + undefined, + stubTranslator + ) expect(viewModel).toEqual( expect.objectContaining({ diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts index e9a2aea21..5058636b6 100644 --- a/src/server/plugins/engine/components/PaymentField.ts +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -14,6 +14,7 @@ import { createError, getPluginOptions } from '~/src/server/plugins/engine/helpers.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { PaymentErrorTypes, PaymentPreAuthError, @@ -101,8 +102,18 @@ export class PaymentField extends FormComponent { return `${formatCurrency(value.amount)} - ${value.description}` } - getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { - const viewModel = super.getViewModel(payload, errors) + getViewModel( + payload: FormPayload, + errors: FormSubmissionError[] | undefined, + translator: Translator, + isForceAccess = false + ) { + const viewModel = super.getViewModel( + payload, + errors, + translator, + isForceAccess + ) // Payload is pre-populated from state if a payment has already been made const paymentState = this.isPaymentState(payload[this.name] as unknown) diff --git a/src/server/plugins/engine/components/RadiosField.test.ts b/src/server/plugins/engine/components/RadiosField.test.ts index 8ccc0ff9f..add0c5649 100644 --- a/src/server/plugins/engine/components/RadiosField.test.ts +++ b/src/server/plugins/engine/components/RadiosField.test.ts @@ -8,6 +8,7 @@ import { type Field } from '~/src/server/plugins/engine/components/helpers/components.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import { listNumber, listNumberExamples, @@ -233,7 +234,11 @@ describe.each([ it('sets Nunjucks component defaults', () => { const item = options.examples[0] - const viewModel = field.getViewModel(getFormData(item.value)) + const viewModel = field.getViewModel( + getFormData(item.value), + undefined, + stubTranslator + ) expect(viewModel).toEqual( expect.objectContaining({ @@ -248,7 +253,11 @@ describe.each([ it.each([...options.examples])( 'sets Nunjucks component radio items', (item) => { - const viewModel = field.getViewModel(getFormData(item.value)) + const viewModel = field.getViewModel( + getFormData(item.value), + undefined, + stubTranslator + ) expect(viewModel.items?.[0]).not.toMatchObject({ value: '' // First item is never empty diff --git a/src/server/plugins/engine/components/SelectField.test.ts b/src/server/plugins/engine/components/SelectField.test.ts index 19dad941d..07d3c369a 100644 --- a/src/server/plugins/engine/components/SelectField.test.ts +++ b/src/server/plugins/engine/components/SelectField.test.ts @@ -8,6 +8,7 @@ import { type Field } from '~/src/server/plugins/engine/components/helpers/components.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import { listNumber, listNumberExamples, @@ -233,7 +234,11 @@ describe.each([ describe('View model', () => { it('sets Nunjucks component defaults', () => { const item = options.examples[0] - const viewModel = field.getViewModel(getFormData(item.value)) + const viewModel = field.getViewModel( + getFormData(item.value), + undefined, + stubTranslator + ) expect(viewModel).toEqual( expect.objectContaining({ @@ -248,7 +253,11 @@ describe.each([ it.each([...options.examples])( 'sets Nunjucks component select options', (item) => { - const viewModel = field.getViewModel(getFormData(item.value)) + const viewModel = field.getViewModel( + getFormData(item.value), + undefined, + stubTranslator + ) expect(viewModel.items?.[0]).toMatchObject({ value: '' // First item is always empty diff --git a/src/server/plugins/engine/components/SelectField.ts b/src/server/plugins/engine/components/SelectField.ts index 2505f5447..23f78cfaf 100644 --- a/src/server/plugins/engine/components/SelectField.ts +++ b/src/server/plugins/engine/components/SelectField.ts @@ -4,6 +4,7 @@ import { } from '@defra/forms-model' import { ListFormComponent } from '~/src/server/plugins/engine/components/ListFormComponent.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type FormPayload, type FormSubmissionError @@ -33,8 +34,18 @@ export class SelectField extends ListFormComponent { this.options = options } - getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { - const viewModel = super.getViewModel(payload, errors) + getViewModel( + payload: FormPayload, + errors: FormSubmissionError[] | undefined, + translator: Translator, + isForceAccess = false + ) { + const viewModel = super.getViewModel( + payload, + errors, + translator, + isForceAccess + ) let { items } = viewModel items = [{ value: '' }, ...items] diff --git a/src/server/plugins/engine/components/SelectionControlField.ts b/src/server/plugins/engine/components/SelectionControlField.ts index 1f1070d79..d2bcab479 100644 --- a/src/server/plugins/engine/components/SelectionControlField.ts +++ b/src/server/plugins/engine/components/SelectionControlField.ts @@ -1,5 +1,6 @@ import { ListFormComponent } from '~/src/server/plugins/engine/components/ListFormComponent.js' import { type ListItem } from '~/src/server/plugins/engine/components/types.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -11,10 +12,20 @@ import { * "Selection controls" are checkboxes and radios (and switches), as per Material UI nomenclature. */ export class SelectionControlField extends ListFormComponent { - getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { + getViewModel( + payload: FormPayload, + errors: FormSubmissionError[] | undefined, + translator: Translator, + isForceAccess = false + ) { const { options } = this - const viewModel = super.getViewModel(payload, errors) + const viewModel = super.getViewModel( + payload, + errors, + translator, + isForceAccess + ) let { fieldset, items, label } = viewModel fieldset ??= { diff --git a/src/server/plugins/engine/components/TelephoneNumberField.test.ts b/src/server/plugins/engine/components/TelephoneNumberField.test.ts index 8ac5863cd..f6742ca07 100644 --- a/src/server/plugins/engine/components/TelephoneNumberField.test.ts +++ b/src/server/plugins/engine/components/TelephoneNumberField.test.ts @@ -9,6 +9,7 @@ import { type Field } from '~/src/server/plugins/engine/components/helpers/components.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import definition from '~/test/form/definitions/blank.js' import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' @@ -220,7 +221,9 @@ describe('TelephoneNumberField', () => { describe('View model', () => { it('sets Nunjucks component defaults', () => { const viewModel = field.getViewModel( - getFormData('Telephone number field') + getFormData('Telephone number field'), + undefined, + stubTranslator ) expect(viewModel).toEqual( diff --git a/src/server/plugins/engine/components/TelephoneNumberField.ts b/src/server/plugins/engine/components/TelephoneNumberField.ts index 4f817bd51..9b391d0bb 100644 --- a/src/server/plugins/engine/components/TelephoneNumberField.ts +++ b/src/server/plugins/engine/components/TelephoneNumberField.ts @@ -3,6 +3,7 @@ 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/index.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -55,8 +56,18 @@ export class TelephoneNumberField extends FormComponent { this.options = options } - getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { - const viewModel = super.getViewModel(payload, errors) + getViewModel( + payload: FormPayload, + errors: FormSubmissionError[] | undefined, + translator: Translator, + isForceAccess = false + ) { + const viewModel = super.getViewModel( + payload, + errors, + translator, + isForceAccess + ) const { attributes } = viewModel attributes.autocomplete = 'tel' diff --git a/src/server/plugins/engine/components/TextField.test.ts b/src/server/plugins/engine/components/TextField.test.ts index 724491ab1..3ef3e5e05 100644 --- a/src/server/plugins/engine/components/TextField.test.ts +++ b/src/server/plugins/engine/components/TextField.test.ts @@ -1,11 +1,13 @@ import { ComponentType, type TextFieldComponent } from '@defra/forms-model' + import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { getAnswer, type Field } from '~/src/server/plugins/engine/components/helpers/components.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import definition from '~/test/form/definitions/blank.js' import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' @@ -184,7 +186,11 @@ describe('TextField', () => { describe('View model', () => { it('sets Nunjucks component defaults', () => { - const viewModel = field.getViewModel(getFormData('Text field')) + const viewModel = field.getViewModel( + getFormData('Text field'), + undefined, + stubTranslator + ) expect(viewModel).toEqual( expect.objectContaining({ @@ -228,7 +234,7 @@ describe('TextField', () => { }) it('falls back to English when no translator supplied', () => { - const viewModel = field.getViewModel({}) + const viewModel = field.getViewModel({}, undefined, stubTranslator) expect(viewModel.label.text).toBe('Example text field') }) }) diff --git a/src/server/plugins/engine/components/UkAddressField.test.ts b/src/server/plugins/engine/components/UkAddressField.test.ts index 98e6f2f7e..b67780127 100644 --- a/src/server/plugins/engine/components/UkAddressField.test.ts +++ b/src/server/plugins/engine/components/UkAddressField.test.ts @@ -4,6 +4,7 @@ import { type UkAddressFieldComponent } from '@defra/forms-model' + import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { UkAddressField } from '~/src/server/plugins/engine/components/UkAddressField.js' import { @@ -12,6 +13,7 @@ import { } from '~/src/server/plugins/engine/components/helpers/components.js' import { type ViewModel } from '~/src/server/plugins/engine/components/types.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import { type FormPayload, type FormState @@ -423,7 +425,7 @@ describe('UkAddressField', () => { it('sets Nunjucks component defaults', () => { const payload = getFormData(address) - const viewModel = field.getViewModel(payload) + const viewModel = field.getViewModel(payload, undefined, stubTranslator) expect(viewModel).toEqual( expect.objectContaining({ @@ -484,7 +486,7 @@ describe('UkAddressField', () => { it('sets Nunjucks component fieldset', () => { const payload = getFormData(address) - const viewModel = field.getViewModel(payload) + const viewModel = field.getViewModel(payload, undefined, stubTranslator) expect(viewModel.fieldset).toEqual({ legend: { diff --git a/src/server/plugins/engine/components/UkAddressField.ts b/src/server/plugins/engine/components/UkAddressField.ts index 3bfd8a143..5556e0183 100644 --- a/src/server/plugins/engine/components/UkAddressField.ts +++ b/src/server/plugins/engine/components/UkAddressField.ts @@ -29,7 +29,6 @@ import { type PostcodeLookupExternalArgs } from '~/src/server/plugins/engine/types.js' import { dispatch } from '~/src/server/plugins/postcode-lookup/routes/index.js' -import { type FormQuery } from '~/src/server/routes/types.js' export class UkAddressField extends FormComponent { declare options: UkAddressFieldComponent['options'] @@ -201,12 +200,18 @@ export class UkAddressField extends FormComponent { getViewModel( payload: FormPayload, - errors?: FormSubmissionError[], - translatorOrQuery?: Translator | FormQuery + errors: FormSubmissionError[] | undefined, + translator: Translator, + isForceAccess = false ) { const { collection, name, options } = this - const viewModel = super.getViewModel(payload, errors, translatorOrQuery) + const viewModel = super.getViewModel( + payload, + errors, + translator, + isForceAccess + ) let { fieldset, hint, label } = viewModel fieldset ??= { @@ -230,11 +235,7 @@ export class UkAddressField extends FormComponent { } } - const components = collection.getViewModel( - payload, - errors, - translatorOrQuery - ) + const components = collection.getViewModel(payload, errors, translator) // Hide UPRN const uprn = components.at(0) diff --git a/src/server/plugins/engine/components/YesNoField.test.ts b/src/server/plugins/engine/components/YesNoField.test.ts index 1178c9f7a..fed2f7f54 100644 --- a/src/server/plugins/engine/components/YesNoField.test.ts +++ b/src/server/plugins/engine/components/YesNoField.test.ts @@ -6,6 +6,7 @@ import { type Field } from '~/src/server/plugins/engine/components/helpers/components.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import { listYesNoExamples } from '~/test/fixtures/list.js' import definition from '~/test/form/definitions/blank.js' import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' @@ -215,7 +216,11 @@ describe('YesNoField', () => { it('sets Nunjucks component defaults', () => { const item = items[0] - const viewModel = field.getViewModel(getFormData(item.value)) + const viewModel = field.getViewModel( + getFormData(item.value), + undefined, + stubTranslator + ) expect(viewModel).toEqual( expect.objectContaining({ @@ -228,7 +233,11 @@ describe('YesNoField', () => { }) it.each([...items])('sets Nunjucks component radio items', (item) => { - const viewModel = field.getViewModel(getFormData(item.value)) + const viewModel = field.getViewModel( + getFormData(item.value), + undefined, + stubTranslator + ) expect(viewModel.items?.[0]).not.toMatchObject({ value: '' // First item is never empty diff --git a/src/server/plugins/engine/components/helpers/components.ts b/src/server/plugins/engine/components/helpers/components.ts index 87e1cc264..b268a2e86 100644 --- a/src/server/plugins/engine/components/helpers/components.ts +++ b/src/server/plugins/engine/components/helpers/components.ts @@ -6,6 +6,7 @@ import { ListFormComponent } from '~/src/server/plugins/engine/components/ListFo import { escapeMarkdown } from '~/src/server/plugins/engine/components/helpers/index.js' import * as Components from '~/src/server/plugins/engine/components/index.js' import { markdown } from '~/src/server/plugins/engine/components/markdownParser.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type FormState } from '~/src/server/plugins/engine/types.js' // All component instances @@ -221,7 +222,8 @@ export function getAnswer( | 'data' // Submission data | 'email' // GOV.UK Notify emails | 'summary' // Check answers summary - } = { format: 'summary' } + } = { format: 'summary' }, + translator?: Translator ) { // Use escaped display text for GOV.UK Notify emails if (options.format === 'email') { @@ -248,7 +250,7 @@ export function getAnswer( } // Use display text for check answers summary (single line) - return field.getDisplayStringFromState(state) + return field.getDisplayStringFromState(state, translator) } /** diff --git a/src/server/plugins/engine/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts index 1d40bd774..cf730b65c 100644 --- a/src/server/plugins/engine/configureEnginePlugin.ts +++ b/src/server/plugins/engine/configureEnginePlugin.ts @@ -68,8 +68,8 @@ export const configureEnginePlugin = async ( saveAndExit, ordnanceSurveyApiKey, ordnanceSurveyApiSecret, - getLanguage: (_request) => { - return 'cy' + getLanguage: (request) => { + return 'lang' in request.query ? request.query.lang : 'en-GB' } } } diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index d157b7d37..bcd644da9 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -581,7 +581,12 @@ export class FormModel { const text = translator ? translator.t('errors.optionsMismatch') - : translate('errors.optionsMismatch', 'en-GB') + : translate( + 'errors.optionsMismatch', + typeof this.def.metadata?.language === 'string' + ? this.def.metadata.language + : 'en-GB' + ) context.errors.push({ text, diff --git a/src/server/plugins/engine/models/SummaryViewModel.ts b/src/server/plugins/engine/models/SummaryViewModel.ts index 837d99d22..d9f58666b 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.ts @@ -58,6 +58,7 @@ export class SummaryViewModel { paymentState?: PaymentState paymentDetails?: CheckAnswers t: (key: string, opts?: Record) => string + private _translator!: Translator constructor( request: FormContextRequest, @@ -71,6 +72,7 @@ export class SummaryViewModel { const { t } = translator this.t = t + this._translator = translator this.page = page this.pageTitle = page.title @@ -89,7 +91,7 @@ export class SummaryViewModel { // Format errors this.errors = result.error?.details.map(getError) - this.details = this.summaryDetails(request, sections) + this.details = this.summaryDetails(request, sections, translator) // Format check answers this.checkAnswers = this.details.map((detail): CheckAnswers => { @@ -129,7 +131,11 @@ export class SummaryViewModel { }) } - private summaryDetails(request: FormContextRequest, sections: Section[]) { + private summaryDetails( + request: FormContextRequest, + sections: Section[], + translator: Translator + ) { const { context, errors } = this const { relevantPages, state } = context @@ -158,7 +164,9 @@ export class SummaryViewModel { if (field instanceof PaymentField) { continue } - items.push(ItemField(page, state, field, { path, errors })) + items.push( + ItemField(page, state, field, { path, errors }, translator) + ) } } }) @@ -225,7 +233,8 @@ export function ItemField( options: { path: string errors?: FormSubmissionError[] - } + }, + translator?: Translator ): DetailItemField { return { name: field.name, @@ -235,7 +244,7 @@ export function ItemField( ? `${field.label} (optional)` : field.label, error: field.getFirstError(options.errors), - value: getAnswer(field, state), + value: getAnswer(field, state, { format: 'summary' }, translator), 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 e1acc9617..1252647e9 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -447,7 +447,7 @@ export class QuestionPageController extends PageController { } const viewModel = this.getViewModel(request, context, translator) - viewModel.errors = collection.getViewErrors(viewModel.errors) + viewModel.errors = collection.getViewErrors(viewModel.errors, translator) const flashedError = request.yar.flash(COMPONENT_STATE_ERROR) const flashedErrors = !Array.isArray(flashedError) ? [flashedError] : [] @@ -572,7 +572,10 @@ export class QuestionPageController extends PageController { */ if (context.errors || isForceAccess) { const viewModel = this.getViewModel(request, context, translator) - viewModel.errors = collection.getViewErrors(viewModel.errors) + viewModel.errors = collection.getViewErrors( + viewModel.errors, + translator + ) // Filter our components based on their conditions using our evaluated state viewModel.components = this.filterConditionalComponents( diff --git a/src/server/plugins/engine/routes/questions.test.ts b/src/server/plugins/engine/routes/questions.test.ts index 23bf504b6..2c1ea6ced 100644 --- a/src/server/plugins/engine/routes/questions.test.ts +++ b/src/server/plugins/engine/routes/questions.test.ts @@ -86,6 +86,7 @@ describe('makeGetHandler', () => { const contextMock = { data: {}, model: {} } as unknown as FormContext const requestMock = { + server: { plugins: { 'forms-engine-plugin': {} } }, params: { path: 'some-path' }, app: { model: modelMock } } as FormRequest @@ -132,6 +133,7 @@ describe('makeGetHandler', () => { const contextMock = { data: {}, model: {} } as unknown as FormContext const requestMock = { + server: { plugins: { 'forms-engine-plugin': {} } }, params: { path: 'some-path' }, app: { model: modelMock } } as FormRequest @@ -173,6 +175,7 @@ describe('makeGetHandler', () => { const contextMock = { data: {}, model: {} } as unknown as FormContext const requestMock = { + server: { plugins: { 'forms-engine-plugin': {} } }, params: { path: 'some-path' }, app: {} } as FormRequest @@ -225,6 +228,7 @@ describe('makeGetHandler', () => { const contextMock = { data: {}, model: {} } as unknown as FormContext const requestMock = { + server: { plugins: { 'forms-engine-plugin': {} } }, params: { path: 'some-path' }, app: { model: modelMock } } as FormRequest @@ -295,6 +299,7 @@ describe('makePostHandler', () => { const contextMock = { data: {}, model: {} } as unknown as FormContext const requestMock = { + server: { plugins: { 'forms-engine-plugin': {} } }, params: { path: 'some-path' }, app: { model: modelMock }, payload: { some: 'payload' } @@ -338,6 +343,7 @@ describe('makePostHandler', () => { const contextMock = { data: {}, model: {} } as unknown as FormContext const requestMock = { + server: { plugins: { 'forms-engine-plugin': {} } }, params: { path: 'some-path' }, app: { model: modelMock }, payload: { some: 'payload' } @@ -383,6 +389,7 @@ describe('makePostHandler', () => { const contextMock = { data: {}, model: {} } as unknown as FormContext const requestMock = { + server: { plugins: { 'forms-engine-plugin': {} } }, params: { path: 'some-path' }, app: { model: modelMock }, payload: { some: 'payload' } @@ -425,6 +432,7 @@ describe('makePostHandler', () => { const contextMock = { data: {}, model: {} } as unknown as FormContext const requestMock = { + server: { plugins: { 'forms-engine-plugin': {} } }, params: { path: 'some-path' }, app: {}, payload: { some: 'payload' } @@ -478,6 +486,7 @@ describe('makePostHandler', () => { const contextMock = { data: {}, model: {} } as unknown as FormContext const requestMock = { + server: { plugins: { 'forms-engine-plugin': {} } }, params: { path: 'some-path' }, app: { model: modelMock }, payload: { some: 'payload' } diff --git a/src/server/plugins/engine/routes/questions.ts b/src/server/plugins/engine/routes/questions.ts index d2d9473ac..81be2b3bb 100644 --- a/src/server/plugins/engine/routes/questions.ts +++ b/src/server/plugins/engine/routes/questions.ts @@ -8,6 +8,7 @@ import { import Joi from 'joi' import { + getPluginOptions, normalisePath, proceed, redirectPath @@ -58,7 +59,9 @@ async function handleHttpEvent( // TODO: Update structured data POST payload with when helper // is updated to removing the dependency on `SummaryViewModel` etc. - const translator = model.createTranslator('en-GB') + const { getLanguage } = getPluginOptions(request.server) + const language = getLanguage?.(request) ?? 'en-GB' + const translator = model.createTranslator(language) const viewModel = new SummaryViewModel(request, page, context, translator) const items = getFormSubmissionData(viewModel.context, viewModel.details) From 5cb37e274325f9e7f69fbbf0d8667401aa87e072 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Tue, 28 Apr 2026 11:18:54 +0100 Subject: [PATCH 61/89] feat(i18n): add Welsh (cy) translations Translate all plugin UI strings into Welsh. Register cy locale in both the global i18next instance and createFormI18nInstance so per-request translators resolve Welsh strings when language is set to 'cy'. --- src/server/plugins/engine/i18n/index.test.ts | 6 +- src/server/plugins/engine/i18n/index.ts | 5 + .../plugins/engine/i18n/translations/cy.json | 258 ++++++++++++++++++ 3 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 src/server/plugins/engine/i18n/translations/cy.json diff --git a/src/server/plugins/engine/i18n/index.test.ts b/src/server/plugins/engine/i18n/index.test.ts index 7ffadc3f8..030f4ea3a 100644 --- a/src/server/plugins/engine/i18n/index.test.ts +++ b/src/server/plugins/engine/i18n/index.test.ts @@ -9,7 +9,11 @@ describe('i18n t()', () => { }) it('falls back to en-GB for an unknown language', () => { - expect(t('errors.title', 'cy')).toBe('There is a problem') + expect(t('errors.title', 'fr')).toBe('There is a problem') + }) + + it('returns the Welsh string for cy locale', () => { + expect(t('errors.title', 'cy')).toBe('Mae problem') }) it('returns the key string itself when the key does not exist', () => { diff --git a/src/server/plugins/engine/i18n/index.ts b/src/server/plugins/engine/i18n/index.ts index 8af122c8f..96efc8edb 100644 --- a/src/server/plugins/engine/i18n/index.ts +++ b/src/server/plugins/engine/i18n/index.ts @@ -1,5 +1,6 @@ import i18next, { createInstance, type i18n } from 'i18next' +import cy from '~/src/server/plugins/engine/i18n/translations/cy.json' with { type: 'json' } import enGB from '~/src/server/plugins/engine/i18n/translations/en-GB.json' with { type: 'json' } import xPirate from '~/src/server/plugins/engine/i18n/translations/x-pirate.json' with { type: 'json' } import { type FormDefinitionTranslations } from '~/src/server/plugins/engine/i18n/types.js' @@ -10,6 +11,7 @@ i18next .init({ resources: { 'en-GB': { translation: enGB }, + cy: { translation: cy }, 'x-pirate': { translation: xPirate } }, fallbackLng: 'en-GB', @@ -41,6 +43,9 @@ export function createFormI18nInstance(formEnGb: BaseTranslations): i18n { plugin: enGB, form: formEnGb }, + cy: { + plugin: cy + }, 'x-pirate': { plugin: xPirate } diff --git a/src/server/plugins/engine/i18n/translations/cy.json b/src/server/plugins/engine/i18n/translations/cy.json new file mode 100644 index 000000000..c981df0df --- /dev/null +++ b/src/server/plugins/engine/i18n/translations/cy.json @@ -0,0 +1,258 @@ +{ + "validation": { + "required": "Nodwch {{lowerFirst(#label)}}", + "selectRequired": "Dewiswch {{lowerFirst(#label)}}", + "pattern": "Nodwch {{lowerFirst(#label)}} dilys", + "format": "Nodwch {{lowerFirst(#label)}} yn y fformat cywir", + "objectRequired": "Nodwch {{#label}}", + "objectMissing": "Rhaid i {{#title}} gynnwys {{lowerFirst(#label)}}", + "max": "Rhaid i {{#label}} fod yn {{#limit}} o nodau neu lai", + "min": "Rhaid i {{#label}} fod yn {{#limit}} o nodau neu fwy", + "minMax": "Rhaid i {{#label}} fod rhwng {{#min}} a {{#max}} o nodau", + "number": "Rhaid i {{#label}} fod yn rhif", + "numberPrecision": "Rhaid i {{#label}} beidio â chael mwy na {{#limit}} lle degol", + "numberInteger": "Rhaid i {{#label}} fod yn rhif cyfan", + "numberMin": "Rhaid i {{#label}} fod yn {{#limit}} neu uwch", + "numberMax": "Rhaid i {{#label}} fod yn {{#limit}} neu is", + "maxWords": "Rhaid i {{#label}} fod yn {{#limit}} o eiriau neu lai" + }, + + "errors": { + "title": "Mae problem", + "optionsMismatch": "Mae'r opsiynau'n wahanol oherwydd i chi newid ateb blaenorol" + }, + + "common": { + "optional": "(dewisol)", + "warningIconFallback": "Rhybudd", + "back": "Yn ôl", + "continue": "Parhau", + "saveAndExit": "Cadw a gadael" + }, + + "pages": { + "question": { + "paymentCancelledBannerTitle": "Pwysig", + "paymentCancelledTitle": "Mae eich taliad wedi'i ganslo", + "paymentCancelledBody": "Cafodd eich manylion talu eu dileu oherwydd bod y ffurflen yn anweithredol am 5 diwrnod.", + "paymentCancelledAction": "Ychwanegwch eich manylion talu eto.", + "backToAddAnother": "Ewch yn ôl i ychwanegu un arall", + "backToCheckAnswers": "Ewch yn ôl i wirio'ch atebion", + "startNow": "Dechrau nawr" + }, + + "summary": { + "title": "Gwiriwch eich atebion cyn anfon eich ffurflen", + "change": "Newid", + "notProvided": "Heb ei ddarparu", + "declaration": "Datganiad", + "acceptAndSubmit": "Derbyn a chyflwyno", + "submit": "Cyflwyno", + "saveAndExit": "Cadw a gadael", + "paymentSuccessBannerTitle": "Llwyddiant", + "paymentSuccessTitle": "Mae gennym eich manylion talu", + "paymentSuccessBody": "Mae eich taliad ar ddal. Byddwn yn codi tâl arnoch pan fyddwch yn cyflwyno'r ffurflen.", + "paymentDetailsTitle": "Manylion talu", + "paymentFor": "Taliad am", + "totalAmount": "Cyfanswm", + "reference": "Cyfeirnod", + "dateOfPayment": "Dyddiad talu", + "submissionFailed": "Roedd problem ac ni chyflwynwyd eich ffurflen. Ceisiwch gyflwyno'r ffurflen eto", + "submissionFailedContactSuffix": " neu gallwch [[contactUsLink]] a dyfynnu'ch cyfeirnod i drefnu ad-daliad", + "contactUsLinkText": "cysylltu â ni (yn agor mewn tab newydd)" + }, + + "repeater": { + "removeAnswer": "Ydych chi'n siŵr eich bod am dynnu'r ateb hwn?", + "remove": "Tynnu", + "cancel": "Canslo", + "lastItemCannotBeRemoved": "Ni ellir tynnu eitem olaf y rhestr", + "itemToRemoveNotFound": "Ni chanfuwyd eitem y rhestr i'w thynnu", + "continue": "Parhau", + "addAnother": "Ychwanegu un arall", + "saveAndExit": "Cadw a gadael", + "change": "Newid", + "notProvided": "Heb ei ddarparu", + "visuallyHiddenItem": "eitem [[index]]", + "pageTitle_one": "Rydych wedi ychwanegu [[count]] ateb", + "pageTitle_other": "Rydych wedi ychwanegu [[count]] ateb", + "tooMany_one": "Dim ond hyd at [[count]] ateb y gallwch ei ychwanegu", + "tooMany_other": "Dim ond hyd at [[count]] ateb y gallwch eu hychwanegu", + "tooFew_one": "Rhaid i chi ychwanegu o leiaf [[count]] ateb", + "tooFew_other": "Rhaid i chi ychwanegu o leiaf [[count]] ateb" + }, + + "fileUpload": { + "upload": "Uwchlwytho ffeil", + "maxFilesReached": "Rydych wedi cyrraedd nifer uchaf y ffeiliau. Tynnwch ffeil i uwchlwytho rhagor." + }, + + "exit": { + "defaultTitle": "Mae eich cynnydd wedi'i gadw", + "whatHappensNext": "Beth sy'n digwydd nesaf", + "body": "Mae cynnydd eich ffurflen wedi'i gadw. Gallwch ddychwelyd i gwblhau eich cais ar unrhyw adeg gan ddefnyddio'r ddolen a ddarperir.", + "returnToApplication": "Dychwelyd i'r cais" + }, + + "confirmation": { + "whatHappensNext": "Beth sy'n digwydd nesaf", + "referenceNumber": "Eich cyfeirnod", + "feedbackLink": "Beth yw eich barn am y gwasanaeth hwn? (yn cymryd 30 eiliad)" + }, + + "preview": { + "warnMissingNotificationEmail": { + "body": "Os hoffech dderbyn e-bost prawf, [[link]] yr hoffech i gyflwyniadau ffurflen fynd iddo.", + "linkText": "nodwch y cyfeiriad e-bost (yn agor mewn tab newydd)" + } + } + }, + + "postcodeLookup": { + "postcodeLabel": "Cod post", + "postcodeHint": "Er enghraifft, AA3 1AB", + "buildingNameLabel": "Enw neu rif yr adeilad (dewisol)", + "buildingNameHint": "Er enghraifft, 15 neu Bryn Melyn", + "selectAddress": "Dewiswch gyfeiriad", + "addressLine1Label": "Llinell cyfeiriad 1", + "addressLine2Label": "Llinell cyfeiriad 2 (dewisol)", + "townLabel": "Tref neu ddinas", + "countyLabel": "Sir (dewisol)", + "findAddress": "Dod o hyd i gyfeiriad", + "enterManually": "nodwch gyfeiriad â llaw", + "searchAgain": "Chwilio eto", + "useThisAddress": "Defnyddio'r cyfeiriad hwn", + "findAnAddressInstead": "dod o hyd i gyfeiriad yn lle hynny", + "noAddressFoundTitle": "Ni chanfuwyd cyfeiriad", + "noAddressFoundBody": "Ni allem ddod o hyd i gyfeiriad cyfatebol", + "addressFound_one": "Canfuwyd [[count]] cyfeiriad ar gyfer", + "addressFound_other": "Canfuwyd [[count]] cyfeiriad ar gyfer", + "validation": { + "invalidPostcode": "Nodwch god post dilys neu nodwch gyfeiriad â llaw", + "requiredPostcode": "Nodwch god post", + "selectAddress": "Dewiswch gyfeiriad", + "requiredAddressLine1": "Nodwch linell cyfeiriad 1", + "requiredTown": "Nodwch dref neu ddinas", + "invalidManualPostcode": "Nodwch god post dilys", + "requiredManualPostcode": "Nodwch god post" + } + }, + + "components": { + "dateField": { + "day": "Diwrnod", + "month": "Mis", + "year": "Blwyddyn", + "validation": { + "dateFormat": "Rhaid i {{#title}} fod yn ddyddiad go iawn", + "dateMin": "Rhaid i {{#title}} fod yr un fath â neu ar ôl {{#limit}}", + "dateMax": "Rhaid i {{#title}} fod yr un fath â neu cyn {{#limit}}" + } + }, + + "monthYearField": { + "month": "Mis", + "year": "Blwyddyn" + }, + + "addressField": { + "uprn": "UPRN", + "line1": "Llinell cyfeiriad 1", + "line2": "Llinell cyfeiriad 2", + "town": "Tref neu ddinas", + "county": "Sir", + "postcode": "Cod post", + "enterAddress": "Nodwch [[shortDescription]]", + "findAddress": "Dod o hyd i gyfeiriad", + "selectedAddress": "Cyfeiriad a ddewiswyd:", + "useDifferentAddress": "Defnyddio cyfeiriad gwahanol", + "enterManually": "nodwch gyfeiriad â llaw" + }, + + "latLongField": { + "latitude": "Lledred", + "longitude": "Hydred", + "latitudeRequired": "Nodwch ledred", + "longitudeRequired": "Nodwch hydred", + "precision": "Rhaid i {{#label}} beidio â chael mwy na 7 lle degol", + "notANumber": "Rhaid i {{#label}} fod yn rif dilys", + "latitudeBase": "Nodwch ledred dilys ar gyfer [[fieldLabel]] fel 51.519450", + "longitudeBase": "Nodwch hydred dilys ar gyfer [[fieldLabel]] fel -0.127758", + "latitudeRange": "Rhaid i ledred [[fieldLabel]] fod rhwng [[min]] a [[max]]", + "longitudeRange": "Rhaid i hydred [[fieldLabel]] fod rhwng [[min]] a [[max]]" + }, + + "eastingNorthingField": { + "easting": "Dwyreiniannau", + "northing": "Gogleddiannau", + "eastingRequired": "Nodwch ddwyreiniannau", + "northingRequired": "Nodwch ogleddiannau", + "eastingDigits": "Rhaid i {{#label}} ar gyfer [[fieldLabel]] fod rhwng 1 a 6 digid", + "northingDigits": "Rhaid i {{#label}} ar gyfer [[fieldLabel]] fod rhwng 1 a 7 digid", + "eastingRange": "Rhaid i {{#label}} ar gyfer [[fieldLabel]] fod rhwng [[min]] a [[max]]", + "northingRange": "Rhaid i {{#label}} ar gyfer [[fieldLabel]] fod rhwng [[min]] a [[max]]" + }, + + "nationalGridField": { + "required": "Nodwch {{lowerFirst(#title)}}", + "pattern": "Nodwch rif maes Grid Cenedlaethol dilys ar gyfer {{lowerFirst(#title)}} fel NG 1234 5678", + "howToFind": "Sut i ddod o hyd i fanylion lleoliad" + }, + + "osGridRefField": { + "required": "Nodwch {{lowerFirst(#title)}}", + "pattern": "Nodwch gyfeirnod grid AO dilys ar gyfer {{lowerFirst(#title)}} fel TQ123456", + "howToFind": "Sut i ddod o hyd i fanylion lleoliad" + }, + + "locationFieldBase": { + "howToFind": "Sut i ddod o hyd i fanylion lleoliad" + }, + + "yesNoField": { + "yes": "Ie", + "no": "Na", + "validation": { + "selectYesNoRequired": "{{#label}} - dewiswch ie neu na" + } + }, + + "declarationField": { + "defaultLabel": "Rwy'n deall ac yn cytuno", + "defaultConfirmationLabel": "Rwy'n cadarnhau fy mod yn deall ac yn derbyn y datganiad hwn", + "notProvided": "Heb ei ddarparu", + "validation": { + "declarationRequired": "Rhaid i chi gadarnhau eich bod yn deall ac yn cytuno â'r {{lowerFirst(#label)}} i barhau" + } + }, + + "geospatialField": { + "added_one": "Ychwanegwyd [[count]] lleoliad", + "added_other": "Ychwanegwyd [[count]] lleoliad" + }, + + "paymentField": { + "detailsRequired": "Angen manylion talu", + "pendingTransaction": "Mae'n bosibl y gwelwch drafodiad ar y gweill yn eich cyfrif banc ond dim ond pan fyddwch yn cyflwyno'r ffurflen y codir tâl arnoch.", + "addPaymentDetails": "Ychwanegu manylion talu", + "cannotTakePayment": "Mae problem ac ni allwn dderbyn taliad. Cysylltwch â ni (manylion yn nhroedyn y ffurflen hon) neu cadwch eich cynnydd a dychwelyd at y ffurflen yn ddiweddarach.", + "testApiKey": "Ychwanegwch allwedd API prawf ddilys cyn i chi allu rhagolwg y daith talu.", + "completePayment": "Cwblhewch y taliad i barhau", + "paymentExpired": "Mae awdurdodiad eich taliad wedi dod i ben. Ychwanegwch eich manylion talu eto.", + "submissionFailed": "Roedd problem ac ni chyflwynwyd eich ffurflen. Ceisiwch gyflwyno'r ffurflen eto.", + "canSubmitAfterPayment": "Gallwch gyflwyno'r ffurflen ar ôl i chi ychwanegu eich manylion talu.", + "preAuthorisedTitle": "Rydych eisoes wedi awdurdodi taliad ar gyfer y ffurflen hon", + "preAuthorisedBody": "Parhewch i gyflwyno'r ffurflen. Ni chodir tâl arnoch ddwywaith.", + "totalAmountLabel": "Cyfanswm:", + "paymentAmountMismatch": "Mae swm y taliad a rhaganrhuddhawyd rywsut yn wahanol i'r hyn a ofynnwyd amdano. Ceisiwch ychwanegu manylion talu eto." + }, + + "fileUploadField": { + "uploaded": "Wedi'i uwchlwytho", + "uploadFailed": "Roedd problem gyda'ch ffeiliau a uwchlwythwyd. Uwchlwythwch nhw eto cyn cyflwyno'r ffurflen.", + "uploadedFilesHeading": "Ffeiliau a uwchlwythwyd", + "filesCount_one": "Uwchlwythwyd [[count]] ffeil", + "filesCount_other": "Uwchlwythwyd [[count]] ffeil" + } + } +} From d7a18b0f65f77dce1c30112d9e1220e9dbe2e7d5 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Tue, 28 Apr 2026 11:29:29 +0100 Subject: [PATCH 62/89] fix(i18n): apply translated shortDescription as Joi label at validation time ComponentCollection.validate now rebuilds the Joi schema with the translated shortDescription (or title) as the field label when a Translator is provided. This ensures #label in validation message templates like 'Nodwch {{lowerFirst(#label)}}' resolves to the translated value (e.g. 'eich enw olaf') rather than the construction-time English value ('your last name'). --- .../engine/components/ComponentCollection.ts | 24 ++++++++- .../engine/components/TextField.test.ts | 50 ++++++++++++++++++- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/server/plugins/engine/components/ComponentCollection.ts b/src/server/plugins/engine/components/ComponentCollection.ts index 64e59d550..b2995ea7d 100644 --- a/src/server/plugins/engine/components/ComponentCollection.ts +++ b/src/server/plugins/engine/components/ComponentCollection.ts @@ -270,7 +270,29 @@ export class ComponentCollection { ? buildLanguageMessages(translator.t) : undefined - const result = this.formSchema.validate(value, { + // When translating, override each non-composite field's Joi label with the + // translated shortDescription (or title) so #label in message templates + // resolves to the correct language rather than the English construction-time value. + let schema = this.formSchema + if (translator) { + const labelOverrides: Record = {} + for (const field of this.fields) { + if (field.collection) continue // composite fields use #title, handled differently + const translatedLabel = + translator.tContent( + field as unknown as ComponentDef, + 'shortDescription' + ) || translator.tContent(field as unknown as ComponentDef, 'title') + if (translatedLabel && translatedLabel !== field.label) { + labelOverrides[field.name] = field.formSchema.label(translatedLabel) + } + } + if (Object.keys(labelOverrides).length) { + schema = schema.keys(labelOverrides) + } + } + + const result = schema.validate(value, { ...opts, ...(messages && { messages }) }) diff --git a/src/server/plugins/engine/components/TextField.test.ts b/src/server/plugins/engine/components/TextField.test.ts index 3ef3e5e05..8c56809c4 100644 --- a/src/server/plugins/engine/components/TextField.test.ts +++ b/src/server/plugins/engine/components/TextField.test.ts @@ -1,6 +1,5 @@ import { ComponentType, type TextFieldComponent } from '@defra/forms-model' - import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { getAnswer, @@ -125,6 +124,55 @@ describe('TextField', () => { expect(result1.errors).toBeTruthy() expect(result2.errors).toBeTruthy() }) + + it('uses translated shortDescription as #label in validation error when translator provided', () => { + const componentId = 'b68df7f1-d4f4-4c17-83c8-402f584906c9' + const cyModel = new FormModel( + { + ...definition, + metadata: { + language: 'en-GB', + translations: { + cy: { + components: { + [componentId]: { + title: 'Beth yw eich enw olaf?', + shortDescription: 'Eich enw olaf' + } + }, + pages: {}, + sections: {}, + listItems: {} + } + } + } + }, + { basePath: 'test' } + ) + + const cyDef: TextFieldComponent = { + id: componentId, + title: 'What is your last name?', + name: 'applicantLastName', + shortDescription: 'Your last name', + type: ComponentType.TextField, + options: { required: true }, + schema: {} + } + + const cyCollection = new ComponentCollection([cyDef], { + model: cyModel + }) + const translator = cyModel.createTranslator('cy') + const result = cyCollection.validate( + { applicantLastName: '' }, + translator + ) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: 'Nodwch eich enw olaf' }) + ]) + }) }) describe('State', () => { From bbae7e09d14e4080bb61193d0aacc9e515d912fc Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 29 Apr 2026 11:01:45 +0100 Subject: [PATCH 63/89] fix(i18n): use translated shortDescription and title in summary table ItemField rows ItemField now uses tContent to resolve translated shortDescription (row key) and title (Change link visuallyHiddenText) for each field. Builds a lookup object with the English values so the GUID-based translation lookup fires correctly. ItemRepeat now accepts and passes translator, and uses t('pages.repeater.pageTitle') for the repeater value string instead of a hardcoded English template. --- .../plugins/engine/models/SummaryViewModel.ts | 57 ++++++++++++++----- 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/src/server/plugins/engine/models/SummaryViewModel.ts b/src/server/plugins/engine/models/SummaryViewModel.ts index d9f58666b..acb062bc7 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.ts @@ -1,4 +1,8 @@ -import { SchemaVersion, type Section } from '@defra/forms-model' +import { + SchemaVersion, + type ComponentDef, + type Section +} from '@defra/forms-model' import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js' import { type PaymentState } from '~/src/server/plugins/engine/components/PaymentField.types.js' @@ -153,10 +157,15 @@ export class SummaryViewModel { if (page instanceof RepeatPageController) { items.push( - ItemRepeat(page, state, { - path: page.getSummaryPath(request), - errors - }) + ItemRepeat( + page, + state, + { + path: page.getSummaryPath(request), + errors + }, + translator + ) ) } else { for (const field of collection.fields) { @@ -194,19 +203,24 @@ function ItemRepeat( options: { path: string errors?: FormSubmissionError[] - } + }, + translator?: Translator ): DetailItemRepeat { const { collection, repeat } = page const { name, title } = repeat.options const values = page.getListFromState(state) - const unit = values.length === 1 ? 'answer' : 'answers' + const count = values.length + const value = count + ? (translator?.t('pages.repeater.pageTitle', { count }) ?? + `You have added ${count} ${count === 1 ? 'answer' : 'answers'}`) + : '' return { name, label: title, title, - value: values.length ? `You have added ${values.length} ${unit}` : '', + value, href: getPageHref(page, options.path, { returnUrl: getPageHref(page, page.getSummaryPath()) }), @@ -216,7 +230,7 @@ function ItemRepeat( // Repeater field detail items subItems: values.map((repeatState) => collection.fields.map((field) => - ItemField(page, repeatState, field, options) + ItemField(page, repeatState, field, options, translator) ) ) } @@ -236,13 +250,28 @@ export function ItemField( }, translator?: Translator ): DetailItemField { + const tContent = translator?.tContent + // FormComponent doesn't expose shortDescription/title as raw def properties, + // so build a lookup object with English values to let tContent's GUID lookup fire. + // `type` is required so tContent routes to the 'components' namespace (not 'sections'). + const fieldDef = { + id: field.id, + type: field.type, + shortDescription: field.label, + title: field.title + } as unknown as ComponentDef + const rawLabel = tContent ? tContent(fieldDef, 'shortDescription') : '' + const translatedLabel = rawLabel !== '' ? rawLabel : field.label + const rawTitle = tContent ? tContent(fieldDef, 'title') : '' + const translatedTitle = rawTitle !== '' ? rawTitle : field.title + const optional = + field.options.required === false + ? ` ${translator?.t('common.optional') ?? '(optional)'}` + : '' return { name: field.name, - label: field.title, - title: - field.options.required === false - ? `${field.label} (optional)` - : field.label, + label: translatedTitle, + title: `${translatedLabel}${optional}`, error: field.getFirstError(options.errors), value: getAnswer(field, state, { format: 'summary' }, translator), href: getPageHref(page, options.path, { From c2965430b4a7857b7dd98ff08a3f3b54003e4682 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 29 Apr 2026 11:01:55 +0100 Subject: [PATCH 64/89] feat(i18n): persist language preference in session; register t as nunjucks global getLanguage now reads from yar session (set on first ?language= query param) so language preference persists across redirects. Registers t as a nunjucks global so templates can call t() directly. --- src/server/index.test.ts | 3 ++- src/server/plugins/engine/configureEnginePlugin.ts | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/server/index.test.ts b/src/server/index.test.ts index ebd747845..19a14e214 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -617,7 +617,8 @@ describe('prepareEnvironment', () => { 'checkComponentTemplates', 'checkErrorTemplates', 'evaluate', - 'govukRebrand' + 'govukRebrand', + 't' ] prepareNunjucksEnvironment(mockEnv, mockPluginOptions) diff --git a/src/server/plugins/engine/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts index cf730b65c..47c1a1898 100644 --- a/src/server/plugins/engine/configureEnginePlugin.ts +++ b/src/server/plugins/engine/configureEnginePlugin.ts @@ -69,7 +69,11 @@ export const configureEnginePlugin = async ( ordnanceSurveyApiKey, ordnanceSurveyApiSecret, getLanguage: (request) => { - return 'lang' in request.query ? request.query.lang : 'en-GB' + if ('language' in request.query) { + request.yar.set('language', request.query.language) + } + + return request.yar.get('language') ?? 'en-GB' } } } From 627381171b708b76d47ba75272f818320fabbbf4 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 29 Apr 2026 12:21:05 +0100 Subject: [PATCH 65/89] fix(i18n): thread translator through FileUploadField.getDisplayStringFromFormValue Uses t('components.fileUploadField.filesCount', { count }) for the check-your-answers display string instead of the hardcoded English "Uploaded N file(s)". Falls back to the English template when no translator is provided (e.g. email formatting). --- .../engine/components/FileUploadField.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/server/plugins/engine/components/FileUploadField.ts b/src/server/plugins/engine/components/FileUploadField.ts index b7eb9da64..c3929cfe4 100644 --- a/src/server/plugins/engine/components/FileUploadField.ts +++ b/src/server/plugins/engine/components/FileUploadField.ts @@ -162,19 +162,28 @@ export class FileUploadField extends FormComponent { return this.isValue(value) ? value : undefined } - getDisplayStringFromFormValue(files: FileState[] | undefined): string { + getDisplayStringFromFormValue( + files: FileState[] | undefined, + translator?: Translator + ): string { if (!files?.length) { return '' } - const unit = files.length === 1 ? 'file' : 'files' - return `Uploaded ${files.length} ${unit}` + return ( + translator?.t('components.fileUploadField.filesCount', { + count: files.length + }) ?? `Uploaded ${files.length} ${files.length === 1 ? 'file' : 'files'}` + ) } - getDisplayStringFromState(state: FormSubmissionState) { + getDisplayStringFromState( + state: FormSubmissionState, + translator?: Translator + ) { const files = this.getFormValueFromState(state) - return this.getDisplayStringFromFormValue(files) + return this.getDisplayStringFromFormValue(files, translator) } getContextValueFromFormValue( From a64a82e8cd2bba22183b0c2d43ae453b6bfd7780 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 29 Apr 2026 16:14:26 +0100 Subject: [PATCH 66/89] fix(i18n): translate composite sub-field labels/messages and DeclarationField default label ComponentCollection.validate now processes composite field sub-fields: - Translates sub-field labels via t(subField.title) so key constants like 'components.addressField.line1' resolve to e.g. "Llinell cyfeiriad 1" (#label) - Calls field.getValidationMessagesOverride(translator) to apply per-request Joi message overrides that replace schema-level English .messages() calls DatePartsField and MonthYearField implement getValidationMessagesOverride to return Welsh objectMissing/dateFormat expressions, fixing date validation errors. DeclarationField no longer resolves the default checkbox label at construction time via tPlugin('en-GB'). It stores undefined and lets getViewModel resolve t('components.declarationField.defaultLabel') at render time. --- .../engine/components/ComponentCollection.ts | 39 +++++++++++++------ .../engine/components/DatePartsField.ts | 14 +++++++ .../engine/components/DeclarationField.ts | 13 ++++--- .../engine/components/FormComponent.ts | 12 ++++++ .../engine/components/MonthYearField.ts | 14 +++++++ 5 files changed, 75 insertions(+), 17 deletions(-) diff --git a/src/server/plugins/engine/components/ComponentCollection.ts b/src/server/plugins/engine/components/ComponentCollection.ts index b2995ea7d..861fce154 100644 --- a/src/server/plugins/engine/components/ComponentCollection.ts +++ b/src/server/plugins/engine/components/ComponentCollection.ts @@ -270,23 +270,40 @@ export class ComponentCollection { ? buildLanguageMessages(translator.t) : undefined - // When translating, override each non-composite field's Joi label with the - // translated shortDescription (or title) so #label in message templates - // resolves to the correct language rather than the English construction-time value. + // When translating, override Joi labels (and custom schema messages where + // needed) so #label/#title in message templates resolve in the correct language. let schema = this.formSchema if (translator) { + const { t } = translator const labelOverrides: Record = {} + for (const field of this.fields) { - if (field.collection) continue // composite fields use #title, handled differently - const translatedLabel = - translator.tContent( - field as unknown as ComponentDef, - 'shortDescription' - ) || translator.tContent(field as unknown as ComponentDef, 'title') - if (translatedLabel && translatedLabel !== field.label) { - labelOverrides[field.name] = field.formSchema.label(translatedLabel) + if (field.collection) { + // Composite field: translate each sub-field's label using the key constant + // (e.g. 'components.addressField.line1' → "Llinell cyfeiriad 1") and apply + // any field-type-specific message overrides (e.g. objectMissing for dates). + const messagesOverride = + field.getValidationMessagesOverride(translator) + for (const subField of field.collection.fields) { + const translatedSubLabel = t(subField.title) || subField.label + let patchedSchema = subField.formSchema.label(translatedSubLabel) + if (messagesOverride) { + patchedSchema = patchedSchema.messages(messagesOverride) + } + labelOverrides[subField.name] = patchedSchema + } + } else { + const translatedLabel = + translator.tContent( + field as unknown as ComponentDef, + 'shortDescription' + ) || translator.tContent(field as unknown as ComponentDef, 'title') + if (translatedLabel && translatedLabel !== field.label) { + labelOverrides[field.name] = field.formSchema.label(translatedLabel) + } } } + if (Object.keys(labelOverrides).length) { schema = schema.keys(labelOverrides) } diff --git a/src/server/plugins/engine/components/DatePartsField.ts b/src/server/plugins/engine/components/DatePartsField.ts index 5aeed8271..d1e607d83 100644 --- a/src/server/plugins/engine/components/DatePartsField.ts +++ b/src/server/plugins/engine/components/DatePartsField.ts @@ -14,6 +14,7 @@ import { type DatePartsState } from '~/src/server/plugins/engine/components/types.js' import { parseStrictDate } from '~/src/server/plugins/engine/date-helper.js' +import { buildValidationMessages } from '~/src/server/plugins/engine/i18n/buildValidationMessages.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { @@ -149,6 +150,19 @@ export class DatePartsField extends FormComponent { return this.getContextValueFromFormValue(value) } + getValidationMessagesOverride(translator: Translator) { + const { t } = translator + return convertToLanguageMessages({ + 'any.required': buildValidationMessages(t).objectMissing, + 'number.base': buildValidationMessages(t).objectMissing, + 'number.precision': buildValidationMessages(t).dateFormat, + 'number.integer': buildValidationMessages(t).dateFormat, + 'number.unsafe': buildValidationMessages(t).dateFormat, + 'number.min': buildValidationMessages(t).dateFormat, + 'number.max': buildValidationMessages(t).dateFormat + }) + } + getViewModel( payload: FormPayload, errors: FormSubmissionError[] | undefined, diff --git a/src/server/plugins/engine/components/DeclarationField.ts b/src/server/plugins/engine/components/DeclarationField.ts index 581971179..410bc4316 100644 --- a/src/server/plugins/engine/components/DeclarationField.ts +++ b/src/server/plugins/engine/components/DeclarationField.ts @@ -31,7 +31,7 @@ import { export class DeclarationField extends FormComponent { declare options: DeclarationFieldComponent['options'] - declare declarationConfirmationLabel: string + declare declarationConfirmationLabel: string | undefined declare formSchema: ArraySchema declare stateSchema: BooleanSchema @@ -69,9 +69,9 @@ export class DeclarationField extends FormComponent { this.options = options this.content = content - this.declarationConfirmationLabel = - options.declarationConfirmationLabel ?? - tPlugin('components.declarationField.defaultLabel', 'en-GB') + // Store only the form-authored label (if provided). If absent, getViewModel + // resolves the default via t() at render time so the correct language is used. + this.declarationConfirmationLabel = options.declarationConfirmationLabel const formComponents = hasFormComponents(props.page?.pageDef) ? props.page.pageDef.components : [] @@ -124,7 +124,8 @@ export class DeclarationField extends FormComponent { ((key: string, opts?: Record) => tPlugin(key, 'en-GB', opts)) return value === 'true' - ? this.declarationConfirmationLabel + ? (this.declarationConfirmationLabel ?? + t('components.declarationField.defaultLabel')) : t('components.declarationField.notProvided') } @@ -140,7 +141,7 @@ export class DeclarationField extends FormComponent { hint, content, declarationConfirmationLabel = t( - 'components.declarationField.defaultConfirmationLabel' + 'components.declarationField.defaultLabel' ) } = this diff --git a/src/server/plugins/engine/components/FormComponent.ts b/src/server/plugins/engine/components/FormComponent.ts index f7cd42205..3177d6f22 100644 --- a/src/server/plugins/engine/components/FormComponent.ts +++ b/src/server/plugins/engine/components/FormComponent.ts @@ -4,6 +4,7 @@ import { type FormMetadata, type Item } from '@defra/forms-model' +import { type LanguageMessages } from 'joi' import { ComponentBase } from '~/src/server/plugins/engine/components/ComponentBase.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' @@ -131,6 +132,17 @@ export class FormComponent extends ComponentBase { return firstError && [firstError] } + /** + * Override in composite fields to return translated Joi message overrides for + * their sub-fields, applied at validation time so schema-level English messages + * are replaced with the correct language. Return null to skip message patching. + */ + getValidationMessagesOverride( + _translator: Translator + ): LanguageMessages | null { + return null + } + getViewModel( payload: FormPayload, errors: FormSubmissionError[] | undefined, diff --git a/src/server/plugins/engine/components/MonthYearField.ts b/src/server/plugins/engine/components/MonthYearField.ts index 41d27092f..23e85ec21 100644 --- a/src/server/plugins/engine/components/MonthYearField.ts +++ b/src/server/plugins/engine/components/MonthYearField.ts @@ -19,6 +19,7 @@ import { type MonthYearState } from '~/src/server/plugins/engine/components/types.js' import { parseStrictDate } from '~/src/server/plugins/engine/date-helper.js' +import { buildValidationMessages } from '~/src/server/plugins/engine/i18n/buildValidationMessages.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { @@ -146,6 +147,19 @@ export class MonthYearField extends FormComponent { return this.getContextValueFromFormValue(value) } + getValidationMessagesOverride(translator: Translator) { + const { t } = translator + return convertToLanguageMessages({ + 'any.required': buildValidationMessages(t).objectMissing, + 'number.base': buildValidationMessages(t).objectMissing, + 'number.precision': buildValidationMessages(t).dateFormat, + 'number.integer': buildValidationMessages(t).dateFormat, + 'number.unsafe': buildValidationMessages(t).dateFormat, + 'number.min': buildValidationMessages(t).dateFormat, + 'number.max': buildValidationMessages(t).dateFormat + }) + } + getViewModel( payload: FormPayload, errors: FormSubmissionError[] | undefined, From 477e39da3096e66ef9b1a6a59cbc6929362f4e77 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 29 Apr 2026 16:23:34 +0100 Subject: [PATCH 67/89] fix(i18n): pass language to postcode lookup dispatch so journey renders in correct language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UkAddressField.dispatcher now resolves the per-request language via getPluginOptions and includes it in the initial session data. The postcode lookup models already read session.initial.language — it was simply never being set. --- src/server/plugins/engine/components/UkAddressField.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/server/plugins/engine/components/UkAddressField.ts b/src/server/plugins/engine/components/UkAddressField.ts index 5556e0183..2b00d6cdb 100644 --- a/src/server/plugins/engine/components/UkAddressField.ts +++ b/src/server/plugins/engine/components/UkAddressField.ts @@ -12,6 +12,7 @@ import { isFormState } from '~/src/server/plugins/engine/components/FormComponent.js' import { TextField } from '~/src/server/plugins/engine/components/TextField.js' +import { getPluginOptions } from '~/src/server/plugins/engine/helpers.js' import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' @@ -310,6 +311,8 @@ export class UkAddressField extends FormComponent { args: PostcodeLookupExternalArgs ) { const { controller, component } = args + const { getLanguage } = getPluginOptions(request.server) + const language = getLanguage?.(request) ?? 'en-GB' return dispatch(request, h, { formName: controller.model.name, @@ -317,7 +320,8 @@ export class UkAddressField extends FormComponent { componentHint: component.hint, componentTitle: component.title || controller.title, step: args.actionArgs.step, - sourceUrl: args.sourceUrl + sourceUrl: args.sourceUrl, + language }) } } From 2bac7ee24fe0b4d9bfff64f080e8979492ce7476 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 30 Apr 2026 10:33:58 +0100 Subject: [PATCH 68/89] fix(i18n): translate YesNo/list items, DeclarationField validation, section titles YesNo list items now store i18n key constants ('components.yesNoField.yes') instead of construction-time English strings. ListFormComponent.getViewModel and getDisplayStringFromFormValue resolve them via tContent/tPlugin at render time. getAnswerMarkdown also resolves item text via translator. DeclarationField.getValidationMessagesOverride returns Welsh declarationRequired expression; ComponentCollection.validate now also applies message overrides for non-composite fields (same pattern as composite sub-fields). SummaryViewModel.summaryDetails resolves section titles via tContent. stubTranslator.tContent now resolves raw values via resolveKey('en-GB') so i18n key constants (e.g. 'components.yesNoField.yes') return 'Yes' in tests. --- src/server/forms/simple-form.yaml | 22 ++++++++++++++++++- .../engine/components/ComponentCollection.ts | 11 +++++++++- .../engine/components/DatePartsField.test.ts | 6 ++--- .../engine/components/DeclarationField.ts | 12 ++++++++++ .../components/EastingNorthingField.test.ts | 5 ++--- .../engine/components/LatLongField.test.ts | 5 ++--- .../engine/components/ListFormComponent.ts | 22 ++++++++++++++----- .../components/LocationFieldHelpers.test.ts | 7 ++---- .../engine/components/MonthYearField.test.ts | 4 ++-- .../engine/components/UkAddressField.test.ts | 11 +++++----- .../engine/components/helpers/components.ts | 18 ++++++++++----- src/server/plugins/engine/models/FormModel.ts | 12 ++++------ .../plugins/engine/models/SummaryViewModel.ts | 5 ++++- .../pageControllers/__stubs__/translator.ts | 18 ++++++++++----- 14 files changed, 109 insertions(+), 49 deletions(-) diff --git a/src/server/forms/simple-form.yaml b/src/server/forms/simple-form.yaml index 182aad736..9836b2999 100644 --- a/src/server/forms/simple-form.yaml +++ b/src/server/forms/simple-form.yaml @@ -23,6 +23,9 @@ metadata: 00738799-3489-4ab2-a57b-542eecb31bfa: title: Pryd yw pen-blwydd {{ applicantFirstName }} {{ applicantLastName }}? shortDescription: Eich pen-blwydd + 123e4567-e89b-12d3-a456-426614174000: + title: Beth yw eich cyfeiriad? + shortDescription: Eich cyfeiriad pages: 622a35ec-3795-418a-81f3-a45746959045: title: Eich enw @@ -33,7 +36,9 @@ metadata: da0fbdb4-a2de-4650-be16-9ba552af135f: title: Dyddiad geni 449a45f6-4541-4a46-91bd-8b8931b07b50: - title: Crynodeb + title: Crynodeb + 123e4567-e89b-12d3-a456-426614174001: + title: Ble rydych chi'n byw? pages: - title: Your name path: '/your-name' @@ -58,6 +63,21 @@ pages: id: b68df7f1-d4f4-4c17-83c8-402f584906c9 next: [] id: 622a35ec-3795-418a-81f3-a45746959045 + - title: Where do you live? + path: '/where-you-live' + components: + - type: UkAddressField + title: What is your address? + name: applicantAddress + shortDescription: Your address + hint: '' + options: + required: true + usePostcodeLookup: true + schema: {} + id: 123e4567-e89b-12d3-a456-426614174000 + next: [] + id: 123e4567-e89b-12d3-a456-426614174001 - title: Upload a copy of your passport controller: FileUploadPageController path: '/upload-passport' diff --git a/src/server/plugins/engine/components/ComponentCollection.ts b/src/server/plugins/engine/components/ComponentCollection.ts index 861fce154..802d44b19 100644 --- a/src/server/plugins/engine/components/ComponentCollection.ts +++ b/src/server/plugins/engine/components/ComponentCollection.ts @@ -298,8 +298,17 @@ export class ComponentCollection { field as unknown as ComponentDef, 'shortDescription' ) || translator.tContent(field as unknown as ComponentDef, 'title') + const messagesOverride = + field.getValidationMessagesOverride(translator) + let patchedSchema = field.formSchema if (translatedLabel && translatedLabel !== field.label) { - labelOverrides[field.name] = field.formSchema.label(translatedLabel) + patchedSchema = patchedSchema.label(translatedLabel) + } + if (messagesOverride) { + patchedSchema = patchedSchema.messages(messagesOverride) + } + if (patchedSchema !== field.formSchema) { + labelOverrides[field.name] = patchedSchema } } } diff --git a/src/server/plugins/engine/components/DatePartsField.test.ts b/src/server/plugins/engine/components/DatePartsField.test.ts index 32ab88ac5..09a48b779 100644 --- a/src/server/plugins/engine/components/DatePartsField.test.ts +++ b/src/server/plugins/engine/components/DatePartsField.test.ts @@ -373,7 +373,7 @@ describe('DatePartsField', () => { items: [ expect.objectContaining( getViewModel(date, 'day', { - label: { text: 'components.dateField.day' }, + label: { text: 'Day' }, classes: 'govuk-input--width-2', value: 31 }) @@ -381,7 +381,7 @@ describe('DatePartsField', () => { expect.objectContaining( getViewModel(date, 'month', { - label: { text: 'components.dateField.month' }, + label: { text: 'Month' }, classes: 'govuk-input--width-2', value: 12 }) @@ -389,7 +389,7 @@ describe('DatePartsField', () => { expect.objectContaining( getViewModel(date, 'year', { - label: { text: 'components.dateField.year' }, + label: { text: 'Year' }, classes: 'govuk-input--width-4', value: 2024 }) diff --git a/src/server/plugins/engine/components/DeclarationField.ts b/src/server/plugins/engine/components/DeclarationField.ts index 410bc4316..b282a20d5 100644 --- a/src/server/plugins/engine/components/DeclarationField.ts +++ b/src/server/plugins/engine/components/DeclarationField.ts @@ -8,6 +8,7 @@ import { import joi, { type ArraySchema, type BooleanSchema, + type LanguageMessages, type StringSchema } from 'joi' @@ -15,6 +16,7 @@ import { FormComponent, isFormValue } from '~/src/server/plugins/engine/components/FormComponent.js' +import { buildValidationMessages } from '~/src/server/plugins/engine/i18n/buildValidationMessages.js' import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' @@ -115,6 +117,16 @@ export class DeclarationField extends FormComponent { return this.isValue(value) ? value : undefined } + getValidationMessagesOverride(translator: Translator) { + const { declarationRequired } = buildValidationMessages(translator.t) + const msg = declarationRequired as unknown as string + return { + 'any.required': msg, + 'any.unknown': msg, + 'array.includesRequiredUnknowns': msg + } as unknown as LanguageMessages + } + getDisplayStringFromFormValue( value: FormValue | FormPayload, translator?: Translator diff --git a/src/server/plugins/engine/components/EastingNorthingField.test.ts b/src/server/plugins/engine/components/EastingNorthingField.test.ts index 4fd94e84d..f785c1267 100644 --- a/src/server/plugins/engine/components/EastingNorthingField.test.ts +++ b/src/server/plugins/engine/components/EastingNorthingField.test.ts @@ -3,7 +3,6 @@ import { type EastingNorthingFieldComponent } from '@defra/forms-model' - import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { EastingNorthingField } from '~/src/server/plugins/engine/components/EastingNorthingField.js' import { @@ -307,7 +306,7 @@ describe('EastingNorthingField', () => { items: [ expect.objectContaining({ label: expect.objectContaining({ - text: 'components.eastingNorthingField.easting' + text: 'Easting' }), // Sub-field title is a key constant; resolved at request time (Task 8/9). name: 'myComponent__easting', @@ -316,7 +315,7 @@ describe('EastingNorthingField', () => { }), expect.objectContaining({ label: expect.objectContaining({ - text: 'components.eastingNorthingField.northing' + text: 'Northing' }), // Sub-field title is a key constant; resolved at request time (Task 8/9). name: 'myComponent__northing', diff --git a/src/server/plugins/engine/components/LatLongField.test.ts b/src/server/plugins/engine/components/LatLongField.test.ts index 517022022..c999ca3d8 100644 --- a/src/server/plugins/engine/components/LatLongField.test.ts +++ b/src/server/plugins/engine/components/LatLongField.test.ts @@ -1,6 +1,5 @@ import { ComponentType, type LatLongFieldComponent } from '@defra/forms-model' - import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { LatLongField } from '~/src/server/plugins/engine/components/LatLongField.js' import { @@ -296,7 +295,7 @@ describe('LatLongField', () => { items: [ expect.objectContaining({ label: expect.objectContaining({ - text: 'components.latLongField.latitude' + text: 'Latitude' }), // Sub-field title is a key constant; resolved at request time (Task 8/9). name: 'myComponent__latitude', @@ -305,7 +304,7 @@ describe('LatLongField', () => { }), expect.objectContaining({ label: expect.objectContaining({ - text: 'components.latLongField.longitude' + text: 'Longitude' }), // Sub-field title is a key constant; resolved at request time (Task 8/9). name: 'myComponent__longitude', diff --git a/src/server/plugins/engine/components/ListFormComponent.ts b/src/server/plugins/engine/components/ListFormComponent.ts index db300bf4a..43b4e200a 100644 --- a/src/server/plugins/engine/components/ListFormComponent.ts +++ b/src/server/plugins/engine/components/ListFormComponent.ts @@ -14,6 +14,7 @@ import joi, { import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' import { type ListItem } from '~/src/server/plugins/engine/components/types.js' +import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { @@ -101,7 +102,8 @@ export class ListFormComponent extends FormComponent { } getDisplayStringFromFormValue( - value: string | number | boolean | Item['value'][] | undefined + value: string | number | boolean | Item['value'][] | undefined, + translator?: Translator ): string { const { items } = this @@ -109,15 +111,22 @@ export class ListFormComponent extends FormComponent { return items .filter((item) => values.includes(item.value)) - .map((item) => item.text) + .map((item) => + translator + ? translator.tContent(item, 'text') || item.text + : tPlugin(item.text, 'en-GB') || item.text + ) .join(', ') } - getDisplayStringFromState(state: FormSubmissionState) { + getDisplayStringFromState( + state: FormSubmissionState, + translator?: Translator + ) { // Allow for array values via subclass const value = this.getFormValueFromState(state) - return this.getDisplayStringFromFormValue(value) + return this.getDisplayStringFromFormValue(value, translator) } getViewModel( @@ -139,9 +148,12 @@ export class ListFormComponent extends FormComponent { // Support multiple values for checkboxes const values = this.isValue(value) ? [value].flat() : [] + const { tContent } = translator + const items = listItems.map((item) => { const selected = values.includes(item.value) - const itemModel: ListItem = { ...item, selected } + const resolvedText = tContent(item, 'text') || item.text + const itemModel: ListItem = { ...item, text: resolvedText, selected } if ('id' in itemModel) { delete itemModel.id diff --git a/src/server/plugins/engine/components/LocationFieldHelpers.test.ts b/src/server/plugins/engine/components/LocationFieldHelpers.test.ts index 3fa899f51..2ea891ef0 100644 --- a/src/server/plugins/engine/components/LocationFieldHelpers.test.ts +++ b/src/server/plugins/engine/components/LocationFieldHelpers.test.ts @@ -1,6 +1,5 @@ import { ComponentType, type LatLongFieldComponent } from '@defra/forms-model' - import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { type LatLongField } from '~/src/server/plugins/engine/components/LatLongField.js' import { @@ -575,15 +574,13 @@ describe('LocationFieldHelpers', () => { const label = viewModel.items[0].label expect(label).toBeDefined() - expect(label?.text).toBe('components.latLongField.latitude') - // Sub-field title is a key constant; resolved at request time (Task 8/9). + expect(label?.text).toBe('Latitude') const labelString = label && 'toString' in label && typeof label.toString === 'function' ? (label as { toString: () => string }).toString() : '' - expect(labelString).toBe('components.latLongField.latitude') - // Sub-field title is a key constant; resolved at request time (Task 8/9). + expect(labelString).toBe('Latitude') }) it('should use existing fieldset if provided', () => { diff --git a/src/server/plugins/engine/components/MonthYearField.test.ts b/src/server/plugins/engine/components/MonthYearField.test.ts index e95d4ae59..56f2754ae 100644 --- a/src/server/plugins/engine/components/MonthYearField.test.ts +++ b/src/server/plugins/engine/components/MonthYearField.test.ts @@ -345,7 +345,7 @@ describe('MonthYearField', () => { items: [ expect.objectContaining( getViewModel(date, 'month', { - label: { text: 'components.monthYearField.month' }, + label: { text: 'Month' }, classes: 'govuk-input--width-2', value: 12 }) @@ -353,7 +353,7 @@ describe('MonthYearField', () => { expect.objectContaining( getViewModel(date, 'year', { - label: { text: 'components.monthYearField.year' }, + label: { text: 'Year' }, classes: 'govuk-input--width-4', value: 2024 }) diff --git a/src/server/plugins/engine/components/UkAddressField.test.ts b/src/server/plugins/engine/components/UkAddressField.test.ts index b67780127..1fe354211 100644 --- a/src/server/plugins/engine/components/UkAddressField.test.ts +++ b/src/server/plugins/engine/components/UkAddressField.test.ts @@ -4,7 +4,6 @@ import { type UkAddressFieldComponent } from '@defra/forms-model' - import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { UkAddressField } from '~/src/server/plugins/engine/components/UkAddressField.js' import { @@ -436,7 +435,7 @@ describe('UkAddressField', () => { components: expect.arrayContaining([ expect.objectContaining({ model: getViewModel(address, 'addressLine1', { - label: { text: 'components.addressField.line1' }, + label: { text: 'Address line 1' }, // Sub-field title is a key constant; resolved at request time (Task 8/9). attributes: { autocomplete: 'address-line1' } }) @@ -444,7 +443,7 @@ describe('UkAddressField', () => { expect.objectContaining({ model: getViewModel(address, 'addressLine2', { - label: { text: 'components.addressField.line2 (optional)' }, + label: { text: 'Address line 2 (optional)' }, // Sub-field title is a key constant; resolved at request time (Task 8/9). attributes: { autocomplete: 'address-line2' }, value: address.addressLine2 @@ -453,7 +452,7 @@ describe('UkAddressField', () => { expect.objectContaining({ model: getViewModel(address, 'town', { - label: { text: 'components.addressField.town' }, + label: { text: 'Town or city' }, // Sub-field title is a key constant; resolved at request time (Task 8/9). classes: 'govuk-!-width-two-thirds', attributes: { autocomplete: 'address-level2' }, @@ -463,7 +462,7 @@ describe('UkAddressField', () => { expect.objectContaining({ model: getViewModel(address, 'county', { - label: { text: 'components.addressField.county (optional)' }, + label: { text: 'County (optional)' }, // Sub-field title is a key constant; resolved at request time (Task 8/9). attributes: { autocomplete: 'address-level1' }, value: address.county @@ -472,7 +471,7 @@ describe('UkAddressField', () => { expect.objectContaining({ model: getViewModel(address, 'postcode', { - label: { text: 'components.addressField.postcode' }, + label: { text: 'Postcode' }, // Sub-field title is a key constant; resolved at request time (Task 8/9). classes: 'govuk-input--width-10', attributes: { autocomplete: 'postal-code' }, diff --git a/src/server/plugins/engine/components/helpers/components.ts b/src/server/plugins/engine/components/helpers/components.ts index b268a2e86..df888528a 100644 --- a/src/server/plugins/engine/components/helpers/components.ts +++ b/src/server/plugins/engine/components/helpers/components.ts @@ -6,6 +6,7 @@ import { ListFormComponent } from '~/src/server/plugins/engine/components/ListFo import { escapeMarkdown } from '~/src/server/plugins/engine/components/helpers/index.js' import * as Components from '~/src/server/plugins/engine/components/index.js' import { markdown } from '~/src/server/plugins/engine/components/markdownParser.js' +import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type FormState } from '~/src/server/plugins/engine/types.js' @@ -227,7 +228,7 @@ export function getAnswer( ) { // Use escaped display text for GOV.UK Notify emails if (options.format === 'email') { - return getAnswerMarkdown(field, state, { format: 'email' }) + return getAnswerMarkdown(field, state, { format: 'email' }, translator) } // Use context value for submission data @@ -245,7 +246,10 @@ export function getAnswer( field instanceof Components.LatLongField ) { return markdown - .parse(getAnswerMarkdown(field, state), { async: false }) + .parse( + getAnswerMarkdown(field, state, { format: 'summary' }, translator), + { async: false } + ) .trim() } @@ -263,9 +267,10 @@ export function getAnswerMarkdown( format: | 'email' // GOV.UK Notify emails | 'summary' // Check answers summary - } = { format: 'summary' } + } = { format: 'summary' }, + translator?: Translator ) { - const answer = field.getDisplayStringFromState(state) + const answer = field.getDisplayStringFromState(state, translator) // Use escaped display text let answerEscaped = `${escapeMarkdown(answer)}\n` @@ -302,7 +307,10 @@ export function getAnswerMarkdown( // Append bullet points answerEscaped += items .map((item) => { - const label = escapeMarkdown(item.text) + const resolvedText = translator + ? translator.tContent(item, 'text') || item.text + : tPlugin(item.text, 'en-GB') || item.text + const label = escapeMarkdown(resolvedText) const value = escapeMarkdown(`(${item.value})`) let line = label diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index bcd644da9..f59ef3a90 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -149,12 +149,8 @@ export class FormModel { } } - const language = - typeof def.metadata?.language === 'string' - ? def.metadata.language - : 'en-GB' - - // Add default lists + // Add default lists. Yes/No text stored as i18n key constants so they + // resolve to the user's language at render time via the translator. def.lists.push({ id: def.schema === SchemaVersion.V1 ? yesNoListName : yesNoListId, name: '__yesNo', @@ -163,12 +159,12 @@ export class FormModel { items: [ { id: '02900d42-83d1-4c72-a719-c4e8228952fa', - text: translate('components.yesNoField.yes', language), + text: 'components.yesNoField.yes', value: true }, { id: 'f39000eb-c51b-4019-8f82-bbda0423f04d', - text: translate('components.yesNoField.no', language), + text: 'components.yesNoField.no', value: false } ] diff --git a/src/server/plugins/engine/models/SummaryViewModel.ts b/src/server/plugins/engine/models/SummaryViewModel.ts index acb062bc7..8d1b54e21 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.ts @@ -181,9 +181,12 @@ export class SummaryViewModel { }) if (items.length) { + const sectionTitle = section + ? translator.tContent(section, 'title') || section.title + : undefined details.push({ name: section?.name, - title: section?.title, + title: sectionTitle, items }) } diff --git a/src/server/plugins/engine/pageControllers/__stubs__/translator.ts b/src/server/plugins/engine/pageControllers/__stubs__/translator.ts index 63a222085..f792b4cda 100644 --- a/src/server/plugins/engine/pageControllers/__stubs__/translator.ts +++ b/src/server/plugins/engine/pageControllers/__stubs__/translator.ts @@ -4,14 +4,20 @@ import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' /** * Shared Translator stub for tests where translation content is not the focus. * Uses the real English i18n keys so tests that assert on English text still pass. - * tContent returns the entity's raw property value (title, hint, etc.). + * tContent resolves the entity's property via the real English t() so i18n key + * constants (e.g. 'components.yesNoField.yes') are resolved to their English + * values ("Yes") rather than returned verbatim. */ export const stubTranslator: Translator = { t: (key: string, opts?: Record) => resolveKey(key, 'en-GB', opts), - tContent: ((entity: unknown, prop: string) => - typeof entity === 'object' && entity !== null - ? (((entity as Record)[prop] as string | undefined) ?? - '') - : '') as unknown as Translator['tContent'] + tContent: ((entity: unknown, prop: string) => { + if (typeof entity !== 'object' || entity === null) return '' + const raw = + ((entity as Record)[prop] as string | undefined) ?? '' + if (!raw) return '' + // Resolve via real English i18n: handles key constants like + // 'components.yesNoField.yes' → "Yes". Plain strings pass through unchanged. + return resolveKey(raw, 'en-GB') || raw + }) as unknown as Translator['tContent'] } From 0c06899536a8c26ff82a22ec30c056fb3ff43fd5 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 30 Apr 2026 10:53:00 +0100 Subject: [PATCH 69/89] add welsh devtool about-you page with YesNo and radios demo --- src/server/forms/simple-form.yaml | 54 ++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/src/server/forms/simple-form.yaml b/src/server/forms/simple-form.yaml index 9836b2999..e6d8becff 100644 --- a/src/server/forms/simple-form.yaml +++ b/src/server/forms/simple-form.yaml @@ -26,6 +26,19 @@ metadata: 123e4567-e89b-12d3-a456-426614174000: title: Beth yw eich cyfeiriad? shortDescription: Eich cyfeiriad + aabbccdd-1122-3344-5566-778899001123: + title: Oes gennych chi drwydded yrru? + shortDescription: Trwydded yrru + aabbccdd-1122-3344-5566-778899001124: + title: Sut rydych chi fel arfer yn teithio? + shortDescription: Eich ffordd o deithio + listItems: + aabbccdd-1122-3344-5566-778899001126: + text: Car + aabbccdd-1122-3344-5566-778899001127: + text: Bws + aabbccdd-1122-3344-5566-778899001128: + text: Trên pages: 622a35ec-3795-418a-81f3-a45746959045: title: Eich enw @@ -39,6 +52,8 @@ metadata: title: Crynodeb 123e4567-e89b-12d3-a456-426614174001: title: Ble rydych chi'n byw? + aabbccdd-1122-3344-5566-778899001122: + title: Amdanoch chi pages: - title: Your name path: '/your-name' @@ -120,10 +135,47 @@ pages: id: '00738799-3489-4ab2-a57b-542eecb31bfa' next: [] id: da0fbdb4-a2de-4650-be16-9ba552af135f + - title: About you + path: '/about-you' + components: + - type: YesNoField + title: Do you have a driving licence? + name: hasDrivingLicence + shortDescription: Driving licence + hint: '' + options: {} + schema: {} + id: aabbccdd-1122-3344-5566-778899001123 + - type: RadiosField + title: How do you usually travel? + name: travelMethod + shortDescription: Your travel method + hint: '' + options: + required: true + schema: {} + list: aabbccdd-1122-3344-5566-778899001125 + id: aabbccdd-1122-3344-5566-778899001124 + next: [] + id: aabbccdd-1122-3344-5566-778899001122 - id: 449a45f6-4541-4a46-91bd-8b8931b07b50 title: '' path: '/summary' controller: SummaryPageController conditions: [] sections: [] -lists: [] +lists: + - id: aabbccdd-1122-3344-5566-778899001125 + name: travelMethods + title: Travel methods + type: string + items: + - id: aabbccdd-1122-3344-5566-778899001126 + text: Car + value: car + - id: aabbccdd-1122-3344-5566-778899001127 + text: Bus + value: bus + - id: aabbccdd-1122-3344-5566-778899001128 + text: Train + value: train From d02cf8b4f9ef5f1a9d75528fba54c6bd853ecd2d Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 30 Apr 2026 11:17:57 +0100 Subject: [PATCH 70/89] fix(i18n): resolve plugin i18n keys for list items with GUIDs; translate YesNo validation resolveContent now calls t(raw) as fallback when a GUID entity has no form translation, so plugin key constants like 'components.yesNoField.yes' stored as list item text are resolved to 'Ie'/'Na' etc. rather than returned verbatim. YesNoField.getValidationMessagesOverride applies the per-request Welsh selectYesNoRequired template at validation time, replacing the English message baked into the schema at construction time. --- src/server/plugins/engine/components/YesNoField.ts | 10 ++++++++++ src/server/plugins/engine/models/FormModel.ts | 5 ++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/server/plugins/engine/components/YesNoField.ts b/src/server/plugins/engine/components/YesNoField.ts index 598253d75..dca2d2e4e 100644 --- a/src/server/plugins/engine/components/YesNoField.ts +++ b/src/server/plugins/engine/components/YesNoField.ts @@ -4,9 +4,12 @@ import { yesNoListName, type YesNoFieldComponent } from '@defra/forms-model' +import { type LanguageMessages } from 'joi' import { SelectionControlField } from '~/src/server/plugins/engine/components/SelectionControlField.js' import { addClassOptionIfNone } from '~/src/server/plugins/engine/components/helpers/index.js' +import { buildValidationMessages } from '~/src/server/plugins/engine/i18n/buildValidationMessages.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.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' @@ -52,6 +55,13 @@ export class YesNoField extends SelectionControlField { this.options = options } + getValidationMessagesOverride(translator: Translator): LanguageMessages { + const { selectYesNoRequired } = buildValidationMessages(translator.t) + return convertToLanguageMessages({ + 'any.required': selectYesNoRequired + }) + } + /** * For error preview page that shows all possible errors on a component */ diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index f59ef3a90..cd4f8058c 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -276,8 +276,11 @@ export class FormModel { ns: 'form' }) if (result === key) { + // No form translation found — fall through to t(raw) so plugin i18n + // key constants (e.g. 'components.yesNoField.yes') are still resolved. const raw = (entity as Record)[prop] - return typeof raw === 'string' ? raw : '' + if (typeof raw !== 'string') return '' + return t(raw) } return result } From d22e359c47b387e74ab1fae85c6697db52cec704 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 30 Apr 2026 12:23:00 +0100 Subject: [PATCH 71/89] refactor(i18n): encapsulate translator resolution in PageController; introduce RenderContext for components Move the getLanguage/createTranslator boilerplate into a protected PageController.getTranslator(request) helper so new page controllers inherit it without any i18n knowledge. Replace the four-positional-param getViewModel signature on all form components with a single RenderContext object { payload, errors, translator, isForceAccess? } so future context additions (permissions, feature flags, etc.) don't require touching every component signature. --- .../components/AutocompleteField.test.ts | 20 +++--- .../engine/components/AutocompleteField.ts | 20 +----- .../engine/components/CheckboxesField.test.ts | 20 +++--- .../engine/components/ComponentCollection.ts | 14 ++-- .../engine/components/DatePartsField.test.ts | 18 ++++- .../engine/components/DatePartsField.ts | 21 ++---- .../components/DeclarationField.test.ts | 70 +++++++++---------- .../engine/components/DeclarationField.ts | 19 ++--- .../components/EastingNorthingField.test.ts | 22 ++++-- .../engine/components/EastingNorthingField.ts | 29 ++------ .../components/EmailAddressField.test.ts | 10 +-- .../engine/components/EmailAddressField.ts | 22 ++---- .../engine/components/FileUploadField.test.ts | 42 +++++------ .../engine/components/FileUploadField.ts | 16 ++--- .../engine/components/FormComponent.ts | 13 ++-- .../engine/components/GeospatialField.test.ts | 10 +-- .../engine/components/GeospatialField.ts | 16 +---- .../engine/components/HiddenField.test.ts | 10 +-- .../engine/components/LatLongField.test.ts | 22 ++++-- .../plugins/engine/components/LatLongField.ts | 29 ++------ .../engine/components/ListFormComponent.ts | 23 ++---- .../components/LocationFieldBase.test.ts | 20 +++--- .../engine/components/LocationFieldBase.ts | 18 +---- .../components/LocationFieldHelpers.test.ts | 60 +++++++++++++--- .../engine/components/LocationFieldHelpers.ts | 14 +--- .../engine/components/MonthYearField.test.ts | 18 ++++- .../engine/components/MonthYearField.ts | 21 ++---- .../components/MultilineTextField.test.ts | 44 ++++++------ .../engine/components/MultilineTextField.ts | 22 ++---- .../NationalGridFieldNumberField.test.ts | 20 +++--- .../engine/components/NumberField.test.ts | 60 ++++++++-------- .../plugins/engine/components/NumberField.ts | 18 +---- .../engine/components/OsGridRefField.test.ts | 20 +++--- .../engine/components/PaymentField.test.ts | 20 +++--- .../plugins/engine/components/PaymentField.ts | 19 ++--- .../engine/components/RadiosField.test.ts | 20 +++--- .../engine/components/SelectField.test.ts | 20 +++--- .../plugins/engine/components/SelectField.ts | 20 +----- .../components/SelectionControlField.ts | 26 ++----- .../components/TelephoneNumberField.test.ts | 10 +-- .../engine/components/TelephoneNumberField.ts | 22 ++---- .../engine/components/TextField.test.ts | 34 ++++++--- .../engine/components/UkAddressField.test.ts | 12 +++- .../engine/components/UkAddressField.ts | 18 ++--- .../engine/components/YesNoField.test.ts | 20 +++--- src/server/plugins/engine/components/types.ts | 9 +++ .../FileUploadPageController.ts | 7 +- .../engine/pageControllers/PageController.ts | 11 ++- .../pageControllers/QuestionPageController.ts | 11 +-- .../pageControllers/RepeatPageController.ts | 21 ++---- .../pageControllers/SummaryPageController.ts | 21 +++--- 51 files changed, 501 insertions(+), 621 deletions(-) diff --git a/src/server/plugins/engine/components/AutocompleteField.test.ts b/src/server/plugins/engine/components/AutocompleteField.test.ts index 0b04cb8e7..ebd334307 100644 --- a/src/server/plugins/engine/components/AutocompleteField.test.ts +++ b/src/server/plugins/engine/components/AutocompleteField.test.ts @@ -266,11 +266,11 @@ describe.each([ it('sets Nunjucks component defaults', () => { const item = options.examples[0] - const viewModel = field.getViewModel( - getFormData(item.value), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData(item.value), + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ @@ -285,11 +285,11 @@ describe.each([ it.each([...options.examples])( 'sets Nunjucks component autocomplete suggestions', (item) => { - const viewModel = field.getViewModel( - getFormData(item.value), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData(item.value), + errors: undefined, + translator: stubTranslator + }) expect(viewModel.items?.[0]).toMatchObject({ value: '' // First item is always empty diff --git a/src/server/plugins/engine/components/AutocompleteField.ts b/src/server/plugins/engine/components/AutocompleteField.ts index 4e83d3f9a..312d38ba6 100644 --- a/src/server/plugins/engine/components/AutocompleteField.ts +++ b/src/server/plugins/engine/components/AutocompleteField.ts @@ -1,12 +1,8 @@ import { type AutocompleteFieldComponent } from '@defra/forms-model' import { SelectField } from '~/src/server/plugins/engine/components/SelectField.js' -import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' +import { type RenderContext } from '~/src/server/plugins/engine/components/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' -import { - type FormPayload, - type FormSubmissionError -} from '~/src/server/plugins/engine/types.js' export class AutocompleteField extends SelectField { declare options: AutocompleteFieldComponent['options'] @@ -35,18 +31,8 @@ export class AutocompleteField extends SelectField { this.formSchema = formSchema } - getViewModel( - payload: FormPayload, - errors: FormSubmissionError[] | undefined, - translator: Translator, - isForceAccess = false - ) { - const viewModel = super.getViewModel( - payload, - errors, - translator, - isForceAccess - ) + getViewModel(context: RenderContext) { + const viewModel = super.getViewModel(context) let { formGroup } = viewModel formGroup ??= {} diff --git a/src/server/plugins/engine/components/CheckboxesField.test.ts b/src/server/plugins/engine/components/CheckboxesField.test.ts index 163bd951c..7084971e9 100644 --- a/src/server/plugins/engine/components/CheckboxesField.test.ts +++ b/src/server/plugins/engine/components/CheckboxesField.test.ts @@ -330,11 +330,11 @@ describe.each([ it('sets Nunjucks component defaults', () => { const item = options.examples[0] - const viewModel = field.getViewModel( - getFormData([item.value]), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData([item.value]), + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ @@ -349,11 +349,11 @@ describe.each([ it.each([...options.examples])( 'sets Nunjucks component checkbox items', (item) => { - const viewModel = field.getViewModel( - getFormData([item.value]), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData([item.value]), + errors: undefined, + translator: stubTranslator + }) expect(viewModel.items?.[0]).not.toMatchObject({ value: '' // First item is never empty diff --git a/src/server/plugins/engine/components/ComponentCollection.ts b/src/server/plugins/engine/components/ComponentCollection.ts index 802d44b19..c84561b86 100644 --- a/src/server/plugins/engine/components/ComponentCollection.ts +++ b/src/server/plugins/engine/components/ComponentCollection.ts @@ -16,7 +16,10 @@ import { type Field, type Guidance } from '~/src/server/plugins/engine/components/helpers/components.js' -import { type ComponentViewModel } from '~/src/server/plugins/engine/components/types.js' +import { + type ComponentViewModel, + type RenderContext +} from '~/src/server/plugins/engine/components/types.js' import { getErrors } from '~/src/server/plugins/engine/helpers.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' @@ -237,12 +240,7 @@ export class ComponentCollection { ) } - getViewModel( - payload: FormPayload, - errors: FormSubmissionError[] | undefined, - translator: Translator, - isForceAccess = false - ) { + getViewModel(context: RenderContext) { const { components } = this const result: ComponentViewModel[] = components.map((component) => { @@ -250,7 +248,7 @@ export class ComponentCollection { const model = component instanceof FormComponent - ? component.getViewModel(payload, errors, translator, isForceAccess) + ? component.getViewModel(context) : component.getViewModel() return { type, isFormComponent, model } diff --git a/src/server/plugins/engine/components/DatePartsField.test.ts b/src/server/plugins/engine/components/DatePartsField.test.ts index 09a48b779..2d64926b0 100644 --- a/src/server/plugins/engine/components/DatePartsField.test.ts +++ b/src/server/plugins/engine/components/DatePartsField.test.ts @@ -362,7 +362,11 @@ describe('DatePartsField', () => { it('sets Nunjucks component defaults', () => { const payload = getFormData(date) - const viewModel = field.getViewModel(payload, undefined, stubTranslator) + const viewModel = field.getViewModel({ + payload, + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ @@ -406,7 +410,11 @@ describe('DatePartsField', () => { year: 'YYYY' }) - const viewModel = field.getViewModel(payload, undefined, stubTranslator) + const viewModel = field.getViewModel({ + payload, + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ @@ -429,7 +437,11 @@ describe('DatePartsField', () => { it('sets Nunjucks component fieldset', () => { const payload = getFormData(date) - const viewModel = field.getViewModel(payload, undefined, stubTranslator) + const viewModel = field.getViewModel({ + payload, + errors: undefined, + translator: stubTranslator + }) expect(viewModel.fieldset).toEqual({ legend: { diff --git a/src/server/plugins/engine/components/DatePartsField.ts b/src/server/plugins/engine/components/DatePartsField.ts index d1e607d83..a575e68ab 100644 --- a/src/server/plugins/engine/components/DatePartsField.ts +++ b/src/server/plugins/engine/components/DatePartsField.ts @@ -11,7 +11,8 @@ import { import { NumberField } from '~/src/server/plugins/engine/components/NumberField.js' import { type DateInputItem, - type DatePartsState + type DatePartsState, + type RenderContext } from '~/src/server/plugins/engine/components/types.js' import { parseStrictDate } from '~/src/server/plugins/engine/date-helper.js' import { buildValidationMessages } from '~/src/server/plugins/engine/i18n/buildValidationMessages.js' @@ -22,7 +23,6 @@ import { 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' @@ -163,20 +163,11 @@ export class DatePartsField extends FormComponent { }) } - getViewModel( - payload: FormPayload, - errors: FormSubmissionError[] | undefined, - translator: Translator, - isForceAccess = false - ) { + getViewModel(context: RenderContext) { + const { errors } = context const { collection, name } = this - const viewModel = super.getViewModel( - payload, - errors, - translator, - isForceAccess - ) + const viewModel = super.getViewModel(context) let { fieldset, label } = viewModel // Check for component errors only @@ -184,7 +175,7 @@ export class DatePartsField extends FormComponent { // Use the component collection to generate the subitems const items: DateInputItem[] = collection - .getViewModel(payload, errors, translator, isForceAccess) + .getViewModel(context) .map(({ model }) => { let { label, type, value, classes, errorMessage } = model diff --git a/src/server/plugins/engine/components/DeclarationField.test.ts b/src/server/plugins/engine/components/DeclarationField.test.ts index 948bba66e..bef098f42 100644 --- a/src/server/plugins/engine/components/DeclarationField.test.ts +++ b/src/server/plugins/engine/components/DeclarationField.test.ts @@ -242,11 +242,11 @@ describe('DeclarationField', () => { describe('View model', () => { it('sets Nunjucks component defaults', () => { - const viewModel = field.getViewModel( - getFormData(undefined), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData(undefined), + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ @@ -280,11 +280,11 @@ describe('DeclarationField', () => { collection = new ComponentCollection([def], { model }) field = collection.fields[0] - const viewModel = field.getViewModel( - getFormData('unchecked'), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData('unchecked'), + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ @@ -310,11 +310,11 @@ describe('DeclarationField', () => { collection = new ComponentCollection([def], { model }) field = collection.fields[0] - const viewModel = field.getViewModel( - getFormData(['true', 'unchecked']), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData(['true', 'unchecked']), + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ @@ -340,11 +340,11 @@ describe('DeclarationField', () => { collection = new ComponentCollection([def], { model }) field = collection.fields[0] - const viewModel = field.getViewModel( - getFormData(['unchecked']), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData(['unchecked']), + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ @@ -370,11 +370,11 @@ describe('DeclarationField', () => { collection = new ComponentCollection([def], { model }) field = collection.fields[0] - const viewModel = field.getViewModel( - getFormData('true'), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData('true'), + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ @@ -409,11 +409,11 @@ describe('DeclarationField', () => { collection = new ComponentCollection([def], { model }) field = collection.fields[0] - const viewModel = field.getViewModel( - getFormData('true'), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData('true'), + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ @@ -437,11 +437,11 @@ describe('DeclarationField', () => { collection = new ComponentCollection([def], { model }) field = collection.fields[0] - const viewModel = field.getViewModel( - getFormData(undefined), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData(undefined), + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ diff --git a/src/server/plugins/engine/components/DeclarationField.ts b/src/server/plugins/engine/components/DeclarationField.ts index b282a20d5..f05617bfd 100644 --- a/src/server/plugins/engine/components/DeclarationField.ts +++ b/src/server/plugins/engine/components/DeclarationField.ts @@ -16,6 +16,7 @@ import { FormComponent, isFormValue } from '~/src/server/plugins/engine/components/FormComponent.js' +import { type RenderContext } from '~/src/server/plugins/engine/components/types.js' import { buildValidationMessages } from '~/src/server/plugins/engine/i18n/buildValidationMessages.js' import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' @@ -25,7 +26,6 @@ import { type FormPayload, type FormState, type FormStateValue, - type FormSubmissionError, type FormSubmissionState, type FormValue } from '~/src/server/plugins/engine/types.js' @@ -141,13 +141,9 @@ export class DeclarationField extends FormComponent { : t('components.declarationField.notProvided') } - getViewModel( - payload: FormPayload, - errors: FormSubmissionError[] | undefined, - translator: Translator, - isForceAccess = false - ) { - const { t } = translator + getViewModel(context: RenderContext) { + const { payload } = context + const { t } = context.translator const { hint, @@ -157,12 +153,7 @@ export class DeclarationField extends FormComponent { ) } = this - const viewModel = super.getViewModel( - payload, - errors, - translator, - isForceAccess - ) + const viewModel = super.getViewModel(context) let { fieldset, label } = viewModel fieldset ??= { diff --git a/src/server/plugins/engine/components/EastingNorthingField.test.ts b/src/server/plugins/engine/components/EastingNorthingField.test.ts index f785c1267..dfbb94594 100644 --- a/src/server/plugins/engine/components/EastingNorthingField.test.ts +++ b/src/server/plugins/engine/components/EastingNorthingField.test.ts @@ -293,7 +293,11 @@ describe('EastingNorthingField', () => { easting: 12345, northing: 1234567 }) - const viewModel = field.getViewModel(payload, undefined, stubTranslator) + const viewModel = field.getViewModel({ + payload, + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ @@ -336,14 +340,14 @@ describe('EastingNorthingField', () => { { model } ) - const viewModel = componentWithInstruction.getViewModel( - getFormData({ + const viewModel = componentWithInstruction.getViewModel({ + payload: getFormData({ easting: 12345, northing: 1234567 }), - undefined, - stubTranslator - ) + errors: undefined, + translator: stubTranslator + }) const instructionText = 'instructionText' in viewModel ? viewModel.instructionText : undefined @@ -366,7 +370,11 @@ describe('EastingNorthingField', () => { } ] - const viewModel = field.getViewModel(payload, errors, stubTranslator) + const viewModel = field.getViewModel({ + payload, + errors, + translator: stubTranslator + }) // Check that error is passed to the viewModel expect(viewModel.errors).toEqual(errors) diff --git a/src/server/plugins/engine/components/EastingNorthingField.ts b/src/server/plugins/engine/components/EastingNorthingField.ts index 36f9702a7..5e3ce6bcc 100644 --- a/src/server/plugins/engine/components/EastingNorthingField.ts +++ b/src/server/plugins/engine/components/EastingNorthingField.ts @@ -16,9 +16,11 @@ import { } from '~/src/server/plugins/engine/components/LocationFieldHelpers.js' import { NumberField } from '~/src/server/plugins/engine/components/NumberField.js' import { createLowerFirstExpression } from '~/src/server/plugins/engine/components/helpers/index.js' -import { type EastingNorthingState } from '~/src/server/plugins/engine/components/types.js' +import { + type EastingNorthingState, + type RenderContext +} from '~/src/server/plugins/engine/components/types.js' import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' -import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -220,26 +222,9 @@ export class EastingNorthingField extends FormComponent { return this.getContextValueFromFormValue(value) } - getViewModel( - payload: FormPayload, - errors: FormSubmissionError[] | undefined, - translator: Translator, - isForceAccess = false - ) { - const viewModel = super.getViewModel( - payload, - errors, - translator, - isForceAccess - ) - return getLocationFieldViewModel( - this, - viewModel, - payload, - errors, - translator, - isForceAccess - ) + getViewModel(context: RenderContext) { + const viewModel = super.getViewModel(context) + return getLocationFieldViewModel(this, viewModel, context) } getViewErrors( diff --git a/src/server/plugins/engine/components/EmailAddressField.test.ts b/src/server/plugins/engine/components/EmailAddressField.test.ts index 83ce253d3..fc24e2daf 100644 --- a/src/server/plugins/engine/components/EmailAddressField.test.ts +++ b/src/server/plugins/engine/components/EmailAddressField.test.ts @@ -209,11 +209,11 @@ describe('EmailAddressField', () => { describe('View model', () => { it('sets Nunjucks component defaults', () => { - const viewModel = field.getViewModel( - getFormData('defra.helpline@defra.gov.uk'), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData('defra.helpline@defra.gov.uk'), + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ diff --git a/src/server/plugins/engine/components/EmailAddressField.ts b/src/server/plugins/engine/components/EmailAddressField.ts index f3c44d4c3..4b652383e 100644 --- a/src/server/plugins/engine/components/EmailAddressField.ts +++ b/src/server/plugins/engine/components/EmailAddressField.ts @@ -2,13 +2,9 @@ import { type EmailAddressFieldComponent } from '@defra/forms-model' import joi from 'joi' import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' -import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' +import { type RenderContext } 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' +import { type ErrorMessageTemplateList } from '~/src/server/plugins/engine/types.js' export class EmailAddressField extends FormComponent { declare options: EmailAddressFieldComponent['options'] @@ -44,18 +40,8 @@ export class EmailAddressField extends FormComponent { this.options = options } - getViewModel( - payload: FormPayload, - errors: FormSubmissionError[] | undefined, - translator: Translator, - isForceAccess = false - ) { - const viewModel = super.getViewModel( - payload, - errors, - translator, - isForceAccess - ) + getViewModel(context: RenderContext) { + const viewModel = super.getViewModel(context) const { attributes } = viewModel attributes.autocomplete = 'email' diff --git a/src/server/plugins/engine/components/FileUploadField.test.ts b/src/server/plugins/engine/components/FileUploadField.test.ts index 01355efce..5555b3ed8 100644 --- a/src/server/plugins/engine/components/FileUploadField.test.ts +++ b/src/server/plugins/engine/components/FileUploadField.test.ts @@ -383,11 +383,11 @@ describe('FileUploadField', () => { describe('View model', () => { it('sets Nunjucks component defaults', () => { - const viewModel = field.getViewModel( - getFormData(validState), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData(validState), + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ @@ -466,12 +466,12 @@ describe('FileUploadField', () => { }) it('sets Nunjucks component defaults (preview URL direct access)', () => { - const viewModel = field.getViewModel( - getFormData(validState), - undefined, - stubTranslator, - true - ) + const viewModel = field.getViewModel({ + payload: getFormData(validState), + errors: undefined, + translator: stubTranslator, + isForceAccess: true + }) expect(viewModel).toEqual( expect.objectContaining({ @@ -525,11 +525,11 @@ describe('FileUploadField', () => { }) it('sets Nunjucks component defaults with temp valid state', () => { - const viewModel = field.getViewModel( - getFormData(validTempState), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData(validTempState), + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ @@ -570,11 +570,11 @@ describe('FileUploadField', () => { }) it('sets Nunjucks component defaults with temp valid state with errors (on POST)', () => { - const viewModel = field.getViewModel( - getFormData(validTempState), - [], - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData(validTempState), + errors: [], + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ diff --git a/src/server/plugins/engine/components/FileUploadField.ts b/src/server/plugins/engine/components/FileUploadField.ts index c3929cfe4..d738fa5c6 100644 --- a/src/server/plugins/engine/components/FileUploadField.ts +++ b/src/server/plugins/engine/components/FileUploadField.ts @@ -9,6 +9,7 @@ import { FormComponent, isUploadState } from '~/src/server/plugins/engine/components/FormComponent.js' +import { type RenderContext } from '~/src/server/plugins/engine/components/types.js' import { getPluginOptions } from '~/src/server/plugins/engine/helpers.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { InvalidComponentStateError } from '~/src/server/plugins/engine/pageControllers/errors.js' @@ -21,10 +22,8 @@ import { type FileUpload, type FileUploadMetadata, type FormContext, - type FormPayload, type FormState, type FormStateValue, - type FormSubmissionError, type FormSubmissionState, type SummaryList, type SummaryListAction, @@ -197,17 +196,12 @@ export class FileUploadField extends FormComponent { return this.getContextValueFromFormValue(files) } - getViewModel( - payload: FormPayload, - errors: FormSubmissionError[] | undefined, - translator: Translator, - isForceAccess = false - ) { + getViewModel(context: RenderContext) { + const { errors, isForceAccess = false } = context + const { t } = context.translator const { options, page, schema } = this - const { t } = translator - - const viewModel = super.getViewModel(payload, errors, translator) + const viewModel = super.getViewModel(context) const { attributes, id, value } = viewModel const files = this.getFormValue(value) ?? [] diff --git a/src/server/plugins/engine/components/FormComponent.ts b/src/server/plugins/engine/components/FormComponent.ts index 3177d6f22..b861ee062 100644 --- a/src/server/plugins/engine/components/FormComponent.ts +++ b/src/server/plugins/engine/components/FormComponent.ts @@ -7,6 +7,7 @@ import { import { type LanguageMessages } from 'joi' import { ComponentBase } from '~/src/server/plugins/engine/components/ComponentBase.js' +import { type RenderContext } from '~/src/server/plugins/engine/components/types.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type FormContext, @@ -143,12 +144,12 @@ export class FormComponent extends ComponentBase { return null } - getViewModel( - payload: FormPayload, - errors: FormSubmissionError[] | undefined, - translator: Translator, - _isForceAccess = false - ) { + getViewModel({ + payload, + errors, + translator, + isForceAccess: _isForceAccess = false + }: RenderContext) { const { hint, name, options = {}, title, viewModel } = this const { t, tContent } = translator diff --git a/src/server/plugins/engine/components/GeospatialField.test.ts b/src/server/plugins/engine/components/GeospatialField.test.ts index 088715085..11e0d7ad7 100644 --- a/src/server/plugins/engine/components/GeospatialField.test.ts +++ b/src/server/plugins/engine/components/GeospatialField.test.ts @@ -221,11 +221,11 @@ describe('GeospatialField', () => { describe('View model', () => { it('sets Nunjucks component defaults', () => { - const viewModel = field.getViewModel( - getFormData('Geospatial'), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData('Geospatial'), + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ diff --git a/src/server/plugins/engine/components/GeospatialField.ts b/src/server/plugins/engine/components/GeospatialField.ts index 9cec29a76..fd54ebc94 100644 --- a/src/server/plugins/engine/components/GeospatialField.ts +++ b/src/server/plugins/engine/components/GeospatialField.ts @@ -7,12 +7,12 @@ import { isGeospatialState } from '~/src/server/plugins/engine/components/FormComponent.js' import { geospatialSchema } from '~/src/server/plugins/engine/components/helpers/geospatial.js' +import { type RenderContext } from '~/src/server/plugins/engine/components/types.js' import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, - type FormPayload, type FormState, type FormStateValue, type FormSubmissionError, @@ -91,18 +91,8 @@ export class GeospatialField extends FormComponent { return this.getContextValueFromFormValue(features) } - getViewModel( - payload: FormPayload, - errors: FormSubmissionError[] | undefined, - translator: Translator, - isForceAccess = false - ) { - const viewModel = super.getViewModel( - payload, - errors, - translator, - isForceAccess - ) + getViewModel(context: RenderContext) { + const viewModel = super.getViewModel(context) const value = typeof viewModel.value === 'string' ? viewModel.value diff --git a/src/server/plugins/engine/components/HiddenField.test.ts b/src/server/plugins/engine/components/HiddenField.test.ts index d157aad0c..bbffe1894 100644 --- a/src/server/plugins/engine/components/HiddenField.test.ts +++ b/src/server/plugins/engine/components/HiddenField.test.ts @@ -165,11 +165,11 @@ describe('HiddenField', () => { describe('View model', () => { it('sets Nunjucks component defaults', () => { - const viewModel = field.getViewModel( - getFormData('Hidden field'), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData('Hidden field'), + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ diff --git a/src/server/plugins/engine/components/LatLongField.test.ts b/src/server/plugins/engine/components/LatLongField.test.ts index c999ca3d8..373a6cb5c 100644 --- a/src/server/plugins/engine/components/LatLongField.test.ts +++ b/src/server/plugins/engine/components/LatLongField.test.ts @@ -282,7 +282,11 @@ describe('LatLongField', () => { latitude: 51.51945, longitude: -0.127758 }) - const viewModel = field.getViewModel(payload, undefined, stubTranslator) + const viewModel = field.getViewModel({ + payload, + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ @@ -325,14 +329,14 @@ describe('LatLongField', () => { { model } ) - const viewModel = componentWithInstruction.getViewModel( - getFormData({ + const viewModel = componentWithInstruction.getViewModel({ + payload: getFormData({ latitude: 51.51945, longitude: -0.127758 }), - undefined, - stubTranslator - ) + errors: undefined, + translator: stubTranslator + }) const instructionText = 'instructionText' in viewModel ? viewModel.instructionText : undefined @@ -355,7 +359,11 @@ describe('LatLongField', () => { } ] - const viewModel = field.getViewModel(payload, errors, stubTranslator) + const viewModel = field.getViewModel({ + payload, + errors, + translator: stubTranslator + }) // Check that error is passed to the viewModel expect(viewModel.errors).toEqual(errors) diff --git a/src/server/plugins/engine/components/LatLongField.ts b/src/server/plugins/engine/components/LatLongField.ts index d4957b725..a1df6d4a2 100644 --- a/src/server/plugins/engine/components/LatLongField.ts +++ b/src/server/plugins/engine/components/LatLongField.ts @@ -13,9 +13,11 @@ import { } from '~/src/server/plugins/engine/components/LocationFieldHelpers.js' import { NumberField } from '~/src/server/plugins/engine/components/NumberField.js' import { createLowerFirstExpression } from '~/src/server/plugins/engine/components/helpers/index.js' -import { type LatLongState } from '~/src/server/plugins/engine/components/types.js' +import { + type LatLongState, + type RenderContext +} from '~/src/server/plugins/engine/components/types.js' import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' -import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -184,26 +186,9 @@ export class LatLongField extends FormComponent { return this.getContextValueFromFormValue(value) } - getViewModel( - payload: FormPayload, - errors: FormSubmissionError[] | undefined, - translator: Translator, - isForceAccess = false - ) { - const viewModel = super.getViewModel( - payload, - errors, - translator, - isForceAccess - ) - return getLocationFieldViewModel( - this, - viewModel, - payload, - errors, - translator, - isForceAccess - ) + getViewModel(context: RenderContext) { + const viewModel = super.getViewModel(context) + return getLocationFieldViewModel(this, viewModel, context) } getViewErrors( diff --git a/src/server/plugins/engine/components/ListFormComponent.ts b/src/server/plugins/engine/components/ListFormComponent.ts index 43b4e200a..27f7a498d 100644 --- a/src/server/plugins/engine/components/ListFormComponent.ts +++ b/src/server/plugins/engine/components/ListFormComponent.ts @@ -13,14 +13,15 @@ import joi, { } from 'joi' import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' -import { type ListItem } from '~/src/server/plugins/engine/components/types.js' +import { + type ListItem, + type RenderContext +} from '~/src/server/plugins/engine/components/types.js' import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, - type FormPayload, - type FormSubmissionError, type FormSubmissionState } from '~/src/server/plugins/engine/types.js' @@ -129,26 +130,16 @@ export class ListFormComponent extends FormComponent { return this.getDisplayStringFromFormValue(value, translator) } - getViewModel( - payload: FormPayload, - errors: FormSubmissionError[] | undefined, - translator: Translator, - isForceAccess = false - ) { + getViewModel(context: RenderContext) { const { items: listItems } = this - const viewModel = super.getViewModel( - payload, - errors, - translator, - isForceAccess - ) + const viewModel = super.getViewModel(context) const { value } = viewModel // Support multiple values for checkboxes const values = this.isValue(value) ? [value].flat() : [] - const { tContent } = translator + const { tContent } = context.translator const items = listItems.map((item) => { const selected = values.includes(item.value) diff --git a/src/server/plugins/engine/components/LocationFieldBase.test.ts b/src/server/plugins/engine/components/LocationFieldBase.test.ts index 8bb4243b9..3493c7c04 100644 --- a/src/server/plugins/engine/components/LocationFieldBase.test.ts +++ b/src/server/plugins/engine/components/LocationFieldBase.test.ts @@ -84,11 +84,11 @@ describe('LocationFieldBase', () => { } as ConstructorParameters[0] const field = new TestLocationField(def, { model }) - const viewModel = field.getViewModel( - getFormData('TEST1234'), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData('TEST1234'), + errors: undefined, + translator: stubTranslator + }) const instructionText = 'instructionText' in viewModel ? viewModel.instructionText : undefined @@ -106,11 +106,11 @@ describe('LocationFieldBase', () => { } as ConstructorParameters[0] const field = new TestLocationField(def, { model }) - const viewModel = field.getViewModel( - getFormData('TEST1234'), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData('TEST1234'), + errors: undefined, + translator: stubTranslator + }) expect( 'instructionText' in viewModel ? viewModel.instructionText : undefined diff --git a/src/server/plugins/engine/components/LocationFieldBase.ts b/src/server/plugins/engine/components/LocationFieldBase.ts index 37c439e2e..c2b09465c 100644 --- a/src/server/plugins/engine/components/LocationFieldBase.ts +++ b/src/server/plugins/engine/components/LocationFieldBase.ts @@ -10,14 +10,12 @@ import { isFormValue } from '~/src/server/plugins/engine/components/FormComponent.js' import { addClassOptionIfNone } from '~/src/server/plugins/engine/components/helpers/index.js' -import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' +import { type RenderContext } 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' @@ -120,18 +118,8 @@ export abstract class LocationFieldBase extends FormComponent { return LocationFieldBase.isText(value) } - getViewModel( - payload: FormPayload, - errors: FormSubmissionError[] | undefined, - translator: Translator, - isForceAccess = false - ) { - const viewModel = super.getViewModel( - payload, - errors, - translator, - isForceAccess - ) + getViewModel(context: RenderContext) { + const viewModel = super.getViewModel(context) if (this.instructionText) { return { diff --git a/src/server/plugins/engine/components/LocationFieldHelpers.test.ts b/src/server/plugins/engine/components/LocationFieldHelpers.test.ts index 2ea891ef0..b410e37f2 100644 --- a/src/server/plugins/engine/components/LocationFieldHelpers.test.ts +++ b/src/server/plugins/engine/components/LocationFieldHelpers.test.ts @@ -277,7 +277,11 @@ describe('LocationFieldHelpers', () => { myComponent__longitude: -0.1 } - const viewModel = field.getViewModel(payload, undefined, stubTranslator) + const viewModel = field.getViewModel({ + payload, + errors: undefined, + translator: stubTranslator + }) expect(viewModel.fieldset).toEqual({ legend: { @@ -308,7 +312,11 @@ describe('LocationFieldHelpers', () => { myComponent__longitude: -0.1 } - const viewModel = field.getViewModel(payload, undefined, stubTranslator) + const viewModel = field.getViewModel({ + payload, + errors: undefined, + translator: stubTranslator + }) const instructionText = 'instructionText' in viewModel ? viewModel.instructionText : undefined @@ -342,7 +350,11 @@ describe('LocationFieldHelpers', () => { } ] - const viewModel = field.getViewModel(payload, errors, stubTranslator) + const viewModel = field.getViewModel({ + payload, + errors, + translator: stubTranslator + }) // Check that errors are passed to the viewModel expect(viewModel.errors).toEqual(errors) @@ -390,7 +402,11 @@ describe('LocationFieldHelpers', () => { } ] - const viewModel = field.getViewModel(payload, errors, stubTranslator) + const viewModel = field.getViewModel({ + payload, + errors, + translator: stubTranslator + }) // Single errors should be displayed at fieldset level expect(viewModel.items[0].errorMessage).toBeUndefined() @@ -438,7 +454,11 @@ describe('LocationFieldHelpers', () => { } ] - const viewModel = field.getViewModel(payload, errors, stubTranslator) + const viewModel = field.getViewModel({ + payload, + errors, + translator: stubTranslator + }) expect(viewModel.items[0].errorMessage).toBeUndefined() expect(viewModel.items[1].errorMessage).toBeUndefined() @@ -470,7 +490,11 @@ describe('LocationFieldHelpers', () => { } // No errors passed in, but the subViewModels might have errors from elsewhere - const viewModel = field.getViewModel(payload, [], stubTranslator) + const viewModel = field.getViewModel({ + payload, + errors: [], + translator: stubTranslator + }) // When no field errors, items should not have error messages expect(viewModel.items[0].errorMessage).toBeUndefined() @@ -512,7 +536,11 @@ describe('LocationFieldHelpers', () => { } ] - const viewModel = field.getViewModel(payload, errors, stubTranslator) + const viewModel = field.getViewModel({ + payload, + errors, + translator: stubTranslator + }) // No individual field errors expect(viewModel.items[0].errorMessage).toBeUndefined() @@ -546,7 +574,11 @@ describe('LocationFieldHelpers', () => { myComponent__longitude: '-0.1' } - const viewModel = field.getViewModel(payload, undefined, stubTranslator) + const viewModel = field.getViewModel({ + payload, + errors: undefined, + translator: stubTranslator + }) // When no errors and no additional classes, classes should not include govuk-input--error expect(viewModel.items[0].classes).not.toContain('govuk-input--error') @@ -570,7 +602,11 @@ describe('LocationFieldHelpers', () => { myComponent__longitude: '-0.1' } - const viewModel = field.getViewModel(payload, undefined, stubTranslator) + const viewModel = field.getViewModel({ + payload, + errors: undefined, + translator: stubTranslator + }) const label = viewModel.items[0].label expect(label).toBeDefined() @@ -600,7 +636,11 @@ describe('LocationFieldHelpers', () => { myComponent__longitude: -0.1 } - const viewModel = field.getViewModel(payload, undefined, stubTranslator) + const viewModel = field.getViewModel({ + payload, + errors: undefined, + translator: stubTranslator + }) expect(viewModel.fieldset).toBeDefined() }) diff --git a/src/server/plugins/engine/components/LocationFieldHelpers.ts b/src/server/plugins/engine/components/LocationFieldHelpers.ts index 466548442..206218a7f 100644 --- a/src/server/plugins/engine/components/LocationFieldHelpers.ts +++ b/src/server/plugins/engine/components/LocationFieldHelpers.ts @@ -6,9 +6,9 @@ import { type LatLongField } from '~/src/server/plugins/engine/components/LatLon import { type DateInputItem, type Label, + type RenderContext, type ViewModel } from '~/src/server/plugins/engine/components/types.js' -import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type FormPayload, type FormSubmissionError, @@ -95,20 +95,12 @@ export function getLocationFieldViewModel( name: string value: FormValue }, - payload: FormPayload, - errors: FormSubmissionError[] | undefined, - translator: Translator, - isForceAccess = false + context: RenderContext ) { const { collection } = component const { fieldset: existingFieldset, label } = viewModel - const subViewModels = collection.getViewModel( - payload, - errors, - translator, - isForceAccess - ) + const subViewModels = collection.getViewModel(context) const fieldErrors: string[] = [] subViewModels.forEach(({ model }) => { diff --git a/src/server/plugins/engine/components/MonthYearField.test.ts b/src/server/plugins/engine/components/MonthYearField.test.ts index 56f2754ae..6430cc7a4 100644 --- a/src/server/plugins/engine/components/MonthYearField.test.ts +++ b/src/server/plugins/engine/components/MonthYearField.test.ts @@ -334,7 +334,11 @@ describe('MonthYearField', () => { it('sets Nunjucks component defaults', () => { const payload = getFormData(date) - const viewModel = field.getViewModel(payload, undefined, stubTranslator) + const viewModel = field.getViewModel({ + payload, + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ @@ -369,7 +373,11 @@ describe('MonthYearField', () => { year: 'YYYY' }) - const viewModel = field.getViewModel(payload, undefined, stubTranslator) + const viewModel = field.getViewModel({ + payload, + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ @@ -388,7 +396,11 @@ describe('MonthYearField', () => { it('sets Nunjucks component fieldset', () => { const payload = getFormData(date) - const viewModel = field.getViewModel(payload, undefined, stubTranslator) + const viewModel = field.getViewModel({ + payload, + errors: undefined, + translator: stubTranslator + }) expect(viewModel.fieldset).toEqual({ legend: { diff --git a/src/server/plugins/engine/components/MonthYearField.ts b/src/server/plugins/engine/components/MonthYearField.ts index 23e85ec21..8938dd8aa 100644 --- a/src/server/plugins/engine/components/MonthYearField.ts +++ b/src/server/plugins/engine/components/MonthYearField.ts @@ -16,7 +16,8 @@ import { import { NumberField } from '~/src/server/plugins/engine/components/NumberField.js' import { type DateInputItem, - type MonthYearState + type MonthYearState, + type RenderContext } from '~/src/server/plugins/engine/components/types.js' import { parseStrictDate } from '~/src/server/plugins/engine/date-helper.js' import { buildValidationMessages } from '~/src/server/plugins/engine/i18n/buildValidationMessages.js' @@ -27,7 +28,6 @@ import { 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' @@ -160,20 +160,11 @@ export class MonthYearField extends FormComponent { }) } - getViewModel( - payload: FormPayload, - errors: FormSubmissionError[] | undefined, - translator: Translator, - isForceAccess = false - ) { + getViewModel(context: RenderContext) { + const { errors } = context const { collection, name } = this - const viewModel = super.getViewModel( - payload, - errors, - translator, - isForceAccess - ) + const viewModel = super.getViewModel(context) let { fieldset, label } = viewModel // Check for component errors only @@ -181,7 +172,7 @@ export class MonthYearField extends FormComponent { // Use the component collection to generate the subitems const items: DateInputItem[] = collection - .getViewModel(payload, errors, translator, isForceAccess) + .getViewModel(context) .map(({ model }) => { let { label, type, value, classes, errorMessage } = model diff --git a/src/server/plugins/engine/components/MultilineTextField.test.ts b/src/server/plugins/engine/components/MultilineTextField.test.ts index 2c040f552..fbd2cd14a 100644 --- a/src/server/plugins/engine/components/MultilineTextField.test.ts +++ b/src/server/plugins/engine/components/MultilineTextField.test.ts @@ -220,11 +220,11 @@ describe('MultilineTextField', () => { describe('View model', () => { it('sets Nunjucks component defaults', () => { - const viewModel = field.getViewModel( - getFormData('Textarea'), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData('Textarea'), + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ @@ -247,23 +247,23 @@ describe('MultilineTextField', () => { { model } ) - const viewModel = field.getViewModel( - getFormData('Textarea'), - undefined, - stubTranslator - ) - - const viewModel1 = componentCustom1.getViewModel( - getFormData('Textarea custom #1'), - undefined, - stubTranslator - ) - - const viewModel2 = componentCustom2.getViewModel( - getFormData('Textarea custom #2'), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData('Textarea'), + errors: undefined, + translator: stubTranslator + }) + + const viewModel1 = componentCustom1.getViewModel({ + payload: getFormData('Textarea custom #1'), + errors: undefined, + translator: stubTranslator + }) + + const viewModel2 = componentCustom2.getViewModel({ + payload: getFormData('Textarea custom #2'), + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ isCharacterOrWordCount: false }) diff --git a/src/server/plugins/engine/components/MultilineTextField.ts b/src/server/plugins/engine/components/MultilineTextField.ts index 26680b0e2..e8b04be44 100644 --- a/src/server/plugins/engine/components/MultilineTextField.ts +++ b/src/server/plugins/engine/components/MultilineTextField.ts @@ -3,13 +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 { type Translator } from '~/src/server/plugins/engine/i18n/types.js' +import { type RenderContext } 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' +import { type ErrorMessageTemplateList } from '~/src/server/plugins/engine/types.js' export class MultilineTextField extends FormComponent { declare options: MultilineTextFieldComponent['options'] @@ -91,20 +87,10 @@ export class MultilineTextField extends FormComponent { this.schema = schema } - getViewModel( - payload: FormPayload, - errors: FormSubmissionError[] | undefined, - translator: Translator, - isForceAccess = false - ) { + getViewModel(context: RenderContext) { const { schema, options, isCharacterOrWordCount } = this - const viewModel = super.getViewModel( - payload, - errors, - translator, - isForceAccess - ) + const viewModel = super.getViewModel(context) let { maxlength, maxwords, rows } = viewModel if (schema.max) { diff --git a/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts b/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts index 12dd7923e..6b10193b8 100644 --- a/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts +++ b/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts @@ -205,11 +205,11 @@ describe('NationalGridFieldNumberField', () => { describe('View model', () => { it('sets Nunjucks component defaults', () => { - const viewModel = field.getViewModel( - getFormData('NG12345678'), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData('NG12345678'), + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ @@ -230,11 +230,11 @@ describe('NationalGridFieldNumberField', () => { { model } ) - const viewModel = componentWithInstruction.getViewModel( - getFormData('NG12345678'), - undefined, - stubTranslator - ) + const viewModel = componentWithInstruction.getViewModel({ + payload: getFormData('NG12345678'), + errors: undefined, + translator: stubTranslator + }) const instructionText = 'instructionText' in viewModel ? viewModel.instructionText : undefined diff --git a/src/server/plugins/engine/components/NumberField.test.ts b/src/server/plugins/engine/components/NumberField.test.ts index c629f6102..c87b4ca9d 100644 --- a/src/server/plugins/engine/components/NumberField.test.ts +++ b/src/server/plugins/engine/components/NumberField.test.ts @@ -210,11 +210,11 @@ describe('NumberField', () => { describe('View model', () => { it('sets Nunjucks component defaults', () => { - const viewModel = field.getViewModel( - getFormData(2024), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData(2024), + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ @@ -232,11 +232,11 @@ describe('NumberField', () => { { model } ) - const viewModel = componentCustom.getViewModel( - getFormData(99.99), - undefined, - stubTranslator - ) + const viewModel = componentCustom.getViewModel({ + payload: getFormData(99.99), + errors: undefined, + translator: stubTranslator + }) expect(viewModel.prefix).toEqual({ text: '£' }) expect(viewModel.suffix).toEqual({ text: 'per item' }) @@ -248,11 +248,11 @@ describe('NumberField', () => { { model } ) - const viewModel = componentCustom.getViewModel( - getFormData(99), - undefined, - stubTranslator - ) + const viewModel = componentCustom.getViewModel({ + payload: getFormData(99), + errors: undefined, + translator: stubTranslator + }) expect(viewModel.attributes).toHaveProperty('inputmode', 'numeric') }) @@ -263,11 +263,11 @@ describe('NumberField', () => { { model } ) - const viewModel = componentCustom.getViewModel( - getFormData(99), - undefined, - stubTranslator - ) + const viewModel = componentCustom.getViewModel({ + payload: getFormData(99), + errors: undefined, + translator: stubTranslator + }) expect(viewModel.attributes).toHaveProperty('inputmode', 'numeric') }) @@ -278,22 +278,22 @@ describe('NumberField', () => { { model } ) - const viewModel = componentCustom.getViewModel( - getFormData(99.99), - undefined, - stubTranslator - ) + const viewModel = componentCustom.getViewModel({ + payload: getFormData(99.99), + errors: undefined, + translator: stubTranslator + }) expect(viewModel.attributes).not.toHaveProperty('inputmode', 'numeric') }) }) it('sets Nunjucks component value when invalid', () => { - const viewModel = field.getViewModel( - getFormData('AA'), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData('AA'), + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toHaveProperty('value', 'AA') }) diff --git a/src/server/plugins/engine/components/NumberField.ts b/src/server/plugins/engine/components/NumberField.ts index 6139ffa26..0b1e2b57a 100644 --- a/src/server/plugins/engine/components/NumberField.ts +++ b/src/server/plugins/engine/components/NumberField.ts @@ -5,14 +5,12 @@ import { FormComponent, isFormValue } from '~/src/server/plugins/engine/components/FormComponent.js' -import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' +import { type RenderContext } 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' @@ -89,20 +87,10 @@ export class NumberField extends FormComponent { return this.isValue(value) ? value : undefined } - getViewModel( - payload: FormPayload, - errors: FormSubmissionError[] | undefined, - translator: Translator, - isForceAccess = false - ) { + getViewModel(context: RenderContext) { const { options, schema } = this - const viewModel = super.getViewModel( - payload, - errors, - translator, - isForceAccess - ) + const viewModel = super.getViewModel(context) let { attributes, prefix, suffix, value } = viewModel if (typeof schema.precision === 'undefined' || schema.precision <= 0) { diff --git a/src/server/plugins/engine/components/OsGridRefField.test.ts b/src/server/plugins/engine/components/OsGridRefField.test.ts index 36d3144f9..8afcdd3e7 100644 --- a/src/server/plugins/engine/components/OsGridRefField.test.ts +++ b/src/server/plugins/engine/components/OsGridRefField.test.ts @@ -221,11 +221,11 @@ describe('OsGridRefField', () => { describe('View model', () => { it('sets Nunjucks component defaults', () => { - const viewModel = field.getViewModel( - getFormData('TQ12345678'), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData('TQ12345678'), + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ @@ -246,11 +246,11 @@ describe('OsGridRefField', () => { { model } ) - const viewModel = componentWithInstruction.getViewModel( - getFormData('TQ12345678'), - undefined, - stubTranslator - ) + const viewModel = componentWithInstruction.getViewModel({ + payload: getFormData('TQ12345678'), + errors: undefined, + translator: stubTranslator + }) const instructionText = 'instructionText' in viewModel ? viewModel.instructionText : undefined diff --git a/src/server/plugins/engine/components/PaymentField.test.ts b/src/server/plugins/engine/components/PaymentField.test.ts index 668aa6ee6..f87bb30a8 100644 --- a/src/server/plugins/engine/components/PaymentField.test.ts +++ b/src/server/plugins/engine/components/PaymentField.test.ts @@ -199,11 +199,11 @@ describe('PaymentField', () => { describe('View model', () => { it('sets Nunjucks component defaults', () => { - const viewModel = field.getViewModel( - getFormData(undefined), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData(undefined), + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ @@ -227,11 +227,11 @@ describe('PaymentField', () => { description: 'Test payment description', isLivePayment: false } as unknown as FormValue - const viewModel = field.getViewModel( - getFormData(paymentForViewModel), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData(paymentForViewModel), + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts index 5058636b6..0bd707b66 100644 --- a/src/server/plugins/engine/components/PaymentField.ts +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -10,11 +10,11 @@ import joi, { type ObjectSchema } from 'joi' import { COMPONENT_STATE_ERROR } from '~/src/server/constants.js' import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' import { type PaymentState } from '~/src/server/plugins/engine/components/PaymentField.types.js' +import { type RenderContext } from '~/src/server/plugins/engine/components/types.js' import { createError, getPluginOptions } from '~/src/server/plugins/engine/helpers.js' -import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { PaymentErrorTypes, PaymentPreAuthError, @@ -27,10 +27,8 @@ import { } from '~/src/server/plugins/engine/types/index.js' import { type ErrorMessageTemplateList, - type FormPayload, type FormState, type FormStateValue, - type FormSubmissionError, type FormSubmissionState, type PaymentExternalArgs } from '~/src/server/plugins/engine/types.js' @@ -102,18 +100,9 @@ export class PaymentField extends FormComponent { return `${formatCurrency(value.amount)} - ${value.description}` } - getViewModel( - payload: FormPayload, - errors: FormSubmissionError[] | undefined, - translator: Translator, - isForceAccess = false - ) { - const viewModel = super.getViewModel( - payload, - errors, - translator, - isForceAccess - ) + getViewModel(context: RenderContext) { + const { payload } = context + const viewModel = super.getViewModel(context) // Payload is pre-populated from state if a payment has already been made const paymentState = this.isPaymentState(payload[this.name] as unknown) diff --git a/src/server/plugins/engine/components/RadiosField.test.ts b/src/server/plugins/engine/components/RadiosField.test.ts index add0c5649..59a31f0c2 100644 --- a/src/server/plugins/engine/components/RadiosField.test.ts +++ b/src/server/plugins/engine/components/RadiosField.test.ts @@ -234,11 +234,11 @@ describe.each([ it('sets Nunjucks component defaults', () => { const item = options.examples[0] - const viewModel = field.getViewModel( - getFormData(item.value), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData(item.value), + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ @@ -253,11 +253,11 @@ describe.each([ it.each([...options.examples])( 'sets Nunjucks component radio items', (item) => { - const viewModel = field.getViewModel( - getFormData(item.value), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData(item.value), + errors: undefined, + translator: stubTranslator + }) expect(viewModel.items?.[0]).not.toMatchObject({ value: '' // First item is never empty diff --git a/src/server/plugins/engine/components/SelectField.test.ts b/src/server/plugins/engine/components/SelectField.test.ts index 07d3c369a..d668ef66a 100644 --- a/src/server/plugins/engine/components/SelectField.test.ts +++ b/src/server/plugins/engine/components/SelectField.test.ts @@ -234,11 +234,11 @@ describe.each([ describe('View model', () => { it('sets Nunjucks component defaults', () => { const item = options.examples[0] - const viewModel = field.getViewModel( - getFormData(item.value), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData(item.value), + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ @@ -253,11 +253,11 @@ describe.each([ it.each([...options.examples])( 'sets Nunjucks component select options', (item) => { - const viewModel = field.getViewModel( - getFormData(item.value), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData(item.value), + errors: undefined, + translator: stubTranslator + }) expect(viewModel.items?.[0]).toMatchObject({ value: '' // First item is always empty diff --git a/src/server/plugins/engine/components/SelectField.ts b/src/server/plugins/engine/components/SelectField.ts index 23f78cfaf..175242d44 100644 --- a/src/server/plugins/engine/components/SelectField.ts +++ b/src/server/plugins/engine/components/SelectField.ts @@ -4,11 +4,7 @@ import { } from '@defra/forms-model' import { ListFormComponent } from '~/src/server/plugins/engine/components/ListFormComponent.js' -import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' -import { - type FormPayload, - type FormSubmissionError -} from '~/src/server/plugins/engine/types.js' +import { type RenderContext } from '~/src/server/plugins/engine/components/types.js' export class SelectField extends ListFormComponent { declare options: @@ -34,18 +30,8 @@ export class SelectField extends ListFormComponent { this.options = options } - getViewModel( - payload: FormPayload, - errors: FormSubmissionError[] | undefined, - translator: Translator, - isForceAccess = false - ) { - const viewModel = super.getViewModel( - payload, - errors, - translator, - isForceAccess - ) + getViewModel(context: RenderContext) { + const viewModel = super.getViewModel(context) let { items } = viewModel items = [{ value: '' }, ...items] diff --git a/src/server/plugins/engine/components/SelectionControlField.ts b/src/server/plugins/engine/components/SelectionControlField.ts index d2bcab479..b6f457245 100644 --- a/src/server/plugins/engine/components/SelectionControlField.ts +++ b/src/server/plugins/engine/components/SelectionControlField.ts @@ -1,31 +1,19 @@ import { ListFormComponent } from '~/src/server/plugins/engine/components/ListFormComponent.js' -import { type ListItem } from '~/src/server/plugins/engine/components/types.js' -import { type Translator } from '~/src/server/plugins/engine/i18n/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' + type ListItem, + type RenderContext +} from '~/src/server/plugins/engine/components/types.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' +import { type ErrorMessageTemplateList } from '~/src/server/plugins/engine/types.js' /** * "Selection controls" are checkboxes and radios (and switches), as per Material UI nomenclature. */ export class SelectionControlField extends ListFormComponent { - getViewModel( - payload: FormPayload, - errors: FormSubmissionError[] | undefined, - translator: Translator, - isForceAccess = false - ) { + getViewModel(context: RenderContext) { const { options } = this - const viewModel = super.getViewModel( - payload, - errors, - translator, - isForceAccess - ) + const viewModel = super.getViewModel(context) let { fieldset, items, label } = viewModel fieldset ??= { diff --git a/src/server/plugins/engine/components/TelephoneNumberField.test.ts b/src/server/plugins/engine/components/TelephoneNumberField.test.ts index f6742ca07..6723db3fd 100644 --- a/src/server/plugins/engine/components/TelephoneNumberField.test.ts +++ b/src/server/plugins/engine/components/TelephoneNumberField.test.ts @@ -220,11 +220,11 @@ describe('TelephoneNumberField', () => { describe('View model', () => { it('sets Nunjucks component defaults', () => { - const viewModel = field.getViewModel( - getFormData('Telephone number field'), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData('Telephone number field'), + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ diff --git a/src/server/plugins/engine/components/TelephoneNumberField.ts b/src/server/plugins/engine/components/TelephoneNumberField.ts index 9b391d0bb..f4cbcf192 100644 --- a/src/server/plugins/engine/components/TelephoneNumberField.ts +++ b/src/server/plugins/engine/components/TelephoneNumberField.ts @@ -3,13 +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/index.js' -import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' +import { type RenderContext } 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' +import { type ErrorMessageTemplateList } from '~/src/server/plugins/engine/types.js' const PATTERN = /^[0-9\\\s+()-]*$/ @@ -56,18 +52,8 @@ export class TelephoneNumberField extends FormComponent { this.options = options } - getViewModel( - payload: FormPayload, - errors: FormSubmissionError[] | undefined, - translator: Translator, - isForceAccess = false - ) { - const viewModel = super.getViewModel( - payload, - errors, - translator, - isForceAccess - ) + getViewModel(context: RenderContext) { + const viewModel = super.getViewModel(context) const { attributes } = viewModel attributes.autocomplete = 'tel' diff --git a/src/server/plugins/engine/components/TextField.test.ts b/src/server/plugins/engine/components/TextField.test.ts index 8c56809c4..aa349e4f8 100644 --- a/src/server/plugins/engine/components/TextField.test.ts +++ b/src/server/plugins/engine/components/TextField.test.ts @@ -234,11 +234,11 @@ describe('TextField', () => { describe('View model', () => { it('sets Nunjucks component defaults', () => { - const viewModel = field.getViewModel( - getFormData('Text field'), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData('Text field'), + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ @@ -255,7 +255,11 @@ describe('TextField', () => { it('calls tContent for the field title', () => { const tContent = jest.fn().mockReturnValue('Translated title') const t = jest.fn().mockReturnValue('(optional)') - const viewModel = field.getViewModel({}, undefined, { t, tContent }) + const viewModel = field.getViewModel({ + payload: {}, + errors: undefined, + translator: { t, tContent } + }) expect(tContent).toHaveBeenCalledWith(field, 'title') expect(viewModel.label.text).toBe('Translated title') }) @@ -266,7 +270,11 @@ describe('TextField', () => { const hintField = hintCollection.fields[0] const tContent = jest.fn().mockReturnValue('Translated hint') const t = jest.fn().mockReturnValue('') - const viewModel = hintField.getViewModel({}, undefined, { t, tContent }) + const viewModel = hintField.getViewModel({ + payload: {}, + errors: undefined, + translator: { t, tContent } + }) expect(tContent).toHaveBeenCalledWith(hintField, 'hint') expect(viewModel.hint?.text).toBe('Translated hint') }) @@ -277,12 +285,20 @@ describe('TextField', () => { const optField = optCollection.fields[0] const tContent = jest.fn().mockReturnValue('Title') const t = jest.fn().mockReturnValue('(optional)') - optField.getViewModel({}, undefined, { t, tContent }) + optField.getViewModel({ + payload: {}, + errors: undefined, + translator: { t, tContent } + }) expect(t).toHaveBeenCalledWith('common.optional') }) it('falls back to English when no translator supplied', () => { - const viewModel = field.getViewModel({}, undefined, stubTranslator) + const viewModel = field.getViewModel({ + payload: {}, + errors: undefined, + translator: stubTranslator + }) expect(viewModel.label.text).toBe('Example text field') }) }) diff --git a/src/server/plugins/engine/components/UkAddressField.test.ts b/src/server/plugins/engine/components/UkAddressField.test.ts index 1fe354211..ed6cbad19 100644 --- a/src/server/plugins/engine/components/UkAddressField.test.ts +++ b/src/server/plugins/engine/components/UkAddressField.test.ts @@ -424,7 +424,11 @@ describe('UkAddressField', () => { it('sets Nunjucks component defaults', () => { const payload = getFormData(address) - const viewModel = field.getViewModel(payload, undefined, stubTranslator) + const viewModel = field.getViewModel({ + payload, + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ @@ -485,7 +489,11 @@ describe('UkAddressField', () => { it('sets Nunjucks component fieldset', () => { const payload = getFormData(address) - const viewModel = field.getViewModel(payload, undefined, stubTranslator) + const viewModel = field.getViewModel({ + payload, + errors: undefined, + translator: stubTranslator + }) expect(viewModel.fieldset).toEqual({ legend: { diff --git a/src/server/plugins/engine/components/UkAddressField.ts b/src/server/plugins/engine/components/UkAddressField.ts index 2b00d6cdb..a15ea6002 100644 --- a/src/server/plugins/engine/components/UkAddressField.ts +++ b/src/server/plugins/engine/components/UkAddressField.ts @@ -12,6 +12,7 @@ import { isFormState } from '~/src/server/plugins/engine/components/FormComponent.js' import { TextField } from '~/src/server/plugins/engine/components/TextField.js' +import { type RenderContext } from '~/src/server/plugins/engine/components/types.js' import { getPluginOptions } from '~/src/server/plugins/engine/helpers.js' import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' @@ -199,20 +200,11 @@ export class UkAddressField extends FormComponent { return uniqueErrors } - getViewModel( - payload: FormPayload, - errors: FormSubmissionError[] | undefined, - translator: Translator, - isForceAccess = false - ) { + getViewModel(context: RenderContext) { + const { payload } = context const { collection, name, options } = this - const viewModel = super.getViewModel( - payload, - errors, - translator, - isForceAccess - ) + const viewModel = super.getViewModel(context) let { fieldset, hint, label } = viewModel fieldset ??= { @@ -236,7 +228,7 @@ export class UkAddressField extends FormComponent { } } - const components = collection.getViewModel(payload, errors, translator) + const components = collection.getViewModel(context) // Hide UPRN const uprn = components.at(0) diff --git a/src/server/plugins/engine/components/YesNoField.test.ts b/src/server/plugins/engine/components/YesNoField.test.ts index fed2f7f54..1a612a666 100644 --- a/src/server/plugins/engine/components/YesNoField.test.ts +++ b/src/server/plugins/engine/components/YesNoField.test.ts @@ -216,11 +216,11 @@ describe('YesNoField', () => { it('sets Nunjucks component defaults', () => { const item = items[0] - const viewModel = field.getViewModel( - getFormData(item.value), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData(item.value), + errors: undefined, + translator: stubTranslator + }) expect(viewModel).toEqual( expect.objectContaining({ @@ -233,11 +233,11 @@ describe('YesNoField', () => { }) it.each([...items])('sets Nunjucks component radio items', (item) => { - const viewModel = field.getViewModel( - getFormData(item.value), - undefined, - stubTranslator - ) + const viewModel = field.getViewModel({ + payload: getFormData(item.value), + errors: undefined, + translator: stubTranslator + }) expect(viewModel.items?.[0]).not.toMatchObject({ value: '' // First item is never empty diff --git a/src/server/plugins/engine/components/types.ts b/src/server/plugins/engine/components/types.ts index 84c1b07a0..574e858a0 100644 --- a/src/server/plugins/engine/components/types.ts +++ b/src/server/plugins/engine/components/types.ts @@ -1,11 +1,20 @@ import { type ComponentType, type Item } from '@defra/forms-model' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { + type FormPayload, type FormSubmissionError, type FormValue, type SummaryList } from '~/src/server/plugins/engine/types.js' +export interface RenderContext { + payload: FormPayload + errors: FormSubmissionError[] | undefined + translator: Translator + isForceAccess?: boolean +} + export type ComponentText = { classes?: string attributes?: string | Record diff --git a/src/server/plugins/engine/pageControllers/FileUploadPageController.ts b/src/server/plugins/engine/pageControllers/FileUploadPageController.ts index 6aec0efb2..3758220d5 100644 --- a/src/server/plugins/engine/pageControllers/FileUploadPageController.ts +++ b/src/server/plugins/engine/pageControllers/FileUploadPageController.ts @@ -12,8 +12,7 @@ import { type FormComponent } from '~/src/server/plugins/engine/components/FormC import { getCacheService, getError, - getExponentialBackoffDelay, - getPluginOptions + getExponentialBackoffDelay } from '~/src/server/plugins/engine/helpers.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' @@ -189,9 +188,7 @@ export class FileUploadPageController extends QuestionPageController { const { filename } = fileToRemove.status.form.file - const { getLanguage } = getPluginOptions(request.server) - const language = getLanguage?.(request) ?? 'en-GB' - const { t } = this.model.createTranslator(language) + const { t } = this.getTranslator(request) return h.view(this.fileDeleteViewName, { ...viewModel, diff --git a/src/server/plugins/engine/pageControllers/PageController.ts b/src/server/plugins/engine/pageControllers/PageController.ts index 668ad1ab4..fc1bb752b 100644 --- a/src/server/plugins/engine/pageControllers/PageController.ts +++ b/src/server/plugins/engine/pageControllers/PageController.ts @@ -16,9 +16,11 @@ import { getStartPath, normalisePath } from '~/src/server/plugins/engine/helpers.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { type ExecutableCondition } from '~/src/server/plugins/engine/models/types.js' import { + type AnyFormRequest, type FormContext, type PageViewModelBase } from '~/src/server/plugins/engine/types.js' @@ -162,6 +164,11 @@ export class PageController { return ControllerPath.Status.valueOf() } + protected getTranslator(request: AnyFormRequest): Translator { + const { getLanguage } = getPluginOptions(request.server) + return this.model.createTranslator(getLanguage?.(request) ?? 'en-GB') + } + makeGetRouteHandler(): ( request: FormRequest, context: FormContext, @@ -169,9 +176,7 @@ export class PageController { ) => ReturnType> { return (request, context, h) => { const { viewModel, viewName } = this - const { getLanguage } = getPluginOptions(request.server) - const language = getLanguage?.(request) ?? 'en-GB' - const { t } = this.model.createTranslator(language) + const { t } = this.getTranslator(request) return h.view(viewName, { ...viewModel, t diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index 1252647e9..96d5310fb 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -24,7 +24,6 @@ import { checkFormStatus, getCacheService, getErrors, - getPluginOptions, getSaveAndExitHelpers, normalisePath, proceed @@ -140,7 +139,7 @@ export class QuestionPageController extends PageController { : '' : viewModel.sectionTitle - const components = collection.getViewModel(payload, errors, translator) + const components = collection.getViewModel({ payload, errors, translator }) const formComponents = components.filter( ({ isFormComponent }) => isFormComponent ) @@ -435,9 +434,7 @@ export class QuestionPageController extends PageController { const { collection, model, viewName } = this const { evaluationState } = context - const { getLanguage } = getPluginOptions(request.server) - const language = getLanguage?.(request) ?? 'en-GB' - const translator = this.model.createTranslator(language) + const translator = this.getTranslator(request) const { t } = translator // Copy any URL params into the form state (if not already done so) @@ -557,9 +554,7 @@ export class QuestionPageController extends PageController { const { isForceAccess, state, evaluationState } = context const action = request.payload.action - const { getLanguage } = getPluginOptions(request.server) - const language = getLanguage?.(request) ?? 'en-GB' - const translator = this.model.createTranslator(language) + const translator = this.getTranslator(request) const { t } = translator if (action?.startsWith(FormAction.External)) { diff --git a/src/server/plugins/engine/pageControllers/RepeatPageController.ts b/src/server/plugins/engine/pageControllers/RepeatPageController.ts index e9e855d85..b2c371ce5 100644 --- a/src/server/plugins/engine/pageControllers/RepeatPageController.ts +++ b/src/server/plugins/engine/pageControllers/RepeatPageController.ts @@ -5,10 +5,7 @@ import Boom from '@hapi/boom' import Joi from 'joi' import { isRepeatState } from '~/src/server/plugins/engine/components/FormComponent.js' -import { - getPluginOptions, - redirectPath -} from '~/src/server/plugins/engine/helpers.js' +import { redirectPath } from '~/src/server/plugins/engine/helpers.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' @@ -197,9 +194,7 @@ export class RepeatPageController extends QuestionPageController { return super.proceed(request, h, nextPath) } - const { getLanguage } = getPluginOptions(request.server) - const language = getLanguage?.(request) ?? 'en-GB' - const translator = this.model.createTranslator(language) + const translator = this.getTranslator(request) const { t } = translator const viewModel = this.getListSummaryViewModel( @@ -237,9 +232,7 @@ export class RepeatPageController extends QuestionPageController { return super.proceed(request, h, nextPath) } - const { getLanguage } = getPluginOptions(request.server) - const language = getLanguage?.(request) ?? 'en-GB' - const translator = this.model.createTranslator(language) + const translator = this.getTranslator(request) const { t } = translator const { action } = this.getFormParams(request) @@ -310,9 +303,7 @@ export class RepeatPageController extends QuestionPageController { const itemId = this.getItemId(request) const item = this.getItemFromList(list, itemId) - const { getLanguage } = getPluginOptions(request.server) - const language = getLanguage?.(request) ?? 'en-GB' - const translator = this.model.createTranslator(language) + const translator = this.getTranslator(request) const { t } = translator if (!item || list.length === 1) { @@ -353,9 +344,7 @@ export class RepeatPageController extends QuestionPageController { const itemId = this.getItemId(request) const item = this.getItemFromList(list, itemId) - const { getLanguage } = getPluginOptions(request.server) - const language = getLanguage?.(request) ?? 'en-GB' - const { t } = this.model.createTranslator(language) + const { t } = this.getTranslator(request) if (!item || list.length === 1) { throw Boom.notFound( diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index 4b6da749a..94b7e4cdd 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -17,8 +17,7 @@ import { checkEmailAddressForLiveFormSubmission, checkFormStatus, createError, - getCacheService, - getPluginOptions + getCacheService } from '~/src/server/plugins/engine/helpers.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { @@ -105,7 +104,11 @@ export class SummaryPageController extends QuestionPageController { } } - const components = this.collection.getViewModel(payload, errors, translator) + const components = this.collection.getViewModel({ + payload, + errors, + translator + }) viewModel.backLink = this.getBackLink(request, context, t) viewModel.feedbackLink = this.feedbackLink @@ -163,9 +166,7 @@ export class SummaryPageController extends QuestionPageController { ) => { const { viewName } = this - const { getLanguage } = getPluginOptions(request.server) - const language = getLanguage?.(request) ?? 'en-GB' - const translator = this.model.createTranslator(language) + const translator = this.getTranslator(request) const { t } = translator const viewModel = this.getSummaryViewModel(request, context, translator) @@ -217,9 +218,7 @@ export class SummaryPageController extends QuestionPageController { checkEmailAddressForLiveFormSubmission(notificationEmail, isPreview) if (notificationEmail) { - const { getLanguage } = getPluginOptions(request.server) - const language = getLanguage?.(request) ?? 'en-GB' - const translator = this.model.createTranslator(language) + const translator = this.getTranslator(request) const viewModel = this.getSummaryViewModel(request, context, translator) try { @@ -326,9 +325,7 @@ export class SummaryPageController extends QuestionPageController { request: FormRequestPayload, h: FormResponseToolkit ) { - const { getLanguage } = getPluginOptions(request.server) - const language = getLanguage?.(request) ?? 'en-GB' - const { t } = this.model.createTranslator(language) + const { t } = this.getTranslator(request) const helpUrl = error.helpLink ?? DEFAULT_PAYMENT_HELP_URL const contactUsLink = `${t('pages.summary.contactUsLinkText')}` From db5ca6687897a96e54004c5f1d929ba4f97fc700 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 30 Apr 2026 13:11:40 +0100 Subject: [PATCH 72/89] swap translation functions with separates per type --- .../plugins/engine/beta/form-context.test.ts | 13 +++- .../plugins/engine/beta/form-context.ts | 6 +- .../engine/components/CheckboxesField.ts | 19 ++--- .../engine/components/ComponentCollection.ts | 5 +- .../engine/components/FileUploadField.test.ts | 5 +- .../engine/components/FormComponent.ts | 10 +-- .../engine/components/ListFormComponent.ts | 6 +- .../engine/components/TextField.test.ts | 38 +++++++--- .../engine/components/helpers/components.ts | 2 +- src/server/plugins/engine/i18n/types.ts | 44 ++++++------ .../plugins/engine/models/FormModel.test.ts | 14 ++-- src/server/plugins/engine/models/FormModel.ts | 39 ++++------ .../engine/models/SummaryViewModel.test.ts | 11 ++- .../plugins/engine/models/SummaryViewModel.ts | 12 ++-- .../QuestionPageController.test.ts | 28 ++++---- .../pageControllers/QuestionPageController.ts | 8 +-- .../RepeatPageController.test.ts | 25 ++++--- .../pageControllers/RepeatPageController.ts | 2 +- .../SummaryPageController.test.ts | 11 ++- .../pageControllers/__stubs__/translator.ts | 27 +++---- .../plugins/engine/routes/index.test.ts | 8 ++- .../plugins/engine/routes/questions.test.ts | 72 ++++++++++++++++--- 22 files changed, 256 insertions(+), 149 deletions(-) diff --git a/src/server/plugins/engine/beta/form-context.test.ts b/src/server/plugins/engine/beta/form-context.test.ts index bda790707..d2518faa4 100644 --- a/src/server/plugins/engine/beta/form-context.test.ts +++ b/src/server/plugins/engine/beta/form-context.test.ts @@ -78,11 +78,14 @@ describe('getFormContext helper', () => { notificationEmail: 'test@example.com' } const definition = { pages: [] } - let formModel: { getFormContext: jest.Mock } + let formModel: { getFormContext: jest.Mock; createTranslator: jest.Mock } beforeEach(() => { jest.clearAllMocks() - formModel = { getFormContext: jest.fn().mockResolvedValue(returnedContext) } + formModel = { + getFormContext: jest.fn().mockResolvedValue(returnedContext), + createTranslator: jest.fn().mockReturnValue({}) + } FormModel.mockImplementation( (_definition: unknown, modelOptions: FormModelOptions) => Object.assign(formModel, { basePath: modelOptions.basePath }) @@ -119,10 +122,14 @@ describe('getFormContext helper', () => { 'https://form-context.local/preview/live/tb-origin/summary' ) + const translator = formModel.createTranslator.mock.results[0].value + + expect(formModel.createTranslator).toHaveBeenCalledWith('en-GB') expect(formModel.getFormContext).toHaveBeenCalledWith( summaryRequest, expect.objectContaining({ $$__referenceNumber: 'CACHED-REF' }), - errors + errors, + translator ) expect(context).toBe(returnedContext) }) diff --git a/src/server/plugins/engine/beta/form-context.ts b/src/server/plugins/engine/beta/form-context.ts index fa5d8836f..dac6a1f50 100644 --- a/src/server/plugins/engine/beta/form-context.ts +++ b/src/server/plugins/engine/beta/form-context.ts @@ -36,6 +36,7 @@ export interface FormModelOptions { export interface FormContextOptions extends FormModelOptions { errors?: FormSubmissionError[] + language?: string } type SummaryRequest = FormContextRequest & { @@ -121,10 +122,13 @@ export async function getFormContext( $$__referenceNumber: cachedState.$$__referenceNumber } as unknown as FormSubmissionState + const translator = formModel.createTranslator(options.language ?? 'en-GB') + return formModel.getFormContext( summaryRequest, formState, - options.errors ?? [] + options.errors ?? [], + translator ) } diff --git a/src/server/plugins/engine/components/CheckboxesField.ts b/src/server/plugins/engine/components/CheckboxesField.ts index 665b1161d..2fafc9163 100644 --- a/src/server/plugins/engine/components/CheckboxesField.ts +++ b/src/server/plugins/engine/components/CheckboxesField.ts @@ -3,6 +3,7 @@ import joi, { type ArraySchema } from 'joi' import { isFormValue } from '~/src/server/plugins/engine/components/FormComponent.js' import { SelectionControlField } from '~/src/server/plugins/engine/components/SelectionControlField.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { type QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' import { @@ -66,7 +67,8 @@ export class CheckboxesField extends SelectionControlField { } getDisplayStringFromFormValue( - selected: (string | number | boolean)[] | undefined + selected: (string | number | boolean)[] | undefined, + translator?: Translator ) { const { items } = this @@ -74,10 +76,11 @@ export class CheckboxesField extends SelectionControlField { return '' } - // Map selected values to text return items .filter((item) => selected.includes(item.value)) - .map((item) => item.text) + .map((item) => + translator ? translator.tListItem(item, 'text') || item.text : item.text + ) .join(', ') } @@ -98,12 +101,12 @@ export class CheckboxesField extends SelectionControlField { return values ?? [] } - getDisplayStringFromState(state: FormSubmissionState) { - // Selected checkbox values + getDisplayStringFromState( + state: FormSubmissionState, + translator?: Translator + ) { const selected = this.getFormValueFromState(state) ?? [] - - // Map selected values to text - return this.getDisplayStringFromFormValue(selected) + return this.getDisplayStringFromFormValue(selected, translator) } getContextValueFromState(state: FormSubmissionState) { diff --git a/src/server/plugins/engine/components/ComponentCollection.ts b/src/server/plugins/engine/components/ComponentCollection.ts index c84561b86..8538df09b 100644 --- a/src/server/plugins/engine/components/ComponentCollection.ts +++ b/src/server/plugins/engine/components/ComponentCollection.ts @@ -292,10 +292,11 @@ export class ComponentCollection { } } else { const translatedLabel = - translator.tContent( + translator.tComponent( field as unknown as ComponentDef, 'shortDescription' - ) || translator.tContent(field as unknown as ComponentDef, 'title') + ) || + translator.tComponent(field as unknown as ComponentDef, 'title') const messagesOverride = field.getValidationMessagesOverride(translator) let patchedSchema = field.formSchema diff --git a/src/server/plugins/engine/components/FileUploadField.test.ts b/src/server/plugins/engine/components/FileUploadField.test.ts index 5555b3ed8..6cd12d701 100644 --- a/src/server/plugins/engine/components/FileUploadField.test.ts +++ b/src/server/plugins/engine/components/FileUploadField.test.ts @@ -914,7 +914,10 @@ describe('FileUploadField', () => { }, createTranslator: () => ({ t: jest.fn((k: string) => k), - tContent: jest.fn() + tPage: jest.fn(), + tComponent: jest.fn(), + tSection: jest.fn(), + tListItem: jest.fn() }) } } diff --git a/src/server/plugins/engine/components/FormComponent.ts b/src/server/plugins/engine/components/FormComponent.ts index b861ee062..39a746026 100644 --- a/src/server/plugins/engine/components/FormComponent.ts +++ b/src/server/plugins/engine/components/FormComponent.ts @@ -152,18 +152,18 @@ export class FormComponent extends ComponentBase { }: RenderContext) { const { hint, name, options = {}, title, viewModel } = this - const { t, tContent } = translator + const { t, tComponent } = translator const isRequired = !('required' in options) || options.required !== false const hideOptional = 'optionalText' in options && options.optionalText const resolvedTitle = - tContent(this as unknown as ComponentDef, 'title') || title + tComponent(this as unknown as ComponentDef, 'title') || title const label = `${resolvedTitle}${!isRequired && !hideOptional ? ` ${t('common.optional')}` : ''}` if (hint) { viewModel.hint = { - text: tContent(this as unknown as ComponentDef, 'hint') || hint + text: tComponent(this as unknown as ComponentDef, 'hint') || hint } } @@ -271,7 +271,7 @@ export class FormComponent extends ComponentBase { } /** - * Type guard — returns true when the value is a Translator (has t and tContent functions), + * Type guard — returns true when the value is a Translator (has t and tComponent functions), * as opposed to a FormQuery (plain string-keyed record). */ export function isTranslator(value: unknown): value is Translator { @@ -279,7 +279,7 @@ export function isTranslator(value: unknown): value is Translator { typeof value === 'object' && value !== null && typeof (value as Record).t === 'function' && - typeof (value as Record).tContent === 'function' + typeof (value as Record).tComponent === 'function' ) } diff --git a/src/server/plugins/engine/components/ListFormComponent.ts b/src/server/plugins/engine/components/ListFormComponent.ts index 27f7a498d..f69324f18 100644 --- a/src/server/plugins/engine/components/ListFormComponent.ts +++ b/src/server/plugins/engine/components/ListFormComponent.ts @@ -114,7 +114,7 @@ export class ListFormComponent extends FormComponent { .filter((item) => values.includes(item.value)) .map((item) => translator - ? translator.tContent(item, 'text') || item.text + ? translator.tListItem(item, 'text') || item.text : tPlugin(item.text, 'en-GB') || item.text ) .join(', ') @@ -139,11 +139,11 @@ export class ListFormComponent extends FormComponent { // Support multiple values for checkboxes const values = this.isValue(value) ? [value].flat() : [] - const { tContent } = context.translator + const { tListItem } = context.translator const items = listItems.map((item) => { const selected = values.includes(item.value) - const resolvedText = tContent(item, 'text') || item.text + const resolvedText = tListItem(item, 'text') || item.text const itemModel: ListItem = { ...item, text: resolvedText, selected } if ('id' in itemModel) { diff --git a/src/server/plugins/engine/components/TextField.test.ts b/src/server/plugins/engine/components/TextField.test.ts index aa349e4f8..724f593c9 100644 --- a/src/server/plugins/engine/components/TextField.test.ts +++ b/src/server/plugins/engine/components/TextField.test.ts @@ -252,30 +252,42 @@ describe('TextField', () => { }) describe('getViewModel with Translator', () => { - it('calls tContent for the field title', () => { - const tContent = jest.fn().mockReturnValue('Translated title') + it('calls tComponent for the field title', () => { + const tComponent = jest.fn().mockReturnValue('Translated title') const t = jest.fn().mockReturnValue('(optional)') const viewModel = field.getViewModel({ payload: {}, errors: undefined, - translator: { t, tContent } + translator: { + t, + tComponent, + tPage: jest.fn(), + tSection: jest.fn(), + tListItem: jest.fn() + } }) - expect(tContent).toHaveBeenCalledWith(field, 'title') + expect(tComponent).toHaveBeenCalledWith(field, 'title') expect(viewModel.label.text).toBe('Translated title') }) - it('calls tContent for the hint when hint is set', () => { + it('calls tComponent for the hint when hint is set', () => { const hintDef = { ...def, hint: 'Enter your name' } const hintCollection = new ComponentCollection([hintDef], { model }) const hintField = hintCollection.fields[0] - const tContent = jest.fn().mockReturnValue('Translated hint') + const tComponent = jest.fn().mockReturnValue('Translated hint') const t = jest.fn().mockReturnValue('') const viewModel = hintField.getViewModel({ payload: {}, errors: undefined, - translator: { t, tContent } + translator: { + t, + tComponent, + tPage: jest.fn(), + tSection: jest.fn(), + tListItem: jest.fn() + } }) - expect(tContent).toHaveBeenCalledWith(hintField, 'hint') + expect(tComponent).toHaveBeenCalledWith(hintField, 'hint') expect(viewModel.hint?.text).toBe('Translated hint') }) @@ -283,12 +295,18 @@ describe('TextField', () => { const optDef = { ...def, options: { required: false } } const optCollection = new ComponentCollection([optDef], { model }) const optField = optCollection.fields[0] - const tContent = jest.fn().mockReturnValue('Title') + const tComponent = jest.fn().mockReturnValue('Title') const t = jest.fn().mockReturnValue('(optional)') optField.getViewModel({ payload: {}, errors: undefined, - translator: { t, tContent } + translator: { + t, + tComponent, + tPage: jest.fn(), + tSection: jest.fn(), + tListItem: jest.fn() + } }) expect(t).toHaveBeenCalledWith('common.optional') }) diff --git a/src/server/plugins/engine/components/helpers/components.ts b/src/server/plugins/engine/components/helpers/components.ts index df888528a..d3687bc9d 100644 --- a/src/server/plugins/engine/components/helpers/components.ts +++ b/src/server/plugins/engine/components/helpers/components.ts @@ -308,7 +308,7 @@ export function getAnswerMarkdown( answerEscaped += items .map((item) => { const resolvedText = translator - ? translator.tContent(item, 'text') || item.text + ? translator.tListItem(item, 'text') || item.text : tPlugin(item.text, 'en-GB') || item.text const label = escapeMarkdown(resolvedText) const value = escapeMarkdown(`(${item.value})`) diff --git a/src/server/plugins/engine/i18n/types.ts b/src/server/plugins/engine/i18n/types.ts index c977fe8b2..e5101f681 100644 --- a/src/server/plugins/engine/i18n/types.ts +++ b/src/server/plugins/engine/i18n/types.ts @@ -7,7 +7,7 @@ import { /** * Structure of the `translations` block in a form definition. - * Also used to derive the valid property keys for tContent overloads. + * Also used to derive the valid property keys for the typed tX functions. */ export type FormDefinitionTranslations = Record< string, @@ -32,30 +32,26 @@ export type FormDefinitionTranslations = Record< type EntityTranslations = FormDefinitionTranslations[string] /** - * Translates form-authored content (titles, hints, etc.) by passing the - * source entity object and property name. Internally constructs the i18next - * key from the entity's GUID. Falls back to entity[prop] when no GUID or - * no translation found. - * - * Valid property names are derived from FormDefinitionTranslations — one - * source of truth; the compiler enforces correctness. - */ -export interface TContentFunction { - ( - entity: ComponentDef, - prop: keyof EntityTranslations['components'][string] - ): string - (entity: Page, prop: keyof EntityTranslations['pages'][string]): string - (entity: Section, prop: keyof EntityTranslations['sections'][string]): string - (entity: Item, prop: keyof EntityTranslations['listItems'][string]): string -} - -/** - * Scoped translator pair returned by model.createTranslator(language). - * t — plugin + host namespace (UI strings, buttons, errors, sub-field labels) - * tContent — form namespace only (question titles, hints, page titles, list item text) + * Scoped translator returned by model.createTranslator(language). + * t — plugin namespace (UI strings, buttons, errors, sub-field labels) + * tPage — form namespace: page titles + * tComponent — form namespace: component titles, hints, shortDescriptions + * tSection — form namespace: section titles + * tListItem — form namespace: list item text */ export interface Translator { t: (key: string, opts?: Record) => string - tContent: TContentFunction + tPage: (page: Page, prop: keyof EntityTranslations['pages'][string]) => string + tComponent: ( + component: ComponentDef, + prop: keyof EntityTranslations['components'][string] + ) => string + tSection: ( + section: Section, + prop: keyof EntityTranslations['sections'][string] + ) => string + tListItem: ( + item: Item, + prop: keyof EntityTranslations['listItems'][string] + ) => string } diff --git a/src/server/plugins/engine/models/FormModel.test.ts b/src/server/plugins/engine/models/FormModel.test.ts index cff0c29e6..65c8bbe95 100644 --- a/src/server/plugins/engine/models/FormModel.test.ts +++ b/src/server/plugins/engine/models/FormModel.test.ts @@ -156,7 +156,7 @@ describe('FormModel', () => { expect(model.versionNumber).toBeUndefined() }) - it('defaults translator to en-GB when metadata.language is absent', () => { + it('creates translator for en-GB', () => { const model = new FormModel(definition, { basePath: 'test' }) const { t } = model.createTranslator('en-GB') expect(t('errors.title')).toBe('There is a problem') @@ -837,21 +837,21 @@ describe('FormModel - Joined Conditions', () => { expect(t('common.continue')).toBe('Sail on') }) - it('returns the component title for tContent with en-GB language (falls back to base en-GB form string)', () => { + it('returns the component title for tComponent with en-GB language (falls back to base en-GB form string)', () => { const model = new FormModel(definitionV2, { basePath: 'test' }) - const { tContent } = model.createTranslator('en-GB') + const { tComponent } = model.createTranslator('en-GB') // pages[0].components[0] has id '717eb213-4e4b-4a2d-9cfd-2780f5e1e3e5' // and title 'Have you previously been married?' - expect(tContent(definitionV2.pages[0].components[0], 'title')).toBe( + expect(tComponent(definitionV2.pages[0].components[0], 'title')).toBe( 'Have you previously been married?' ) }) - it('returns the component title for tContent with cy language (no Welsh translation registered → en-GB fallback)', () => { + it('returns the component title for tComponent with cy language (no Welsh translation registered → en-GB fallback)', () => { const model = new FormModel(definitionV2, { basePath: 'test' }) - const { tContent } = model.createTranslator('cy') + const { tComponent } = model.createTranslator('cy') // No Welsh translations registered → falls back to en-GB base form string - expect(tContent(definitionV2.pages[0].components[0], 'title')).toBe( + expect(tComponent(definitionV2.pages[0].components[0], 'title')).toBe( 'Have you previously been married?' ) }) diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index cd4f8058c..20f574fd4 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -44,13 +44,9 @@ import { setPageTitles } from '~/src/server/plugins/engine/helpers.js' import { extractBaseTranslations } from '~/src/server/plugins/engine/i18n/extractBaseTranslations.js' -import { - createFormI18nInstance, - t as translate -} from '~/src/server/plugins/engine/i18n/index.js' +import { createFormI18nInstance } from '~/src/server/plugins/engine/i18n/index.js' import { type FormDefinitionTranslations, - type TContentFunction, type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type ExecutableCondition } from '~/src/server/plugins/engine/models/types.js' @@ -285,18 +281,16 @@ export class FormModel { return result } - const tContent = ((entity: unknown, prop: string): string => { - const e = entity as Record - if ('path' in e) - return resolveContent(e as { id?: string }, 'pages', prop) - if ('value' in e && 'text' in e) - return resolveContent(e as { id?: string }, 'listItems', prop) - if ('type' in e) - return resolveContent(e as { id?: string }, 'components', prop) - return resolveContent(e as { id?: string }, 'sections', prop) - }) as TContentFunction - - return { t, tContent } + return { + t, + tPage: (entity, prop) => resolveContent(entity, 'pages', prop as string), + tComponent: (entity, prop) => + resolveContent(entity, 'components', prop as string), + tSection: (entity, prop) => + resolveContent(entity, 'sections', prop as string), + tListItem: (entity, prop) => + resolveContent(entity, 'listItems', prop as string) + } } /** @@ -578,14 +572,9 @@ export class FormModel { if (isInvalid) { context.errors ??= [] - const text = translator - ? translator.t('errors.optionsMismatch') - : translate( - 'errors.optionsMismatch', - typeof this.def.metadata?.language === 'string' - ? this.def.metadata.language - : 'en-GB' - ) + const text = (translator ?? this.createTranslator('en-GB')).t( + 'errors.optionsMismatch' + ) context.errors.push({ text, diff --git a/src/server/plugins/engine/models/SummaryViewModel.test.ts b/src/server/plugins/engine/models/SummaryViewModel.test.ts index 4460d94a8..598c5fe9b 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.test.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.test.ts @@ -391,9 +391,16 @@ describe('SummaryViewModel with per-request Translator', () => { mockTranslator = { t: jest.fn().mockReturnValue('mocked'), - tContent: jest + tPage: jest.fn().mockReturnValue('mocked-content') as Translator['tPage'], + tComponent: jest .fn() - .mockReturnValue('mocked-content') as Translator['tContent'] + .mockReturnValue('mocked-content') as Translator['tComponent'], + tSection: jest + .fn() + .mockReturnValue('mocked-content') as Translator['tSection'], + tListItem: jest + .fn() + .mockReturnValue('mocked-content') as Translator['tListItem'] } }) diff --git a/src/server/plugins/engine/models/SummaryViewModel.ts b/src/server/plugins/engine/models/SummaryViewModel.ts index 8d1b54e21..aa310587f 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.ts @@ -182,7 +182,7 @@ export class SummaryViewModel { if (items.length) { const sectionTitle = section - ? translator.tContent(section, 'title') || section.title + ? translator.tSection(section, 'title') || section.title : undefined details.push({ name: section?.name, @@ -253,19 +253,19 @@ export function ItemField( }, translator?: Translator ): DetailItemField { - const tContent = translator?.tContent // FormComponent doesn't expose shortDescription/title as raw def properties, - // so build a lookup object with English values to let tContent's GUID lookup fire. - // `type` is required so tContent routes to the 'components' namespace (not 'sections'). + // so build a lookup object with English values to let tComponent's GUID lookup fire. const fieldDef = { id: field.id, type: field.type, shortDescription: field.label, title: field.title } as unknown as ComponentDef - const rawLabel = tContent ? tContent(fieldDef, 'shortDescription') : '' + const rawLabel = translator + ? translator.tComponent(fieldDef, 'shortDescription') + : '' const translatedLabel = rawLabel !== '' ? rawLabel : field.label - const rawTitle = tContent ? tContent(fieldDef, 'title') : '' + const rawTitle = translator ? translator.tComponent(fieldDef, 'title') : '' const translatedTitle = rawTitle !== '' ? rawTitle : field.title const optional = field.options.required === false diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts index 1f2972737..3982e22f4 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts @@ -1735,13 +1735,12 @@ describe('QuestionPageController translator support', () => { describe('getViewModel with translator', () => { it('uses mock t from translator for plugin strings', () => { const mockT = jest.fn((key: string) => `translated:${key}`) - const mockTContent = jest.fn( - (entity: unknown, prop: string) => `content:${prop}` - ) - const translator: Translator = { t: mockT, - tContent: mockTContent as Translator['tContent'] + tPage: jest.fn((_, prop) => `content:${prop}`), + tComponent: jest.fn((_, prop) => `content:${prop}`), + tSection: jest.fn((_, prop) => `content:${prop}`), + tListItem: jest.fn((_, prop) => `content:${prop}`) } // Use a request with returnUrl so that getBackLink calls t() for back link text @@ -1767,15 +1766,18 @@ describe('QuestionPageController translator support', () => { expect(mockT).toHaveBeenCalledWith('pages.question.backToCheckAnswers') }) - it('uses mock tContent from translator for page entity', () => { + it('uses mock tPage from translator for page entity', () => { const mockT = jest.fn((key: string) => key) - const mockTContent = jest.fn( - (entity: unknown, prop: string) => `content:${prop}` + const mockTPage = jest.fn( + (_entity: unknown, prop: string) => `content:${prop}` ) const translator: Translator = { t: mockT, - tContent: mockTContent as Translator['tContent'] + tPage: mockTPage as Translator['tPage'], + tComponent: jest.fn(() => ''), + tSection: jest.fn(() => ''), + tListItem: jest.fn(() => '') } const context = model.getFormContext(requestPage1, { @@ -1784,8 +1786,7 @@ describe('QuestionPageController translator support', () => { controller1.getViewModel(requestPage1, context, translator) - // tContent should have been called with the page entity - expect(mockTContent).toHaveBeenCalledWith( + expect(mockTPage).toHaveBeenCalledWith( expect.objectContaining({ path: page1.path }), 'title' ) @@ -1795,7 +1796,10 @@ describe('QuestionPageController translator support', () => { const mockT = jest.fn().mockReturnValue('Back to check answers') const mockTranslatorLocal: Translator = { t: mockT, - tContent: stubTranslator.tContent + tPage: stubTranslator.tPage, + tComponent: stubTranslator.tComponent, + tSection: stubTranslator.tSection, + tListItem: stubTranslator.tListItem } // Use a request with returnUrl so that getBackLink calls translator.t for back link text diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index 96d5310fb..9b41ef590 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -125,17 +125,15 @@ export class QuestionPageController extends PageController { const { collection, viewModel } = this const { payload, errors } = context - const { t, tContent } = translator + const { t, tPage, tSection } = translator let { showTitle } = viewModel - // Resolve page title via tContent - let pageTitle = tContent(this.pageDef, 'title') || viewModel.pageTitle + let pageTitle = tPage(this.pageDef, 'title') || viewModel.pageTitle - // Resolve section title via tContent const sectionTitle = this.section ? this.section.hideTitle !== true - ? tContent(this.section, 'title') + ? tSection(this.section, 'title') : '' : viewModel.sectionTitle diff --git a/src/server/plugins/engine/pageControllers/RepeatPageController.test.ts b/src/server/plugins/engine/pageControllers/RepeatPageController.test.ts index 4c8636178..0be236813 100644 --- a/src/server/plugins/engine/pageControllers/RepeatPageController.test.ts +++ b/src/server/plugins/engine/pageControllers/RepeatPageController.test.ts @@ -286,10 +286,12 @@ describe('RepeatPageController', () => { describe('getViewModel with Translator', () => { it('passes translator through to super.getViewModel without throwing', () => { const mockT = jest.fn((key: string) => `translated:${key}`) - const mockTContent = jest.fn((_entity: object, _prop: string) => '') const translator: Translator = { t: mockT, - tContent: mockTContent as Translator['tContent'] + tPage: jest.fn(() => ''), + tComponent: jest.fn(() => ''), + tSection: jest.fn(() => ''), + tListItem: jest.fn(() => '') } const context = model.getFormContext(requestPageItem, { @@ -306,14 +308,15 @@ describe('RepeatPageController', () => { expect(viewModel).toHaveProperty('sectionTitle') }) - it('uses translator.tContent for page title when translator is supplied', () => { + it('uses translator.tPage for page title when translator is supplied', () => { const mockT = jest.fn((key: string) => `translated:${key}`) - const mockTContent = jest.fn( - (_entity: object, _prop: string) => 'translated-title' - ) + const mockTPage = jest.fn(() => 'translated-title') const translator: Translator = { t: mockT, - tContent: mockTContent as Translator['tContent'] + tPage: mockTPage as Translator['tPage'], + tComponent: jest.fn(() => ''), + tSection: jest.fn(() => ''), + tListItem: jest.fn(() => '') } const context = model.getFormContext(requestPageItem, { @@ -326,7 +329,7 @@ describe('RepeatPageController', () => { translator ) - expect(mockTContent).toHaveBeenCalled() + expect(mockTPage).toHaveBeenCalled() expect(viewModel).toHaveProperty('pageTitle', 'translated-title') }) }) @@ -338,10 +341,12 @@ describe('RepeatPageController', () => { it('calls the supplied translator t for plugin strings', () => { const mockT = jest.fn((key: string) => `translated:${key}`) - const mockTContent = jest.fn((_entity: object, _prop: string) => '') const translator: Translator = { t: mockT, - tContent: mockTContent as Translator['tContent'] + tPage: jest.fn(() => ''), + tComponent: jest.fn(() => ''), + tSection: jest.fn(() => ''), + tListItem: jest.fn(() => '') } const context = model.getFormContext(requestPageSummary, { diff --git a/src/server/plugins/engine/pageControllers/RepeatPageController.ts b/src/server/plugins/engine/pageControllers/RepeatPageController.ts index b2c371ce5..76c5fd5de 100644 --- a/src/server/plugins/engine/pageControllers/RepeatPageController.ts +++ b/src/server/plugins/engine/pageControllers/RepeatPageController.ts @@ -450,7 +450,7 @@ export class RepeatPageController extends QuestionPageController { } const itemDisplayText = collection.fields.length - ? collection.fields[0].getDisplayStringFromState(item) + ? collection.fields[0].getDisplayStringFromState(item, translator) : '' summaryList.rows.push({ diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts index 0aa0f08e0..5adba6cc1 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts @@ -92,7 +92,16 @@ describe('SummaryPageController', () => { it('should create a per-request translator and pass t to the view', async () => { const mockTranslator: Translator = { t: jest.fn().mockReturnValue('translated'), - tContent: jest.fn().mockReturnValue('content') as Translator['tContent'] + tPage: jest.fn().mockReturnValue('content') as Translator['tPage'], + tComponent: jest + .fn() + .mockReturnValue('content') as Translator['tComponent'], + tSection: jest + .fn() + .mockReturnValue('content') as Translator['tSection'], + tListItem: jest + .fn() + .mockReturnValue('content') as Translator['tListItem'] } const createTranslatorSpy = jest diff --git a/src/server/plugins/engine/pageControllers/__stubs__/translator.ts b/src/server/plugins/engine/pageControllers/__stubs__/translator.ts index f792b4cda..ee99fbf96 100644 --- a/src/server/plugins/engine/pageControllers/__stubs__/translator.ts +++ b/src/server/plugins/engine/pageControllers/__stubs__/translator.ts @@ -4,20 +4,23 @@ import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' /** * Shared Translator stub for tests where translation content is not the focus. * Uses the real English i18n keys so tests that assert on English text still pass. - * tContent resolves the entity's property via the real English t() so i18n key - * constants (e.g. 'components.yesNoField.yes') are resolved to their English - * values ("Yes") rather than returned verbatim. + * Each tX function resolves the entity's property via the real English t() so + * i18n key constants (e.g. 'components.yesNoField.yes') resolve to their English + * values ("Yes") rather than being returned verbatim. */ +function resolveEntity(entity: unknown, prop: string): string { + if (typeof entity !== 'object' || entity === null) return '' + const raw = + ((entity as Record)[prop] as string | undefined) ?? '' + if (!raw) return '' + return resolveKey(raw, 'en-GB') || raw +} + export const stubTranslator: Translator = { t: (key: string, opts?: Record) => resolveKey(key, 'en-GB', opts), - tContent: ((entity: unknown, prop: string) => { - if (typeof entity !== 'object' || entity === null) return '' - const raw = - ((entity as Record)[prop] as string | undefined) ?? '' - if (!raw) return '' - // Resolve via real English i18n: handles key constants like - // 'components.yesNoField.yes' → "Yes". Plain strings pass through unchanged. - return resolveKey(raw, 'en-GB') || raw - }) as unknown as Translator['tContent'] + tPage: (entity, prop) => resolveEntity(entity, prop as string), + tComponent: (entity, prop) => resolveEntity(entity, prop as string), + tSection: (entity, prop) => resolveEntity(entity, prop as string), + tListItem: (entity, prop) => resolveEntity(entity, prop as string) } diff --git a/src/server/plugins/engine/routes/index.test.ts b/src/server/plugins/engine/routes/index.test.ts index 3628eed91..5f4937a5d 100644 --- a/src/server/plugins/engine/routes/index.test.ts +++ b/src/server/plugins/engine/routes/index.test.ts @@ -39,7 +39,13 @@ function buildMockModel( }), createTranslator: jest .fn() - .mockReturnValue({ t: jest.fn(), tContent: jest.fn() }), + .mockReturnValue({ + t: jest.fn(), + tPage: jest.fn(), + tComponent: jest.fn(), + tSection: jest.fn(), + tListItem: jest.fn() + }), pages: pagesControllerOverride, services: servicesOverride } as unknown as FormModel diff --git a/src/server/plugins/engine/routes/questions.test.ts b/src/server/plugins/engine/routes/questions.test.ts index 2c1ea6ced..f70dea6e8 100644 --- a/src/server/plugins/engine/routes/questions.test.ts +++ b/src/server/plugins/engine/routes/questions.test.ts @@ -68,7 +68,13 @@ describe('makeGetHandler', () => { def: { name: 'Hello world' }, createTranslator: jest .fn() - .mockReturnValue({ t: jest.fn(), tContent: jest.fn() }) + .mockReturnValue({ + t: jest.fn(), + tPage: jest.fn(), + tComponent: jest.fn(), + tSection: jest.fn(), + tListItem: jest.fn() + }) } as unknown as FormModel const pageMock = createMockPageController( @@ -113,7 +119,13 @@ describe('makeGetHandler', () => { def: { name: 'Hello world' }, createTranslator: jest .fn() - .mockReturnValue({ t: jest.fn(), tContent: jest.fn() }) + .mockReturnValue({ + t: jest.fn(), + tPage: jest.fn(), + tComponent: jest.fn(), + tSection: jest.fn(), + tListItem: jest.fn() + }) } as unknown as FormModel const pageMock = createMockPageController( @@ -158,7 +170,13 @@ describe('makeGetHandler', () => { def: { name: 'Hello world' }, createTranslator: jest .fn() - .mockReturnValue({ t: jest.fn(), tContent: jest.fn() }) + .mockReturnValue({ + t: jest.fn(), + tPage: jest.fn(), + tComponent: jest.fn(), + tSection: jest.fn(), + tListItem: jest.fn() + }) } as unknown as FormModel const pageMock = createMockPageController( @@ -211,7 +229,13 @@ describe('makeGetHandler', () => { def: { name: 'Hello world' }, createTranslator: jest .fn() - .mockReturnValue({ t: jest.fn(), tContent: jest.fn() }) + .mockReturnValue({ + t: jest.fn(), + tPage: jest.fn(), + tComponent: jest.fn(), + tSection: jest.fn(), + tListItem: jest.fn() + }) } as unknown as FormModel const pageMock = createMockPageController( @@ -281,7 +305,13 @@ describe('makePostHandler', () => { def: { name: 'Hello world' }, createTranslator: jest .fn() - .mockReturnValue({ t: jest.fn(), tContent: jest.fn() }) + .mockReturnValue({ + t: jest.fn(), + tPage: jest.fn(), + tComponent: jest.fn(), + tSection: jest.fn(), + tListItem: jest.fn() + }) } as unknown as FormModel const pageMock = createMockPageController( @@ -324,7 +354,13 @@ describe('makePostHandler', () => { def: { name: 'Hello world' }, createTranslator: jest .fn() - .mockReturnValue({ t: jest.fn(), tContent: jest.fn() }) + .mockReturnValue({ + t: jest.fn(), + tPage: jest.fn(), + tComponent: jest.fn(), + tSection: jest.fn(), + tListItem: jest.fn() + }) } as unknown as FormModel const pageMock = createMockPageController( @@ -371,7 +407,13 @@ describe('makePostHandler', () => { def: { name: 'Hello world' }, createTranslator: jest .fn() - .mockReturnValue({ t: jest.fn(), tContent: jest.fn() }) + .mockReturnValue({ + t: jest.fn(), + tPage: jest.fn(), + tComponent: jest.fn(), + tSection: jest.fn(), + tListItem: jest.fn() + }) } as unknown as FormModel const pageMock = createMockPageController( @@ -415,7 +457,13 @@ describe('makePostHandler', () => { def: { name: 'Hello world' }, createTranslator: jest .fn() - .mockReturnValue({ t: jest.fn(), tContent: jest.fn() }) + .mockReturnValue({ + t: jest.fn(), + tPage: jest.fn(), + tComponent: jest.fn(), + tSection: jest.fn(), + tListItem: jest.fn() + }) } as unknown as FormModel const pageMock = createMockPageController( @@ -469,7 +517,13 @@ describe('makePostHandler', () => { def: { name: 'Hello world' }, createTranslator: jest .fn() - .mockReturnValue({ t: jest.fn(), tContent: jest.fn() }) + .mockReturnValue({ + t: jest.fn(), + tPage: jest.fn(), + tComponent: jest.fn(), + tSection: jest.fn(), + tListItem: jest.fn() + }) } as unknown as FormModel const pageMock = createMockPageController( From fe7a9fc9f194923e84acb0d85d5f429b698d53d0 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 30 Apr 2026 14:05:37 +0100 Subject: [PATCH 73/89] tidy up signatures --- .../engine/components/ComponentCollection.ts | 10 +++- .../engine/components/DatePartsField.ts | 12 +++-- .../engine/components/EastingNorthingField.ts | 14 ++++-- .../engine/components/FormComponent.ts | 8 +++- .../engine/components/GeospatialField.test.ts | 48 +++++++++++++++++++ .../engine/components/GeospatialField.ts | 20 ++++++-- .../plugins/engine/components/LatLongField.ts | 16 +++++-- .../engine/components/MonthYearField.ts | 12 +++-- .../plugins/engine/components/PaymentField.ts | 6 ++- .../engine/components/UkAddressField.ts | 12 +++-- .../plugins/engine/i18n/translations/cy.json | 5 +- .../engine/i18n/translations/en-GB.json | 5 +- .../engine/i18n/translations/x-pirate.json | 5 +- 13 files changed, 144 insertions(+), 29 deletions(-) diff --git a/src/server/plugins/engine/components/ComponentCollection.ts b/src/server/plugins/engine/components/ComponentCollection.ts index 8538df09b..42fc1798a 100644 --- a/src/server/plugins/engine/components/ComponentCollection.ts +++ b/src/server/plugins/engine/components/ComponentCollection.ts @@ -221,8 +221,14 @@ export class ComponentCollection { /** * Get all errors for all fields in this collection */ - getErrors(errors?: FormSubmissionError[]): FormSubmissionError[] | undefined { - return this.getFieldErrors((field) => field.getErrors(errors), errors) + getErrors( + errors?: FormSubmissionError[], + translator?: Translator + ): FormSubmissionError[] | undefined { + return this.getFieldErrors( + (field) => field.getErrors(errors, translator), + errors + ) } /** diff --git a/src/server/plugins/engine/components/DatePartsField.ts b/src/server/plugins/engine/components/DatePartsField.ts index a575e68ab..8c59d9af4 100644 --- a/src/server/plugins/engine/components/DatePartsField.ts +++ b/src/server/plugins/engine/components/DatePartsField.ts @@ -109,7 +109,10 @@ export class DatePartsField extends FormComponent { return this.isState(value) ? value : undefined } - getDisplayStringFromFormValue(formValue: DatePartsState | undefined) { + getDisplayStringFromFormValue( + formValue: DatePartsState | undefined, + _translator?: Translator + ) { if (!formValue) { return '' } @@ -120,10 +123,13 @@ export class DatePartsField extends FormComponent { ) } - getDisplayStringFromState(state: FormSubmissionState) { + getDisplayStringFromState( + state: FormSubmissionState, + translator?: Translator + ) { const value = this.getFormValueFromState(state) - return this.getDisplayStringFromFormValue(value) + return this.getDisplayStringFromFormValue(value, translator) } getContextValueFromFormValue(value: DatePartsState | undefined) { diff --git a/src/server/plugins/engine/components/EastingNorthingField.ts b/src/server/plugins/engine/components/EastingNorthingField.ts index 5e3ce6bcc..7b3433141 100644 --- a/src/server/plugins/engine/components/EastingNorthingField.ts +++ b/src/server/plugins/engine/components/EastingNorthingField.ts @@ -21,6 +21,7 @@ import { type RenderContext } from '~/src/server/plugins/engine/components/types.js' import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -190,7 +191,8 @@ export class EastingNorthingField extends FormComponent { } getDisplayStringFromFormValue( - value: EastingNorthingState | undefined + value: EastingNorthingState | undefined, + _translator?: Translator ): string { if (!value) { return '' @@ -200,10 +202,13 @@ export class EastingNorthingField extends FormComponent { return `${value.easting}, ${value.northing}` } - getDisplayStringFromState(state: FormSubmissionState) { + getDisplayStringFromState( + state: FormSubmissionState, + translator?: Translator + ) { const value = this.getFormValueFromState(state) - return this.getDisplayStringFromFormValue(value) + return this.getDisplayStringFromFormValue(value, translator) } getContextValueFromFormValue( @@ -228,7 +233,8 @@ export class EastingNorthingField extends FormComponent { } getViewErrors( - errors?: FormSubmissionError[] + errors?: FormSubmissionError[], + _translator?: Translator ): FormSubmissionError[] | undefined { const allErrors = this.getErrors(errors) return deduplicateErrorsByHref(allErrors) diff --git a/src/server/plugins/engine/components/FormComponent.ts b/src/server/plugins/engine/components/FormComponent.ts index 39a746026..321356d0f 100644 --- a/src/server/plugins/engine/components/FormComponent.ts +++ b/src/server/plugins/engine/components/FormComponent.ts @@ -102,7 +102,10 @@ export class FormComponent extends ComponentBase { } } - getErrors(errors?: FormSubmissionError[]): FormSubmissionError[] | undefined { + getErrors( + errors?: FormSubmissionError[], + _translator?: Translator + ): FormSubmissionError[] | undefined { const { name } = this // Filter component and child errors only @@ -127,7 +130,8 @@ export class FormComponent extends ComponentBase { } getViewErrors( - errors?: FormSubmissionError[] + errors?: FormSubmissionError[], + _translator?: Translator ): FormSubmissionError[] | undefined { const firstError = this.getFirstError(errors) return firstError && [firstError] diff --git a/src/server/plugins/engine/components/GeospatialField.test.ts b/src/server/plugins/engine/components/GeospatialField.test.ts index 11e0d7ad7..a34bee62f 100644 --- a/src/server/plugins/engine/components/GeospatialField.test.ts +++ b/src/server/plugins/engine/components/GeospatialField.test.ts @@ -381,5 +381,53 @@ describe('GeospatialField', () => { }) ]) }) + + it('getViewErrors uses translator for description error text', () => { + const component = { + title: 'Example geospatial field', + name: 'myComponent', + type: ComponentType.GeospatialField, + options: { required: true } + } satisfies GeospatialFieldComponent + + const collection = new ComponentCollection([component], { model }) + const invalidSingleState: GeospatialState = [ + { + type: 'Feature', + properties: { + coordinateGridReference: 'ST 00001', + centroidGridReference: 'ST 00001', + description: '' + }, + geometry: { + coordinates: [-2.5723699109417737, 53.2380485215034], + type: 'Point' + }, + id: 'a' + } + ] + + const mockT = jest.fn().mockReturnValue('translated description error') + const mockTranslator = { ...stubTranslator, t: mockT } + + const result = collection.validate(getFormData(invalidSingleState)) + const geospatialField = collection.components.at(0) as GeospatialField + + const viewErrors = geospatialField.getViewErrors( + result.errors, + mockTranslator + ) + + expect(mockT).toHaveBeenCalledWith( + 'components.geospatialField.validation.descriptionRequired', + { count: 1 } + ) + expect(viewErrors).toEqual([ + expect.objectContaining({ + href: '#description_0', + text: 'translated description error' + }) + ]) + }) }) }) diff --git a/src/server/plugins/engine/components/GeospatialField.ts b/src/server/plugins/engine/components/GeospatialField.ts index fd54ebc94..97589af77 100644 --- a/src/server/plugins/engine/components/GeospatialField.ts +++ b/src/server/plugins/engine/components/GeospatialField.ts @@ -104,13 +104,24 @@ export class GeospatialField extends FormComponent { } } - getErrors(errors?: FormSubmissionError[]): FormSubmissionError[] | undefined { + getErrors( + errors?: FormSubmissionError[], + translator?: Translator + ): FormSubmissionError[] | undefined { const fieldErrors = super.getErrors(errors) + const t = + translator?.t ?? + ((key: string, opts?: Record) => + tPlugin(key, 'en-GB', opts)) + fieldErrors?.forEach((err) => { if (err.name === 'description') { err.href = `#description_${err.path[1]}` - err.text = `Enter description for location ${Number(err.path[1]) + 1}` + err.text = t( + 'components.geospatialField.validation.descriptionRequired', + { count: Number(err.path[1]) + 1 } + ) } }) @@ -118,9 +129,10 @@ export class GeospatialField extends FormComponent { } getViewErrors( - errors?: FormSubmissionError[] + errors?: FormSubmissionError[], + translator?: Translator ): FormSubmissionError[] | undefined { - return this.getErrors(errors) + return this.getErrors(errors, translator) } isValue(value?: FormStateValue | FormState): value is GeospatialState { diff --git a/src/server/plugins/engine/components/LatLongField.ts b/src/server/plugins/engine/components/LatLongField.ts index a1df6d4a2..13479ad21 100644 --- a/src/server/plugins/engine/components/LatLongField.ts +++ b/src/server/plugins/engine/components/LatLongField.ts @@ -18,6 +18,7 @@ import { type RenderContext } from '~/src/server/plugins/engine/components/types.js' import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -156,7 +157,10 @@ export class LatLongField extends FormComponent { return LatLongField.isLatLong(value) ? value : undefined } - getDisplayStringFromFormValue(value: LatLongState | undefined): string { + getDisplayStringFromFormValue( + value: LatLongState | undefined, + _translator?: Translator + ): string { if (!value) { return '' } @@ -165,10 +169,13 @@ export class LatLongField extends FormComponent { return `${value.latitude}, ${value.longitude}` } - getDisplayStringFromState(state: FormSubmissionState) { + getDisplayStringFromState( + state: FormSubmissionState, + translator?: Translator + ) { const value = this.getFormValueFromState(state) - return this.getDisplayStringFromFormValue(value) + return this.getDisplayStringFromFormValue(value, translator) } getContextValueFromFormValue(value: LatLongState | undefined): string | null { @@ -192,7 +199,8 @@ export class LatLongField extends FormComponent { } getViewErrors( - errors?: FormSubmissionError[] + errors?: FormSubmissionError[], + _translator?: Translator ): FormSubmissionError[] | undefined { const allErrors = this.getErrors(errors) return deduplicateErrorsByHref(allErrors) diff --git a/src/server/plugins/engine/components/MonthYearField.ts b/src/server/plugins/engine/components/MonthYearField.ts index 8938dd8aa..55f531ccb 100644 --- a/src/server/plugins/engine/components/MonthYearField.ts +++ b/src/server/plugins/engine/components/MonthYearField.ts @@ -103,7 +103,10 @@ export class MonthYearField extends FormComponent { return MonthYearField.isMonthYear(value) ? value : undefined } - getDisplayStringFromFormValue(value: MonthYearState | undefined): string { + getDisplayStringFromFormValue( + value: MonthYearState | undefined, + _translator?: Translator + ): string { if (!value) { return '' } @@ -115,10 +118,13 @@ export class MonthYearField extends FormComponent { return `${monthString} ${value.year}` } - getDisplayStringFromState(state: FormSubmissionState) { + getDisplayStringFromState( + state: FormSubmissionState, + translator?: Translator + ) { const value = this.getFormValueFromState(state) - return this.getDisplayStringFromFormValue(value) + return this.getDisplayStringFromFormValue(value, translator) } getContextValueFromFormValue( diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts index 0bd707b66..c9a602057 100644 --- a/src/server/plugins/engine/components/PaymentField.ts +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -15,6 +15,7 @@ import { createError, getPluginOptions } from '~/src/server/plugins/engine/helpers.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { PaymentErrorTypes, PaymentPreAuthError, @@ -90,7 +91,10 @@ export class PaymentField extends FormComponent { return this.isPaymentState(value) ? value : undefined } - getDisplayStringFromState(state: FormSubmissionState): string { + getDisplayStringFromState( + state: FormSubmissionState, + _translator?: Translator + ): string { const value = this.getPaymentStateFromState(state) if (!value) { diff --git a/src/server/plugins/engine/components/UkAddressField.ts b/src/server/plugins/engine/components/UkAddressField.ts index a15ea6002..58db32935 100644 --- a/src/server/plugins/engine/components/UkAddressField.ts +++ b/src/server/plugins/engine/components/UkAddressField.ts @@ -153,14 +153,20 @@ export class UkAddressField extends FormComponent { return this.getContextValueFromFormValue(value) } - getDisplayStringFromFormValue(value: UkAddressState | undefined): string { + getDisplayStringFromFormValue( + value: UkAddressState | undefined, + _translator?: Translator + ): string { return this.getContextValueFromFormValue(value)?.join(', ') ?? '' } - getDisplayStringFromState(state: FormSubmissionState) { + getDisplayStringFromState( + state: FormSubmissionState, + translator?: Translator + ) { const value = this.getFormValueFromState(state) - return this.getDisplayStringFromFormValue(value) + return this.getDisplayStringFromFormValue(value, translator) } /** diff --git a/src/server/plugins/engine/i18n/translations/cy.json b/src/server/plugins/engine/i18n/translations/cy.json index c981df0df..d6feb4164 100644 --- a/src/server/plugins/engine/i18n/translations/cy.json +++ b/src/server/plugins/engine/i18n/translations/cy.json @@ -228,7 +228,10 @@ "geospatialField": { "added_one": "Ychwanegwyd [[count]] lleoliad", - "added_other": "Ychwanegwyd [[count]] lleoliad" + "added_other": "Ychwanegwyd [[count]] lleoliad", + "validation": { + "descriptionRequired": "Rhowch ddisgrifiad ar gyfer lleoliad [[count]]" + } }, "paymentField": { diff --git a/src/server/plugins/engine/i18n/translations/en-GB.json b/src/server/plugins/engine/i18n/translations/en-GB.json index 68e74a064..b23c78583 100644 --- a/src/server/plugins/engine/i18n/translations/en-GB.json +++ b/src/server/plugins/engine/i18n/translations/en-GB.json @@ -228,7 +228,10 @@ "geospatialField": { "added_one": "Added [[count]] location", - "added_other": "Added [[count]] locations" + "added_other": "Added [[count]] locations", + "validation": { + "descriptionRequired": "Enter description for location [[count]]" + } }, "paymentField": { diff --git a/src/server/plugins/engine/i18n/translations/x-pirate.json b/src/server/plugins/engine/i18n/translations/x-pirate.json index 7e5a6c2b7..756df692b 100644 --- a/src/server/plugins/engine/i18n/translations/x-pirate.json +++ b/src/server/plugins/engine/i18n/translations/x-pirate.json @@ -195,7 +195,10 @@ "geospatialField": { "added_one": "Ye've marked [[count]] location on the treasure map", - "added_other": "Ye've marked [[count]] locations on the treasure map" + "added_other": "Ye've marked [[count]] locations on the treasure map", + "validation": { + "descriptionRequired": "Mark yer description for location [[count]] on the map, ye scallywag" + } }, "paymentField": { From e24d5a0fd63e4d37fc7cbbefd30d43f5356e7003 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 30 Apr 2026 14:44:29 +0100 Subject: [PATCH 74/89] refactor(i18n): make translator required on all rendering methods; eliminate fallback patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit translator is now required on getDisplayStringFromState, getDisplayStringFromFormValue, getViewErrors, getAnswer, and getAnswerMarkdown. All callers either thread a per-request translator or construct one via model.createTranslator() which defaults to en-GB. Removes all translator?.t ?? tPlugin() fallback patterns — English now lives only in en-GB.json. GeospatialField, FileUploadField, UkAddressField, CheckboxesField, DeclarationField, and ListFormComponent cleaned up accordingly. getErrors retains optional translator since it is called from validation paths without a request context. --- .../engine/components/CheckboxesField.ts | 8 +- .../engine/components/ComponentCollection.ts | 2 +- .../engine/components/DatePartsField.ts | 4 +- .../engine/components/DeclarationField.ts | 8 +- .../engine/components/EastingNorthingField.ts | 6 +- .../engine/components/FileUploadField.test.ts | 16 ++- .../engine/components/FileUploadField.ts | 12 +- .../engine/components/FormComponent.ts | 6 +- .../engine/components/GeospatialField.test.ts | 28 +++- .../engine/components/GeospatialField.ts | 14 +- .../plugins/engine/components/LatLongField.ts | 6 +- .../engine/components/ListFormComponent.ts | 11 +- .../engine/components/MonthYearField.ts | 4 +- .../plugins/engine/components/PaymentField.ts | 2 +- .../engine/components/UkAddressField.ts | 12 +- .../components/helpers/components.test.ts | 136 ++++++++++++++---- .../engine/components/helpers/components.ts | 11 +- src/server/plugins/engine/helpers.ts | 9 +- src/server/plugins/engine/models/FormModel.ts | 2 +- .../engine/outputFormatters/human/v1.ts | 5 +- .../engine/outputFormatters/machine/v1.ts | 24 ++-- .../pageControllers/SummaryPageController.ts | 34 +++-- .../pageControllers/helpers/submission.ts | 18 ++- src/server/plugins/engine/routes/questions.ts | 6 +- test/form/govuk-notify.test.js | 2 +- 25 files changed, 258 insertions(+), 128 deletions(-) diff --git a/src/server/plugins/engine/components/CheckboxesField.ts b/src/server/plugins/engine/components/CheckboxesField.ts index 2fafc9163..8491912f5 100644 --- a/src/server/plugins/engine/components/CheckboxesField.ts +++ b/src/server/plugins/engine/components/CheckboxesField.ts @@ -68,7 +68,7 @@ export class CheckboxesField extends SelectionControlField { getDisplayStringFromFormValue( selected: (string | number | boolean)[] | undefined, - translator?: Translator + translator: Translator ) { const { items } = this @@ -78,9 +78,7 @@ export class CheckboxesField extends SelectionControlField { return items .filter((item) => selected.includes(item.value)) - .map((item) => - translator ? translator.tListItem(item, 'text') || item.text : item.text - ) + .map((item) => translator.tListItem(item, 'text') || item.text) .join(', ') } @@ -103,7 +101,7 @@ export class CheckboxesField extends SelectionControlField { getDisplayStringFromState( state: FormSubmissionState, - translator?: Translator + translator: Translator ) { const selected = this.getFormValueFromState(state) ?? [] return this.getDisplayStringFromFormValue(selected, translator) diff --git a/src/server/plugins/engine/components/ComponentCollection.ts b/src/server/plugins/engine/components/ComponentCollection.ts index 42fc1798a..ca4c9344f 100644 --- a/src/server/plugins/engine/components/ComponentCollection.ts +++ b/src/server/plugins/engine/components/ComponentCollection.ts @@ -238,7 +238,7 @@ export class ComponentCollection { */ getViewErrors( errors?: FormSubmissionError[], - translator?: Translator + translator: Translator ): FormSubmissionError[] | undefined { return this.getFieldErrors( (field) => field.getViewErrors(errors, translator), diff --git a/src/server/plugins/engine/components/DatePartsField.ts b/src/server/plugins/engine/components/DatePartsField.ts index 8c59d9af4..df19243c6 100644 --- a/src/server/plugins/engine/components/DatePartsField.ts +++ b/src/server/plugins/engine/components/DatePartsField.ts @@ -111,7 +111,7 @@ export class DatePartsField extends FormComponent { getDisplayStringFromFormValue( formValue: DatePartsState | undefined, - _translator?: Translator + _translator: Translator ) { if (!formValue) { return '' @@ -125,7 +125,7 @@ export class DatePartsField extends FormComponent { getDisplayStringFromState( state: FormSubmissionState, - translator?: Translator + translator: Translator ) { const value = this.getFormValueFromState(state) diff --git a/src/server/plugins/engine/components/DeclarationField.ts b/src/server/plugins/engine/components/DeclarationField.ts index f05617bfd..709a68cc7 100644 --- a/src/server/plugins/engine/components/DeclarationField.ts +++ b/src/server/plugins/engine/components/DeclarationField.ts @@ -18,7 +18,6 @@ import { } from '~/src/server/plugins/engine/components/FormComponent.js' import { type RenderContext } from '~/src/server/plugins/engine/components/types.js' import { buildValidationMessages } from '~/src/server/plugins/engine/i18n/buildValidationMessages.js' -import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { @@ -129,12 +128,9 @@ export class DeclarationField extends FormComponent { getDisplayStringFromFormValue( value: FormValue | FormPayload, - translator?: Translator + translator: Translator ): string { - const t = - translator?.t ?? - ((key: string, opts?: Record) => - tPlugin(key, 'en-GB', opts)) + const { t } = translator return value === 'true' ? (this.declarationConfirmationLabel ?? t('components.declarationField.defaultLabel')) diff --git a/src/server/plugins/engine/components/EastingNorthingField.ts b/src/server/plugins/engine/components/EastingNorthingField.ts index 7b3433141..13f915d5b 100644 --- a/src/server/plugins/engine/components/EastingNorthingField.ts +++ b/src/server/plugins/engine/components/EastingNorthingField.ts @@ -192,7 +192,7 @@ export class EastingNorthingField extends FormComponent { getDisplayStringFromFormValue( value: EastingNorthingState | undefined, - _translator?: Translator + _translator: Translator ): string { if (!value) { return '' @@ -204,7 +204,7 @@ export class EastingNorthingField extends FormComponent { getDisplayStringFromState( state: FormSubmissionState, - translator?: Translator + translator: Translator ) { const value = this.getFormValueFromState(state) @@ -234,7 +234,7 @@ export class EastingNorthingField extends FormComponent { getViewErrors( errors?: FormSubmissionError[], - _translator?: Translator + _translator: Translator ): FormSubmissionError[] | undefined { const allErrors = this.getErrors(errors) return deduplicateErrorsByHref(allErrors) diff --git a/src/server/plugins/engine/components/FileUploadField.test.ts b/src/server/plugins/engine/components/FileUploadField.test.ts index 6cd12d701..4e628f626 100644 --- a/src/server/plugins/engine/components/FileUploadField.test.ts +++ b/src/server/plugins/engine/components/FileUploadField.test.ts @@ -325,10 +325,20 @@ describe('FileUploadField', () => { const state1 = getFormState(validState) const state2 = getFormState(null) - const answer1 = getAnswer(field, state1) - const answer2 = getAnswer(field, state2) + const answer1 = getAnswer( + field, + state1, + { format: 'summary' }, + stubTranslator + ) + const answer2 = getAnswer( + field, + state2, + { format: 'summary' }, + stubTranslator + ) - expect(answer1).toBe('Uploaded 3 files') + expect(answer1).toBe('3 files uploaded') expect(answer2).toBe('') }) diff --git a/src/server/plugins/engine/components/FileUploadField.ts b/src/server/plugins/engine/components/FileUploadField.ts index d738fa5c6..57ca58592 100644 --- a/src/server/plugins/engine/components/FileUploadField.ts +++ b/src/server/plugins/engine/components/FileUploadField.ts @@ -163,22 +163,20 @@ export class FileUploadField extends FormComponent { getDisplayStringFromFormValue( files: FileState[] | undefined, - translator?: Translator + translator: Translator ): string { if (!files?.length) { return '' } - return ( - translator?.t('components.fileUploadField.filesCount', { - count: files.length - }) ?? `Uploaded ${files.length} ${files.length === 1 ? 'file' : 'files'}` - ) + return translator.t('components.fileUploadField.filesCount', { + count: files.length + }) } getDisplayStringFromState( state: FormSubmissionState, - translator?: Translator + translator: Translator ) { const files = this.getFormValueFromState(state) diff --git a/src/server/plugins/engine/components/FormComponent.ts b/src/server/plugins/engine/components/FormComponent.ts index 321356d0f..5f0d6a2ed 100644 --- a/src/server/plugins/engine/components/FormComponent.ts +++ b/src/server/plugins/engine/components/FormComponent.ts @@ -131,7 +131,7 @@ export class FormComponent extends ComponentBase { getViewErrors( errors?: FormSubmissionError[], - _translator?: Translator + _translator: Translator ): FormSubmissionError[] | undefined { const firstError = this.getFirstError(errors) return firstError && [firstError] @@ -198,7 +198,7 @@ export class FormComponent extends ComponentBase { getDisplayStringFromFormValue( value: FormValue | FormPayload, - _translator?: Translator + _translator: Translator ): string { // Map selected values to text // eslint-disable-next-line @typescript-eslint/no-base-to-string @@ -207,7 +207,7 @@ export class FormComponent extends ComponentBase { getDisplayStringFromState( state: FormSubmissionState, - translator?: Translator + translator: Translator ): string { const value = this.getFormValueFromState(state) return this.getDisplayStringFromFormValue(value, translator) diff --git a/src/server/plugins/engine/components/GeospatialField.test.ts b/src/server/plugins/engine/components/GeospatialField.test.ts index a34bee62f..f2739cdeb 100644 --- a/src/server/plugins/engine/components/GeospatialField.test.ts +++ b/src/server/plugins/engine/components/GeospatialField.test.ts @@ -151,8 +151,18 @@ describe('GeospatialField', () => { const state1 = getFormState(validSingleState) const state2 = getFormState(null) - const answer1 = getAnswer(field, state1) - const answer2 = getAnswer(field, state2) + const answer1 = getAnswer( + field, + state1, + { format: 'summary' }, + stubTranslator + ) + const answer2 = getAnswer( + field, + state2, + { format: 'summary' }, + stubTranslator + ) expect(answer1).toBe('Added 1 location') expect(answer2).toBe('') @@ -162,8 +172,18 @@ describe('GeospatialField', () => { const state1 = getFormState(validState) const state2 = getFormState(null) - const answer1 = getAnswer(field, state1) - const answer2 = getAnswer(field, state2) + const answer1 = getAnswer( + field, + state1, + { format: 'summary' }, + stubTranslator + ) + const answer2 = getAnswer( + field, + state2, + { format: 'summary' }, + stubTranslator + ) expect(answer1).toBe('Added 4 locations') expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/GeospatialField.ts b/src/server/plugins/engine/components/GeospatialField.ts index 97589af77..f2d3e3841 100644 --- a/src/server/plugins/engine/components/GeospatialField.ts +++ b/src/server/plugins/engine/components/GeospatialField.ts @@ -57,22 +57,20 @@ export class GeospatialField extends FormComponent { getDisplayStringFromFormValue( features: GeospatialState | undefined, - translator?: Translator + translator: Translator ): string { if (!features?.length) { return '' } - const t = - translator?.t ?? - ((key: string, opts?: Record) => - tPlugin(key, 'en-GB', opts)) - return t('components.geospatialField.added', { count: features.length }) + return translator.t('components.geospatialField.added', { + count: features.length + }) } getDisplayStringFromState( state: FormSubmissionState, - translator?: Translator + translator: Translator ) { const features = this.getFormValueFromState(state) @@ -130,7 +128,7 @@ export class GeospatialField extends FormComponent { getViewErrors( errors?: FormSubmissionError[], - translator?: Translator + translator: Translator ): FormSubmissionError[] | undefined { return this.getErrors(errors, translator) } diff --git a/src/server/plugins/engine/components/LatLongField.ts b/src/server/plugins/engine/components/LatLongField.ts index 13479ad21..475c5aab8 100644 --- a/src/server/plugins/engine/components/LatLongField.ts +++ b/src/server/plugins/engine/components/LatLongField.ts @@ -159,7 +159,7 @@ export class LatLongField extends FormComponent { getDisplayStringFromFormValue( value: LatLongState | undefined, - _translator?: Translator + _translator: Translator ): string { if (!value) { return '' @@ -171,7 +171,7 @@ export class LatLongField extends FormComponent { getDisplayStringFromState( state: FormSubmissionState, - translator?: Translator + translator: Translator ) { const value = this.getFormValueFromState(state) @@ -200,7 +200,7 @@ export class LatLongField extends FormComponent { getViewErrors( errors?: FormSubmissionError[], - _translator?: Translator + _translator: Translator ): FormSubmissionError[] | undefined { const allErrors = this.getErrors(errors) return deduplicateErrorsByHref(allErrors) diff --git a/src/server/plugins/engine/components/ListFormComponent.ts b/src/server/plugins/engine/components/ListFormComponent.ts index f69324f18..dd2ec323e 100644 --- a/src/server/plugins/engine/components/ListFormComponent.ts +++ b/src/server/plugins/engine/components/ListFormComponent.ts @@ -17,7 +17,6 @@ import { type ListItem, type RenderContext } from '~/src/server/plugins/engine/components/types.js' -import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { @@ -104,7 +103,7 @@ export class ListFormComponent extends FormComponent { getDisplayStringFromFormValue( value: string | number | boolean | Item['value'][] | undefined, - translator?: Translator + translator: Translator ): string { const { items } = this @@ -112,17 +111,13 @@ export class ListFormComponent extends FormComponent { return items .filter((item) => values.includes(item.value)) - .map((item) => - translator - ? translator.tListItem(item, 'text') || item.text - : tPlugin(item.text, 'en-GB') || item.text - ) + .map((item) => translator.tListItem(item, 'text') || item.text) .join(', ') } getDisplayStringFromState( state: FormSubmissionState, - translator?: Translator + translator: Translator ) { // Allow for array values via subclass const value = this.getFormValueFromState(state) diff --git a/src/server/plugins/engine/components/MonthYearField.ts b/src/server/plugins/engine/components/MonthYearField.ts index 55f531ccb..b2bb26aa7 100644 --- a/src/server/plugins/engine/components/MonthYearField.ts +++ b/src/server/plugins/engine/components/MonthYearField.ts @@ -105,7 +105,7 @@ export class MonthYearField extends FormComponent { getDisplayStringFromFormValue( value: MonthYearState | undefined, - _translator?: Translator + _translator: Translator ): string { if (!value) { return '' @@ -120,7 +120,7 @@ export class MonthYearField extends FormComponent { getDisplayStringFromState( state: FormSubmissionState, - translator?: Translator + translator: Translator ) { const value = this.getFormValueFromState(state) diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts index c9a602057..f77035165 100644 --- a/src/server/plugins/engine/components/PaymentField.ts +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -93,7 +93,7 @@ export class PaymentField extends FormComponent { getDisplayStringFromState( state: FormSubmissionState, - _translator?: Translator + _translator: Translator ): string { const value = this.getPaymentStateFromState(state) diff --git a/src/server/plugins/engine/components/UkAddressField.ts b/src/server/plugins/engine/components/UkAddressField.ts index 58db32935..07762e60b 100644 --- a/src/server/plugins/engine/components/UkAddressField.ts +++ b/src/server/plugins/engine/components/UkAddressField.ts @@ -14,7 +14,6 @@ import { import { TextField } from '~/src/server/plugins/engine/components/TextField.js' import { type RenderContext } from '~/src/server/plugins/engine/components/types.js' import { getPluginOptions } from '~/src/server/plugins/engine/helpers.js' -import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' import { @@ -155,14 +154,14 @@ export class UkAddressField extends FormComponent { getDisplayStringFromFormValue( value: UkAddressState | undefined, - _translator?: Translator + _translator: Translator ): string { return this.getContextValueFromFormValue(value)?.join(', ') ?? '' } getDisplayStringFromState( state: FormSubmissionState, - translator?: Translator + translator: Translator ) { const value = this.getFormValueFromState(state) @@ -174,12 +173,9 @@ export class UkAddressField extends FormComponent { */ getViewErrors( errors?: FormSubmissionError[], - translator?: Translator + translator: Translator ): FormSubmissionError[] | undefined { - const t = translator - ? translator.t - : (key: string, opts?: Record) => - tPlugin(key, 'en-GB', opts) + const { t } = translator const uniqueErrors = this.getErrors(errors)?.filter( (error, index, self) => diff --git a/src/server/plugins/engine/components/helpers/components.test.ts b/src/server/plugins/engine/components/helpers/components.test.ts index 87ae55e43..18f5d65cf 100644 --- a/src/server/plugins/engine/components/helpers/components.test.ts +++ b/src/server/plugins/engine/components/helpers/components.test.ts @@ -22,6 +22,7 @@ import { OsGridRefField } from '~/src/server/plugins/engine/components/index.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import definition from '~/test/form/definitions/blank.js' describe('Location field formatting', () => { @@ -52,7 +53,12 @@ describe('Location field formatting', () => { locationEN__northing: 654321 } - const answer = getAnswer(field, state, { format: 'email' }) + const answer = getAnswer( + field, + state, + { format: 'email' }, + stubTranslator + ) expect(answer).toBe('Easting: 123456\nNorthing: 654321\n') }) @@ -62,7 +68,7 @@ describe('Location field formatting', () => { locationEN__northing: 654321 } - const answer = getAnswer(field, state, { format: 'data' }) + const answer = getAnswer(field, state, { format: 'data' }, stubTranslator) expect(answer).toBe('Easting: 123456\nNorthing: 654321') }) @@ -72,7 +78,12 @@ describe('Location field formatting', () => { locationEN__northing: 654321 } - const answer = getAnswer(field, state, { format: 'summary' }) + const answer = getAnswer( + field, + state, + { format: 'summary' }, + stubTranslator + ) // Should render as HTML from markdown expect(answer).toContain('Easting: 123456') expect(answer).toContain('Northing: 654321') @@ -81,7 +92,12 @@ describe('Location field formatting', () => { it('returns empty string when no values', () => { const state = {} - const answer = getAnswer(field, state, { format: 'email' }) + const answer = getAnswer( + field, + state, + { format: 'email' }, + stubTranslator + ) expect(answer).toBe('') }) }) @@ -105,7 +121,12 @@ describe('Location field formatting', () => { locationLL__longitude: -0.127758 } - const answer = getAnswer(field, state, { format: 'email' }) + const answer = getAnswer( + field, + state, + { format: 'email' }, + stubTranslator + ) expect(answer).toBe('Latitude: 51.51945\nLongitude: -0.127758\n') }) @@ -115,7 +136,7 @@ describe('Location field formatting', () => { locationLL__longitude: -0.127758 } - const answer = getAnswer(field, state, { format: 'data' }) + const answer = getAnswer(field, state, { format: 'data' }, stubTranslator) expect(answer).toBe('Latitude: 51.51945\nLongitude: -0.127758') }) @@ -125,7 +146,12 @@ describe('Location field formatting', () => { locationLL__longitude: -0.127758 } - const answer = getAnswer(field, state, { format: 'summary' }) + const answer = getAnswer( + field, + state, + { format: 'summary' }, + stubTranslator + ) // Should render as HTML from markdown expect(answer).toContain('Latitude: 51.51945') expect(answer).toContain('Longitude: -0.127758') @@ -134,7 +160,12 @@ describe('Location field formatting', () => { it('returns empty string when no values', () => { const state = {} - const answer = getAnswer(field, state, { format: 'email' }) + const answer = getAnswer( + field, + state, + { format: 'email' }, + stubTranslator + ) expect(answer).toBe('') }) }) @@ -157,7 +188,12 @@ describe('Location field formatting', () => { gridRef: 'TQ123456' } - const answer = getAnswer(field, state, { format: 'email' }) + const answer = getAnswer( + field, + state, + { format: 'email' }, + stubTranslator + ) expect(answer).toBe('TQ123456\n') }) @@ -166,7 +202,7 @@ describe('Location field formatting', () => { gridRef: 'TQ123456' } - const answer = getAnswer(field, state, { format: 'data' }) + const answer = getAnswer(field, state, { format: 'data' }, stubTranslator) expect(answer).toBe('TQ123456') }) @@ -175,7 +211,12 @@ describe('Location field formatting', () => { gridRef: 'TQ123456' } - const answer = getAnswer(field, state, { format: 'summary' }) + const answer = getAnswer( + field, + state, + { format: 'summary' }, + stubTranslator + ) expect(answer).toBe('TQ123456') }) }) @@ -198,7 +239,12 @@ describe('Location field formatting', () => { ngField: 'NG12345678' } - const answer = getAnswer(field, state, { format: 'email' }) + const answer = getAnswer( + field, + state, + { format: 'email' }, + stubTranslator + ) expect(answer).toBe('NG12345678\n') }) @@ -207,7 +253,7 @@ describe('Location field formatting', () => { ngField: 'NG12345678' } - const answer = getAnswer(field, state, { format: 'data' }) + const answer = getAnswer(field, state, { format: 'data' }, stubTranslator) expect(answer).toBe('NG12345678') }) @@ -216,7 +262,12 @@ describe('Location field formatting', () => { ngField: 'NG12345678' } - const answer = getAnswer(field, state, { format: 'summary' }) + const answer = getAnswer( + field, + state, + { format: 'summary' }, + stubTranslator + ) expect(answer).toBe('NG12345678') }) }) @@ -239,7 +290,12 @@ describe('Location field formatting', () => { geoField: validState } - const answer = getAnswer(field, state, { format: 'email' }) + const answer = getAnswer( + field, + state, + { format: 'email' }, + stubTranslator + ) expect(answer).toBe('Added 4 locations\n') }) @@ -248,7 +304,7 @@ describe('Location field formatting', () => { geoField: validState } - const answer = getAnswer(field, state, { format: 'data' }) + const answer = getAnswer(field, state, { format: 'data' }, stubTranslator) expect(answer).toBe('a,b,c,d') }) @@ -257,7 +313,12 @@ describe('Location field formatting', () => { geoField: validState } - const answer = getAnswer(field, state, { format: 'summary' }) + const answer = getAnswer( + field, + state, + { format: 'summary' }, + stubTranslator + ) expect(answer).toBe('Added 4 locations') }) }) @@ -281,7 +342,12 @@ describe('Location field formatting', () => { declField: true } - const answer = getAnswer(field, state, { format: 'email' }) + const answer = getAnswer( + field, + state, + { format: 'email' }, + stubTranslator + ) expect(answer).toBe('I understand and agree\n') }) @@ -290,7 +356,12 @@ describe('Location field formatting', () => { declField: false } - const answer = getAnswer(field, state, { format: 'email' }) + const answer = getAnswer( + field, + state, + { format: 'email' }, + stubTranslator + ) expect(answer).toBe('Not provided\n') }) @@ -299,7 +370,7 @@ describe('Location field formatting', () => { declField: true } - const answer = getAnswer(field, state, { format: 'data' }) + const answer = getAnswer(field, state, { format: 'data' }, stubTranslator) expect(answer).toBe('true') }) @@ -308,14 +379,14 @@ describe('Location field formatting', () => { declField: false } - const answer = getAnswer(field, state, { format: 'data' }) + const answer = getAnswer(field, state, { format: 'data' }, stubTranslator) expect(answer).toBe('false') }) it('formats for data output when no value', () => { const state = {} - const answer = getAnswer(field, state, { format: 'data' }) + const answer = getAnswer(field, state, { format: 'data' }, stubTranslator) expect(answer).toBe('false') }) @@ -324,7 +395,12 @@ describe('Location field formatting', () => { declField: true } - const answer = getAnswer(field, state, { format: 'summary' }) + const answer = getAnswer( + field, + state, + { format: 'summary' }, + stubTranslator + ) expect(answer).toBe('I understand and agree') }) @@ -333,14 +409,24 @@ describe('Location field formatting', () => { declField: false } - const answer = getAnswer(field, state, { format: 'summary' }) + const answer = getAnswer( + field, + state, + { format: 'summary' }, + stubTranslator + ) expect(answer).toBe('Not provided') }) it('formats for summary display when no value', () => { const state = {} - const answer = getAnswer(field, state, { format: 'summary' }) + const answer = getAnswer( + field, + state, + { format: 'summary' }, + stubTranslator + ) expect(answer).toBe('Not provided') }) }) diff --git a/src/server/plugins/engine/components/helpers/components.ts b/src/server/plugins/engine/components/helpers/components.ts index d3687bc9d..a3e929f60 100644 --- a/src/server/plugins/engine/components/helpers/components.ts +++ b/src/server/plugins/engine/components/helpers/components.ts @@ -6,7 +6,6 @@ import { ListFormComponent } from '~/src/server/plugins/engine/components/ListFo import { escapeMarkdown } from '~/src/server/plugins/engine/components/helpers/index.js' import * as Components from '~/src/server/plugins/engine/components/index.js' import { markdown } from '~/src/server/plugins/engine/components/markdownParser.js' -import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type FormState } from '~/src/server/plugins/engine/types.js' @@ -224,7 +223,7 @@ export function getAnswer( | 'email' // GOV.UK Notify emails | 'summary' // Check answers summary } = { format: 'summary' }, - translator?: Translator + translator: Translator ) { // Use escaped display text for GOV.UK Notify emails if (options.format === 'email') { @@ -268,7 +267,7 @@ export function getAnswerMarkdown( | 'email' // GOV.UK Notify emails | 'summary' // Check answers summary } = { format: 'summary' }, - translator?: Translator + translator: Translator ) { const answer = field.getDisplayStringFromState(state, translator) @@ -307,9 +306,7 @@ export function getAnswerMarkdown( // Append bullet points answerEscaped += items .map((item) => { - const resolvedText = translator - ? translator.tListItem(item, 'text') || item.text - : tPlugin(item.text, 'en-GB') || item.text + const resolvedText = translator.tListItem(item, 'text') || item.text const label = escapeMarkdown(resolvedText) const value = escapeMarkdown(`(${item.value})`) @@ -349,7 +346,7 @@ export function getAnswerMarkdown( answerEscaped = contextValue ? `${contextValue}\n` : '' } else if (field instanceof Components.GeospatialField) { const features = field.getFormValueFromState(state) - const value = field.getDisplayStringFromFormValue(features) + const value = field.getDisplayStringFromFormValue(features, translator) answerEscaped = value ? `${value}\n` : '' } diff --git a/src/server/plugins/engine/helpers.ts b/src/server/plugins/engine/helpers.ts index 14c060adf..ab08d7617 100644 --- a/src/server/plugins/engine/helpers.ts +++ b/src/server/plugins/engine/helpers.ts @@ -111,7 +111,14 @@ engine.registerFilter('answer', function (name: string) { return } - const answer = getAnswer(component as Field, globals.context.relevantState) + const field = component as Field + const translator = field.model.createTranslator() + const answer = getAnswer( + field, + globals.context.relevantState, + { format: 'summary' }, + translator + ) return answer }) diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index 20f574fd4..342ff48f6 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -249,7 +249,7 @@ export class FormModel { } /** Returns a scoped translator pair for the given language. */ - createTranslator(language: string): Translator { + createTranslator(language = 'en-GB'): Translator { const { i18nInstance } = this const t = (key: string, opts?: Record): string => diff --git a/src/server/plugins/engine/outputFormatters/human/v1.ts b/src/server/plugins/engine/outputFormatters/human/v1.ts index a2e6bef13..448c85ff6 100644 --- a/src/server/plugins/engine/outputFormatters/human/v1.ts +++ b/src/server/plugins/engine/outputFormatters/human/v1.ts @@ -30,6 +30,7 @@ export function format( formStatus: ReturnType, _formMetadata?: FormMetadata ) { + const translator = model.createTranslator() const { files } = submitResponse.result const formName = escapeMarkdown(model.name) @@ -72,9 +73,7 @@ export function format( lines.push(`[${filename}](${designerUrl}/file-download/${fileId})\n`) } else { lines.push( - getAnswer(item.field, item.state, { - format: 'email' - }) + getAnswer(item.field, item.state, { format: 'email' }, translator) ) } diff --git a/src/server/plugins/engine/outputFormatters/machine/v1.ts b/src/server/plugins/engine/outputFormatters/machine/v1.ts index 73907567e..5b43a5a07 100644 --- a/src/server/plugins/engine/outputFormatters/machine/v1.ts +++ b/src/server/plugins/engine/outputFormatters/machine/v1.ts @@ -7,6 +7,7 @@ import { config } from '~/src/config/index.js' import { getAnswer } from '~/src/server/plugins/engine/components/helpers/components.js' import { FileUploadField } from '~/src/server/plugins/engine/components/index.js' import { type checkFormStatus } from '~/src/server/plugins/engine/helpers.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { type DetailItem, @@ -25,9 +26,10 @@ export function format( _formStatus: ReturnType, _formMetadata?: FormMetadata ) { + const translator = model.createTranslator() const now = new Date() - const categorisedData = categoriseData(items) + const categorisedData = categoriseData(items, translator) const data = { meta: { @@ -68,7 +70,7 @@ export function format( * } * } */ -function categoriseData(items: DetailItem[]) { +function categoriseData(items: DetailItem[], translator: Translator) { const output: { main: Record repeaters: Record[]> @@ -77,13 +79,16 @@ function categoriseData(items: DetailItem[]) { items.forEach((item) => { if ('subItems' in item) { - output.repeaters[item.name] = extractRepeaters(item) + output.repeaters[item.name] = extractRepeaters(item, translator) } else if (isFileUploadFieldItem(item)) { output.files[item.name] = extractFileUploads(item) } else { - output.main[item.name] = getAnswer(item.field, item.state, { - format: 'data' - }) + output.main[item.name] = getAnswer( + item.field, + item.state, + { format: 'data' }, + translator + ) } }) @@ -95,7 +100,7 @@ function categoriseData(items: DetailItem[]) { * @param item - the repeater item * @returns the repeater item */ -function extractRepeaters(item: DetailItemRepeat) { +function extractRepeaters(item: DetailItemRepeat, translator: Translator) { const repeaters: Record[] = [] item.subItems.forEach((inputRepeaterItem) => { @@ -105,9 +110,8 @@ function extractRepeaters(item: DetailItemRepeat) { outputRepeaterItem[repeaterComponent.name] = getAnswer( repeaterComponent.field, repeaterComponent.state, - { - format: 'data' - } + { format: 'data' }, + translator ) }) diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index 94b7e4cdd..fb6075f45 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -228,7 +228,8 @@ export class SummaryPageController extends QuestionPageController { request, viewModel, model, - notificationEmail + notificationEmail, + translator ) } catch (error) { return this.handleSubmissionError(error, request, h) @@ -362,7 +363,8 @@ export async function submitForm( request: FormRequestPayload, summaryViewModel: SummaryViewModel, model: FormModel, - emailAddress: string + emailAddress: string, + translator: Translator ) { await finaliseComponents(request, formMetadata, context) @@ -375,7 +377,8 @@ export async function submitForm( const items = getFormSubmissionData( summaryViewModel.context, - summaryViewModel.details + summaryViewModel.details, + translator ) try { @@ -384,7 +387,8 @@ export async function submitForm( model, items, emailAddress, - request.yar.id + request.yar.id, + translator ) if (submitResponse === undefined) { @@ -457,7 +461,8 @@ function submitData( model: FormModel, items: DetailItem[], retrievalKey: string, - sessionId: string + sessionId: string, + translator: Translator ) { const { formSubmissionService } = model.services const { submit } = formSubmissionService @@ -465,14 +470,18 @@ function submitData( const payload: SubmitPayload = { sessionId, retrievalKey, - main: buildMainRecords(items), - repeaters: buildRepeaterRecords(items) + main: buildMainRecords(items, translator), + repeaters: buildRepeaterRecords(items, translator) } return submit(payload) } -export function getFormSubmissionData(context: FormContext, details: Detail[]) { +export function getFormSubmissionData( + context: FormContext, + details: Detail[], + translator: Translator +) { const items = context.relevantPages .map(({ href }) => details.flatMap(({ items }) => @@ -481,7 +490,7 @@ export function getFormSubmissionData(context: FormContext, details: Detail[]) { ) .flat() - const paymentItems = getPaymentFieldItems(context) + const paymentItems = getPaymentFieldItems(context, translator) return [...items, ...paymentItems] } @@ -490,7 +499,10 @@ export function getFormSubmissionData(context: FormContext, details: Detail[]) { * Gets DetailItems for PaymentField components * PaymentField is excluded from summaryDetails for UI but needs to be in submission data */ -function getPaymentFieldItems(context: FormContext): DetailItemField[] { +function getPaymentFieldItems( + context: FormContext, + translator: Translator +): DetailItemField[] { const items: DetailItemField[] = [] for (const page of context.relevantPages) { @@ -504,7 +516,7 @@ function getPaymentFieldItems(context: FormContext): DetailItemField[] { field, state: context.state, href: page.href, - value: field.getDisplayStringFromState(context.state) + value: field.getDisplayStringFromState(context.state, translator) }) } } diff --git a/src/server/plugins/engine/pageControllers/helpers/submission.ts b/src/server/plugins/engine/pageControllers/helpers/submission.ts index 34810bdd8..d7106d3cf 100644 --- a/src/server/plugins/engine/pageControllers/helpers/submission.ts +++ b/src/server/plugins/engine/pageControllers/helpers/submission.ts @@ -3,6 +3,7 @@ import { type SubmitPayload } from '@defra/forms-model' import { GeospatialField } from '~/src/server/plugins/engine/components/GeospatialField.js' import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js' import { getAnswer } from '~/src/server/plugins/engine/components/helpers/components.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type DetailItem, type DetailItemField @@ -23,7 +24,10 @@ export interface SubmitRecord { * Regular fields are converted to single records, while PaymentField * components are expanded into four separate records. */ -export function buildMainRecords(items: DetailItem[]): SubmitRecord[] { +export function buildMainRecords( + items: DetailItem[], + translator: Translator +): SubmitRecord[] { const fieldItems = items.filter( (item): item is DetailItemField => 'field' in item ) @@ -45,7 +49,7 @@ export function buildMainRecords(items: DetailItem[]): SubmitRecord[] { records.push({ name: item.name, title: item.label, - value: getAnswer(item.field, item.state, { format: 'data' }) + value: getAnswer(item.field, item.state, { format: 'data' }, translator) }) } } @@ -101,7 +105,8 @@ export function buildPaymentRecords(item: DetailItemField): SubmitRecord[] { * Builds the repeater submission records from repeater items. */ export function buildRepeaterRecords( - items: DetailItem[] + items: DetailItem[], + translator: Translator ): SubmitPayload['repeaters'] { return items .filter((item) => 'subItems' in item) @@ -119,7 +124,12 @@ export function buildRepeaterRecords( ? JSON.stringify( subItem.field.getFormValueFromState(subItem.state) ) - : getAnswer(subItem.field, subItem.state, { format: 'data' }) + : getAnswer( + subItem.field, + subItem.state, + { format: 'data' }, + translator + ) })) ) })) diff --git a/src/server/plugins/engine/routes/questions.ts b/src/server/plugins/engine/routes/questions.ts index 81be2b3bb..6c3182df5 100644 --- a/src/server/plugins/engine/routes/questions.ts +++ b/src/server/plugins/engine/routes/questions.ts @@ -63,7 +63,11 @@ async function handleHttpEvent( const language = getLanguage?.(request) ?? 'en-GB' const translator = model.createTranslator(language) const viewModel = new SummaryViewModel(request, page, context, translator) - const items = getFormSubmissionData(viewModel.context, viewModel.details) + const items = getFormSubmissionData( + viewModel.context, + viewModel.details, + translator + ) // @ts-expect-error - function signature will be refactored in the next iteration of the formatter const payload = format(context, items, model, undefined, undefined) diff --git a/test/form/govuk-notify.test.js b/test/form/govuk-notify.test.js index 8b83b2db1..49c6dbbc8 100644 --- a/test/form/govuk-notify.test.js +++ b/test/form/govuk-notify.test.js @@ -235,7 +235,7 @@ describe('Submission journey test', () => { ## Upload your methodology statement - Uploaded 1 file: + 1 file uploaded: * [test\\.pdf](https://forms-designer/file-download/5a76a1a3-bc8a-4bc0-859a-116d775c7f15) From d15fc685c10798f97378b792f3f2e77e9e2713af Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Wed, 24 Jun 2026 08:29:56 +0100 Subject: [PATCH 75/89] Stash for rebase --- .../plugins/engine/beta/form-context.ts | 4 +--- .../engine/components/ComponentCollection.ts | 12 ++++++------ .../engine/components/EastingNorthingField.ts | 6 +++--- .../engine/components/FormComponent.ts | 17 +++++++++-------- .../engine/components/GeospatialField.ts | 18 +++++++----------- .../plugins/engine/components/LatLongField.ts | 6 +++--- .../engine/components/UkAddressField.ts | 10 +++++----- .../plugins/engine/models/SummaryViewModel.ts | 19 +++++++------------ .../pageControllers/QuestionPageController.ts | 6 +++--- .../plugins/nunjucks/enviroment.test.js | 2 -- src/server/plugins/nunjucks/filters/answer.js | 11 ++++++++++- 11 files changed, 54 insertions(+), 57 deletions(-) diff --git a/src/server/plugins/engine/beta/form-context.ts b/src/server/plugins/engine/beta/form-context.ts index dac6a1f50..a3f2c87b6 100644 --- a/src/server/plugins/engine/beta/form-context.ts +++ b/src/server/plugins/engine/beta/form-context.ts @@ -154,9 +154,7 @@ export async function resolveFormModel( // The models cache is created lazily per server instance // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (!server.app.models) { - server.app.models = new Map() - } + server.app.models ??= new Map() const cache = server.app.models as Map< string, diff --git a/src/server/plugins/engine/components/ComponentCollection.ts b/src/server/plugins/engine/components/ComponentCollection.ts index ca4c9344f..2d42772d9 100644 --- a/src/server/plugins/engine/components/ComponentCollection.ts +++ b/src/server/plugins/engine/components/ComponentCollection.ts @@ -222,11 +222,11 @@ export class ComponentCollection { * Get all errors for all fields in this collection */ getErrors( - errors?: FormSubmissionError[], - translator?: Translator + translator: Translator, + errors?: FormSubmissionError[] ): FormSubmissionError[] | undefined { return this.getFieldErrors( - (field) => field.getErrors(errors, translator), + (field) => field.getErrors(translator, errors), errors ) } @@ -237,11 +237,11 @@ export class ComponentCollection { * Composite fields like UKAddress can choose to return more than one error. */ getViewErrors( - errors?: FormSubmissionError[], - translator: Translator + translator: Translator, + errors?: FormSubmissionError[] ): FormSubmissionError[] | undefined { return this.getFieldErrors( - (field) => field.getViewErrors(errors, translator), + (field) => field.getViewErrors(translator, errors), errors ) } diff --git a/src/server/plugins/engine/components/EastingNorthingField.ts b/src/server/plugins/engine/components/EastingNorthingField.ts index 13f915d5b..b4dd517e1 100644 --- a/src/server/plugins/engine/components/EastingNorthingField.ts +++ b/src/server/plugins/engine/components/EastingNorthingField.ts @@ -233,10 +233,10 @@ export class EastingNorthingField extends FormComponent { } getViewErrors( - errors?: FormSubmissionError[], - _translator: Translator + translator: Translator, + errors?: FormSubmissionError[] ): FormSubmissionError[] | undefined { - const allErrors = this.getErrors(errors) + const allErrors = this.getErrors(translator, errors) return deduplicateErrorsByHref(allErrors) } diff --git a/src/server/plugins/engine/components/FormComponent.ts b/src/server/plugins/engine/components/FormComponent.ts index 5f0d6a2ed..b2aa565f9 100644 --- a/src/server/plugins/engine/components/FormComponent.ts +++ b/src/server/plugins/engine/components/FormComponent.ts @@ -103,8 +103,8 @@ export class FormComponent extends ComponentBase { } getErrors( - errors?: FormSubmissionError[], - _translator?: Translator + _translator: Translator, + errors?: FormSubmissionError[] ): FormSubmissionError[] | undefined { const { name } = this @@ -124,16 +124,17 @@ export class FormComponent extends ComponentBase { } getFirstError( + translator: Translator, errors?: FormSubmissionError[] ): FormSubmissionError | undefined { - return this.getErrors(errors)?.[0] + return this.getErrors(translator, errors)?.[0] } getViewErrors( - errors?: FormSubmissionError[], - _translator: Translator + translator: Translator, + errors?: FormSubmissionError[] ): FormSubmissionError[] | undefined { - const firstError = this.getFirstError(errors) + const firstError = this.getFirstError(translator, errors) return firstError && [firstError] } @@ -172,8 +173,8 @@ export class FormComponent extends ComponentBase { } // Filter component errors only - const componentErrors = this.getErrors(errors) - const componentError = this.getFirstError(componentErrors) + const componentErrors = this.getErrors(translator, errors) + const componentError = this.getFirstError(translator, componentErrors) if (componentErrors) { viewModel.errors = componentErrors diff --git a/src/server/plugins/engine/components/GeospatialField.ts b/src/server/plugins/engine/components/GeospatialField.ts index f2d3e3841..17a680049 100644 --- a/src/server/plugins/engine/components/GeospatialField.ts +++ b/src/server/plugins/engine/components/GeospatialField.ts @@ -8,7 +8,6 @@ import { } from '~/src/server/plugins/engine/components/FormComponent.js' import { geospatialSchema } from '~/src/server/plugins/engine/components/helpers/geospatial.js' import { type RenderContext } from '~/src/server/plugins/engine/components/types.js' -import { t as tPlugin } from '~/src/server/plugins/engine/i18n/index.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { @@ -103,15 +102,12 @@ export class GeospatialField extends FormComponent { } getErrors( - errors?: FormSubmissionError[], - translator?: Translator + translator: Translator, + errors?: FormSubmissionError[] ): FormSubmissionError[] | undefined { - const fieldErrors = super.getErrors(errors) + const fieldErrors = super.getErrors(translator, errors) - const t = - translator?.t ?? - ((key: string, opts?: Record) => - tPlugin(key, 'en-GB', opts)) + const t = translator.t fieldErrors?.forEach((err) => { if (err.name === 'description') { @@ -127,10 +123,10 @@ export class GeospatialField extends FormComponent { } getViewErrors( - errors?: FormSubmissionError[], - translator: Translator + translator: Translator, + errors?: FormSubmissionError[] ): FormSubmissionError[] | undefined { - return this.getErrors(errors, translator) + return this.getErrors(translator, errors) } isValue(value?: FormStateValue | FormState): value is GeospatialState { diff --git a/src/server/plugins/engine/components/LatLongField.ts b/src/server/plugins/engine/components/LatLongField.ts index 475c5aab8..91449777f 100644 --- a/src/server/plugins/engine/components/LatLongField.ts +++ b/src/server/plugins/engine/components/LatLongField.ts @@ -199,10 +199,10 @@ export class LatLongField extends FormComponent { } getViewErrors( - errors?: FormSubmissionError[], - _translator: Translator + translator: Translator, + errors?: FormSubmissionError[] ): FormSubmissionError[] | undefined { - const allErrors = this.getErrors(errors) + const allErrors = this.getErrors(translator, errors) return deduplicateErrorsByHref(allErrors) } diff --git a/src/server/plugins/engine/components/UkAddressField.ts b/src/server/plugins/engine/components/UkAddressField.ts index 07762e60b..95bdbc882 100644 --- a/src/server/plugins/engine/components/UkAddressField.ts +++ b/src/server/plugins/engine/components/UkAddressField.ts @@ -172,12 +172,12 @@ export class UkAddressField extends FormComponent { * Returns one error per child field */ getViewErrors( - errors?: FormSubmissionError[], - translator: Translator + translator: Translator, + errors?: FormSubmissionError[] ): FormSubmissionError[] | undefined { const { t } = translator - const uniqueErrors = this.getErrors(errors)?.filter( + const uniqueErrors = this.getErrors(translator, errors)?.filter( (error, index, self) => index === self.findIndex((err) => err.name === error.name) ) @@ -203,7 +203,7 @@ export class UkAddressField extends FormComponent { } getViewModel(context: RenderContext) { - const { payload } = context + const { payload, translator } = context const { collection, name, options } = this const viewModel = super.getViewModel(context) @@ -245,7 +245,7 @@ export class UkAddressField extends FormComponent { const usePostcodeLookup = this.shouldUsePostcodeLookup() const value = usePostcodeLookup - ? this.getDisplayStringFromState(payload) + ? this.getDisplayStringFromState(payload, translator) : undefined return { diff --git a/src/server/plugins/engine/models/SummaryViewModel.ts b/src/server/plugins/engine/models/SummaryViewModel.ts index aa310587f..1e108f537 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.ts @@ -207,17 +207,14 @@ function ItemRepeat( path: string errors?: FormSubmissionError[] }, - translator?: Translator + translator: Translator ): DetailItemRepeat { const { collection, repeat } = page const { name, title } = repeat.options const values = page.getListFromState(state) const count = values.length - const value = count - ? (translator?.t('pages.repeater.pageTitle', { count }) ?? - `You have added ${count} ${count === 1 ? 'answer' : 'answers'}`) - : '' + const value = translator.t('pages.repeater.pageTitle', { count }) return { name, @@ -251,7 +248,7 @@ export function ItemField( path: string errors?: FormSubmissionError[] }, - translator?: Translator + translator: Translator ): DetailItemField { // FormComponent doesn't expose shortDescription/title as raw def properties, // so build a lookup object with English values to let tComponent's GUID lookup fire. @@ -261,21 +258,19 @@ export function ItemField( shortDescription: field.label, title: field.title } as unknown as ComponentDef - const rawLabel = translator - ? translator.tComponent(fieldDef, 'shortDescription') - : '' + const rawLabel = translator.tComponent(fieldDef, 'shortDescription') const translatedLabel = rawLabel !== '' ? rawLabel : field.label - const rawTitle = translator ? translator.tComponent(fieldDef, 'title') : '' + const rawTitle = translator.tComponent(fieldDef, 'title') const translatedTitle = rawTitle !== '' ? rawTitle : field.title const optional = field.options.required === false - ? ` ${translator?.t('common.optional') ?? '(optional)'}` + ? ` ${translator.t('common.optional')}` : '' return { name: field.name, label: translatedTitle, title: `${translatedLabel}${optional}`, - error: field.getFirstError(options.errors), + error: field.getFirstError(translator, options.errors), value: getAnswer(field, state, { format: 'summary' }, translator), 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 9b41ef590..9be5663d6 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -442,7 +442,7 @@ export class QuestionPageController extends PageController { } const viewModel = this.getViewModel(request, context, translator) - viewModel.errors = collection.getViewErrors(viewModel.errors, translator) + viewModel.errors = collection.getViewErrors(translator, viewModel.errors) const flashedError = request.yar.flash(COMPONENT_STATE_ERROR) const flashedErrors = !Array.isArray(flashedError) ? [flashedError] : [] @@ -566,8 +566,8 @@ export class QuestionPageController extends PageController { if (context.errors || isForceAccess) { const viewModel = this.getViewModel(request, context, translator) viewModel.errors = collection.getViewErrors( - viewModel.errors, - translator + translator, + viewModel.errors ) // Filter our components based on their conditions using our evaluated state diff --git a/src/server/plugins/nunjucks/enviroment.test.js b/src/server/plugins/nunjucks/enviroment.test.js index 49a93d161..ccb68eaa2 100644 --- a/src/server/plugins/nunjucks/enviroment.test.js +++ b/src/server/plugins/nunjucks/enviroment.test.js @@ -4,13 +4,11 @@ import { environment } from '~/src/server/plugins/nunjucks/environment.js' describe('Nunjucks environment', () => { describe('t global function', () => { it('has t as a global function', () => { - const globals = /** @type {any} */ (environment).globals expect(typeof globals.t).toBe('function') }) it('t global returns the English string for a known key', () => { - const globals = /** @type {any} */ (environment).globals expect(globals.t('errors.title')).toBe('There is a problem') }) diff --git a/src/server/plugins/nunjucks/filters/answer.js b/src/server/plugins/nunjucks/filters/answer.js index cb7b84f2f..c0672e411 100644 --- a/src/server/plugins/nunjucks/filters/answer.js +++ b/src/server/plugins/nunjucks/filters/answer.js @@ -18,7 +18,16 @@ export function answer(name) { return undefined } - return getAnswer(/** @type {Field} */ (component), context.relevantState) + const field = /** @type {Field} */ (component) + const translator = field.model.createTranslator() + const answer = getAnswer( + field, + context.relevantState, + { format: 'summary' }, + translator + ) + + return answer } /** From e1570a76a0f158281007f57fcab3581d973b9d3f Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Wed, 24 Jun 2026 16:59:18 +0100 Subject: [PATCH 76/89] Stash --- package-lock.json | 1381 +++++++++-------- package.json | 2 +- src/server/plugins/engine/form-context.ts | 6 +- .../plugins/engine/i18n/translations/cy.json | 4 +- .../engine/i18n/translations/en-GB.json | 4 +- 5 files changed, 753 insertions(+), 644 deletions(-) diff --git a/package-lock.json b/package-lock.json index 78dc2905d..033e21078 100644 --- a/package-lock.json +++ b/package-lock.json @@ -151,7 +151,7 @@ }, "engines": { "node": ">=22.11.0 <25.0.0", - "npm": ">=10.9.0 <11.6.4" + "npm": ">=10.9.0 <=11.17.0" } }, "node_modules/@adobe/css-tools": { @@ -213,12 +213,12 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -227,29 +227,29 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -266,13 +266,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -294,13 +294,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -364,9 +364,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -386,27 +386,27 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -484,27 +484,27 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -525,25 +525,25 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -1991,31 +1991,31 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -2023,13 +2023,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -5654,9 +5654,9 @@ } }, "node_modules/@hapi/content": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@hapi/content/-/content-6.0.1.tgz", - "integrity": "sha512-lQ2vOoFMNYxwKVnKf+3Pi3PfoviM4EJYlT9JbrBPfEc0xKMiVDqqXF8UTE1S1oKhHQliWSP5t6zTKNlmaXBGcQ==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/content/-/content-6.0.2.tgz", + "integrity": "sha512-OKyCOTjNR1hftwSjk9ueyAQTw8AwapvzBrPIWMGn39vhR5PmqLdYFmLc35bsSBye7gSMnlkXfc679bUdMIcRyQ==", "license": "BSD-3-Clause", "dependencies": { "@hapi/boom": "^10.0.0" @@ -5746,9 +5746,9 @@ "license": "BSD-3-Clause" }, "node_modules/@hapi/inert": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@hapi/inert/-/inert-7.1.0.tgz", - "integrity": "sha512-5X+cl/Ozm0U9uPGGX1dSKhnhTQIf161bH/kkTN9OBVAZKFG+nrj8j/NMj6S1zBBZWmQrkVRNPfCUGrXzB4fCFQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@hapi/inert/-/inert-7.1.2.tgz", + "integrity": "sha512-fr0XmsYTUhahIbioBemFlc+o4ro4bj1HPMKN3dG9m6XC+UkF9fdVYfb7uNoDj0vO+HdXendbKk6YEfZG9ToeTQ==", "license": "BSD-3-Clause", "dependencies": { "@hapi/ammo": "^6.0.1", @@ -5987,9 +5987,9 @@ } }, "node_modules/@hapi/wreck": { - "version": "18.1.0", - "resolved": "https://registry.npmjs.org/@hapi/wreck/-/wreck-18.1.0.tgz", - "integrity": "sha512-0z6ZRCmFEfV/MQqkQomJ7sl/hyxvcZM7LtuVqN3vdAO4vM9eBbowl0kaqQj9EJJQab+3Uuh1GxbGIBFy4NfJ4w==", + "version": "18.1.2", + "resolved": "https://registry.npmjs.org/@hapi/wreck/-/wreck-18.1.2.tgz", + "integrity": "sha512-3dMnV2pfhQiyEqu8DL3VBmxkdLiRDiiUDuG79Dp+UK1gL9ZxAfDOUhB6k3D5MLqcgJJ1IARyGFhwoc1NITr/pg==", "license": "BSD-3-Clause", "dependencies": { "@hapi/boom": "^10.0.1", @@ -6212,9 +6212,9 @@ } }, "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", "dev": true, "license": "MIT", "engines": { @@ -6222,17 +6222,17 @@ } }, "node_modules/@jest/console": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.2.0.tgz", - "integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.4.1.tgz", + "integrity": "sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", "slash": "^3.0.0" }, "engines": { @@ -6250,39 +6250,39 @@ } }, "node_modules/@jest/core": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.2.0.tgz", - "integrity": "sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.4.2.tgz", + "integrity": "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.2.0", - "@jest/pattern": "30.0.1", - "@jest/reporters": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/console": "30.4.1", + "@jest/pattern": "30.4.0", + "@jest/reporters": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "ci-info": "^4.2.0", "exit-x": "^0.2.2", + "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-changed-files": "30.2.0", - "jest-config": "30.2.0", - "jest-haste-map": "30.2.0", - "jest-message-util": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-resolve-dependencies": "30.2.0", - "jest-runner": "30.2.0", - "jest-runtime": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "jest-watcher": "30.2.0", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", + "jest-changed-files": "30.4.1", + "jest-config": "30.4.2", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-resolve-dependencies": "30.4.2", + "jest-runner": "30.4.2", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "jest-watcher": "30.4.1", + "pretty-format": "30.4.1", "slash": "^3.0.0" }, "engines": { @@ -6297,6 +6297,19 @@ } } }, + "node_modules/@jest/core/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/core/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", @@ -6311,27 +6324,21 @@ } }, "node_modules/@jest/core/node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/core/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/@jest/core/node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -6343,9 +6350,9 @@ } }, "node_modules/@jest/diff-sequences": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", - "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", + "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", "dev": true, "license": "MIT", "engines": { @@ -6353,39 +6360,39 @@ } }, "node_modules/@jest/environment": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", - "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", - "jest-mock": "30.2.0" + "jest-mock": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==", "dev": true, "license": "MIT", "dependencies": { - "expect": "30.2.0", - "jest-snapshot": "30.2.0" + "expect": "30.4.1", + "jest-snapshot": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", - "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", + "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6396,18 +6403,18 @@ } }, "node_modules/@jest/fake-timers": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", - "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", - "@sinonjs/fake-timers": "^13.0.0", + "@jest/types": "30.4.1", + "@sinonjs/fake-timers": "^15.4.0", "@types/node": "*", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -6424,62 +6431,62 @@ } }, "node_modules/@jest/globals": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz", - "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.4.1.tgz", + "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/expect": "30.2.0", - "@jest/types": "30.2.0", - "jest-mock": "30.2.0" + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/types": "30.4.1", + "jest-mock": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/pattern": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", - "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", - "jest-regex-util": "30.0.1" + "jest-regex-util": "30.4.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/reporters": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.2.0.tgz", - "integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.4.1.tgz", + "integrity": "sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/console": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "@types/node": "*", "chalk": "^4.1.2", "collect-v8-coverage": "^1.0.2", "exit-x": "^0.2.2", - "glob": "^10.3.10", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-instrument": "^6.0.0", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "jest-worker": "30.2.0", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", "slash": "^3.0.0", "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" @@ -6497,9 +6504,9 @@ } }, "node_modules/@jest/reporters/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -6529,15 +6536,15 @@ } }, "node_modules/@jest/reporters/node_modules/jest-worker": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", - "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", + "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.2.0", + "jest-util": "30.4.1", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" }, @@ -6601,13 +6608,13 @@ } }, "node_modules/@jest/snapshot-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", - "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.4.1.tgz", + "integrity": "sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.4.1", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "natural-compare": "^1.4.0" @@ -6632,14 +6639,14 @@ } }, "node_modules/@jest/test-result": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.2.0.tgz", - "integrity": "sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.4.1.tgz", + "integrity": "sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.2.0", - "@jest/types": "30.2.0", + "@jest/console": "30.4.1", + "@jest/types": "30.4.1", "@types/istanbul-lib-coverage": "^2.0.6", "collect-v8-coverage": "^1.0.2" }, @@ -6648,15 +6655,15 @@ } }, "node_modules/@jest/test-sequencer": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz", - "integrity": "sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.4.1.tgz", + "integrity": "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.2.0", + "@jest/test-result": "30.4.1", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", + "jest-haste-map": "30.4.1", "slash": "^3.0.0" }, "engines": { @@ -6674,24 +6681,23 @@ } }, "node_modules/@jest/transform": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", - "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.4.1.tgz", + "integrity": "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", - "@jest/types": "30.2.0", + "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-util": "30.2.0", - "micromatch": "^4.0.8", + "jest-haste-map": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" @@ -6711,14 +6717,14 @@ } }, "node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/pattern": "30.0.1", - "@jest/schemas": "30.0.5", + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", @@ -6729,6 +6735,19 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/@jest/types/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@joi/date": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@joi/date/-/date-2.1.1.tgz", @@ -9453,13 +9472,13 @@ } }, "node_modules/@pkgr/core": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", - "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", "dev": true, "license": "MIT", "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + "node": "^14.18.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/pkgr" @@ -9702,9 +9721,9 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -12951,16 +12970,16 @@ } }, "node_modules/babel-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", - "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", + "integrity": "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "30.2.0", + "@jest/transform": "30.4.1", "@types/babel__core": "^7.20.5", "babel-plugin-istanbul": "^7.0.1", - "babel-preset-jest": "30.2.0", + "babel-preset-jest": "30.4.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" @@ -13038,9 +13057,9 @@ } }, "node_modules/babel-plugin-jest-hoist": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", - "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz", + "integrity": "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==", "dev": true, "license": "MIT", "dependencies": { @@ -13208,13 +13227,13 @@ } }, "node_modules/babel-preset-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", - "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.4.0.tgz", + "integrity": "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "30.2.0", + "babel-plugin-jest-hoist": "30.4.0", "babel-preset-current-node-syntax": "^1.2.0" }, "engines": { @@ -13326,9 +13345,9 @@ "license": "BSD-3-Clause" }, "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -13339,7 +13358,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.14.0", + "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -13385,21 +13404,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/body-parser/node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/bonjour-service": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", @@ -14369,15 +14373,15 @@ "license": "MIT" }, "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "version": "9.2.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.3.tgz", + "integrity": "sha512-ihjs0E2SxvDgq/MK418hX6YycQgKhsqxpbZuZbHo0yKfqDWdymWMjWYIpCIzqDDLLKClHlXev8whW/8WXmJ0BA==", "dev": true, "license": "MIT", "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", - "shell-quote": "1.8.3", + "shell-quote": "1.8.4", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" @@ -18144,18 +18148,18 @@ } }, "node_modules/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.2.0", + "@jest/expect-utils": "30.4.1", "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -18171,14 +18175,14 @@ } }, "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -18197,7 +18201,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -18258,21 +18262,6 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, - "node_modules/express/node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/express/node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -20092,9 +20081,9 @@ } }, "node_modules/http-proxy-middleware": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", - "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz", + "integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==", "license": "MIT", "dependencies": { "@types/http-proxy": "^1.17.8", @@ -21273,9 +21262,9 @@ } }, "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -21317,9 +21306,9 @@ } }, "node_modules/istanbul-lib-report/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -21393,16 +21382,16 @@ } }, "node_modules/jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", - "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.4.2.tgz", + "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.2.0", - "@jest/types": "30.2.0", + "@jest/core": "30.4.2", + "@jest/types": "30.4.1", "import-local": "^3.2.0", - "jest-cli": "30.2.0" + "jest-cli": "30.4.2" }, "bin": { "jest": "bin/jest.js" @@ -21420,14 +21409,14 @@ } }, "node_modules/jest-changed-files": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz", - "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.4.1.tgz", + "integrity": "sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==", "dev": true, "license": "MIT", "dependencies": { "execa": "^5.1.1", - "jest-util": "30.2.0", + "jest-util": "30.4.1", "p-limit": "^3.1.0" }, "engines": { @@ -21435,29 +21424,29 @@ } }, "node_modules/jest-circus": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz", - "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.4.2.tgz", + "integrity": "sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/expect": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "co": "^4.6.0", "dedent": "^1.6.0", "is-generator-fn": "^2.1.0", - "jest-each": "30.2.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-runtime": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", + "jest-each": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", "p-limit": "^3.1.0", - "pretty-format": "30.2.0", + "pretty-format": "30.4.1", "pure-rand": "^7.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" @@ -21466,6 +21455,19 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-circus/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/jest-circus/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", @@ -21480,27 +21482,21 @@ } }, "node_modules/jest-circus/node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-circus/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/jest-circus/node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -21512,21 +21508,21 @@ } }, "node_modules/jest-cli": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz", - "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.4.2.tgz", + "integrity": "sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", + "@jest/core": "30.4.2", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", "chalk": "^4.1.2", "exit-x": "^0.2.2", "import-local": "^3.2.0", - "jest-config": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", + "jest-config": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", "yargs": "^17.7.2" }, "bin": { @@ -21545,34 +21541,33 @@ } }, "node_modules/jest-config": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", - "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.4.2.tgz", + "integrity": "sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", "@jest/get-type": "30.1.0", - "@jest/pattern": "30.0.1", - "@jest/test-sequencer": "30.2.0", - "@jest/types": "30.2.0", - "babel-jest": "30.2.0", + "@jest/pattern": "30.4.0", + "@jest/test-sequencer": "30.4.1", + "@jest/types": "30.4.1", + "babel-jest": "30.4.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "deepmerge": "^4.3.1", - "glob": "^10.3.10", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "jest-circus": "30.2.0", - "jest-docblock": "30.2.0", - "jest-environment-node": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-runner": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "micromatch": "^4.0.8", + "jest-circus": "30.4.2", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-runner": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", "parse-json": "^5.2.0", - "pretty-format": "30.2.0", + "pretty-format": "30.4.1", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, @@ -21596,6 +21591,19 @@ } } }, + "node_modules/jest-config/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/jest-config/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", @@ -21610,9 +21618,9 @@ } }, "node_modules/jest-config/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -21658,27 +21666,21 @@ } }, "node_modules/jest-config/node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-config/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/jest-config/node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -21690,16 +21692,29 @@ } }, "node_modules/jest-diff": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", - "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.0.1", + "@jest/diff-sequences": "30.4.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "pretty-format": "30.2.0" + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-diff/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -21719,31 +21734,25 @@ } }, "node_modules/jest-diff/node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-diff/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/jest-docblock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", - "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.4.0.tgz", + "integrity": "sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==", "dev": true, "license": "MIT", "dependencies": { @@ -21754,17 +21763,30 @@ } }, "node_modules/jest-each": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz", - "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.4.1.tgz", + "integrity": "sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.2.0", + "@jest/types": "30.4.1", "chalk": "^4.1.2", - "jest-util": "30.2.0", - "pretty-format": "30.2.0" + "jest-util": "30.4.1", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -21784,41 +21806,35 @@ } }, "node_modules/jest-each/node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-each/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/jest-environment-node": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz", - "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", + "integrity": "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", - "jest-mock": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0" + "jest-mock": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -21850,21 +21866,21 @@ } }, "node_modules/jest-haste-map": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", - "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.4.1.tgz", + "integrity": "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.4.1", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", "graceful-fs": "^4.2.11", - "jest-regex-util": "30.0.1", - "jest-util": "30.2.0", - "jest-worker": "30.2.0", - "micromatch": "^4.0.8", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", + "picomatch": "^4.0.3", "walker": "^1.0.8" }, "engines": { @@ -21875,15 +21891,15 @@ } }, "node_modules/jest-haste-map/node_modules/jest-worker": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", - "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", + "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.2.0", + "jest-util": "30.4.1", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" }, @@ -21891,6 +21907,19 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-haste-map/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/jest-haste-map/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -21908,14 +21937,27 @@ } }, "node_modules/jest-leak-detector": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz", - "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz", + "integrity": "sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "pretty-format": "30.2.0" + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-leak-detector/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -21935,38 +21977,45 @@ } }, "node_modules/jest-leak-detector/node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-leak-detector/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/jest-matcher-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", - "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "jest-diff": "30.2.0", - "pretty-format": "30.2.0" + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -21986,41 +22035,36 @@ } }, "node_modules/jest-matcher-utils/node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-matcher-utils/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/jest-message-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", - "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.2.0", + "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -22028,6 +22072,19 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-message-util/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/jest-message-util/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", @@ -22041,28 +22098,35 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/jest-message-util/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/jest-message-util/node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-message-util/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/jest-message-util/node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -22074,15 +22138,15 @@ } }, "node_modules/jest-mock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", - "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.4.1", "@types/node": "*", - "jest-util": "30.2.0" + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -22107,9 +22171,9 @@ } }, "node_modules/jest-regex-util": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", - "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", "dev": true, "license": "MIT", "engines": { @@ -22117,18 +22181,18 @@ } }, "node_modules/jest-resolve": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz", - "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.4.1.tgz", + "integrity": "sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==", "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", + "jest-haste-map": "30.4.1", "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", "slash": "^3.0.0", "unrs-resolver": "^1.7.11" }, @@ -22137,14 +22201,14 @@ } }, "node_modules/jest-resolve-dependencies": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz", - "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.4.2.tgz", + "integrity": "sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==", "dev": true, "license": "MIT", "dependencies": { - "jest-regex-util": "30.0.1", - "jest-snapshot": "30.2.0" + "jest-regex-util": "30.4.0", + "jest-snapshot": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -22161,32 +22225,32 @@ } }, "node_modules/jest-runner": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz", - "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.4.2.tgz", + "integrity": "sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.2.0", - "@jest/environment": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/console": "30.4.1", + "@jest/environment": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "emittery": "^0.13.1", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", - "jest-docblock": "30.2.0", - "jest-environment-node": "30.2.0", - "jest-haste-map": "30.2.0", - "jest-leak-detector": "30.2.0", - "jest-message-util": "30.2.0", - "jest-resolve": "30.2.0", - "jest-runtime": "30.2.0", - "jest-util": "30.2.0", - "jest-watcher": "30.2.0", - "jest-worker": "30.2.0", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-haste-map": "30.4.1", + "jest-leak-detector": "30.4.1", + "jest-message-util": "30.4.1", + "jest-resolve": "30.4.1", + "jest-runtime": "30.4.2", + "jest-util": "30.4.1", + "jest-watcher": "30.4.1", + "jest-worker": "30.4.1", "p-limit": "^3.1.0", "source-map-support": "0.5.13" }, @@ -22195,15 +22259,15 @@ } }, "node_modules/jest-runner/node_modules/jest-worker": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", - "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", + "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.2.0", + "jest-util": "30.4.1", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" }, @@ -22228,32 +22292,32 @@ } }, "node_modules/jest-runtime": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", - "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.4.2.tgz", + "integrity": "sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", - "@jest/globals": "30.2.0", + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/globals": "30.4.1", "@jest/source-map": "30.0.1", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "cjs-module-lexer": "^2.1.0", "collect-v8-coverage": "^1.0.2", - "glob": "^10.3.10", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, @@ -22262,9 +22326,9 @@ } }, "node_modules/jest-runtime/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -22320,9 +22384,9 @@ } }, "node_modules/jest-snapshot": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", - "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.4.1.tgz", + "integrity": "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==", "dev": true, "license": "MIT", "dependencies": { @@ -22331,20 +22395,20 @@ "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.2.0", + "@jest/expect-utils": "30.4.1", "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/snapshot-utils": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", - "expect": "30.2.0", + "expect": "30.4.1", "graceful-fs": "^4.2.11", - "jest-diff": "30.2.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "pretty-format": "30.2.0", + "jest-diff": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "pretty-format": "30.4.1", "semver": "^7.7.2", "synckit": "^0.11.8" }, @@ -22352,6 +22416,19 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-snapshot/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/jest-snapshot/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", @@ -22366,31 +22443,25 @@ } }, "node_modules/jest-snapshot/node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-snapshot/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -22401,18 +22472,18 @@ } }, "node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -22432,18 +22503,31 @@ } }, "node_modules/jest-validate": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", - "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", + "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.2.0", + "@jest/types": "30.4.1", "camelcase": "^6.3.0", "chalk": "^4.1.2", "leven": "^3.1.0", - "pretty-format": "30.2.0" + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -22463,41 +22547,35 @@ } }, "node_modules/jest-validate/node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-validate/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/jest-watcher": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz", - "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.4.1.tgz", + "integrity": "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", - "jest-util": "30.2.0", + "jest-util": "30.4.1", "string-length": "^4.0.2" }, "engines": { @@ -22611,9 +22689,9 @@ } }, "node_modules/joi": { - "version": "17.13.3", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", - "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "version": "17.13.4", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.4.tgz", + "integrity": "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==", "license": "BSD-3-Clause", "dependencies": { "@hapi/hoek": "^9.3.0", @@ -22654,9 +22732,19 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -22870,13 +22958,13 @@ } }, "node_modules/launch-editor": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.13.1.tgz", - "integrity": "sha512-lPSddlAAluRKJ7/cjRFoXUFzaX7q/YKI7yPHuEvSJVqoXvFnJov1/Ud87Aa4zULIbA9Nja4mSPK8l0z/7eV2wA==", + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz", + "integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==", "license": "MIT", "dependencies": { "picocolors": "^1.1.1", - "shell-quote": "^1.8.3" + "shell-quote": "^1.8.4" } }, "node_modules/leven": { @@ -23116,9 +23204,9 @@ } }, "node_modules/liquidjs": { - "version": "10.25.7", - "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.25.7.tgz", - "integrity": "sha512-rPCjJLiD4eDhQjvv964AeXFC+HbeYBbZrd7Z82Q6hqv1lX7G+5w4SJcKLn9CAAAwHI4aS3dTdo083UB79K3pDA==", + "version": "10.27.1", + "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.27.1.tgz", + "integrity": "sha512-ylE+1q2kSef1UxAyxqbyuWM3FRWS1v48JK1Y3CoW3bD6TSNXZh0+GsVnihujEpKyR+Jejx2aRAFfC3AHm9rElg==", "license": "MIT", "dependencies": { "commander": "^10.0.0" @@ -29409,10 +29497,9 @@ } }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", - "dev": true, + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -29614,6 +29701,22 @@ "dev": true, "license": "MIT" }, + "node_modules/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-is-19": { + "name": "react-is", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "dev": true, + "license": "MIT" + }, "node_modules/react-loadable": { "name": "@docusaurus/react-loadable", "version": "6.0.0", @@ -31560,9 +31663,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -32839,13 +32942,13 @@ } }, "node_modules/synckit": { - "version": "0.11.12", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", - "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", "dev": true, "license": "MIT", "dependencies": { - "@pkgr/core": "^0.2.9" + "@pkgr/core": "^0.3.6" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -33665,9 +33768,9 @@ } }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -34387,9 +34490,9 @@ } }, "node_modules/webpack-bundle-analyzer/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", "license": "MIT", "engines": { "node": ">=8.3.0" @@ -34525,9 +34628,9 @@ } }, "node_modules/webpack-dev-server": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.4.tgz", - "integrity": "sha512-GqDPGZN9bRqKBTkp4aWkobDDHMsrXKoGSdOH56smIri8qR0JG8gfL8/v/f/OZR3/OKXjG8uwJbFVhKm/FNU/UA==", + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.5.tgz", + "integrity": "sha512-4wZtCquSuv9CKX8oybo+mqxtxZqWz47uM1Ch94lxowBztOhWCbhqvRbfC/mODOwxgV2brY+JGZpHq58/SuVFYg==", "license": "MIT", "dependencies": { "@types/bonjour": "^3.5.13", @@ -35025,9 +35128,9 @@ } }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 946923cc1..97934a5e9 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ }, "engines": { "node": ">=22.11.0 <25.0.0", - "npm": ">=10.9.0 <11.6.4" + "npm": ">=10.9.0 <=11.17.0" }, "license": "SEE LICENSE IN LICENSE", "dependencies": { diff --git a/src/server/plugins/engine/form-context.ts b/src/server/plugins/engine/form-context.ts index 5b66dbc94..f4d47c929 100644 --- a/src/server/plugins/engine/form-context.ts +++ b/src/server/plugins/engine/form-context.ts @@ -121,7 +121,9 @@ export async function getFormContext( $$__referenceNumber: cachedState.$$__referenceNumber } as unknown as FormSubmissionState - const translator = formModel.createTranslator(options.language ?? 'en-GB') + const language = + (yar.get('language') as string | undefined) ?? options.language ?? 'en-GB' + const translator = formModel.createTranslator(language) return formModel.getFormContext( summaryRequest, @@ -153,7 +155,7 @@ export async function resolveFormModel( } // The models cache is created lazily per server instance - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + server.app.models ??= new Map() const cache = server.app.models diff --git a/src/server/plugins/engine/i18n/translations/cy.json b/src/server/plugins/engine/i18n/translations/cy.json index d6feb4164..e61928c61 100644 --- a/src/server/plugins/engine/i18n/translations/cy.json +++ b/src/server/plugins/engine/i18n/translations/cy.json @@ -59,7 +59,9 @@ "dateOfPayment": "Dyddiad talu", "submissionFailed": "Roedd problem ac ni chyflwynwyd eich ffurflen. Ceisiwch gyflwyno'r ffurflen eto", "submissionFailedContactSuffix": " neu gallwch [[contactUsLink]] a dyfynnu'ch cyfeirnod i drefnu ad-daliad", - "contactUsLinkText": "cysylltu â ni (yn agor mewn tab newydd)" + "contactUsLinkText": "cysylltu â ni (yn agor mewn tab newydd)", + "confirmationEmail": "welsh confirmation email (optional)", + "confirmationEmailHint": "welsh Enter your email address to receive a confirmation email and a copy of your answers" }, "repeater": { diff --git a/src/server/plugins/engine/i18n/translations/en-GB.json b/src/server/plugins/engine/i18n/translations/en-GB.json index b23c78583..937408ffb 100644 --- a/src/server/plugins/engine/i18n/translations/en-GB.json +++ b/src/server/plugins/engine/i18n/translations/en-GB.json @@ -59,7 +59,9 @@ "dateOfPayment": "Date of payment", "submissionFailed": "There was a problem and your form was not submitted. Try submitting the form again", "submissionFailedContactSuffix": " or you can [[contactUsLink]] and quote your reference number to arrange a refund", - "contactUsLinkText": "contact us (opens in new tab)" + "contactUsLinkText": "contact us (opens in new tab)", + "confirmationEmail": "Confirmation email (optional)", + "confirmationEmailHint": "Enter your email address to receive a confirmation email and a copy of your answers" }, "repeater": { From fc6e221becde6c26834af5e284544082c280b6f6 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Thu, 25 Jun 2026 09:02:19 +0100 Subject: [PATCH 77/89] Fixed types --- src/server/plugins/engine/configureEnginePlugin.ts | 7 +++++-- src/server/plugins/engine/types.ts | 2 +- src/server/types.ts | 2 ++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/server/plugins/engine/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts index 47c1a1898..5a7011cc5 100644 --- a/src/server/plugins/engine/configureEnginePlugin.ts +++ b/src/server/plugins/engine/configureEnginePlugin.ts @@ -7,7 +7,10 @@ import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { plugin } from '~/src/server/plugins/engine/plugin.js' import * as defaultServices from '~/src/server/plugins/engine/services/index.js' import { formsService } from '~/src/server/plugins/engine/services/localFormsService.js' -import { type PluginOptions } from '~/src/server/plugins/engine/types.js' +import { + type AnyFormRequest, + type PluginOptions +} from '~/src/server/plugins/engine/types.js' import { findPackageRoot } from '~/src/server/plugins/engine/vision.js' import { devtoolContext } from '~/src/server/plugins/nunjucks/context.js' import { type CacheService } from '~/src/server/services/cacheService.js' @@ -68,7 +71,7 @@ export const configureEnginePlugin = async ( saveAndExit, ordnanceSurveyApiKey, ordnanceSurveyApiSecret, - getLanguage: (request) => { + getLanguage: (request: AnyFormRequest) => { if ('language' in request.query) { request.yar.set('language', request.query.language) } diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 41ef19d09..d0fde654c 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -511,7 +511,7 @@ export interface PluginOptions { viewContext: PluginProperties['forms-engine-plugin']['viewContext'] preparePageEventRequestOptions?: PreparePageEventRequestOptions onRequest?: OnRequestCallback - getLanguage?: (request: AnyFormRequest) => string | undefined + getLanguage?: (request: AnyFormRequest) => string baseUrl: string // base URL of the application, protocol and hostname e.g. "https://myapp.com" ordnanceSurveyApiKey?: string ordnanceSurveyApiSecret?: string diff --git a/src/server/types.ts b/src/server/types.ts index c51ba9390..7e68fe5ee 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -10,6 +10,7 @@ import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { type DetailItem } from '~/src/server/plugins/engine/models/types.js' import { type PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js' import { + type AnyFormRequest, type FormContext, type OnRequestCallback, type PluginOptions, @@ -59,6 +60,7 @@ export interface RouteConfig { cacheServiceCreator?: (server: Server) => CacheService ordnanceSurveyApiKey?: string ordnanceSurveyApiSecret?: string + getLanguage?: (request: AnyFormRequest) => string } export interface OutputService { From 555ba0213b4ef659931dbc8f814aa8b75d8962a6 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Thu, 25 Jun 2026 11:23:47 +0100 Subject: [PATCH 78/89] Temp stash with only 3 test suites failing --- scripts/page-preview-fixtures.js | 30 +++- .../components/AutocompleteField.test.ts | 8 +- .../engine/components/CheckboxesField.ts | 6 +- .../engine/components/DatePartsField.test.ts | 8 +- .../components/DeclarationField.test.ts | 8 +- .../components/EastingNorthingField.test.ts | 10 +- .../components/EmailAddressField.test.ts | 8 +- .../engine/components/GeospatialField.test.ts | 55 ++++--- .../engine/components/GeospatialField.ts | 24 +-- .../engine/components/HiddenField.test.ts | 8 +- .../engine/components/LatLongField.test.ts | 10 +- .../engine/components/MonthYearField.test.ts | 8 +- .../components/MultilineTextField.test.ts | 12 +- .../NationalGridFieldNumberField.test.ts | 8 +- .../engine/components/NumberField.test.ts | 8 +- .../engine/components/OsGridRefField.test.ts | 8 +- .../engine/components/PaymentField.test.ts | 155 ++++++------------ .../plugins/engine/components/PaymentField.ts | 4 +- .../engine/components/RadiosField.test.ts | 8 +- .../engine/components/SelectField.test.ts | 8 +- .../components/TelephoneNumberField.test.ts | 8 +- .../engine/components/TextField.test.ts | 8 +- .../engine/components/UkAddressField.test.ts | 10 +- .../plugins/engine/form-context.test.ts | 2 +- src/server/plugins/engine/form-context.ts | 1 - .../plugins/engine/i18n/translations/cy.json | 3 +- .../outputFormatters/human/v1.payment.test.ts | 5 + .../engine/outputFormatters/human/v1.test.ts | 1 + .../machine/v2.payment.test.ts | 1 + .../outputFormatters/machine/v2.test.ts | 5 + .../QuestionPageController.test.ts | 18 +- .../SummaryPageController.test.ts | 15 +- src/server/utils/utils.js | 15 ++ 33 files changed, 284 insertions(+), 202 deletions(-) diff --git a/scripts/page-preview-fixtures.js b/scripts/page-preview-fixtures.js index bc4858d29..a4452f180 100644 --- a/scripts/page-preview-fixtures.js +++ b/scripts/page-preview-fixtures.js @@ -65,9 +65,14 @@ function pageViewContext({ server: { plugins: { 'forms-engine-plugin': {} } } }) ) + const translator = model.createTranslator() return getViewModelOverride ? getViewModelOverride(controller, model, mockRequest, mockContext) - : controller.getViewModel(/** @type {any} */ (mockRequest), mockContext) + : controller.getViewModel( + /** @type {any} */ (mockRequest), + mockContext, + translator + ) } const fileUploadWithFilesVariant = /** @type {any} */ ( @@ -192,13 +197,18 @@ export const pageFixtures = { ] } ], - getViewModelOverride: (ctrl, _model, req, ctx) => { + getViewModelOverride: (ctrl, model, req, ctx) => { const repeat = /** @type {RepeatPageController} */ (ctrl) - const vm = repeat.getListSummaryViewModel(req, ctx, [ - { itemId: '1', fullname: 'Sarah Phillips' }, - { itemId: '2', fullname: 'David Jones' }, - { itemId: '3', fullname: 'Emma Wilson' } - ]) + const vm = repeat.getListSummaryViewModel( + req, + ctx, + [ + { itemId: '1', fullname: 'Sarah Phillips' }, + { itemId: '2', fullname: 'David Jones' }, + { itemId: '3', fullname: 'Emma Wilson' } + ], + model.createTranslator() + ) return /** @type {PageViewModel} */ ( /** @type {unknown} */ ({ ...vm, @@ -300,10 +310,12 @@ export const pageFixtures = { ], renderPage: '/summary', state: { fullname: 'Sarah Phillips', email: 'sarah@example.gov.uk' }, - getViewModelOverride: (ctrl, _model, req, ctx) => { + getViewModelOverride: (ctrl, model, req, ctx) => { const summary = /** @type {SummaryPageController} */ (ctrl) return /** @type {PageViewModel} */ ( - /** @type {unknown} */ (summary.getSummaryViewModel(req, ctx)) + /** @type {unknown} */ ( + summary.getSummaryViewModel(req, ctx, model.createTranslator()) + ) ) } }) diff --git a/src/server/plugins/engine/components/AutocompleteField.test.ts b/src/server/plugins/engine/components/AutocompleteField.test.ts index ebd334307..f5bb5cac4 100644 --- a/src/server/plugins/engine/components/AutocompleteField.test.ts +++ b/src/server/plugins/engine/components/AutocompleteField.test.ts @@ -21,6 +21,10 @@ import { import definition from '~/test/form/definitions/blank.js' import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' +const translator = new FormModel(definition, { + basePath: '/' +}).createTranslator() + describe.each([ { component: { @@ -207,8 +211,8 @@ describe.each([ const state1 = getFormState(item.value) const state2 = getFormState(null) - const answer1 = getAnswer(field, state1) - const answer2 = getAnswer(field, state2) + const answer1 = getAnswer(field, state1, undefined, translator) + const answer2 = getAnswer(field, state2, undefined, translator) expect(answer1).toBe(item.text) expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/CheckboxesField.ts b/src/server/plugins/engine/components/CheckboxesField.ts index 9d9007304..f6bc84bdd 100644 --- a/src/server/plugins/engine/components/CheckboxesField.ts +++ b/src/server/plugins/engine/components/CheckboxesField.ts @@ -42,9 +42,9 @@ export class CheckboxesField extends SelectionControlField { .label(this.label) .required() .messages({ - 'array.min': messageTemplate.arrayMin as string, - 'array.max': messageTemplate.arrayMax as string, - 'array.length': messageTemplate.arrayLength as string + 'array.min': messageTemplate.arrayMin, + 'array.max': messageTemplate.arrayMax, + 'array.length': messageTemplate.arrayLength }) if (options.required === false) { diff --git a/src/server/plugins/engine/components/DatePartsField.test.ts b/src/server/plugins/engine/components/DatePartsField.test.ts index 346969af6..6794c4325 100644 --- a/src/server/plugins/engine/components/DatePartsField.test.ts +++ b/src/server/plugins/engine/components/DatePartsField.test.ts @@ -17,6 +17,10 @@ import { } from '~/src/server/plugins/engine/types.js' import definition from '~/test/form/definitions/blank.js' +const translator = new FormModel(definition, { + basePath: '/' +}).createTranslator() + describe('DatePartsField', () => { let model: FormModel @@ -283,8 +287,8 @@ describe('DatePartsField', () => { const state1 = getFormState(date) const state2 = getFormState({}) - const answer1 = getAnswer(field, state1) - const answer2 = getAnswer(field, state2) + const answer1 = getAnswer(field, state1, undefined, translator) + const answer2 = getAnswer(field, state2, undefined, translator) expect(answer1).toBe('31 December 2024') expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/DeclarationField.test.ts b/src/server/plugins/engine/components/DeclarationField.test.ts index bef098f42..1b4ad6823 100644 --- a/src/server/plugins/engine/components/DeclarationField.test.ts +++ b/src/server/plugins/engine/components/DeclarationField.test.ts @@ -16,6 +16,10 @@ import declarationWithGuidance from '~/test/form/definitions/declaration-with-gu import declarationWithoutGuidance from '~/test/form/definitions/declaration-without-guidance.js' import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' +const translator = new FormModel(definition, { + basePath: '/' +}).createTranslator() + describe('DeclarationField', () => { let model: FormModel @@ -185,8 +189,8 @@ describe('DeclarationField', () => { // context - boolean // state - boolean // string - I confirm that I understand and accept this declaration - const answer1 = getAnswer(field, state1) - const answer2 = getAnswer(field, state2) + const answer1 = getAnswer(field, state1, undefined, translator) + const answer2 = getAnswer(field, state2, undefined, translator) expect(answer1).toBe('I understand and agree') expect(answer2).toBe('Not provided') diff --git a/src/server/plugins/engine/components/EastingNorthingField.test.ts b/src/server/plugins/engine/components/EastingNorthingField.test.ts index dfbb94594..0bf3e167f 100644 --- a/src/server/plugins/engine/components/EastingNorthingField.test.ts +++ b/src/server/plugins/engine/components/EastingNorthingField.test.ts @@ -14,6 +14,10 @@ import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__st import { type FormSubmissionError } from '~/src/server/plugins/engine/types.js' import definition from '~/test/form/definitions/blank.js' +const translator = new FormModel(definition, { + basePath: '/' +}).createTranslator() + describe('EastingNorthingField', () => { let model: FormModel @@ -209,8 +213,8 @@ describe('EastingNorthingField', () => { }) const state2 = getFormState({}) - const answer1 = getAnswer(field, state1) - const answer2 = getAnswer(field, state2) + const answer1 = getAnswer(field, state1, undefined, translator) + const answer2 = getAnswer(field, state2, undefined, translator) expect(answer1).toBe('Easting: 12345
Northing: 1234567
') expect(answer2).toBe('') @@ -411,7 +415,7 @@ describe('EastingNorthingField', () => { } ] - const viewErrors = field.getViewErrors(errors) + const viewErrors = field.getViewErrors(translator, errors) // Should return all errors, not just the first one expect(viewErrors).toHaveLength(2) diff --git a/src/server/plugins/engine/components/EmailAddressField.test.ts b/src/server/plugins/engine/components/EmailAddressField.test.ts index fc24e2daf..5e1e2bb0a 100644 --- a/src/server/plugins/engine/components/EmailAddressField.test.ts +++ b/src/server/plugins/engine/components/EmailAddressField.test.ts @@ -13,6 +13,10 @@ import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__st import definition from '~/test/form/definitions/blank.js' import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' +const translator = new FormModel(definition, { + basePath: '/' +}).createTranslator() + describe('EmailAddressField', () => { let model: FormModel @@ -155,8 +159,8 @@ describe('EmailAddressField', () => { const state1 = getFormState('defra.helpline@defra.gov.uk') const state2 = getFormState(null) - const answer1 = getAnswer(field, state1) - const answer2 = getAnswer(field, state2) + const answer1 = getAnswer(field, state1, undefined, translator) + const answer2 = getAnswer(field, state2, undefined, translator) expect(answer1).toBe('defra.helpline@defra.gov.uk') expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/GeospatialField.test.ts b/src/server/plugins/engine/components/GeospatialField.test.ts index 7dc836b13..cbd7d9678 100644 --- a/src/server/plugins/engine/components/GeospatialField.test.ts +++ b/src/server/plugins/engine/components/GeospatialField.test.ts @@ -1,5 +1,6 @@ import { ComponentType, + type ComponentDef, type GeospatialFieldComponent } from '@defra/forms-model' @@ -19,6 +20,10 @@ import { type GeospatialState } from '~/src/server/plugins/engine/types.js' import definition from '~/test/form/definitions/blank.js' import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' +const translator = new FormModel(definition, { + basePath: '/' +}).createTranslator() + describe('GeospatialField', () => { let model: FormModel @@ -286,7 +291,7 @@ describe('GeospatialField', () => { value: getFormData([]), errors: [ expect.objectContaining({ - text: 'Define at least 1 features' + text: 'validation.features.min' // 'Define at least 1 features' }) ] } @@ -336,7 +341,7 @@ describe('GeospatialField', () => { value: getFormData([]), errors: [ expect.objectContaining({ - text: 'Define at least 2 features' + text: 'validation.features.min' // 'Define at least 2 features' }) ] } @@ -358,7 +363,7 @@ describe('GeospatialField', () => { value: getFormData(validSingleState), errors: [ expect.objectContaining({ - text: 'Define at least 2 features' + text: 'validation.features.min' // 'Define at least 2 features' }) ] } @@ -391,7 +396,7 @@ describe('GeospatialField', () => { value: getFormData([]), errors: [ expect.objectContaining({ - text: 'Define at least 1 features' + text: 'validation.features.min' // 'Define at least 1 features' }) ] } @@ -419,7 +424,7 @@ describe('GeospatialField', () => { value: getFormData(validState), errors: [ expect.objectContaining({ - text: 'Only 1 features can be defined' + text: 'validation.features.max' // 'Only 1 features can be defined' }) ] } @@ -446,7 +451,7 @@ describe('GeospatialField', () => { value: getFormData([]), errors: [ expect.objectContaining({ - text: 'Define exactly 1 features' + text: 'validation.features.length' // 'Define exactly 1 features' }) ] } @@ -474,7 +479,7 @@ describe('GeospatialField', () => { value: getFormData(validState), errors: [ expect.objectContaining({ - text: 'Define exactly 1 features' + text: 'validation.features.length' // 'Define exactly 1 features' }) ] } @@ -520,7 +525,7 @@ describe('GeospatialField', () => { value: getFormData([]), errors: [ expect.objectContaining({ - text: 'Define at least 2 features' + text: 'validation.features.min' // 'Define at least 2 features' }) ] } @@ -537,7 +542,7 @@ describe('GeospatialField', () => { value: getFormData(validSingleState), errors: [ expect.objectContaining({ - text: 'Define at least 2 features' + text: 'validation.features.min' // 'Define at least 2 features' }) ] } @@ -588,7 +593,7 @@ describe('GeospatialField', () => { value: getFormData(validState), errors: [ expect.objectContaining({ - text: 'Only 1 features can be defined' + text: 'validation.features.max' // 'Only 1 features can be defined' }) ] } @@ -615,7 +620,7 @@ describe('GeospatialField', () => { value: getFormData([]), errors: [ expect.objectContaining({ - text: 'Define exactly 1 features' + text: 'validation.features.length' // 'Define exactly 1 features' }) ] } @@ -638,7 +643,7 @@ describe('GeospatialField', () => { value: getFormData(validState), errors: [ expect.objectContaining({ - text: 'Define exactly 1 features' + text: 'validation.features.length' // Define exactly 1 features }) ] } @@ -691,7 +696,7 @@ describe('GeospatialField', () => { const result = collection.validate(getFormData(invalidSingleState)) const geospatialField = collection.components.at(0) as GeospatialField - const errors = geospatialField.getErrors(result.errors) + const errors = geospatialField.getErrors(translator, result.errors) expect(errors).toEqual([ expect.objectContaining({ name: 'description', @@ -729,15 +734,25 @@ describe('GeospatialField', () => { } ] + const mockT = jest.fn().mockReturnValue('translated country error') + const mockTranslator = { ...stubTranslator, t: mockT } + const result = collection.validate(getFormData(invalidSingleState)) const geospatialField = collection.components.at(0) as GeospatialField - const errors = geospatialField.getErrors(result.errors) - expect(errors).toEqual([ + const viewErrors = geospatialField.getViewErrors( + mockTranslator, + result.errors + ) + + expect(mockT).toHaveBeenCalledWith( + 'components.geospatialField.validation.wrongCountry', + { count: 1, country: 'Scotland' } + ) + expect(viewErrors).toEqual([ expect.objectContaining({ - name: 0, href: '#description_0', - text: 'Location 1 must be in Scotland' + text: 'translated country error' }) ]) }) @@ -748,7 +763,7 @@ describe('GeospatialField', () => { name: 'myComponent', type: ComponentType.GeospatialField, options: { required: true } - } + } as unknown as ComponentDef const collection = new ComponentCollection([component], { model }) const invalidSingleState: GeospatialState = [ @@ -774,8 +789,8 @@ describe('GeospatialField', () => { const geospatialField = collection.components.at(0) as GeospatialField const viewErrors = geospatialField.getViewErrors( - result.errors, - mockTranslator + mockTranslator, + result.errors ) expect(mockT).toHaveBeenCalledWith( diff --git a/src/server/plugins/engine/components/GeospatialField.ts b/src/server/plugins/engine/components/GeospatialField.ts index e0673a51d..c8a353b9b 100644 --- a/src/server/plugins/engine/components/GeospatialField.ts +++ b/src/server/plugins/engine/components/GeospatialField.ts @@ -32,13 +32,11 @@ export class GeospatialField extends FormComponent { const { options } = def - const formSchema = getGeospatialSchema(def) - .label(this.label) - .messages({ - 'array.min': messageTemplate.featuresMin as string, - 'array.max': messageTemplate.featuresMax as string, - 'array.length': messageTemplate.featuresLength as string - }) + const formSchema = getGeospatialSchema(def).label(this.label).messages({ + 'array.min': messageTemplate.featuresMin, + 'array.max': messageTemplate.featuresMax, + 'array.length': messageTemplate.featuresLength + }) this.formSchema = formSchema this.stateSchema = formSchema.default(null) @@ -114,15 +112,19 @@ export class GeospatialField extends FormComponent { ): FormSubmissionError[] | undefined { const fieldErrors = super.getErrors(translator, errors) - const t = translator.t - fieldErrors?.forEach((err) => { if (err.name === 'description') { err.href = `#description_${err.path[1]}` - err.text = `Enter description for location ${Number(err.path[1]) + 1}` + err.text = translator.t( + 'components.geospatialField.validation.descriptionRequired', + { count: Number(err.path[1]) + 1 } + ) } else if (typeof err.name === 'number' && err.context?.country) { err.href = `#description_${err.path[1]}` - err.text = `Location ${Number(err.path[1]) + 1} must be in ${err.context.country}` + err.text = translator.t( + 'components.geospatialField.validation.wrongCountry', + { count: Number(err.path[1]) + 1, country: err.context.country } + ) } }) diff --git a/src/server/plugins/engine/components/HiddenField.test.ts b/src/server/plugins/engine/components/HiddenField.test.ts index bbffe1894..5c354bff1 100644 --- a/src/server/plugins/engine/components/HiddenField.test.ts +++ b/src/server/plugins/engine/components/HiddenField.test.ts @@ -10,6 +10,10 @@ import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__st import definition from '~/test/form/definitions/blank.js' import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' +const translator = new FormModel(definition, { + basePath: '/' +}).createTranslator() + describe('HiddenField', () => { let model: FormModel @@ -111,8 +115,8 @@ describe('HiddenField', () => { const state1 = getFormState('Hidden field') const state2 = getFormState(null) - const answer1 = getAnswer(field, state1) - const answer2 = getAnswer(field, state2) + const answer1 = getAnswer(field, state1, undefined, translator) + const answer2 = getAnswer(field, state2, undefined, translator) expect(answer1).toBe('Hidden field') expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/LatLongField.test.ts b/src/server/plugins/engine/components/LatLongField.test.ts index 373a6cb5c..746851e30 100644 --- a/src/server/plugins/engine/components/LatLongField.test.ts +++ b/src/server/plugins/engine/components/LatLongField.test.ts @@ -11,6 +11,10 @@ import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__st import { type FormSubmissionError } from '~/src/server/plugins/engine/types.js' import definition from '~/test/form/definitions/blank.js' +const translator = new FormModel(definition, { + basePath: '/' +}).createTranslator() + describe('LatLongField', () => { let model: FormModel @@ -198,8 +202,8 @@ describe('LatLongField', () => { }) const state2 = getFormState({}) - const answer1 = getAnswer(field, state1) - const answer2 = getAnswer(field, state2) + const answer1 = getAnswer(field, state1, undefined, translator) + const answer2 = getAnswer(field, state2, undefined, translator) expect(answer1).toBe('Latitude: 51.51945
Longitude: -0.127758
') expect(answer2).toBe('') @@ -400,7 +404,7 @@ describe('LatLongField', () => { } ] - const viewErrors = field.getViewErrors(errors) + const viewErrors = field.getViewErrors(translator, errors) expect(viewErrors).toHaveLength(2) expect(viewErrors).toEqual([ diff --git a/src/server/plugins/engine/components/MonthYearField.test.ts b/src/server/plugins/engine/components/MonthYearField.test.ts index 7dcd9827a..0550f6f76 100644 --- a/src/server/plugins/engine/components/MonthYearField.test.ts +++ b/src/server/plugins/engine/components/MonthYearField.test.ts @@ -16,6 +16,10 @@ import { } from '~/src/server/plugins/engine/types.js' import definition from '~/test/form/definitions/blank.js' +const translator = new FormModel(definition, { + basePath: '/' +}).createTranslator() + describe('MonthYearField', () => { let model: FormModel @@ -259,8 +263,8 @@ describe('MonthYearField', () => { const state1 = getFormState(date) const state2 = getFormState({}) - const answer1 = getAnswer(field, state1) - const answer2 = getAnswer(field, state2) + const answer1 = getAnswer(field, state1, undefined, translator) + const answer2 = getAnswer(field, state2, undefined, translator) expect(answer1).toBe('December 2024') expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/MultilineTextField.test.ts b/src/server/plugins/engine/components/MultilineTextField.test.ts index fd9339f26..977a17043 100644 --- a/src/server/plugins/engine/components/MultilineTextField.test.ts +++ b/src/server/plugins/engine/components/MultilineTextField.test.ts @@ -14,6 +14,10 @@ import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__st import definition from '~/test/form/definitions/blank.js' import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' +const translator = new FormModel(definition, { + basePath: '/' +}).createTranslator() + describe('MultilineTextField', () => { let model: FormModel @@ -155,8 +159,8 @@ describe('MultilineTextField', () => { const state1 = getFormState('Textarea') const state2 = getFormState(null) - const answer1 = getAnswer(field, state1) - const answer2 = getAnswer(field, state2) + const answer1 = getAnswer(field, state1, undefined, translator) + const answer2 = getAnswer(field, state2, undefined, translator) expect(answer1).toBe('Textarea') expect(answer2).toBe('') @@ -166,8 +170,8 @@ describe('MultilineTextField', () => { const state1 = getFormState('Line 1\r\nLine 2\r\nLine 3') const state2 = getFormState('Line 1\r\n\r\nLine 2\r\n\r\n\r\nLine 3') - const answer1 = getAnswer(field, state1) - const answer2 = getAnswer(field, state2) + const answer1 = getAnswer(field, state1, undefined, translator) + const answer2 = getAnswer(field, state2, undefined, translator) expect(answer1).toBe('Line 1
Line 2
Line 3
') expect(answer2).toBe('Line 1
Line 2
Line 3
') diff --git a/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts b/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts index 6b10193b8..b31811efb 100644 --- a/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts +++ b/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts @@ -14,6 +14,10 @@ import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__st import definition from '~/test/form/definitions/blank.js' import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' +const translator = new FormModel(definition, { + basePath: '/' +}).createTranslator() + describe('NationalGridFieldNumberField', () => { let model: FormModel @@ -151,8 +155,8 @@ describe('NationalGridFieldNumberField', () => { const state1 = getFormState('NG12345678') const state2 = getFormState(null) - const answer1 = getAnswer(field, state1) - const answer2 = getAnswer(field, state2) + const answer1 = getAnswer(field, state1, undefined, translator) + const answer2 = getAnswer(field, state2, undefined, translator) expect(answer1).toBe('NG12345678') expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/NumberField.test.ts b/src/server/plugins/engine/components/NumberField.test.ts index c87b4ca9d..8955f60e9 100644 --- a/src/server/plugins/engine/components/NumberField.test.ts +++ b/src/server/plugins/engine/components/NumberField.test.ts @@ -11,6 +11,10 @@ import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__st import definition from '~/test/form/definitions/blank.js' import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' +const translator = new FormModel(definition, { + basePath: '/' +}).createTranslator() + describe('NumberField', () => { let model: FormModel @@ -156,8 +160,8 @@ describe('NumberField', () => { const state1 = getFormState(2024) const state2 = getFormState(null) - const answer1 = getAnswer(field, state1) - const answer2 = getAnswer(field, state2) + const answer1 = getAnswer(field, state1, undefined, translator) + const answer2 = getAnswer(field, state2, undefined, translator) expect(answer1).toBe('2024') expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/OsGridRefField.test.ts b/src/server/plugins/engine/components/OsGridRefField.test.ts index 8afcdd3e7..6bf9d4018 100644 --- a/src/server/plugins/engine/components/OsGridRefField.test.ts +++ b/src/server/plugins/engine/components/OsGridRefField.test.ts @@ -11,6 +11,10 @@ import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__st import definition from '~/test/form/definitions/blank.js' import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' +const translator = new FormModel(definition, { + basePath: '/' +}).createTranslator() + describe('OsGridRefField', () => { let model: FormModel @@ -167,8 +171,8 @@ describe('OsGridRefField', () => { const state1 = getFormState('TQ12345678') const state2 = getFormState(null) - const answer1 = getAnswer(field, state1) - const answer2 = getAnswer(field, state2) + const answer1 = getAnswer(field, state1, undefined, translator) + const answer2 = getAnswer(field, state2, undefined, translator) expect(answer1).toBe('TQ12345678') expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/PaymentField.test.ts b/src/server/plugins/engine/components/PaymentField.test.ts index 44df696c5..2f3372856 100644 --- a/src/server/plugins/engine/components/PaymentField.test.ts +++ b/src/server/plugins/engine/components/PaymentField.test.ts @@ -31,6 +31,26 @@ import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' jest.mock('~/src/server/services/httpService.ts') +const translator = new FormModel(definition, { + basePath: '/' +}).createTranslator() + +const mockRequestBase = { + server: { + plugins: { + // eslint-disable-next-line no-useless-computed-key + ['forms-engine-plugin']: { + baseUrl: 'base-url' + } + } + }, + query: {}, + yar: { + get: jest.fn(), + set: jest.fn() + } +} as unknown as FormRequestPayload + const mockServices = { formsService: { getFormSecret: () => 'secret-value' @@ -190,8 +210,8 @@ describe('PaymentField', () => { const state1 = getFormState(paymentForState as unknown as FormValue) const state2 = getFormState(null) - const answer1 = getAnswer(field, state1) - const answer2 = getAnswer(field, state2) + const answer1 = getAnswer(field, state1, undefined, translator) + const answer2 = getAnswer(field, state2, undefined, translator) expect(answer1).toBe('£150.00 - payment description') expect(answer2).toBe('') @@ -278,14 +298,7 @@ describe('PaymentField', () => { it('should create payment and redirect to gov pay', async () => { const mockYarSet = jest.fn() const mockRequest = { - server: { - plugins: { - // eslint-disable-next-line no-useless-computed-key - ['forms-engine-plugin']: { - baseUrl: 'base-url' - } - } - }, + ...mockRequestBase, yar: { set: mockYarSet } @@ -349,19 +362,7 @@ describe('PaymentField', () => { const mockH = { redirect: jest.fn().mockReturnValueOnce({ code: mockRedirectCode }) } as unknown as FormResponseToolkit - const mockRequest = { - server: { - plugins: { - // eslint-disable-next-line no-useless-computed-key - ['forms-engine-plugin']: { - baseUrl: 'base-url' - } - } - }, - yar: { - set: jest.fn() - } - } as unknown as FormRequestPayload + const mockRequest = mockRequestBase const args = { controller: { model: { @@ -404,14 +405,7 @@ describe('PaymentField', () => { const mockYarSet = jest.fn() const mockYarFlash = jest.fn() const mockRequest = { - server: { - plugins: { - // eslint-disable-next-line no-useless-computed-key - ['forms-engine-plugin']: { - baseUrl: 'base-url' - } - } - }, + ...mockRequestBase, yar: { set: mockYarSet, flash: mockYarFlash @@ -466,14 +460,7 @@ describe('PaymentField', () => { const mockYarSet = jest.fn() const mockYarFlash = jest.fn() const mockRequest = { - server: { - plugins: { - // eslint-disable-next-line no-useless-computed-key - ['forms-engine-plugin']: { - baseUrl: 'base-url' - } - } - }, + ...mockRequestBase, yar: { set: mockYarSet, flash: mockYarFlash @@ -527,9 +514,7 @@ describe('PaymentField', () => { describe('onSubmit', () => { it('should throw if missing state', async () => { - const mockRequest = { - server: { plugins: { 'forms-engine-plugin': {} } } - } as unknown as FormRequestPayload + const mockRequest = mockRequestBase const error = await paymentField .onSubmit( @@ -547,9 +532,7 @@ describe('PaymentField', () => { }) it('should ignore if our state says payment already captured', async () => { - const mockRequest = { - server: { plugins: { 'forms-engine-plugin': {} } } - } as unknown as FormRequestPayload + const mockRequest = mockRequestBase await paymentField.onSubmit( mockRequest, @@ -572,9 +555,7 @@ describe('PaymentField', () => { }) it('should mark payment already captured according to gov pay', async () => { - const mockRequest = { - server: { plugins: { 'forms-engine-plugin': {} } } - } as unknown as FormRequestPayload + const mockRequest = mockRequestBase jest .mocked(get) // @ts-expect-error - partial mock @@ -601,9 +582,7 @@ describe('PaymentField', () => { }) it('should throw if bad status', async () => { - const mockRequest = { - server: { plugins: { 'forms-engine-plugin': {} } } - } as unknown as FormRequestPayload + const mockRequest = mockRequestBase jest .mocked(get) // @ts-expect-error - partial mock @@ -636,9 +615,7 @@ describe('PaymentField', () => { }) it('should throw if error during capture', async () => { - const mockRequest = { - server: { plugins: { 'forms-engine-plugin': {} } } - } as unknown as FormRequestPayload + const mockRequest = mockRequestBase jest .mocked(get) // @ts-expect-error - partial mock @@ -673,9 +650,7 @@ describe('PaymentField', () => { }) it('should throw if amount mismatch', async () => { - const mockRequest = { - server: { plugins: { 'forms-engine-plugin': {} } } - } as unknown as FormRequestPayload + const mockRequest = mockRequestBase jest .mocked(get) // @ts-expect-error - partial mock @@ -710,9 +685,7 @@ describe('PaymentField', () => { }) it('should capture payment if no errors', async () => { - const mockRequest = { - server: { plugins: { 'forms-engine-plugin': {} } } - } as unknown as FormRequestPayload + const mockRequest = mockRequestBase jest .mocked(get) // @ts-expect-error - partial mock @@ -886,14 +859,7 @@ describe('PaymentField', () => { const mockH = { redirect: jest.fn().mockReturnValueOnce({ code: mockRedirectCode }) } as unknown as FormResponseToolkit - const mockRequest = { - server: { - plugins: { - 'forms-engine-plugin': { baseUrl: 'base-url' } - } - }, - yar: { set: jest.fn() } - } as unknown as FormRequestPayload + const mockRequest = mockRequestBase const args = { controller: { model: { @@ -902,7 +868,8 @@ describe('PaymentField', () => { services: mockServices, conditions: { 'cond-zero': { fn: () => true } - } + }, + createTranslator: (lang: string) => model.createTranslator(lang) }, getState: jest.fn().mockResolvedValueOnce({ $$__referenceNumber: 'ref-123' @@ -923,11 +890,7 @@ describe('PaymentField', () => { it('should use resolved amount when creating payment', async () => { const mockYarSet = jest.fn() const mockRequest = { - server: { - plugins: { - 'forms-engine-plugin': { baseUrl: 'base-url' } - } - }, + ...mockRequestBase, yar: { set: mockYarSet } } as unknown as FormRequestPayload const mockH = { @@ -952,7 +915,8 @@ describe('PaymentField', () => { services: mockServices, conditions: { 'cond-100': { fn: () => true } - } + }, + createTranslator: (lang: string) => model.createTranslator(lang) }, getState: jest.fn().mockResolvedValueOnce({ $$__referenceNumber: 'ref-123' @@ -993,14 +957,7 @@ describe('PaymentField', () => { } } satisfies PaymentFieldComponent - const mockRequest = { - server: { - plugins: { - 'forms-engine-plugin': { baseUrl: 'base-url' } - } - }, - yar: { set: jest.fn() } - } as unknown as FormRequestPayload + const mockRequest = mockRequestBase const mockH = { redirect: jest .fn() @@ -1012,7 +969,8 @@ describe('PaymentField', () => { formId: 'formid', basePath: 'base-path', services: mockServices, - conditions: {} + conditions: {}, + createTranslator: (lang: string) => model.createTranslator(lang) }, getState: jest.fn().mockResolvedValueOnce({ $$__referenceNumber: 'ref-123', @@ -1057,14 +1015,7 @@ describe('PaymentField', () => { } } satisfies PaymentFieldComponent - const mockRequest = { - server: { - plugins: { - 'forms-engine-plugin': { baseUrl: 'base-url' } - } - }, - yar: { set: jest.fn() } - } as unknown as FormRequestPayload + const mockRequest = mockRequestBase const mockH = { redirect: jest .fn() @@ -1076,7 +1027,8 @@ describe('PaymentField', () => { formId: 'formid', basePath: 'base-path', services: mockServices, - conditions: {} + conditions: {}, + createTranslator: (lang: string) => model.createTranslator(lang) }, getState: jest.fn().mockResolvedValueOnce({ $$__referenceNumber: 'ref-123' @@ -1120,14 +1072,7 @@ describe('PaymentField', () => { } } satisfies PaymentFieldComponent - const mockRequest = { - server: { - plugins: { - 'forms-engine-plugin': { baseUrl: 'base-url' } - } - }, - yar: { set: jest.fn() } - } as unknown as FormRequestPayload + const mockRequest = mockRequestBase const mockH = { redirect: jest .fn() @@ -1139,7 +1084,8 @@ describe('PaymentField', () => { formId: 'formid', basePath: 'base-path', services: mockServices, - conditions: {} + conditions: {}, + createTranslator: (lang: string) => model.createTranslator(lang) }, getState: jest.fn().mockResolvedValueOnce({ $$__referenceNumber: 'ref-123', @@ -1190,11 +1136,12 @@ describe('PaymentField', () => { const paymentField = collection.fields[0] as PaymentField paymentField.model = { services: mockServices, - conditions: {} + conditions: {}, + createTranslator: (lang: string) => model.createTranslator(lang) } as unknown as FormModel it('should return early when resolved amount is 0', async () => { - const mockRequest = {} as unknown as FormRequestPayload + const mockRequest = mockRequestBase await paymentField.onSubmit( mockRequest, diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts index 94bea30a8..8682aaf57 100644 --- a/src/server/plugins/engine/components/PaymentField.ts +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -39,6 +39,7 @@ import { createPaymentService, formatCurrency } from '~/src/server/plugins/payment/helper.js' +import { getLanguage } from '~/src/server/utils/utils.js' export class PaymentField extends FormComponent { declare options: PaymentFieldComponent['options'] @@ -330,8 +331,7 @@ export class PaymentField extends FormComponent { _metadata: FormMetadata, context: FormContext ): Promise { - const { getLanguage } = getPluginOptions(request.server) - const language = getLanguage?.(request) ?? 'en-GB' + const language = getLanguage(request) const { t } = this.model.createTranslator(language) // Zero-amount bypass — no capture needed diff --git a/src/server/plugins/engine/components/RadiosField.test.ts b/src/server/plugins/engine/components/RadiosField.test.ts index 59a31f0c2..127512842 100644 --- a/src/server/plugins/engine/components/RadiosField.test.ts +++ b/src/server/plugins/engine/components/RadiosField.test.ts @@ -18,6 +18,10 @@ import { import definition from '~/test/form/definitions/blank.js' import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' +const translator = new FormModel(definition, { + basePath: '/' +}).createTranslator() + describe.each([ { component: { @@ -175,8 +179,8 @@ describe.each([ const state1 = getFormState(item.value) const state2 = getFormState(null) - const answer1 = getAnswer(field, state1) - const answer2 = getAnswer(field, state2) + const answer1 = getAnswer(field, state1, undefined, translator) + const answer2 = getAnswer(field, state2, undefined, translator) expect(answer1).toBe(item.text) expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/SelectField.test.ts b/src/server/plugins/engine/components/SelectField.test.ts index d668ef66a..40b6b89ca 100644 --- a/src/server/plugins/engine/components/SelectField.test.ts +++ b/src/server/plugins/engine/components/SelectField.test.ts @@ -18,6 +18,10 @@ import { import definition from '~/test/form/definitions/blank.js' import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' +const translator = new FormModel(definition, { + basePath: '/' +}).createTranslator() + describe.each([ { component: { @@ -176,8 +180,8 @@ describe.each([ const state1 = getFormState(item.value) const state2 = getFormState(null) - const answer1 = getAnswer(field, state1) - const answer2 = getAnswer(field, state2) + const answer1 = getAnswer(field, state1, undefined, translator) + const answer2 = getAnswer(field, state2, undefined, translator) expect(answer1).toBe(item.text) expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/TelephoneNumberField.test.ts b/src/server/plugins/engine/components/TelephoneNumberField.test.ts index 3e77c7fb5..4a0b4ceb9 100644 --- a/src/server/plugins/engine/components/TelephoneNumberField.test.ts +++ b/src/server/plugins/engine/components/TelephoneNumberField.test.ts @@ -15,6 +15,10 @@ import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__st import definition from '~/test/form/definitions/blank.js' import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' +const translator = new FormModel(definition, { + basePath: '/' +}).createTranslator() + describe('TelephoneNumberField', () => { let model: FormModel @@ -168,8 +172,8 @@ describe('TelephoneNumberField', () => { const state1 = getFormState('+447900000000') const state2 = getFormState(null) - const answer1 = getAnswer(field, state1) - const answer2 = getAnswer(field, state2) + const answer1 = getAnswer(field, state1, undefined, translator) + const answer2 = getAnswer(field, state2, undefined, translator) expect(answer1).toBe('+447900000000') expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/TextField.test.ts b/src/server/plugins/engine/components/TextField.test.ts index fcdf54296..3d35a4f10 100644 --- a/src/server/plugins/engine/components/TextField.test.ts +++ b/src/server/plugins/engine/components/TextField.test.ts @@ -10,6 +10,10 @@ import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__st import definition from '~/test/form/definitions/blank.js' import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' +const translator = new FormModel(definition, { + basePath: '/' +}).createTranslator() + describe('TextField', () => { let model: FormModel @@ -180,8 +184,8 @@ describe('TextField', () => { const state1 = getFormState('Text field') const state2 = getFormState(null) - const answer1 = getAnswer(field, state1) - const answer2 = getAnswer(field, state2) + const answer1 = getAnswer(field, state1, undefined, translator) + const answer2 = getAnswer(field, state2, undefined, translator) expect(answer1).toBe('Text field') expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/UkAddressField.test.ts b/src/server/plugins/engine/components/UkAddressField.test.ts index ed6cbad19..2e128749f 100644 --- a/src/server/plugins/engine/components/UkAddressField.test.ts +++ b/src/server/plugins/engine/components/UkAddressField.test.ts @@ -19,6 +19,10 @@ import { } from '~/src/server/plugins/engine/types.js' import definition from '~/test/form/definitions/blank.js' +const translator = new FormModel(definition, { + basePath: '/' +}).createTranslator() + describe('UkAddressField', () => { let model: FormModel @@ -351,8 +355,8 @@ describe('UkAddressField', () => { const state1 = getFormState(address) const state2 = getFormState({}) - const answer1 = getAnswer(field, state1) - const answer2 = getAnswer(field, state2) + const answer1 = getAnswer(field, state1, undefined, translator) + const answer2 = getAnswer(field, state2, undefined, translator) expect(answer1).toBe( 'Richard Fairclough House
Knutsford Road
Warrington
Cheshire
WA4 1HT
' @@ -793,7 +797,7 @@ describe('UkAddressField', () => { const result = collection.validate(input) expect(result).toEqual(output) - const errors = collection.getErrors(result.errors) + const errors = collection.getErrors(translator, result.errors) expect(errors).toEqual(output.errors) } ) diff --git a/src/server/plugins/engine/form-context.test.ts b/src/server/plugins/engine/form-context.test.ts index 092f0b0ca..79428d14f 100644 --- a/src/server/plugins/engine/form-context.test.ts +++ b/src/server/plugins/engine/form-context.test.ts @@ -68,7 +68,7 @@ const { describe('getFormContext helper', () => { const request = { - yar: { set: jest.fn() } as unknown as Request['yar'], + yar: { set: jest.fn(), get: jest.fn() } as unknown as Request['yar'], server: { app: {}, realm: { modifiers: { route: { prefix: '' } } } diff --git a/src/server/plugins/engine/form-context.ts b/src/server/plugins/engine/form-context.ts index f4d47c929..a451f2701 100644 --- a/src/server/plugins/engine/form-context.ts +++ b/src/server/plugins/engine/form-context.ts @@ -155,7 +155,6 @@ export async function resolveFormModel( } // The models cache is created lazily per server instance - server.app.models ??= new Map() const cache = server.app.models diff --git a/src/server/plugins/engine/i18n/translations/cy.json b/src/server/plugins/engine/i18n/translations/cy.json index e61928c61..adc0573da 100644 --- a/src/server/plugins/engine/i18n/translations/cy.json +++ b/src/server/plugins/engine/i18n/translations/cy.json @@ -232,7 +232,8 @@ "added_one": "Ychwanegwyd [[count]] lleoliad", "added_other": "Ychwanegwyd [[count]] lleoliad", "validation": { - "descriptionRequired": "Rhowch ddisgrifiad ar gyfer lleoliad [[count]]" + "descriptionRequired": "Rhowch ddisgrifiad ar gyfer lleoliad [[count]]", + "wrongCountry": "welsh Location [[count]] must be in [[country]]" } }, diff --git a/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts b/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts index 3d2118762..a714c4825 100644 --- a/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts +++ b/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts @@ -14,6 +14,10 @@ import { type FormSubmissionState } from '~/src/server/plugins/engine/types.js' import { FormStatus } from '~/src/server/routes/types.js' import definitionPayment from '~/test/form/definitions/payment.js' +const translator = new FormModel(definitionPayment, { + basePath: '/' +}).createTranslator() + describe('v1 human formatter', () => { describe('Payment', () => { const modelPayment = new FormModel(definitionPayment, { @@ -85,6 +89,7 @@ describe('v1 human formatter', () => { const itemsPayment = getFormSubmissionData( summaryViewModelPayment.context, summaryViewModelPayment.details, + translator, modelPayment ) diff --git a/src/server/plugins/engine/outputFormatters/human/v1.test.ts b/src/server/plugins/engine/outputFormatters/human/v1.test.ts index 303b04df1..b4ffbb8ef 100644 --- a/src/server/plugins/engine/outputFormatters/human/v1.test.ts +++ b/src/server/plugins/engine/outputFormatters/human/v1.test.ts @@ -76,6 +76,7 @@ describe('v1 human formatter', () => { const items = getFormSubmissionData( summaryViewModel.context, summaryViewModel.details, + stubTranslator, model ) diff --git a/src/server/plugins/engine/outputFormatters/machine/v2.payment.test.ts b/src/server/plugins/engine/outputFormatters/machine/v2.payment.test.ts index 63c23395c..658584511 100644 --- a/src/server/plugins/engine/outputFormatters/machine/v2.payment.test.ts +++ b/src/server/plugins/engine/outputFormatters/machine/v2.payment.test.ts @@ -85,6 +85,7 @@ const summaryViewModel = controller.getSummaryViewModel( const items = getFormSubmissionData( summaryViewModel.context, summaryViewModel.details, + stubTranslator, model ) diff --git a/src/server/plugins/engine/outputFormatters/machine/v2.test.ts b/src/server/plugins/engine/outputFormatters/machine/v2.test.ts index 416ffcdc9..5320de027 100644 --- a/src/server/plugins/engine/outputFormatters/machine/v2.test.ts +++ b/src/server/plugins/engine/outputFormatters/machine/v2.test.ts @@ -23,6 +23,10 @@ import { import { FormStatus } from '~/src/server/routes/types.js' import definition from '~/test/form/definitions/repeat-mixed.js' +const translator = new FormModel(definition, { + basePath: '/' +}).createTranslator() + const submitResponse = { message: 'Submit completed', result: { @@ -286,6 +290,7 @@ describe('getPersonalisation', () => { const items = getFormSubmissionData( summaryViewModel.context, summaryViewModel.details, + translator, model ) diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts index 479f1a433..96928cb6e 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts @@ -32,6 +32,10 @@ import definitionConditionsComplex from '~/test/form/definitions/conditions-comp import definitionConditionsDates from '~/test/form/definitions/conditions-dates.js' import definitionPaymentV2Conditional from '~/test/form/definitions/payment-v2-conditional.js' +const translator = new FormModel(definitionConditionsBasic, { + basePath: '/' +}).createTranslator() + describe('QuestionPageController', () => { let page1: PageQuestion let page1Url: URL @@ -1913,7 +1917,7 @@ describe('QuestionPageController translator support', () => { yesNoField: false }) - const viewModel = paymentPage.getViewModel(request, context) + const viewModel = paymentPage.getViewModel(request, context, translator) const paymentComp = viewModel.components.find( (c) => 'amount' in c.model && 'paymentState' in c.model ) @@ -1930,7 +1934,7 @@ describe('QuestionPageController translator support', () => { yesNoField: true }) - const viewModel = paymentPage.getViewModel(request, context) + const viewModel = paymentPage.getViewModel(request, context, translator) const paymentComp = viewModel.components.find( (c) => 'amount' in c.model && 'paymentState' in c.model ) @@ -1945,7 +1949,7 @@ describe('QuestionPageController translator support', () => { $$__referenceNumber: 'foobar' }) - const viewModel = paymentPage.getViewModel(request, context) + const viewModel = paymentPage.getViewModel(request, context, translator) const paymentComp = viewModel.components.find( (c) => 'amount' in c.model && 'paymentState' in c.model ) @@ -1960,7 +1964,7 @@ describe('QuestionPageController translator support', () => { yesNoField: false }) - const viewModel = paymentPage.getViewModel(request, context) + const viewModel = paymentPage.getViewModel(request, context, translator) expect(viewModel).toHaveProperty('allowSaveAndExit', false) }) @@ -1985,7 +1989,7 @@ describe('QuestionPageController translator support', () => { // rather than isPaymentPage for this page. jest.spyOn(choicePage, 'shouldShowSaveAndExit').mockReturnValue(true) - const viewModel = choicePage.getViewModel(request, context) + const viewModel = choicePage.getViewModel(request, context, translator) expect(viewModel).toHaveProperty('allowSaveAndExit', true) }) @@ -2013,7 +2017,7 @@ describe('QuestionPageController translator support', () => { yesNoField: false }) - const viewModel = paymentPage.getViewModel(request, context) + const viewModel = paymentPage.getViewModel(request, context, translator) expect(viewModel).toHaveProperty('showSubmitButton', false) }) @@ -2039,7 +2043,7 @@ describe('QuestionPageController translator support', () => { } } as unknown as FormSubmissionState) - const viewModel = paymentPage.getViewModel(request, context) + const viewModel = paymentPage.getViewModel(request, context, translator) expect(viewModel).toHaveProperty('showSubmitButton', true) }) diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts index 803e34707..98c8b66f1 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts @@ -15,6 +15,10 @@ import { type CacheService } from '~/src/server/services/cacheService.js' import definition from '~/test/form/definitions/basic.js' import definitionPaymentV2Conditional from '~/test/form/definitions/payment-v2-conditional.js' +const translator = new FormModel(definition, { + basePath: '/' +}).createTranslator() + describe('SummaryPageController', () => { let model: FormModel let controller: SummaryPageController @@ -420,7 +424,7 @@ describe('SummaryPageController - Payment (DF-832)', () => { const request = { ...requestPage, params: { ...requestPage.params, path: 'summary' }, - yar: { id: 'session-id' }, + yar: { id: 'session-id', set: jest.fn(), get: jest.fn() }, logger: { info: jest.fn(), error: jest.fn() } } as unknown as FormRequestPayload @@ -458,7 +462,8 @@ describe('SummaryPageController - Payment (DF-832)', () => { request, viewModel, model, - 'notify@example.com' + 'notify@example.com', + translator ) ).rejects.toMatchObject({ name: 'PaymentSubmissionError' @@ -479,7 +484,8 @@ describe('SummaryPageController - Payment (DF-832)', () => { request, viewModel, model, - 'notify@example.com' + 'notify@example.com', + translator ) ).rejects.toBe(err) }) @@ -499,7 +505,8 @@ describe('SummaryPageController - Payment (DF-832)', () => { request, viewModel, model, - 'notify@example.com' + 'notify@example.com', + translator ) // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const paymentCall = formSubmissionSubmit.mock.calls[0][0] diff --git a/src/server/utils/utils.js b/src/server/utils/utils.js index a4f9d44c9..3d0f77904 100644 --- a/src/server/utils/utils.js +++ b/src/server/utils/utils.js @@ -33,3 +33,18 @@ export function isValidUUID(str) { const { error } = Joi.string().uuid().validate(str) return error === undefined } + +/** + * @param {AnyFormRequest} request + */ +export function getLanguage(request) { + if ('language' in request.query) { + request.yar.set('language', request.query.language) + } + + return /** @type {string} */ (request.yar.get('language') ?? 'en-GB') +} + +/** + * @import { AnyFormRequest } from '~/src/server/plugins/engine/types.js' + */ From 56e9023c11b4cd2f331d9cd8cbaad8de452ac46d Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Thu, 25 Jun 2026 11:56:30 +0100 Subject: [PATCH 79/89] Further test fixes --- .../components/helpers/components.test.ts | 25 +++++++++++-- .../plugins/engine/models/FormModel.test.ts | 15 -------- .../helpers/submission.test.ts | 35 +++++++++++++------ .../plugins/nunjucks/filters/answer.test.js | 13 +++++-- 4 files changed, 57 insertions(+), 31 deletions(-) diff --git a/src/server/plugins/engine/components/helpers/components.test.ts b/src/server/plugins/engine/components/helpers/components.test.ts index 18f5d65cf..76a7a7055 100644 --- a/src/server/plugins/engine/components/helpers/components.test.ts +++ b/src/server/plugins/engine/components/helpers/components.test.ts @@ -25,6 +25,10 @@ import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import definition from '~/test/form/definitions/blank.js' +const translator = new FormModel(definition, { + basePath: '/' +}).createTranslator() + describe('Location field formatting', () => { let model: FormModel @@ -445,7 +449,12 @@ describe('Location field formatting', () => { locationEN__northing: 654321 } - const answer = getAnswerMarkdown(field, state, { format: 'email' }) + const answer = getAnswerMarkdown( + field, + state, + { format: 'email' }, + translator + ) expect(answer).toBe('Easting: 123456\nNorthing: 654321\n') }) @@ -462,7 +471,12 @@ describe('Location field formatting', () => { locationLL__longitude: -0.127758 } - const answer = getAnswerMarkdown(field, state, { format: 'email' }) + const answer = getAnswerMarkdown( + field, + state, + { format: 'email' }, + translator + ) expect(answer).toBe('Latitude: 51.51945\nLongitude: -0.127758\n') }) @@ -478,7 +492,12 @@ describe('Location field formatting', () => { gridRef: 'TQ123456' } - const answer = getAnswerMarkdown(field, state, { format: 'email' }) + const answer = getAnswerMarkdown( + field, + state, + { format: 'email' }, + translator + ) expect(answer).toBe('TQ123456\n') }) }) diff --git a/src/server/plugins/engine/models/FormModel.test.ts b/src/server/plugins/engine/models/FormModel.test.ts index 2c302f45d..6271ec3e6 100644 --- a/src/server/plugins/engine/models/FormModel.test.ts +++ b/src/server/plugins/engine/models/FormModel.test.ts @@ -145,21 +145,6 @@ describe('FormModel', () => { expect(model.schemaVersion).toBe(SchemaVersion.V1) }) - it('sets versionNumber from options', () => { - const model = new FormModel(definition, { - basePath: 'test', - versionNumber: 42 - }) - - expect(model.versionNumber).toBe(42) - }) - - it('sets versionNumber to undefined when not provided', () => { - const model = new FormModel(definition, { basePath: 'test' }) - - expect(model.versionNumber).toBeUndefined() - }) - it('creates translator for en-GB', () => { const model = new FormModel(definition, { basePath: 'test' }) const { t } = model.createTranslator('en-GB') diff --git a/src/server/plugins/engine/pageControllers/helpers/submission.test.ts b/src/server/plugins/engine/pageControllers/helpers/submission.test.ts index d72ef6258..355db49c8 100644 --- a/src/server/plugins/engine/pageControllers/helpers/submission.test.ts +++ b/src/server/plugins/engine/pageControllers/helpers/submission.test.ts @@ -2,6 +2,7 @@ import { GeospatialField } from '~/src/server/plugins/engine/components/Geospati import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js' import { TextField } from '~/src/server/plugins/engine/components/TextField.js' import { validSingleState } from '~/src/server/plugins/engine/components/helpers/__stubs__/geospatial.js' +import { FormModel } from '~/src/server/plugins/engine/models/index.js' import { type DetailItemField } from '~/src/server/plugins/engine/models/types.js' import { buildMainRecords, @@ -9,6 +10,11 @@ import { buildRepeaterRecords } from '~/src/server/plugins/engine/pageControllers/helpers/submission.js' import { type FormSubmissionState } from '~/src/server/plugins/engine/types.js' +import { definition } from '~/test/fixtures/form.js' + +const translator = new FormModel(definition, { + basePath: '/' +}).createTranslator() describe('Submission helpers', () => { describe('buildPaymentRecords', () => { @@ -123,7 +129,7 @@ describe('Submission helpers', () => { describe('buildMainRecords', () => { it('should return empty array for empty items', () => { - const result = buildMainRecords([]) + const result = buildMainRecords([], translator) expect(result).toEqual([]) }) @@ -145,7 +151,7 @@ describe('Submission helpers', () => { } ] as unknown as DetailItemField[] - const result = buildMainRecords(items) + const result = buildMainRecords(items, translator) expect(result).toHaveLength(1) expect(result[0]).toEqual({ @@ -183,7 +189,7 @@ describe('Submission helpers', () => { } ] as unknown as DetailItemField[] - const result = buildMainRecords(items) + const result = buildMainRecords(items, translator) expect(result).toHaveLength(4) expect(result.map((r) => r.name)).toEqual([ @@ -233,7 +239,7 @@ describe('Submission helpers', () => { } ] as unknown as DetailItemField[] - const result = buildMainRecords(items) + const result = buildMainRecords(items, translator) // 1 regular field + 4 payment fields = 5 records expect(result).toHaveLength(5) @@ -248,9 +254,10 @@ describe('Submission helpers', () => { subItems: [[]] } - const result = buildMainRecords([ - repeaterItem as unknown as DetailItemField - ]) + const result = buildMainRecords( + [repeaterItem as unknown as DetailItemField], + translator + ) expect(result).toEqual([]) }) @@ -272,7 +279,7 @@ describe('Submission helpers', () => { } ] as unknown as DetailItemField[] - const result = buildMainRecords(items) + const result = buildMainRecords(items, translator) expect(result).toHaveLength(1) expect(result).toEqual([ @@ -299,7 +306,10 @@ describe('Submission helpers', () => { } ] - const result = buildRepeaterRecords(items as unknown as DetailItemField[]) + const result = buildRepeaterRecords( + items as unknown as DetailItemField[], + translator + ) expect(result).toEqual([]) }) @@ -330,7 +340,10 @@ describe('Submission helpers', () => { } ] - const result = buildRepeaterRecords(items as unknown as DetailItemField[]) + const result = buildRepeaterRecords( + items as unknown as DetailItemField[], + translator + ) expect(result).toHaveLength(1) expect(result[0].name).toBe('addresses') @@ -363,7 +376,7 @@ describe('Submission helpers', () => { } ] as unknown as DetailItemField[] - const result = buildRepeaterRecords(items) + const result = buildRepeaterRecords(items, translator) expect(result).toHaveLength(1) expect(result).toEqual([ diff --git a/src/server/plugins/nunjucks/filters/answer.test.js b/src/server/plugins/nunjucks/filters/answer.test.js index 603e8024a..bf53ea62d 100644 --- a/src/server/plugins/nunjucks/filters/answer.test.js +++ b/src/server/plugins/nunjucks/filters/answer.test.js @@ -68,7 +68,14 @@ describe('answer Nunjucks filter', () => { it('returns the answer', () => { const mockFormComponent = { isFormComponent: true, - someProperty: 'value' + someProperty: 'value', + model: { + createTranslator: jest.fn().mockImplementation(() => { + return { + t: jest.fn().mockReturnValue('translated text') + } + }) + } } mockThis.ctx.context?.componentMap.set( 'validFormComponent', @@ -80,7 +87,9 @@ describe('answer Nunjucks filter', () => { expect(getAnswer).toHaveBeenCalledWith( mockFormComponent, - mockThis.ctx.context?.relevantState + mockThis.ctx.context?.relevantState, + { format: 'summary' }, + { t: expect.anything() } ) expect(result).toBe('test answer') }) From e5fcfc1d2aafa37afd2823f7ae914c4c7879448b Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Fri, 26 Jun 2026 10:38:28 +0100 Subject: [PATCH 80/89] Consolidated language methods --- .../plugins/engine/components/FileUploadField.ts | 5 ++--- .../plugins/engine/components/PaymentField.ts | 10 ++++------ .../plugins/engine/components/UkAddressField.ts | 6 ++---- src/server/plugins/engine/configureEnginePlugin.ts | 7 ++++--- .../engine/pageControllers/StatusPageController.ts | 9 +++------ src/server/plugins/engine/routes/index.ts | 5 ++--- src/server/plugins/engine/routes/questions.ts | 5 ++--- src/server/plugins/engine/types.ts | 3 ++- src/server/types.ts | 2 +- src/server/utils/utils.js | 14 ++++++++------ 10 files changed, 30 insertions(+), 36 deletions(-) diff --git a/src/server/plugins/engine/components/FileUploadField.ts b/src/server/plugins/engine/components/FileUploadField.ts index 57ca58592..e982bb2ed 100644 --- a/src/server/plugins/engine/components/FileUploadField.ts +++ b/src/server/plugins/engine/components/FileUploadField.ts @@ -10,7 +10,6 @@ import { isUploadState } from '~/src/server/plugins/engine/components/FormComponent.js' import { type RenderContext } from '~/src/server/plugins/engine/components/types.js' -import { getPluginOptions } from '~/src/server/plugins/engine/helpers.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { InvalidComponentStateError } from '~/src/server/plugins/engine/pageControllers/errors.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' @@ -34,6 +33,7 @@ import { } from '~/src/server/plugins/engine/types.js' import { render } from '~/src/server/plugins/nunjucks/index.js' import { type FormRequestPayload } from '~/src/server/routes/types.js' +import { resolveLanguage } from '~/src/server/utils/utils.js' export const uploadIdSchema = joi.string().uuid().required() @@ -312,8 +312,7 @@ export class FileUploadField extends FormComponent { metadata: FormMetadata, context: FormContext ) { - const { getLanguage } = getPluginOptions(request.server) - const language = getLanguage?.(request) ?? 'en-GB' + const language = resolveLanguage(request, metadata) const { t } = this.model.createTranslator(language) const notificationEmail = metadata.notificationEmail diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts index 8682aaf57..5de6a4751 100644 --- a/src/server/plugins/engine/components/PaymentField.ts +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -39,7 +39,7 @@ import { createPaymentService, formatCurrency } from '~/src/server/plugins/payment/helper.js' -import { getLanguage } from '~/src/server/utils/utils.js' +import { resolveLanguage } from '~/src/server/utils/utils.js' export class PaymentField extends FormComponent { declare options: PaymentFieldComponent['options'] @@ -234,10 +234,8 @@ export class PaymentField extends FormComponent { ): Promise { const { options, name: componentName } = args.component const { model } = args.controller - const { getLanguage } = getPluginOptions(request.server) - const language = getLanguage?.(request) ?? 'en-GB' + const language = resolveLanguage(request) const { t } = model.createTranslator(language) - const state = await args.controller.getState(request) const { baseUrl } = getPluginOptions(request.server) const summaryUrl = `${baseUrl}/${model.basePath}/summary` @@ -328,10 +326,10 @@ export class PaymentField extends FormComponent { */ async onSubmit( request: FormRequestPayload, - _metadata: FormMetadata, + metadata: FormMetadata, context: FormContext ): Promise { - const language = getLanguage(request) + const language = resolveLanguage(request, metadata) const { t } = this.model.createTranslator(language) // Zero-amount bypass — no capture needed diff --git a/src/server/plugins/engine/components/UkAddressField.ts b/src/server/plugins/engine/components/UkAddressField.ts index 03927f513..598185ae4 100644 --- a/src/server/plugins/engine/components/UkAddressField.ts +++ b/src/server/plugins/engine/components/UkAddressField.ts @@ -9,9 +9,7 @@ import { } from '~/src/server/plugins/engine/components/FormComponent.js' import { TextField } from '~/src/server/plugins/engine/components/TextField.js' import { type RenderContext } from '~/src/server/plugins/engine/components/types.js' -import { getPluginOptions } from '~/src/server/plugins/engine/helpers.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' -import { type QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' import { type FormRequestPayload, type FormResponseToolkit @@ -26,6 +24,7 @@ import { type PostcodeLookupExternalArgs } from '~/src/server/plugins/engine/types.js' import { dispatch } from '~/src/server/plugins/postcode-lookup/routes/index.js' +import { resolveLanguage } from '~/src/server/utils/utils.js' export class UkAddressField extends FormComponent { declare options: UkAddressFieldComponent['options'] @@ -298,8 +297,7 @@ export class UkAddressField extends FormComponent { args: PostcodeLookupExternalArgs ) { const { controller, component } = args - const { getLanguage } = getPluginOptions(request.server) - const language = getLanguage?.(request) ?? 'en-GB' + const language = resolveLanguage(request) return dispatch(request, h, { formName: controller.model.name, diff --git a/src/server/plugins/engine/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts index 5a7011cc5..132bc617c 100644 --- a/src/server/plugins/engine/configureEnginePlugin.ts +++ b/src/server/plugins/engine/configureEnginePlugin.ts @@ -1,6 +1,6 @@ import { join, parse } from 'node:path' -import { type FormDefinition } from '@defra/forms-model' +import { type FormDefinition, type FormMetadata } from '@defra/forms-model' import { FORM_PREFIX } from '~/src/server/constants.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' @@ -71,12 +71,13 @@ export const configureEnginePlugin = async ( saveAndExit, ordnanceSurveyApiKey, ordnanceSurveyApiSecret, - getLanguage: (request: AnyFormRequest) => { + getLanguage: (request: AnyFormRequest, metadata?: FormMetadata) => { if ('language' in request.query) { request.yar.set('language', request.query.language) } - return request.yar.get('language') ?? 'en-GB' + // @ts-expect-error - 'language' does not yet exist on FormMetadata + return request.yar.get('language') ?? metadata?.language ?? 'en-GB' } } } diff --git a/src/server/plugins/engine/pageControllers/StatusPageController.ts b/src/server/plugins/engine/pageControllers/StatusPageController.ts index 9407a8c4b..f9e6e4c7e 100644 --- a/src/server/plugins/engine/pageControllers/StatusPageController.ts +++ b/src/server/plugins/engine/pageControllers/StatusPageController.ts @@ -1,9 +1,6 @@ import { type PageStatus } from '@defra/forms-model' -import { - getCacheService, - getPluginOptions -} from '~/src/server/plugins/engine/helpers.js' +import { getCacheService } from '~/src/server/plugins/engine/helpers.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' import { type FormContext } from '~/src/server/plugins/engine/types.js' @@ -11,6 +8,7 @@ import { type FormRequest, type FormResponseToolkit } from '~/src/server/routes/types.js' +import { resolveLanguage } from '~/src/server/utils/utils.js' export class StatusPageController extends QuestionPageController { declare pageDef: PageStatus @@ -35,8 +33,7 @@ export class StatusPageController extends QuestionPageController { ) => { const { viewModel, viewName } = this - const { getLanguage } = getPluginOptions(request.server) - const language = getLanguage?.(request) ?? 'en-GB' + const language = resolveLanguage(request) const translator = this.model.createTranslator(language) const { t } = translator diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index 0d6dd8233..e39d5e555 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -20,7 +20,6 @@ import { findPage, getCacheService, getPage, - getPluginOptions, getStartPath, proceed } from '~/src/server/plugins/engine/helpers.js' @@ -44,6 +43,7 @@ import { type FormRequest, type FormResponseToolkit } from '~/src/server/routes/types.js' +import { resolveLanguage } from '~/src/server/utils/utils.js' export async function redirectOrMakeHandler( request: AnyFormRequest, @@ -83,8 +83,7 @@ export async function redirectOrMakeHandler( state = await importExternalComponentState(request, page, state) const flash = cacheService.getFlash(request) - const { getLanguage } = getPluginOptions(request.server) - const language = getLanguage?.(request) ?? 'en-GB' + const language = resolveLanguage(request) const translator = model.createTranslator(language) const context = model.getFormContext( request, diff --git a/src/server/plugins/engine/routes/questions.ts b/src/server/plugins/engine/routes/questions.ts index 15bbde49b..07283ad76 100644 --- a/src/server/plugins/engine/routes/questions.ts +++ b/src/server/plugins/engine/routes/questions.ts @@ -8,7 +8,6 @@ import { import Joi from 'joi' import { - getPluginOptions, normalisePath, proceed, redirectPath @@ -45,6 +44,7 @@ import { stateSchema } from '~/src/server/schemas/index.js' import * as httpService from '~/src/server/services/httpService.js' +import { resolveLanguage } from '~/src/server/utils/utils.js' async function handleHttpEvent( request: AnyFormRequest, @@ -59,8 +59,7 @@ async function handleHttpEvent( // TODO: Update structured data POST payload with when helper // is updated to removing the dependency on `SummaryViewModel` etc. - const { getLanguage } = getPluginOptions(request.server) - const language = getLanguage?.(request) ?? 'en-GB' + const language = resolveLanguage(request) const translator = model.createTranslator(language) const viewModel = new SummaryViewModel(request, page, context, translator) const items = getFormSubmissionData( diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index d0fde654c..b5efe0888 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -1,6 +1,7 @@ import { type ComponentDef, type Event, + type FormMetadata, type FormVersionMetadata, type Item, type List, @@ -511,7 +512,7 @@ export interface PluginOptions { viewContext: PluginProperties['forms-engine-plugin']['viewContext'] preparePageEventRequestOptions?: PreparePageEventRequestOptions onRequest?: OnRequestCallback - getLanguage?: (request: AnyFormRequest) => string + getLanguage?: (request: AnyFormRequest, metadata?: FormMetadata) => string baseUrl: string // base URL of the application, protocol and hostname e.g. "https://myapp.com" ordnanceSurveyApiKey?: string ordnanceSurveyApiSecret?: string diff --git a/src/server/types.ts b/src/server/types.ts index 7e68fe5ee..35149a915 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -60,7 +60,7 @@ export interface RouteConfig { cacheServiceCreator?: (server: Server) => CacheService ordnanceSurveyApiKey?: string ordnanceSurveyApiSecret?: string - getLanguage?: (request: AnyFormRequest) => string + getLanguage?: (request: AnyFormRequest, metadata?: FormMetadata) => string } export interface OutputService { diff --git a/src/server/utils/utils.js b/src/server/utils/utils.js index 3d0f77904..82f8409b4 100644 --- a/src/server/utils/utils.js +++ b/src/server/utils/utils.js @@ -2,6 +2,7 @@ import { getTraceId } from '@defra/hapi-tracing' import Joi from 'joi' import { config } from '~/src/config/index.js' +import { getPluginOptions } from '~/src/server/plugins/engine/helpers.js' /** * Returns a set of headers to use in an HTTP request, merging them with any existing headers in options. @@ -35,16 +36,17 @@ export function isValidUUID(str) { } /** + * * @param {AnyFormRequest} request + * @param {FormMetadata} [metadata] + * @returns */ -export function getLanguage(request) { - if ('language' in request.query) { - request.yar.set('language', request.query.language) - } - - return /** @type {string} */ (request.yar.get('language') ?? 'en-GB') +export function resolveLanguage(request, metadata) { + const { getLanguage } = getPluginOptions(request.server) + return getLanguage?.(request, metadata) ?? 'en-GB' } /** + * @import { FormMetadata } from '@defra/forms-model' * @import { AnyFormRequest } from '~/src/server/plugins/engine/types.js' */ From 0a21a64b3351760261a5e86b7cecaf9af42a9dd8 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Mon, 29 Jun 2026 08:54:52 +0100 Subject: [PATCH 81/89] Fixed code/test --- src/server/plugins/engine/models/SummaryViewModel.ts | 3 ++- src/server/utils/utils.js | 2 -- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/server/plugins/engine/models/SummaryViewModel.ts b/src/server/plugins/engine/models/SummaryViewModel.ts index b0048d6e6..444e63a40 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.ts @@ -216,7 +216,8 @@ function ItemRepeat( const values = page.getListFromState(state) const count = values.length - const value = translator.t('pages.repeater.pageTitle', { count }) + const value = + count === 0 ? '' : translator.t('pages.repeater.pageTitle', { count }) return { name, diff --git a/src/server/utils/utils.js b/src/server/utils/utils.js index 82f8409b4..c0653f70b 100644 --- a/src/server/utils/utils.js +++ b/src/server/utils/utils.js @@ -36,10 +36,8 @@ export function isValidUUID(str) { } /** - * * @param {AnyFormRequest} request * @param {FormMetadata} [metadata] - * @returns */ export function resolveLanguage(request, metadata) { const { getLanguage } = getPluginOptions(request.server) From 804ae6ab277f572309757fe7343feb22224dff39 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Mon, 29 Jun 2026 11:52:57 +0100 Subject: [PATCH 82/89] Fixed auto-doc generation + added remain welsh translations --- scripts/generate-component-previews.js | 7 ++++- scripts/generate-component-previews.test.js | 31 ++++++++++++++----- .../plugins/engine/i18n/translations/cy.json | 6 ++-- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/scripts/generate-component-previews.js b/scripts/generate-component-previews.js index 9ab7856b3..59c492d8d 100644 --- a/scripts/generate-component-previews.js +++ b/scripts/generate-component-previews.js @@ -5,6 +5,7 @@ import { markdownToHtml } from '@defra/forms-model' // Static imports so Jest can mock them (dynamic computed-path imports cannot be mocked). import { createComponent } from '~/src/server/plugins/engine/components/helpers/components.js' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import { environment } from '~/src/server/plugins/nunjucks/environment.js' // Register the markdown filter that the engine plugin normally adds at server init. @@ -26,7 +27,11 @@ export function renderComponent(fixture) { /** @type {unknown} */ (fixture.model) ) const component = createComponent(fixture.def, { model }) - const viewModel = component.getViewModel(fixture.payload, []) + const viewModel = component.getViewModel({ + payload: fixture.payload, + errors: [], + translator: stubTranslator + }) // Apply large label/legend sizing to match how QuestionPageController styles // a single-component page. isPageHeading is intentionally omitted — setting it diff --git a/scripts/generate-component-previews.test.js b/scripts/generate-component-previews.test.js index 033e6213e..dc91aa861 100644 --- a/scripts/generate-component-previews.test.js +++ b/scripts/generate-component-previews.test.js @@ -27,6 +27,7 @@ import { } from './generate-component-previews.js' import { createComponent } from '~/src/server/plugins/engine/components/helpers/components.ts' +import { stubTranslator } from '~/src/server/plugins/engine/pageControllers/__stubs__/translator.js' import { environment } from '~/src/server/plugins/nunjucks/environment.js' describe('component-preview-fixtures', () => { @@ -132,6 +133,10 @@ describe('buildPartialMdx', () => { describe('renderComponent', () => { let mockGetViewModel + const mockModel = { + createTranslator: () => stubTranslator + } + beforeEach(() => { mockGetViewModel = jest.fn().mockReturnValue({ type: 'TextField', @@ -145,22 +150,32 @@ describe('renderComponent', () => { }) it('calls createComponent with def and model from fixture', () => { - renderComponent(fixtures.TextField) + renderComponent({ + ...fixtures.TextField, + model: mockModel + }) expect(createComponent).toHaveBeenCalledWith(fixtures.TextField.def, { - model: fixtures.TextField.model + model: mockModel }) }) it('calls getViewModel with payload and empty errors array', () => { - renderComponent(fixtures.TextField) - expect(mockGetViewModel).toHaveBeenCalledWith( - fixtures.TextField.payload, - [] - ) + renderComponent({ + ...fixtures.TextField, + model: mockModel + }) + expect(mockGetViewModel).toHaveBeenCalledWith({ + payload: {}, + errors: [], + translator: stubTranslator + }) }) it('passes viewModel wrapped as { type, model } to renderString', () => { - renderComponent(fixtures.TextField) + renderComponent({ + ...fixtures.TextField, + model: mockModel + }) expect(environment.renderString).toHaveBeenCalledWith( expect.stringContaining('componentList'), expect.objectContaining({ diff --git a/src/server/plugins/engine/i18n/translations/cy.json b/src/server/plugins/engine/i18n/translations/cy.json index adc0573da..2cfb700f9 100644 --- a/src/server/plugins/engine/i18n/translations/cy.json +++ b/src/server/plugins/engine/i18n/translations/cy.json @@ -60,8 +60,8 @@ "submissionFailed": "Roedd problem ac ni chyflwynwyd eich ffurflen. Ceisiwch gyflwyno'r ffurflen eto", "submissionFailedContactSuffix": " neu gallwch [[contactUsLink]] a dyfynnu'ch cyfeirnod i drefnu ad-daliad", "contactUsLinkText": "cysylltu â ni (yn agor mewn tab newydd)", - "confirmationEmail": "welsh confirmation email (optional)", - "confirmationEmailHint": "welsh Enter your email address to receive a confirmation email and a copy of your answers" + "confirmationEmail": "E-bost cadarnhad (dewisol)", + "confirmationEmailHint": "Rhowch eich cyfeiriad e-bost i dderbyn e-bost cadarnhau a chopi o'ch atebion" }, "repeater": { @@ -233,7 +233,7 @@ "added_other": "Ychwanegwyd [[count]] lleoliad", "validation": { "descriptionRequired": "Rhowch ddisgrifiad ar gyfer lleoliad [[count]]", - "wrongCountry": "welsh Location [[count]] must be in [[country]]" + "wrongCountry": "Rhaid i leoliad [[count]] fod y tu mewn i [[country]]" } }, From 1ac4b119662d4d8355ed4f3c35c472c3aa1c9b2d Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Mon, 29 Jun 2026 14:16:22 +0100 Subject: [PATCH 83/89] Sonar fixes - part 1 --- .../components/AutocompleteField.test.ts | 4 +- .../engine/components/CheckboxesField.test.ts | 35 ++-- .../engine/components/DatePartsField.test.ts | 4 +- .../components/DeclarationField.test.ts | 4 +- .../components/EastingNorthingField.test.ts | 4 +- .../components/EmailAddressField.test.ts | 4 +- .../engine/components/FileUploadField.test.ts | 18 +- .../engine/components/FormComponent.ts | 4 +- .../engine/components/GeospatialField.test.ts | 36 ++-- .../engine/components/HiddenField.test.ts | 4 +- .../engine/components/LatLongField.test.ts | 4 +- .../engine/components/MonthYearField.test.ts | 4 +- .../components/MultilineTextField.test.ts | 8 +- .../NationalGridFieldNumberField.test.ts | 4 +- .../engine/components/NumberField.test.ts | 4 +- .../engine/components/OsGridRefField.test.ts | 4 +- .../engine/components/PaymentField.test.ts | 4 +- .../engine/components/RadiosField.test.ts | 4 +- .../engine/components/SelectField.test.ts | 4 +- .../components/TelephoneNumberField.test.ts | 4 +- .../engine/components/TextField.test.ts | 4 +- .../engine/components/UkAddressField.test.ts | 4 +- .../engine/components/YesNoField.test.ts | 10 +- .../components/helpers/components.test.ts | 196 ++++++------------ .../engine/components/helpers/components.ts | 12 +- src/server/plugins/engine/helpers.ts | 9 +- src/server/plugins/engine/models/FormModel.ts | 2 +- .../engine/models/SummaryViewModel.test.ts | 30 ++- .../plugins/engine/models/SummaryViewModel.ts | 4 +- .../engine/outputFormatters/human/v1.ts | 2 +- .../engine/outputFormatters/machine/v1.ts | 13 +- .../pageControllers/helpers/submission.ts | 11 +- src/server/plugins/nunjucks/filters/answer.js | 11 +- .../plugins/nunjucks/filters/answer.test.js | 4 +- 34 files changed, 203 insertions(+), 270 deletions(-) diff --git a/src/server/plugins/engine/components/AutocompleteField.test.ts b/src/server/plugins/engine/components/AutocompleteField.test.ts index f5bb5cac4..080f5a7dc 100644 --- a/src/server/plugins/engine/components/AutocompleteField.test.ts +++ b/src/server/plugins/engine/components/AutocompleteField.test.ts @@ -211,8 +211,8 @@ describe.each([ const state1 = getFormState(item.value) const state2 = getFormState(null) - const answer1 = getAnswer(field, state1, undefined, translator) - const answer2 = getAnswer(field, state2, undefined, translator) + const answer1 = getAnswer(field, state1, translator) + const answer2 = getAnswer(field, state2, translator) expect(answer1).toBe(item.text) expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/CheckboxesField.test.ts b/src/server/plugins/engine/components/CheckboxesField.test.ts index e4727402c..f2b80974b 100644 --- a/src/server/plugins/engine/components/CheckboxesField.test.ts +++ b/src/server/plugins/engine/components/CheckboxesField.test.ts @@ -23,7 +23,9 @@ import { import definition from '~/test/form/definitions/blank.js' import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' -const translator = (new FormModel(definition, { basePath: '/'})).createTranslator() +const translator = new FormModel(definition, { + basePath: '/' +}).createTranslator() describe.each([ { @@ -307,8 +309,8 @@ describe.each([ const state1 = getFormState([item.value]) const state2 = getFormState(null) - const answer1 = getAnswer(field, state1, undefined, translator) - const answer2 = getAnswer(field, state2, undefined, translator) + const answer1 = getAnswer(field, state1, translator) + const answer2 = getAnswer(field, state2, translator) expect(answer1).toBe(outdent`
    @@ -325,7 +327,7 @@ describe.each([ const item2 = options.examples[2] const state = getFormState([item1.value, item2.value]) - const answer = getAnswer(field, state, undefined, translator) + const answer = getAnswer(field, state, translator) expect(answer).toBe(outdent`
      @@ -459,13 +461,19 @@ describe.each([ describe('getDisplayStringFromFormValue', () => { it('returns empty string when value is undefined', () => { const checkboxField = field as CheckboxesField - const result = checkboxField.getDisplayStringFromFormValue(undefined, translator) + const result = checkboxField.getDisplayStringFromFormValue( + undefined, + translator + ) expect(result).toBe('') }) it('returns empty string when value is empty array', () => { const checkboxField = field as CheckboxesField - const result = checkboxField.getDisplayStringFromFormValue([], translator) + const result = checkboxField.getDisplayStringFromFormValue( + [], + translator + ) expect(result).toBe('') }) @@ -473,9 +481,10 @@ describe.each([ 'returns text for single selected value', (item) => { const checkboxField = field as CheckboxesField - const result = checkboxField.getDisplayStringFromFormValue([ - item.value - ], translator) + const result = checkboxField.getDisplayStringFromFormValue( + [item.value], + translator + ) expect(result).toBe(item.text) } ) @@ -485,10 +494,10 @@ describe.each([ const item1 = options.examples[0] const item2 = options.examples[2] - const result = checkboxField.getDisplayStringFromFormValue([ - item1.value, - item2.value - ], translator) + const result = checkboxField.getDisplayStringFromFormValue( + [item1.value, item2.value], + translator + ) expect(result).toBe(`${item1.text}, ${item2.text}`) }) diff --git a/src/server/plugins/engine/components/DatePartsField.test.ts b/src/server/plugins/engine/components/DatePartsField.test.ts index 6794c4325..464e9a11a 100644 --- a/src/server/plugins/engine/components/DatePartsField.test.ts +++ b/src/server/plugins/engine/components/DatePartsField.test.ts @@ -287,8 +287,8 @@ describe('DatePartsField', () => { const state1 = getFormState(date) const state2 = getFormState({}) - const answer1 = getAnswer(field, state1, undefined, translator) - const answer2 = getAnswer(field, state2, undefined, translator) + const answer1 = getAnswer(field, state1, translator) + const answer2 = getAnswer(field, state2, translator) expect(answer1).toBe('31 December 2024') expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/DeclarationField.test.ts b/src/server/plugins/engine/components/DeclarationField.test.ts index 1b4ad6823..1bdc92597 100644 --- a/src/server/plugins/engine/components/DeclarationField.test.ts +++ b/src/server/plugins/engine/components/DeclarationField.test.ts @@ -189,8 +189,8 @@ describe('DeclarationField', () => { // context - boolean // state - boolean // string - I confirm that I understand and accept this declaration - const answer1 = getAnswer(field, state1, undefined, translator) - const answer2 = getAnswer(field, state2, undefined, translator) + const answer1 = getAnswer(field, state1, translator) + const answer2 = getAnswer(field, state2, translator) expect(answer1).toBe('I understand and agree') expect(answer2).toBe('Not provided') diff --git a/src/server/plugins/engine/components/EastingNorthingField.test.ts b/src/server/plugins/engine/components/EastingNorthingField.test.ts index 0bf3e167f..fafa40e1f 100644 --- a/src/server/plugins/engine/components/EastingNorthingField.test.ts +++ b/src/server/plugins/engine/components/EastingNorthingField.test.ts @@ -213,8 +213,8 @@ describe('EastingNorthingField', () => { }) const state2 = getFormState({}) - const answer1 = getAnswer(field, state1, undefined, translator) - const answer2 = getAnswer(field, state2, undefined, translator) + const answer1 = getAnswer(field, state1, translator) + const answer2 = getAnswer(field, state2, translator) expect(answer1).toBe('Easting: 12345
      Northing: 1234567
      ') expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/EmailAddressField.test.ts b/src/server/plugins/engine/components/EmailAddressField.test.ts index 5e1e2bb0a..01284e965 100644 --- a/src/server/plugins/engine/components/EmailAddressField.test.ts +++ b/src/server/plugins/engine/components/EmailAddressField.test.ts @@ -159,8 +159,8 @@ describe('EmailAddressField', () => { const state1 = getFormState('defra.helpline@defra.gov.uk') const state2 = getFormState(null) - const answer1 = getAnswer(field, state1, undefined, translator) - const answer2 = getAnswer(field, state2, undefined, translator) + const answer1 = getAnswer(field, state1, translator) + const answer2 = getAnswer(field, state2, translator) expect(answer1).toBe('defra.helpline@defra.gov.uk') expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/FileUploadField.test.ts b/src/server/plugins/engine/components/FileUploadField.test.ts index 4e628f626..649952b81 100644 --- a/src/server/plugins/engine/components/FileUploadField.test.ts +++ b/src/server/plugins/engine/components/FileUploadField.test.ts @@ -325,18 +325,12 @@ describe('FileUploadField', () => { const state1 = getFormState(validState) const state2 = getFormState(null) - const answer1 = getAnswer( - field, - state1, - { format: 'summary' }, - stubTranslator - ) - const answer2 = getAnswer( - field, - state2, - { format: 'summary' }, - stubTranslator - ) + const answer1 = getAnswer(field, state1, stubTranslator, { + format: 'summary' + }) + const answer2 = getAnswer(field, state2, stubTranslator, { + format: 'summary' + }) expect(answer1).toBe('3 files uploaded') expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/FormComponent.ts b/src/server/plugins/engine/components/FormComponent.ts index 1ffc98b50..19d2aafca 100644 --- a/src/server/plugins/engine/components/FormComponent.ts +++ b/src/server/plugins/engine/components/FormComponent.ts @@ -180,7 +180,9 @@ export class FormComponent extends ComponentBase { const resolvedTitle = tComponent(this as unknown as ComponentDef, 'title') || title - const label = `${resolvedTitle}${!isRequired && !hideOptional ? ` ${t('common.optional')}` : ''}` + const optionalTag = + !isRequired && !hideOptional ? ` ${t('common.optional')}` : '' + const label = `${resolvedTitle}${optionalTag}` if (hint) { viewModel.hint = { diff --git a/src/server/plugins/engine/components/GeospatialField.test.ts b/src/server/plugins/engine/components/GeospatialField.test.ts index cbd7d9678..cf1b3e184 100644 --- a/src/server/plugins/engine/components/GeospatialField.test.ts +++ b/src/server/plugins/engine/components/GeospatialField.test.ts @@ -156,18 +156,12 @@ describe('GeospatialField', () => { const state1 = getFormState(validSingleState) const state2 = getFormState(null) - const answer1 = getAnswer( - field, - state1, - { format: 'summary' }, - stubTranslator - ) - const answer2 = getAnswer( - field, - state2, - { format: 'summary' }, - stubTranslator - ) + const answer1 = getAnswer(field, state1, stubTranslator, { + format: 'summary' + }) + const answer2 = getAnswer(field, state2, stubTranslator, { + format: 'summary' + }) expect(answer1).toBe('Added 1 location') expect(answer2).toBe('') @@ -177,18 +171,12 @@ describe('GeospatialField', () => { const state1 = getFormState(validState) const state2 = getFormState(null) - const answer1 = getAnswer( - field, - state1, - { format: 'summary' }, - stubTranslator - ) - const answer2 = getAnswer( - field, - state2, - { format: 'summary' }, - stubTranslator - ) + const answer1 = getAnswer(field, state1, stubTranslator, { + format: 'summary' + }) + const answer2 = getAnswer(field, state2, stubTranslator, { + format: 'summary' + }) expect(answer1).toBe('Added 4 locations') expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/HiddenField.test.ts b/src/server/plugins/engine/components/HiddenField.test.ts index 5c354bff1..77fa8cc17 100644 --- a/src/server/plugins/engine/components/HiddenField.test.ts +++ b/src/server/plugins/engine/components/HiddenField.test.ts @@ -115,8 +115,8 @@ describe('HiddenField', () => { const state1 = getFormState('Hidden field') const state2 = getFormState(null) - const answer1 = getAnswer(field, state1, undefined, translator) - const answer2 = getAnswer(field, state2, undefined, translator) + const answer1 = getAnswer(field, state1, translator) + const answer2 = getAnswer(field, state2, translator) expect(answer1).toBe('Hidden field') expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/LatLongField.test.ts b/src/server/plugins/engine/components/LatLongField.test.ts index 746851e30..a4a56c807 100644 --- a/src/server/plugins/engine/components/LatLongField.test.ts +++ b/src/server/plugins/engine/components/LatLongField.test.ts @@ -202,8 +202,8 @@ describe('LatLongField', () => { }) const state2 = getFormState({}) - const answer1 = getAnswer(field, state1, undefined, translator) - const answer2 = getAnswer(field, state2, undefined, translator) + const answer1 = getAnswer(field, state1, translator) + const answer2 = getAnswer(field, state2, translator) expect(answer1).toBe('Latitude: 51.51945
      Longitude: -0.127758
      ') expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/MonthYearField.test.ts b/src/server/plugins/engine/components/MonthYearField.test.ts index 0550f6f76..075c57b90 100644 --- a/src/server/plugins/engine/components/MonthYearField.test.ts +++ b/src/server/plugins/engine/components/MonthYearField.test.ts @@ -263,8 +263,8 @@ describe('MonthYearField', () => { const state1 = getFormState(date) const state2 = getFormState({}) - const answer1 = getAnswer(field, state1, undefined, translator) - const answer2 = getAnswer(field, state2, undefined, translator) + const answer1 = getAnswer(field, state1, translator) + const answer2 = getAnswer(field, state2, translator) expect(answer1).toBe('December 2024') expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/MultilineTextField.test.ts b/src/server/plugins/engine/components/MultilineTextField.test.ts index 977a17043..4c9fefe71 100644 --- a/src/server/plugins/engine/components/MultilineTextField.test.ts +++ b/src/server/plugins/engine/components/MultilineTextField.test.ts @@ -159,8 +159,8 @@ describe('MultilineTextField', () => { const state1 = getFormState('Textarea') const state2 = getFormState(null) - const answer1 = getAnswer(field, state1, undefined, translator) - const answer2 = getAnswer(field, state2, undefined, translator) + const answer1 = getAnswer(field, state1, translator) + const answer2 = getAnswer(field, state2, translator) expect(answer1).toBe('Textarea') expect(answer2).toBe('') @@ -170,8 +170,8 @@ describe('MultilineTextField', () => { const state1 = getFormState('Line 1\r\nLine 2\r\nLine 3') const state2 = getFormState('Line 1\r\n\r\nLine 2\r\n\r\n\r\nLine 3') - const answer1 = getAnswer(field, state1, undefined, translator) - const answer2 = getAnswer(field, state2, undefined, translator) + const answer1 = getAnswer(field, state1, translator) + const answer2 = getAnswer(field, state2, translator) expect(answer1).toBe('Line 1
      Line 2
      Line 3
      ') expect(answer2).toBe('Line 1
      Line 2
      Line 3
      ') diff --git a/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts b/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts index b31811efb..d31972650 100644 --- a/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts +++ b/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts @@ -155,8 +155,8 @@ describe('NationalGridFieldNumberField', () => { const state1 = getFormState('NG12345678') const state2 = getFormState(null) - const answer1 = getAnswer(field, state1, undefined, translator) - const answer2 = getAnswer(field, state2, undefined, translator) + const answer1 = getAnswer(field, state1, translator) + const answer2 = getAnswer(field, state2, translator) expect(answer1).toBe('NG12345678') expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/NumberField.test.ts b/src/server/plugins/engine/components/NumberField.test.ts index 8955f60e9..f9de841ed 100644 --- a/src/server/plugins/engine/components/NumberField.test.ts +++ b/src/server/plugins/engine/components/NumberField.test.ts @@ -160,8 +160,8 @@ describe('NumberField', () => { const state1 = getFormState(2024) const state2 = getFormState(null) - const answer1 = getAnswer(field, state1, undefined, translator) - const answer2 = getAnswer(field, state2, undefined, translator) + const answer1 = getAnswer(field, state1, translator) + const answer2 = getAnswer(field, state2, translator) expect(answer1).toBe('2024') expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/OsGridRefField.test.ts b/src/server/plugins/engine/components/OsGridRefField.test.ts index 6bf9d4018..0b6184b05 100644 --- a/src/server/plugins/engine/components/OsGridRefField.test.ts +++ b/src/server/plugins/engine/components/OsGridRefField.test.ts @@ -171,8 +171,8 @@ describe('OsGridRefField', () => { const state1 = getFormState('TQ12345678') const state2 = getFormState(null) - const answer1 = getAnswer(field, state1, undefined, translator) - const answer2 = getAnswer(field, state2, undefined, translator) + const answer1 = getAnswer(field, state1, translator) + const answer2 = getAnswer(field, state2, translator) expect(answer1).toBe('TQ12345678') expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/PaymentField.test.ts b/src/server/plugins/engine/components/PaymentField.test.ts index 2f3372856..30211787c 100644 --- a/src/server/plugins/engine/components/PaymentField.test.ts +++ b/src/server/plugins/engine/components/PaymentField.test.ts @@ -210,8 +210,8 @@ describe('PaymentField', () => { const state1 = getFormState(paymentForState as unknown as FormValue) const state2 = getFormState(null) - const answer1 = getAnswer(field, state1, undefined, translator) - const answer2 = getAnswer(field, state2, undefined, translator) + const answer1 = getAnswer(field, state1, translator) + const answer2 = getAnswer(field, state2, translator) expect(answer1).toBe('£150.00 - payment description') expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/RadiosField.test.ts b/src/server/plugins/engine/components/RadiosField.test.ts index 127512842..ef2238009 100644 --- a/src/server/plugins/engine/components/RadiosField.test.ts +++ b/src/server/plugins/engine/components/RadiosField.test.ts @@ -179,8 +179,8 @@ describe.each([ const state1 = getFormState(item.value) const state2 = getFormState(null) - const answer1 = getAnswer(field, state1, undefined, translator) - const answer2 = getAnswer(field, state2, undefined, translator) + const answer1 = getAnswer(field, state1, translator) + const answer2 = getAnswer(field, state2, translator) expect(answer1).toBe(item.text) expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/SelectField.test.ts b/src/server/plugins/engine/components/SelectField.test.ts index 40b6b89ca..e9d1e6a21 100644 --- a/src/server/plugins/engine/components/SelectField.test.ts +++ b/src/server/plugins/engine/components/SelectField.test.ts @@ -180,8 +180,8 @@ describe.each([ const state1 = getFormState(item.value) const state2 = getFormState(null) - const answer1 = getAnswer(field, state1, undefined, translator) - const answer2 = getAnswer(field, state2, undefined, translator) + const answer1 = getAnswer(field, state1, translator) + const answer2 = getAnswer(field, state2, translator) expect(answer1).toBe(item.text) expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/TelephoneNumberField.test.ts b/src/server/plugins/engine/components/TelephoneNumberField.test.ts index 4a0b4ceb9..9c60f1f82 100644 --- a/src/server/plugins/engine/components/TelephoneNumberField.test.ts +++ b/src/server/plugins/engine/components/TelephoneNumberField.test.ts @@ -172,8 +172,8 @@ describe('TelephoneNumberField', () => { const state1 = getFormState('+447900000000') const state2 = getFormState(null) - const answer1 = getAnswer(field, state1, undefined, translator) - const answer2 = getAnswer(field, state2, undefined, translator) + const answer1 = getAnswer(field, state1, translator) + const answer2 = getAnswer(field, state2, translator) expect(answer1).toBe('+447900000000') expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/TextField.test.ts b/src/server/plugins/engine/components/TextField.test.ts index 3d35a4f10..5bfe3d9f8 100644 --- a/src/server/plugins/engine/components/TextField.test.ts +++ b/src/server/plugins/engine/components/TextField.test.ts @@ -184,8 +184,8 @@ describe('TextField', () => { const state1 = getFormState('Text field') const state2 = getFormState(null) - const answer1 = getAnswer(field, state1, undefined, translator) - const answer2 = getAnswer(field, state2, undefined, translator) + const answer1 = getAnswer(field, state1, translator) + const answer2 = getAnswer(field, state2, translator) expect(answer1).toBe('Text field') expect(answer2).toBe('') diff --git a/src/server/plugins/engine/components/UkAddressField.test.ts b/src/server/plugins/engine/components/UkAddressField.test.ts index 2e128749f..cddc0c5eb 100644 --- a/src/server/plugins/engine/components/UkAddressField.test.ts +++ b/src/server/plugins/engine/components/UkAddressField.test.ts @@ -355,8 +355,8 @@ describe('UkAddressField', () => { const state1 = getFormState(address) const state2 = getFormState({}) - const answer1 = getAnswer(field, state1, undefined, translator) - const answer2 = getAnswer(field, state2, undefined, translator) + const answer1 = getAnswer(field, state1, translator) + const answer2 = getAnswer(field, state2, translator) expect(answer1).toBe( 'Richard Fairclough House
      Knutsford Road
      Warrington
      Cheshire
      WA4 1HT
      ' diff --git a/src/server/plugins/engine/components/YesNoField.test.ts b/src/server/plugins/engine/components/YesNoField.test.ts index 5aa5bbd88..279222c33 100644 --- a/src/server/plugins/engine/components/YesNoField.test.ts +++ b/src/server/plugins/engine/components/YesNoField.test.ts @@ -11,7 +11,9 @@ import { listYesNoExamples } from '~/test/fixtures/list.js' import definition from '~/test/form/definitions/blank.js' import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' -const translator = (new FormModel(definition, { basePath: '/'})).createTranslator() +const translator = new FormModel(definition, { + basePath: '/' +}).createTranslator() describe('YesNoField', () => { let def: YesNoFieldComponent @@ -146,9 +148,9 @@ describe('YesNoField', () => { const state2 = getFormState(false) const state3 = getFormState(null) - const answer1 = getAnswer(field, state1, undefined, translator) - const answer2 = getAnswer(field, state2, undefined, translator) - const answer3 = getAnswer(field, state3, undefined, translator) + const answer1 = getAnswer(field, state1, translator) + const answer2 = getAnswer(field, state2, translator) + const answer3 = getAnswer(field, state3, translator) expect(answer1).toBe('Yes') expect(answer2).toBe('No') diff --git a/src/server/plugins/engine/components/helpers/components.test.ts b/src/server/plugins/engine/components/helpers/components.test.ts index 76a7a7055..f0a2a20a9 100644 --- a/src/server/plugins/engine/components/helpers/components.test.ts +++ b/src/server/plugins/engine/components/helpers/components.test.ts @@ -57,12 +57,9 @@ describe('Location field formatting', () => { locationEN__northing: 654321 } - const answer = getAnswer( - field, - state, - { format: 'email' }, - stubTranslator - ) + const answer = getAnswer(field, state, stubTranslator, { + format: 'email' + }) expect(answer).toBe('Easting: 123456\nNorthing: 654321\n') }) @@ -72,7 +69,7 @@ describe('Location field formatting', () => { locationEN__northing: 654321 } - const answer = getAnswer(field, state, { format: 'data' }, stubTranslator) + const answer = getAnswer(field, state, stubTranslator, { format: 'data' }) expect(answer).toBe('Easting: 123456\nNorthing: 654321') }) @@ -82,12 +79,9 @@ describe('Location field formatting', () => { locationEN__northing: 654321 } - const answer = getAnswer( - field, - state, - { format: 'summary' }, - stubTranslator - ) + const answer = getAnswer(field, state, stubTranslator, { + format: 'summary' + }) // Should render as HTML from markdown expect(answer).toContain('Easting: 123456') expect(answer).toContain('Northing: 654321') @@ -96,12 +90,9 @@ describe('Location field formatting', () => { it('returns empty string when no values', () => { const state = {} - const answer = getAnswer( - field, - state, - { format: 'email' }, - stubTranslator - ) + const answer = getAnswer(field, state, stubTranslator, { + format: 'email' + }) expect(answer).toBe('') }) }) @@ -125,12 +116,9 @@ describe('Location field formatting', () => { locationLL__longitude: -0.127758 } - const answer = getAnswer( - field, - state, - { format: 'email' }, - stubTranslator - ) + const answer = getAnswer(field, state, stubTranslator, { + format: 'email' + }) expect(answer).toBe('Latitude: 51.51945\nLongitude: -0.127758\n') }) @@ -140,7 +128,7 @@ describe('Location field formatting', () => { locationLL__longitude: -0.127758 } - const answer = getAnswer(field, state, { format: 'data' }, stubTranslator) + const answer = getAnswer(field, state, stubTranslator, { format: 'data' }) expect(answer).toBe('Latitude: 51.51945\nLongitude: -0.127758') }) @@ -150,12 +138,9 @@ describe('Location field formatting', () => { locationLL__longitude: -0.127758 } - const answer = getAnswer( - field, - state, - { format: 'summary' }, - stubTranslator - ) + const answer = getAnswer(field, state, stubTranslator, { + format: 'summary' + }) // Should render as HTML from markdown expect(answer).toContain('Latitude: 51.51945') expect(answer).toContain('Longitude: -0.127758') @@ -164,12 +149,9 @@ describe('Location field formatting', () => { it('returns empty string when no values', () => { const state = {} - const answer = getAnswer( - field, - state, - { format: 'email' }, - stubTranslator - ) + const answer = getAnswer(field, state, stubTranslator, { + format: 'email' + }) expect(answer).toBe('') }) }) @@ -192,12 +174,9 @@ describe('Location field formatting', () => { gridRef: 'TQ123456' } - const answer = getAnswer( - field, - state, - { format: 'email' }, - stubTranslator - ) + const answer = getAnswer(field, state, stubTranslator, { + format: 'email' + }) expect(answer).toBe('TQ123456\n') }) @@ -206,7 +185,7 @@ describe('Location field formatting', () => { gridRef: 'TQ123456' } - const answer = getAnswer(field, state, { format: 'data' }, stubTranslator) + const answer = getAnswer(field, state, stubTranslator, { format: 'data' }) expect(answer).toBe('TQ123456') }) @@ -215,12 +194,9 @@ describe('Location field formatting', () => { gridRef: 'TQ123456' } - const answer = getAnswer( - field, - state, - { format: 'summary' }, - stubTranslator - ) + const answer = getAnswer(field, state, stubTranslator, { + format: 'summary' + }) expect(answer).toBe('TQ123456') }) }) @@ -243,12 +219,9 @@ describe('Location field formatting', () => { ngField: 'NG12345678' } - const answer = getAnswer( - field, - state, - { format: 'email' }, - stubTranslator - ) + const answer = getAnswer(field, state, stubTranslator, { + format: 'email' + }) expect(answer).toBe('NG12345678\n') }) @@ -257,7 +230,7 @@ describe('Location field formatting', () => { ngField: 'NG12345678' } - const answer = getAnswer(field, state, { format: 'data' }, stubTranslator) + const answer = getAnswer(field, state, stubTranslator, { format: 'data' }) expect(answer).toBe('NG12345678') }) @@ -266,12 +239,9 @@ describe('Location field formatting', () => { ngField: 'NG12345678' } - const answer = getAnswer( - field, - state, - { format: 'summary' }, - stubTranslator - ) + const answer = getAnswer(field, state, stubTranslator, { + format: 'summary' + }) expect(answer).toBe('NG12345678') }) }) @@ -294,12 +264,9 @@ describe('Location field formatting', () => { geoField: validState } - const answer = getAnswer( - field, - state, - { format: 'email' }, - stubTranslator - ) + const answer = getAnswer(field, state, stubTranslator, { + format: 'email' + }) expect(answer).toBe('Added 4 locations\n') }) @@ -308,7 +275,7 @@ describe('Location field formatting', () => { geoField: validState } - const answer = getAnswer(field, state, { format: 'data' }, stubTranslator) + const answer = getAnswer(field, state, stubTranslator, { format: 'data' }) expect(answer).toBe('a,b,c,d') }) @@ -317,12 +284,9 @@ describe('Location field formatting', () => { geoField: validState } - const answer = getAnswer( - field, - state, - { format: 'summary' }, - stubTranslator - ) + const answer = getAnswer(field, state, stubTranslator, { + format: 'summary' + }) expect(answer).toBe('Added 4 locations') }) }) @@ -346,12 +310,9 @@ describe('Location field formatting', () => { declField: true } - const answer = getAnswer( - field, - state, - { format: 'email' }, - stubTranslator - ) + const answer = getAnswer(field, state, stubTranslator, { + format: 'email' + }) expect(answer).toBe('I understand and agree\n') }) @@ -360,12 +321,9 @@ describe('Location field formatting', () => { declField: false } - const answer = getAnswer( - field, - state, - { format: 'email' }, - stubTranslator - ) + const answer = getAnswer(field, state, stubTranslator, { + format: 'email' + }) expect(answer).toBe('Not provided\n') }) @@ -374,7 +332,7 @@ describe('Location field formatting', () => { declField: true } - const answer = getAnswer(field, state, { format: 'data' }, stubTranslator) + const answer = getAnswer(field, state, stubTranslator, { format: 'data' }) expect(answer).toBe('true') }) @@ -383,14 +341,14 @@ describe('Location field formatting', () => { declField: false } - const answer = getAnswer(field, state, { format: 'data' }, stubTranslator) + const answer = getAnswer(field, state, stubTranslator, { format: 'data' }) expect(answer).toBe('false') }) it('formats for data output when no value', () => { const state = {} - const answer = getAnswer(field, state, { format: 'data' }, stubTranslator) + const answer = getAnswer(field, state, stubTranslator, { format: 'data' }) expect(answer).toBe('false') }) @@ -399,12 +357,9 @@ describe('Location field formatting', () => { declField: true } - const answer = getAnswer( - field, - state, - { format: 'summary' }, - stubTranslator - ) + const answer = getAnswer(field, state, stubTranslator, { + format: 'summary' + }) expect(answer).toBe('I understand and agree') }) @@ -413,24 +368,18 @@ describe('Location field formatting', () => { declField: false } - const answer = getAnswer( - field, - state, - { format: 'summary' }, - stubTranslator - ) + const answer = getAnswer(field, state, stubTranslator, { + format: 'summary' + }) expect(answer).toBe('Not provided') }) it('formats for summary display when no value', () => { const state = {} - const answer = getAnswer( - field, - state, - { format: 'summary' }, - stubTranslator - ) + const answer = getAnswer(field, state, stubTranslator, { + format: 'summary' + }) expect(answer).toBe('Not provided') }) }) @@ -449,12 +398,9 @@ describe('Location field formatting', () => { locationEN__northing: 654321 } - const answer = getAnswerMarkdown( - field, - state, - { format: 'email' }, - translator - ) + const answer = getAnswerMarkdown(field, state, translator, { + format: 'email' + }) expect(answer).toBe('Easting: 123456\nNorthing: 654321\n') }) @@ -471,12 +417,9 @@ describe('Location field formatting', () => { locationLL__longitude: -0.127758 } - const answer = getAnswerMarkdown( - field, - state, - { format: 'email' }, - translator - ) + const answer = getAnswerMarkdown(field, state, translator, { + format: 'email' + }) expect(answer).toBe('Latitude: 51.51945\nLongitude: -0.127758\n') }) @@ -492,12 +435,9 @@ describe('Location field formatting', () => { gridRef: 'TQ123456' } - const answer = getAnswerMarkdown( - field, - state, - { format: 'email' }, - translator - ) + const answer = getAnswerMarkdown(field, state, translator, { + format: 'email' + }) expect(answer).toBe('TQ123456\n') }) }) diff --git a/src/server/plugins/engine/components/helpers/components.ts b/src/server/plugins/engine/components/helpers/components.ts index 5418ebb90..9d62de2ed 100644 --- a/src/server/plugins/engine/components/helpers/components.ts +++ b/src/server/plugins/engine/components/helpers/components.ts @@ -222,17 +222,17 @@ export function createComponent( export function getAnswer( field: Field, state: FormState, + translator: Translator, options: { format: | 'data' // Submission data | 'email' // GOV.UK Notify emails | 'summary' // Check answers summary - } = { format: 'summary' }, - translator: Translator + } = { format: 'summary' } ) { // Use escaped display text for GOV.UK Notify emails if (options.format === 'email') { - return getAnswerMarkdown(field, state, { format: 'email' }, translator) + return getAnswerMarkdown(field, state, translator, { format: 'email' }) } // Use context value for submission data @@ -251,7 +251,7 @@ export function getAnswer( ) { return markdown .parse( - getAnswerMarkdown(field, state, { format: 'summary' }, translator), + getAnswerMarkdown(field, state, translator, { format: 'summary' }), { async: false } ) .trim() @@ -267,12 +267,12 @@ export function getAnswer( export function getAnswerMarkdown( field: Field, state: FormState, + translator: Translator, options: { format: | 'email' // GOV.UK Notify emails | 'summary' // Check answers summary - } = { format: 'summary' }, - translator: Translator + } = { format: 'summary' } ) { const answer = field.getDisplayStringFromState(state, translator) diff --git a/src/server/plugins/engine/helpers.ts b/src/server/plugins/engine/helpers.ts index 57d19e4a6..9c103576b 100644 --- a/src/server/plugins/engine/helpers.ts +++ b/src/server/plugins/engine/helpers.ts @@ -111,12 +111,9 @@ engine.registerFilter('answer', function (name: string) { const field = component as Field const translator = field.model.createTranslator() - const answer = getAnswer( - field, - globals.context.relevantState, - { format: 'summary' }, - translator - ) + const answer = getAnswer(field, globals.context.relevantState, translator, { + format: 'summary' + }) return answer }) diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index d02cbf461..92324ffd9 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -88,7 +88,7 @@ export class FormModel { conditions: Partial> pages: PageControllerClass[] services: Services - private i18nInstance: i18n + private readonly i18nInstance: i18n controllers?: Record pageDefMap: Map diff --git a/src/server/plugins/engine/models/SummaryViewModel.test.ts b/src/server/plugins/engine/models/SummaryViewModel.test.ts index 598c5fe9b..c6ddfcf9d 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.test.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.test.ts @@ -412,9 +412,13 @@ describe('SummaryViewModel with per-request Translator', () => { } context = model.getFormContext(request, state) - expect( - new SummaryViewModel(request, page, context, mockTranslator) - ).toBeDefined() + const viewModel = new SummaryViewModel( + request, + page, + context, + mockTranslator + ) + expect(viewModel).toBeDefined() expect(mockTranslator.t).toHaveBeenCalledWith('pages.summary.change') }) @@ -427,9 +431,13 @@ describe('SummaryViewModel with per-request Translator', () => { } context = model.getFormContext(request, state) - expect( - new SummaryViewModel(request, page, context, mockTranslator) - ).toBeDefined() + const viewModel = new SummaryViewModel( + request, + page, + context, + mockTranslator + ) + expect(viewModel).toBeDefined() expect(mockTranslator.t).toHaveBeenCalledWith('pages.summary.notProvided') }) @@ -442,9 +450,13 @@ describe('SummaryViewModel with per-request Translator', () => { } context = model.getFormContext(request, state) - expect( - new SummaryViewModel(request, page, context, stubTranslator) - ).toBeDefined() + const viewModel = new SummaryViewModel( + request, + page, + context, + stubTranslator + ) + expect(viewModel).toBeDefined() }) }) diff --git a/src/server/plugins/engine/models/SummaryViewModel.ts b/src/server/plugins/engine/models/SummaryViewModel.ts index 444e63a40..846336301 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.ts @@ -62,7 +62,7 @@ export class SummaryViewModel { paymentState?: PaymentState paymentDetails?: CheckAnswers t: (key: string, opts?: Record) => string - private _translator!: Translator + private readonly _translator!: Translator paymentRequired?: boolean paymentPreAuthorized?: boolean @@ -274,7 +274,7 @@ export function ItemField( label: translatedTitle, title: `${translatedLabel}${optional}`, error: field.getFirstError(translator, options.errors), - value: getAnswer(field, state, { format: 'summary' }, translator), + value: getAnswer(field, state, translator, { format: 'summary' }), href: getPageHref(page, options.path, { returnUrl: getPageHref(page, page.getSummaryPath()) }), diff --git a/src/server/plugins/engine/outputFormatters/human/v1.ts b/src/server/plugins/engine/outputFormatters/human/v1.ts index 448c85ff6..6d72202a7 100644 --- a/src/server/plugins/engine/outputFormatters/human/v1.ts +++ b/src/server/plugins/engine/outputFormatters/human/v1.ts @@ -73,7 +73,7 @@ export function format( lines.push(`[${filename}](${designerUrl}/file-download/${fileId})\n`) } else { lines.push( - getAnswer(item.field, item.state, { format: 'email' }, translator) + getAnswer(item.field, item.state, translator, { format: 'email' }) ) } diff --git a/src/server/plugins/engine/outputFormatters/machine/v1.ts b/src/server/plugins/engine/outputFormatters/machine/v1.ts index 5b43a5a07..c81b0b6ee 100644 --- a/src/server/plugins/engine/outputFormatters/machine/v1.ts +++ b/src/server/plugins/engine/outputFormatters/machine/v1.ts @@ -83,12 +83,9 @@ function categoriseData(items: DetailItem[], translator: Translator) { } else if (isFileUploadFieldItem(item)) { output.files[item.name] = extractFileUploads(item) } else { - output.main[item.name] = getAnswer( - item.field, - item.state, - { format: 'data' }, - translator - ) + output.main[item.name] = getAnswer(item.field, item.state, translator, { + format: 'data' + }) } }) @@ -110,8 +107,8 @@ function extractRepeaters(item: DetailItemRepeat, translator: Translator) { outputRepeaterItem[repeaterComponent.name] = getAnswer( repeaterComponent.field, repeaterComponent.state, - { format: 'data' }, - translator + translator, + { format: 'data' } ) }) diff --git a/src/server/plugins/engine/pageControllers/helpers/submission.ts b/src/server/plugins/engine/pageControllers/helpers/submission.ts index e2e701aec..0a080ee60 100644 --- a/src/server/plugins/engine/pageControllers/helpers/submission.ts +++ b/src/server/plugins/engine/pageControllers/helpers/submission.ts @@ -50,7 +50,7 @@ export function buildMainRecords( records.push({ name: item.name, title: item.label, - value: getAnswer(item.field, item.state, { format: 'data' }, translator) + value: getAnswer(item.field, item.state, translator, { format: 'data' }) }) } } @@ -124,12 +124,9 @@ export function buildRepeaterRecords( const formValue = subItem.field.getFormValueFromState(subItem.state) value = formValue === undefined ? '' : JSON.stringify(formValue) } else { - value = getAnswer( - subItem.field, - subItem.state, - { format: 'data' }, - translator - ) + value = getAnswer(subItem.field, subItem.state, translator, { + format: 'data' + }) } return { diff --git a/src/server/plugins/nunjucks/filters/answer.js b/src/server/plugins/nunjucks/filters/answer.js index c0672e411..00ba8ddfa 100644 --- a/src/server/plugins/nunjucks/filters/answer.js +++ b/src/server/plugins/nunjucks/filters/answer.js @@ -20,14 +20,9 @@ export function answer(name) { const field = /** @type {Field} */ (component) const translator = field.model.createTranslator() - const answer = getAnswer( - field, - context.relevantState, - { format: 'summary' }, - translator - ) - - return answer + return getAnswer(field, context.relevantState, translator, { + format: 'summary' + }) } /** diff --git a/src/server/plugins/nunjucks/filters/answer.test.js b/src/server/plugins/nunjucks/filters/answer.test.js index bf53ea62d..1ba2b8135 100644 --- a/src/server/plugins/nunjucks/filters/answer.test.js +++ b/src/server/plugins/nunjucks/filters/answer.test.js @@ -88,8 +88,8 @@ describe('answer Nunjucks filter', () => { expect(getAnswer).toHaveBeenCalledWith( mockFormComponent, mockThis.ctx.context?.relevantState, - { format: 'summary' }, - { t: expect.anything() } + { t: expect.anything() }, + { format: 'summary' } ) expect(result).toBe('test answer') }) From 86efc3a1b921574d9c2eb3d92b1591584eb166c2 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Mon, 29 Jun 2026 14:24:04 +0100 Subject: [PATCH 84/89] Prettified --- .../i18n/extractBaseTranslations.test.ts | 4 +- .../plugins/engine/routes/index.test.ts | 16 +- .../plugins/engine/routes/questions.test.ts | 144 ++++++++---------- .../plugins/postcode-lookup/routes/index.js | 10 +- 4 files changed, 79 insertions(+), 95 deletions(-) diff --git a/src/server/plugins/engine/i18n/extractBaseTranslations.test.ts b/src/server/plugins/engine/i18n/extractBaseTranslations.test.ts index 0e4759851..cfe18ed14 100644 --- a/src/server/plugins/engine/i18n/extractBaseTranslations.test.ts +++ b/src/server/plugins/engine/i18n/extractBaseTranslations.test.ts @@ -91,7 +91,9 @@ describe('extractBaseTranslations', () => { }) it('skips components without an id', () => { - interface PageWithComponents { components: Record[] } + interface PageWithComponents { + components: Record[] + } const firstPage = def.pages[0] as unknown as PageWithComponents const defNoId = { ...def, diff --git a/src/server/plugins/engine/routes/index.test.ts b/src/server/plugins/engine/routes/index.test.ts index 5f4937a5d..ef2b3a770 100644 --- a/src/server/plugins/engine/routes/index.test.ts +++ b/src/server/plugins/engine/routes/index.test.ts @@ -37,15 +37,13 @@ function buildMockModel( isForceAccess: false, data: {} }), - createTranslator: jest - .fn() - .mockReturnValue({ - t: jest.fn(), - tPage: jest.fn(), - tComponent: jest.fn(), - tSection: jest.fn(), - tListItem: jest.fn() - }), + createTranslator: jest.fn().mockReturnValue({ + t: jest.fn(), + tPage: jest.fn(), + tComponent: jest.fn(), + tSection: jest.fn(), + tListItem: jest.fn() + }), pages: pagesControllerOverride, services: servicesOverride } as unknown as FormModel diff --git a/src/server/plugins/engine/routes/questions.test.ts b/src/server/plugins/engine/routes/questions.test.ts index f70dea6e8..41cd0a01e 100644 --- a/src/server/plugins/engine/routes/questions.test.ts +++ b/src/server/plugins/engine/routes/questions.test.ts @@ -66,15 +66,13 @@ describe('makeGetHandler', () => { const modelMock = { basePath: 'some-base-path', def: { name: 'Hello world' }, - createTranslator: jest - .fn() - .mockReturnValue({ - t: jest.fn(), - tPage: jest.fn(), - tComponent: jest.fn(), - tSection: jest.fn(), - tListItem: jest.fn() - }) + createTranslator: jest.fn().mockReturnValue({ + t: jest.fn(), + tPage: jest.fn(), + tComponent: jest.fn(), + tSection: jest.fn(), + tListItem: jest.fn() + }) } as unknown as FormModel const pageMock = createMockPageController( @@ -117,15 +115,13 @@ describe('makeGetHandler', () => { const modelMock = { basePath: 'some-base-path', def: { name: 'Hello world' }, - createTranslator: jest - .fn() - .mockReturnValue({ - t: jest.fn(), - tPage: jest.fn(), - tComponent: jest.fn(), - tSection: jest.fn(), - tListItem: jest.fn() - }) + createTranslator: jest.fn().mockReturnValue({ + t: jest.fn(), + tPage: jest.fn(), + tComponent: jest.fn(), + tSection: jest.fn(), + tListItem: jest.fn() + }) } as unknown as FormModel const pageMock = createMockPageController( @@ -168,15 +164,13 @@ describe('makeGetHandler', () => { const modelMock = { basePath: 'some-base-path', def: { name: 'Hello world' }, - createTranslator: jest - .fn() - .mockReturnValue({ - t: jest.fn(), - tPage: jest.fn(), - tComponent: jest.fn(), - tSection: jest.fn(), - tListItem: jest.fn() - }) + createTranslator: jest.fn().mockReturnValue({ + t: jest.fn(), + tPage: jest.fn(), + tComponent: jest.fn(), + tSection: jest.fn(), + tListItem: jest.fn() + }) } as unknown as FormModel const pageMock = createMockPageController( @@ -227,15 +221,13 @@ describe('makeGetHandler', () => { const modelMock = { basePath: 'some-base-path', def: { name: 'Hello world' }, - createTranslator: jest - .fn() - .mockReturnValue({ - t: jest.fn(), - tPage: jest.fn(), - tComponent: jest.fn(), - tSection: jest.fn(), - tListItem: jest.fn() - }) + createTranslator: jest.fn().mockReturnValue({ + t: jest.fn(), + tPage: jest.fn(), + tComponent: jest.fn(), + tSection: jest.fn(), + tListItem: jest.fn() + }) } as unknown as FormModel const pageMock = createMockPageController( @@ -303,15 +295,13 @@ describe('makePostHandler', () => { const modelMock = { basePath: 'some-base-path', def: { name: 'Hello world' }, - createTranslator: jest - .fn() - .mockReturnValue({ - t: jest.fn(), - tPage: jest.fn(), - tComponent: jest.fn(), - tSection: jest.fn(), - tListItem: jest.fn() - }) + createTranslator: jest.fn().mockReturnValue({ + t: jest.fn(), + tPage: jest.fn(), + tComponent: jest.fn(), + tSection: jest.fn(), + tListItem: jest.fn() + }) } as unknown as FormModel const pageMock = createMockPageController( @@ -352,15 +342,13 @@ describe('makePostHandler', () => { const modelMock = { basePath: 'some-base-path', def: { name: 'Hello world' }, - createTranslator: jest - .fn() - .mockReturnValue({ - t: jest.fn(), - tPage: jest.fn(), - tComponent: jest.fn(), - tSection: jest.fn(), - tListItem: jest.fn() - }) + createTranslator: jest.fn().mockReturnValue({ + t: jest.fn(), + tPage: jest.fn(), + tComponent: jest.fn(), + tSection: jest.fn(), + tListItem: jest.fn() + }) } as unknown as FormModel const pageMock = createMockPageController( @@ -405,15 +393,13 @@ describe('makePostHandler', () => { const modelMock = { basePath: 'some-base-path', def: { name: 'Hello world' }, - createTranslator: jest - .fn() - .mockReturnValue({ - t: jest.fn(), - tPage: jest.fn(), - tComponent: jest.fn(), - tSection: jest.fn(), - tListItem: jest.fn() - }) + createTranslator: jest.fn().mockReturnValue({ + t: jest.fn(), + tPage: jest.fn(), + tComponent: jest.fn(), + tSection: jest.fn(), + tListItem: jest.fn() + }) } as unknown as FormModel const pageMock = createMockPageController( @@ -455,15 +441,13 @@ describe('makePostHandler', () => { const modelMock = { basePath: 'some-base-path', def: { name: 'Hello world' }, - createTranslator: jest - .fn() - .mockReturnValue({ - t: jest.fn(), - tPage: jest.fn(), - tComponent: jest.fn(), - tSection: jest.fn(), - tListItem: jest.fn() - }) + createTranslator: jest.fn().mockReturnValue({ + t: jest.fn(), + tPage: jest.fn(), + tComponent: jest.fn(), + tSection: jest.fn(), + tListItem: jest.fn() + }) } as unknown as FormModel const pageMock = createMockPageController( @@ -515,15 +499,13 @@ describe('makePostHandler', () => { const modelMock = { basePath: 'some-base-path', def: { name: 'Hello world' }, - createTranslator: jest - .fn() - .mockReturnValue({ - t: jest.fn(), - tPage: jest.fn(), - tComponent: jest.fn(), - tSection: jest.fn(), - tListItem: jest.fn() - }) + createTranslator: jest.fn().mockReturnValue({ + t: jest.fn(), + tPage: jest.fn(), + tComponent: jest.fn(), + tSection: jest.fn(), + tListItem: jest.fn() + }) } as unknown as FormModel const pageMock = createMockPageController( diff --git a/src/server/plugins/postcode-lookup/routes/index.js b/src/server/plugins/postcode-lookup/routes/index.js index 1dbb88a56..95e220ed7 100644 --- a/src/server/plugins/postcode-lookup/routes/index.js +++ b/src/server/plugins/postcode-lookup/routes/index.js @@ -173,8 +173,9 @@ async function detailsPostHandler(request, h, options) { const session = getSessionState(request) const { ordnanceSurveyApiKey: apiKey } = options const language = session.initial.language ?? 'en-GB' - const { value: details, error } = - createDetailsPayloadSchema(language).validate(request.payload) + const { value: details, error } = createDetailsPayloadSchema( + language + ).validate(request.payload) let model @@ -205,8 +206,9 @@ async function selectPostHandler(request, h, options) { const session = getSessionState(request) const { ordnanceSurveyApiKey: apiKey } = options const language = session.initial.language ?? 'en-GB' - const { value: select, error } = - createSelectPayloadSchema(language).validate(request.payload) + const { value: select, error } = createSelectPayloadSchema(language).validate( + request.payload + ) if (error) { const model = await selectViewModel({ session, apiKey }, select, error) From 58550af5410c46a954cba00d24994438c2b2ccc2 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 29 Jun 2026 16:18:54 +0100 Subject: [PATCH 85/89] Sonar fixes (Remove nested ternary) --- .../engine/pageControllers/QuestionPageController.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index c55ec92e7..543650718 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -130,15 +130,14 @@ export class QuestionPageController extends PageController { const { t, tPage, tSection } = translator - let { showTitle } = viewModel + let { showTitle, sectionTitle } = viewModel let pageTitle = tPage(this.pageDef, 'title') || viewModel.pageTitle - const sectionTitle = this.section - ? this.section.hideTitle !== true - ? tSection(this.section, 'title') - : '' - : viewModel.sectionTitle + if (this.section) { + sectionTitle = + this.section.hideTitle !== true ? tSection(this.section, 'title') : '' + } const components = collection.getViewModel({ payload, errors, translator }) const formComponents = components.filter( From 389f9ef08973801b0cf56301c21395d0395fbfdd Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 30 Jun 2026 13:57:31 +0100 Subject: [PATCH 86/89] Form root-level translations + language property --- .../engine/components/FileUploadField.test.ts | 4 +- .../engine/components/TextField.test.ts | 12 ++++-- src/server/plugins/engine/helpers.test.ts | 4 +- .../plugins/engine/i18n/translations/cy.json | 9 +++- .../engine/i18n/translations/en-GB.json | 9 +++- src/server/plugins/engine/i18n/types.ts | 3 ++ src/server/plugins/engine/models/FormModel.ts | 43 ++++++++++++++++++- .../engine/models/SummaryViewModel.test.ts | 4 +- .../QuestionPageController.test.ts | 12 ++++-- .../RepeatPageController.test.ts | 12 ++++-- .../SummaryPageController.test.ts | 4 +- .../pageControllers/__stubs__/translator.ts | 4 +- .../plugins/engine/routes/index.test.ts | 4 +- .../plugins/engine/routes/questions.test.ts | 36 ++++++++++++---- src/server/plugins/engine/types.ts | 3 ++ .../engine/views/partials/preview-banner.html | 8 ++-- 16 files changed, 137 insertions(+), 34 deletions(-) diff --git a/src/server/plugins/engine/components/FileUploadField.test.ts b/src/server/plugins/engine/components/FileUploadField.test.ts index 649952b81..2886b39bf 100644 --- a/src/server/plugins/engine/components/FileUploadField.test.ts +++ b/src/server/plugins/engine/components/FileUploadField.test.ts @@ -921,7 +921,9 @@ describe('FileUploadField', () => { tPage: jest.fn(), tComponent: jest.fn(), tSection: jest.fn(), - tListItem: jest.fn() + tListItem: jest.fn(), + tForm: jest.fn(), + language: 'en-GB' }) } } diff --git a/src/server/plugins/engine/components/TextField.test.ts b/src/server/plugins/engine/components/TextField.test.ts index 5bfe3d9f8..1223fdbbe 100644 --- a/src/server/plugins/engine/components/TextField.test.ts +++ b/src/server/plugins/engine/components/TextField.test.ts @@ -267,7 +267,9 @@ describe('TextField', () => { tComponent, tPage: jest.fn(), tSection: jest.fn(), - tListItem: jest.fn() + tListItem: jest.fn(), + tForm: jest.fn(), + language: 'en-GB' } }) expect(tComponent).toHaveBeenCalledWith(field, 'title') @@ -288,7 +290,9 @@ describe('TextField', () => { tComponent, tPage: jest.fn(), tSection: jest.fn(), - tListItem: jest.fn() + tListItem: jest.fn(), + tForm: jest.fn(), + language: 'en-GB' } }) expect(tComponent).toHaveBeenCalledWith(hintField, 'hint') @@ -309,7 +313,9 @@ describe('TextField', () => { tComponent, tPage: jest.fn(), tSection: jest.fn(), - tListItem: jest.fn() + tListItem: jest.fn(), + tForm: jest.fn(), + language: 'en-GB' } }) expect(t).toHaveBeenCalledWith('common.optional') diff --git a/src/server/plugins/engine/helpers.test.ts b/src/server/plugins/engine/helpers.test.ts index b75d0f537..588c97b94 100644 --- a/src/server/plugins/engine/helpers.test.ts +++ b/src/server/plugins/engine/helpers.test.ts @@ -545,7 +545,9 @@ describe('Helpers', () => { componentDefMap: model.componentDefMap, pageMap: model.pageMap, componentMap: model.componentMap, - referenceNumber: 'foobar' + referenceNumber: 'foobar', + translator: model.createTranslator(), + languages: [] } }) diff --git a/src/server/plugins/engine/i18n/translations/cy.json b/src/server/plugins/engine/i18n/translations/cy.json index 2cfb700f9..a98cf4aaf 100644 --- a/src/server/plugins/engine/i18n/translations/cy.json +++ b/src/server/plugins/engine/i18n/translations/cy.json @@ -27,7 +27,9 @@ "warningIconFallback": "Rhybudd", "back": "Yn ôl", "continue": "Parhau", - "saveAndExit": "Cadw a gadael" + "saveAndExit": "Cadw a gadael", + "draft": "ddrafft", + "live": "fyw" }, "pages": { @@ -106,7 +108,10 @@ "warnMissingNotificationEmail": { "body": "Os hoffech dderbyn e-bost prawf, [[link]] yr hoffech i gyflwyniadau ffurflen fynd iddo.", "linkText": "nodwch y cyfeiriad e-bost (yn agor mewn tab newydd)" - } + }, + "bannerForce": "Rhagolwg o ffurflen [[previewMode]] yw hon. Peidiwch â rhoi gwybodaeth bersonol.", + "bannerText": "Dyma ragolwg o dudalen ffurflen [[previewMode]] rydych chi'n ei golygu.", + "previousPages": "Mae'n dibynnu ar atebion o dudalennau cynharach yn y ffurflen. Yn y fersiwn fyw, bydd angen i ddefnyddwyr gwblhau'r cwestiynau hynny yn gyntaf." } }, diff --git a/src/server/plugins/engine/i18n/translations/en-GB.json b/src/server/plugins/engine/i18n/translations/en-GB.json index 937408ffb..26b0a8d97 100644 --- a/src/server/plugins/engine/i18n/translations/en-GB.json +++ b/src/server/plugins/engine/i18n/translations/en-GB.json @@ -27,7 +27,9 @@ "warningIconFallback": "Warning", "back": "Back", "continue": "Continue", - "saveAndExit": "Save and exit" + "saveAndExit": "Save and exit", + "draft": "draft", + "live": "live" }, "pages": { @@ -106,7 +108,10 @@ "warnMissingNotificationEmail": { "body": "If you want to receive a test email, [[link]] you want form submissions to go to.", "linkText": "enter the email address (opens in new tab)" - } + }, + "bannerForce": "This is a preview of a [[previewMode]] form. Do not enter personal information.", + "bannerText": "This is a preview of a [[previewMode]] form page you are editing.", + "previousPages": "It depends on answers from earlier pages in the form. In the live version, users will need to complete those questions first." } }, diff --git a/src/server/plugins/engine/i18n/types.ts b/src/server/plugins/engine/i18n/types.ts index e5101f681..c0927154b 100644 --- a/src/server/plugins/engine/i18n/types.ts +++ b/src/server/plugins/engine/i18n/types.ts @@ -34,6 +34,7 @@ type EntityTranslations = FormDefinitionTranslations[string] /** * Scoped translator returned by model.createTranslator(language). * t — plugin namespace (UI strings, buttons, errors, sub-field labels) + * tForm — form namespace: form attributes, such as name * tPage — form namespace: page titles * tComponent — form namespace: component titles, hints, shortDescriptions * tSection — form namespace: section titles @@ -41,6 +42,7 @@ type EntityTranslations = FormDefinitionTranslations[string] */ export interface Translator { t: (key: string, opts?: Record) => string + tForm: (prop: string) => string tPage: (page: Page, prop: keyof EntityTranslations['pages'][string]) => string tComponent: ( component: ComponentDef, @@ -54,4 +56,5 @@ export interface Translator { item: Item, prop: keyof EntityTranslations['listItems'][string] ) => string + language: string } diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index 92324ffd9..de8a50904 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -284,15 +284,26 @@ export class FormModel { return result } + const resolveRootContent = (prop: string) => { + const key = `form.${prop}` + const translation = i18nInstance.t(key, { lng: language, ns: 'form' }) + if (translation === key && prop in this.def) { + return (this.def as unknown as Record)[prop] ?? key + } + return translation + } + return { t, + tForm: (prop) => resolveRootContent(prop), tPage: (entity, prop) => resolveContent(entity, 'pages', prop as string), tComponent: (entity, prop) => resolveContent(entity, 'components', prop as string), tSection: (entity, prop) => resolveContent(entity, 'sections', prop as string), tListItem: (entity, prop) => - resolveContent(entity, 'listItems', prop as string) + resolveContent(entity, 'listItems', prop as string), + language } } @@ -431,7 +442,9 @@ export class FormModel { componentDefMap: this.componentDefMap, pageMap: this.pageMap, componentMap: this.componentMap, - referenceNumber: getReferenceNumber(state) + referenceNumber: getReferenceNumber(state), + languages: getAvailableLanguages(this.def), + translator } // Validate current page @@ -729,3 +742,29 @@ function getReferenceNumber(state: FormSubmissionState): string { return state.$$__referenceNumber } + +const EN_GB = 'en-GB' + +const allowedLanguages = { + + [EN_GB]: 'English', + cy: 'Welsh (Cymraeg)' +} as Record + +function getAvailableLanguages( + def: FormDefinition +): { name: string; code: string }[] { + if (def.metadata?.translations) { + const translations = Object.getOwnPropertyNames(def.metadata.translations) + if (!translations.includes(EN_GB)) { + translations.unshift(EN_GB) + } + return translations + .filter((lang) => allowedLanguages[lang]) + .map((lang) => ({ + code: lang, + name: allowedLanguages[lang] + })) + } + return [] +} diff --git a/src/server/plugins/engine/models/SummaryViewModel.test.ts b/src/server/plugins/engine/models/SummaryViewModel.test.ts index c6ddfcf9d..32964fdda 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.test.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.test.ts @@ -400,7 +400,9 @@ describe('SummaryViewModel with per-request Translator', () => { .mockReturnValue('mocked-content') as Translator['tSection'], tListItem: jest .fn() - .mockReturnValue('mocked-content') as Translator['tListItem'] + .mockReturnValue('mocked-content') as Translator['tListItem'], + tForm: jest.fn().mockReturnValue('mocked-content') as Translator['tForm'], + language: 'en-GB' } }) diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts index 96928cb6e..d217231e0 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts @@ -1745,7 +1745,9 @@ describe('QuestionPageController translator support', () => { tPage: jest.fn((_, prop) => `content:${prop}`), tComponent: jest.fn((_, prop) => `content:${prop}`), tSection: jest.fn((_, prop) => `content:${prop}`), - tListItem: jest.fn((_, prop) => `content:${prop}`) + tListItem: jest.fn((_, prop) => `content:${prop}`), + tForm: jest.fn((prop) => `content:${prop}`), + language: 'en-GB' } // Use a request with returnUrl so that getBackLink calls t() for back link text @@ -1782,7 +1784,9 @@ describe('QuestionPageController translator support', () => { tPage: mockTPage as Translator['tPage'], tComponent: jest.fn(() => ''), tSection: jest.fn(() => ''), - tListItem: jest.fn(() => '') + tListItem: jest.fn(() => ''), + tForm: jest.fn(() => ''), + language: 'en-GB' } const context = model.getFormContext(requestPage1, { @@ -1804,7 +1808,9 @@ describe('QuestionPageController translator support', () => { tPage: stubTranslator.tPage, tComponent: stubTranslator.tComponent, tSection: stubTranslator.tSection, - tListItem: stubTranslator.tListItem + tListItem: stubTranslator.tListItem, + tForm: stubTranslator.tForm, + language: 'en-GB' } // Use a request with returnUrl so that getBackLink calls translator.t for back link text diff --git a/src/server/plugins/engine/pageControllers/RepeatPageController.test.ts b/src/server/plugins/engine/pageControllers/RepeatPageController.test.ts index 0be236813..d2a85c7d2 100644 --- a/src/server/plugins/engine/pageControllers/RepeatPageController.test.ts +++ b/src/server/plugins/engine/pageControllers/RepeatPageController.test.ts @@ -291,7 +291,9 @@ describe('RepeatPageController', () => { tPage: jest.fn(() => ''), tComponent: jest.fn(() => ''), tSection: jest.fn(() => ''), - tListItem: jest.fn(() => '') + tListItem: jest.fn(() => ''), + tForm: jest.fn(() => ''), + language: 'en-GB' } const context = model.getFormContext(requestPageItem, { @@ -316,7 +318,9 @@ describe('RepeatPageController', () => { tPage: mockTPage as Translator['tPage'], tComponent: jest.fn(() => ''), tSection: jest.fn(() => ''), - tListItem: jest.fn(() => '') + tListItem: jest.fn(() => ''), + tForm: jest.fn(() => ''), + language: 'en-GB' } const context = model.getFormContext(requestPageItem, { @@ -346,7 +350,9 @@ describe('RepeatPageController', () => { tPage: jest.fn(() => ''), tComponent: jest.fn(() => ''), tSection: jest.fn(() => ''), - tListItem: jest.fn(() => '') + tListItem: jest.fn(() => ''), + tForm: jest.fn(() => ''), + language: 'en-GB' } const context = model.getFormContext(requestPageSummary, { diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts index 98c8b66f1..d7d9df8bc 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts @@ -109,7 +109,9 @@ describe('SummaryPageController', () => { .mockReturnValue('content') as Translator['tSection'], tListItem: jest .fn() - .mockReturnValue('content') as Translator['tListItem'] + .mockReturnValue('content') as Translator['tListItem'], + tForm: jest.fn().mockReturnValue('content') as Translator['tForm'], + language: 'en-GB' } const createTranslatorSpy = jest diff --git a/src/server/plugins/engine/pageControllers/__stubs__/translator.ts b/src/server/plugins/engine/pageControllers/__stubs__/translator.ts index ee99fbf96..585f79fdf 100644 --- a/src/server/plugins/engine/pageControllers/__stubs__/translator.ts +++ b/src/server/plugins/engine/pageControllers/__stubs__/translator.ts @@ -19,8 +19,10 @@ function resolveEntity(entity: unknown, prop: string): string { export const stubTranslator: Translator = { t: (key: string, opts?: Record) => resolveKey(key, 'en-GB', opts), + tForm: (prop) => resolveEntity({}, prop), tPage: (entity, prop) => resolveEntity(entity, prop as string), tComponent: (entity, prop) => resolveEntity(entity, prop as string), tSection: (entity, prop) => resolveEntity(entity, prop as string), - tListItem: (entity, prop) => resolveEntity(entity, prop as string) + tListItem: (entity, prop) => resolveEntity(entity, prop as string), + language: 'en-GB' } diff --git a/src/server/plugins/engine/routes/index.test.ts b/src/server/plugins/engine/routes/index.test.ts index ef2b3a770..1dde54696 100644 --- a/src/server/plugins/engine/routes/index.test.ts +++ b/src/server/plugins/engine/routes/index.test.ts @@ -42,7 +42,9 @@ function buildMockModel( tPage: jest.fn(), tComponent: jest.fn(), tSection: jest.fn(), - tListItem: jest.fn() + tListItem: jest.fn(), + tForm: jest.fn(), + language: 'en-GB' }), pages: pagesControllerOverride, services: servicesOverride diff --git a/src/server/plugins/engine/routes/questions.test.ts b/src/server/plugins/engine/routes/questions.test.ts index 41cd0a01e..43de7de81 100644 --- a/src/server/plugins/engine/routes/questions.test.ts +++ b/src/server/plugins/engine/routes/questions.test.ts @@ -71,7 +71,9 @@ describe('makeGetHandler', () => { tPage: jest.fn(), tComponent: jest.fn(), tSection: jest.fn(), - tListItem: jest.fn() + tListItem: jest.fn(), + tForm: jest.fn(), + language: 'en-GB' }) } as unknown as FormModel @@ -120,7 +122,9 @@ describe('makeGetHandler', () => { tPage: jest.fn(), tComponent: jest.fn(), tSection: jest.fn(), - tListItem: jest.fn() + tListItem: jest.fn(), + tForm: jest.fn(), + language: 'en-GB' }) } as unknown as FormModel @@ -169,7 +173,9 @@ describe('makeGetHandler', () => { tPage: jest.fn(), tComponent: jest.fn(), tSection: jest.fn(), - tListItem: jest.fn() + tListItem: jest.fn(), + tForm: jest.fn(), + language: 'en-GB' }) } as unknown as FormModel @@ -226,7 +232,9 @@ describe('makeGetHandler', () => { tPage: jest.fn(), tComponent: jest.fn(), tSection: jest.fn(), - tListItem: jest.fn() + tListItem: jest.fn(), + tForm: jest.fn(), + language: 'en-GB' }) } as unknown as FormModel @@ -300,7 +308,9 @@ describe('makePostHandler', () => { tPage: jest.fn(), tComponent: jest.fn(), tSection: jest.fn(), - tListItem: jest.fn() + tListItem: jest.fn(), + tForm: jest.fn(), + language: 'en-GB' }) } as unknown as FormModel @@ -347,7 +357,9 @@ describe('makePostHandler', () => { tPage: jest.fn(), tComponent: jest.fn(), tSection: jest.fn(), - tListItem: jest.fn() + tListItem: jest.fn(), + tForm: jest.fn(), + language: 'en-GB' }) } as unknown as FormModel @@ -398,7 +410,9 @@ describe('makePostHandler', () => { tPage: jest.fn(), tComponent: jest.fn(), tSection: jest.fn(), - tListItem: jest.fn() + tListItem: jest.fn(), + tForm: jest.fn(), + language: 'en-GB' }) } as unknown as FormModel @@ -446,7 +460,9 @@ describe('makePostHandler', () => { tPage: jest.fn(), tComponent: jest.fn(), tSection: jest.fn(), - tListItem: jest.fn() + tListItem: jest.fn(), + tForm: jest.fn(), + language: 'en-GB' }) } as unknown as FormModel @@ -504,7 +520,9 @@ describe('makePostHandler', () => { tPage: jest.fn(), tComponent: jest.fn(), tSection: jest.fn(), - tListItem: jest.fn() + tListItem: jest.fn(), + tForm: jest.fn(), + language: 'en-GB' }) } as unknown as FormModel diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index b5efe0888..0870502f2 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -32,6 +32,7 @@ import { type LatLongState, type MonthYearState } from '~/src/server/plugins/engine/components/types.js' +import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { type DetailItemField } from '~/src/server/plugins/engine/models/types.js' import { type PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js' @@ -200,6 +201,8 @@ export interface FormContext { pageMap: Map componentMap: Map referenceNumber: string + languages: { name: string; code: string }[] + translator: Translator | undefined } export type FormContextRequest = ( diff --git a/src/server/plugins/engine/views/partials/preview-banner.html b/src/server/plugins/engine/views/partials/preview-banner.html index 66dfb75e6..3d4cc427b 100644 --- a/src/server/plugins/engine/views/partials/preview-banner.html +++ b/src/server/plugins/engine/views/partials/preview-banner.html @@ -9,23 +9,23 @@ {%- endmacro -%} {% call govukNotificationBanner() %} + {% set previewModeTranslated = t('common.' + previewMode) %} {% if not context.isForceAccess %}

      - This is a preview of a {{ previewMode }} form. Do not enter personal information. + {{ t('pages.preview.bannerForce', { previewMode: previewModeTranslated }) }}

      {{ _closeLink() }} {% else %}

      - This is a preview of a {{ previewMode }} form page you are editing. + {{ t('pages.preview.bannerText', { previewMode: previewModeTranslated }) }}

      {{ _closeLink() }} {% if hasPreviousPages %}

      - It depends on answers from earlier pages in the form. In the live - version, users will need to complete those questions first. + {{ t('pages.preview.previousPages') }}

      {% endif %} {% endif %} From 42b308ad6f727e657040c16fb8ed1cb1a0446acc Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 30 Jun 2026 14:12:24 +0100 Subject: [PATCH 87/89] Prettified + extra tests --- .../plugins/engine/models/FormModel.test.ts | 54 ++++++++++++++++++- src/server/plugins/engine/models/FormModel.ts | 3 +- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/server/plugins/engine/models/FormModel.test.ts b/src/server/plugins/engine/models/FormModel.test.ts index 6271ec3e6..ccc01368b 100644 --- a/src/server/plugins/engine/models/FormModel.test.ts +++ b/src/server/plugins/engine/models/FormModel.test.ts @@ -9,7 +9,10 @@ import { } from '@defra/forms-model' import { todayAsDateOnly } from '~/src/server/plugins/engine/date-helper.js' -import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { + FormModel, + getAvailableLanguages +} from '~/src/server/plugins/engine/models/FormModel.js' import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js' import { type FormContextRequest } from '~/src/server/plugins/engine/types.js' import { FormAction } from '~/src/server/routes/types.js' @@ -795,6 +798,55 @@ describe('FormModel - Joined Conditions', () => { 'Have you previously been married?' ) }) + + it('returns the form name with en-GB language (falls back to base en-GB form string)', () => { + const model = new FormModel(definitionV2, { basePath: 'test' }) + const { tForm } = model.createTranslator('en-GB') + expect(tForm('name')).toBe('Conditions V2') + }) + }) + + describe('getAvailableLanguages', () => { + it('should return list of languages, omitting unrecognised but adding english if not in list', () => { + const def = { + metadata: { + translations: { + cy: {}, + fr: { + field1: 123 + } + } + } + } as unknown as FormDefinition + expect(getAvailableLanguages(def)).toEqual([ + { code: 'en-GB', name: 'English' }, + { code: 'cy', name: 'Welsh (Cymraeg)' } + ]) + }) + + it('should not add english if already in list', () => { + const def = { + metadata: { + translations: { + 'en-GB': { + field1: 123 + }, + cy: {} + } + } + } as unknown as FormDefinition + expect(getAvailableLanguages(def)).toEqual([ + { code: 'en-GB', name: 'English' }, + { code: 'cy', name: 'Welsh (Cymraeg)' } + ]) + }) + + it('should return empty list if no translations', () => { + const def = { + metadata: {} + } as unknown as FormDefinition + expect(getAvailableLanguages(def)).toEqual([]) + }) }) describe('moreThanOnePaymentQuestion', () => { diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index de8a50904..a8a918396 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -746,12 +746,11 @@ function getReferenceNumber(state: FormSubmissionState): string { const EN_GB = 'en-GB' const allowedLanguages = { - [EN_GB]: 'English', cy: 'Welsh (Cymraeg)' } as Record -function getAvailableLanguages( +export function getAvailableLanguages( def: FormDefinition ): { name: string; code: string }[] { if (def.metadata?.translations) { From 95772c836bb348fe0915c90bbd70132f5bfa7bc1 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 30 Jun 2026 14:27:29 +0100 Subject: [PATCH 88/89] Removed TODO --- src/server/plugins/engine/form-context.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/server/plugins/engine/form-context.ts b/src/server/plugins/engine/form-context.ts index a451f2701..579f08077 100644 --- a/src/server/plugins/engine/form-context.ts +++ b/src/server/plugins/engine/form-context.ts @@ -78,7 +78,6 @@ export async function getFormModel( }, services, options.controllers - // TODO - should we pass a new translator in here? ) } From 4b61a207ac847986f712736238d396b64451a309 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 30 Jun 2026 15:43:28 +0100 Subject: [PATCH 89/89] Changed Welsh name in language options --- src/server/plugins/engine/models/FormModel.test.ts | 4 ++-- src/server/plugins/engine/models/FormModel.ts | 2 +- src/server/plugins/engine/pageControllers/PageController.ts | 5 ++--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/server/plugins/engine/models/FormModel.test.ts b/src/server/plugins/engine/models/FormModel.test.ts index ccc01368b..7a9cbccff 100644 --- a/src/server/plugins/engine/models/FormModel.test.ts +++ b/src/server/plugins/engine/models/FormModel.test.ts @@ -820,7 +820,7 @@ describe('FormModel - Joined Conditions', () => { } as unknown as FormDefinition expect(getAvailableLanguages(def)).toEqual([ { code: 'en-GB', name: 'English' }, - { code: 'cy', name: 'Welsh (Cymraeg)' } + { code: 'cy', name: 'Cymraeg' } ]) }) @@ -837,7 +837,7 @@ describe('FormModel - Joined Conditions', () => { } as unknown as FormDefinition expect(getAvailableLanguages(def)).toEqual([ { code: 'en-GB', name: 'English' }, - { code: 'cy', name: 'Welsh (Cymraeg)' } + { code: 'cy', name: 'Cymraeg' } ]) }) diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index a8a918396..fb8fc2a2e 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -747,7 +747,7 @@ const EN_GB = 'en-GB' const allowedLanguages = { [EN_GB]: 'English', - cy: 'Welsh (Cymraeg)' + cy: 'Cymraeg' } as Record export function getAvailableLanguages( diff --git a/src/server/plugins/engine/pageControllers/PageController.ts b/src/server/plugins/engine/pageControllers/PageController.ts index fc1bb752b..8925da579 100644 --- a/src/server/plugins/engine/pageControllers/PageController.ts +++ b/src/server/plugins/engine/pageControllers/PageController.ts @@ -11,7 +11,6 @@ import { type Lifecycle, type RouteOptions, type Server } from '@hapi/hapi' import { type ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { type FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' import { - getPluginOptions, getSaveAndExitHelpers, getStartPath, normalisePath @@ -31,6 +30,7 @@ import { type FormRequestRefs, type FormResponseToolkit } from '~/src/server/routes/types.js' +import { resolveLanguage } from '~/src/server/utils/utils.js' export class PageController { /** @@ -165,8 +165,7 @@ export class PageController { } protected getTranslator(request: AnyFormRequest): Translator { - const { getLanguage } = getPluginOptions(request.server) - return this.model.createTranslator(getLanguage?.(request) ?? 'en-GB') + return this.model.createTranslator(resolveLanguage(request)) } makeGetRouteHandler(): (