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/package-lock.json b/package-lock.json
index 7913b5fce..6177a4dc3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -49,6 +49,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",
@@ -150,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": {
@@ -212,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"
},
@@ -226,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",
@@ -265,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"
@@ -293,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"
@@ -363,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"
@@ -385,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"
@@ -483,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"
@@ -524,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"
@@ -1981,40 +1982,40 @@
}
},
"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"
}
},
"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": {
@@ -2022,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"
@@ -5653,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"
@@ -5745,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",
@@ -5986,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",
@@ -6211,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": {
@@ -6221,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": {
@@ -6249,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": {
@@ -6296,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",
@@ -6310,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",
@@ -6342,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": {
@@ -6352,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": {
@@ -6395,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"
@@ -6423,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"
@@ -6496,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": {
@@ -6528,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"
},
@@ -6600,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"
@@ -6631,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"
},
@@ -6647,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": {
@@ -6673,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"
@@ -6710,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": "*",
@@ -6728,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",
@@ -9452,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"
@@ -9701,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": {
@@ -12950,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"
@@ -13037,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": {
@@ -13207,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": {
@@ -13325,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",
@@ -13338,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"
@@ -13384,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",
@@ -14368,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"
@@ -18143,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"
@@ -18170,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",
@@ -18196,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",
@@ -18257,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",
@@ -20091,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",
@@ -20207,6 +20197,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",
@@ -21241,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": {
@@ -21285,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": {
@@ -21361,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"
@@ -21388,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": {
@@ -21403,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"
@@ -21434,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",
@@ -21448,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",
@@ -21480,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": {
@@ -21513,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"
},
@@ -21564,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",
@@ -21578,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": {
@@ -21626,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",
@@ -21658,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"
@@ -21687,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": {
@@ -21722,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"
@@ -21752,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"
@@ -21818,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": {
@@ -21843,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"
},
@@ -21859,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",
@@ -21876,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"
@@ -21903,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"
@@ -21954,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"
},
@@ -21996,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",
@@ -22009,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",
@@ -22042,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"
@@ -22075,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": {
@@ -22085,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"
},
@@ -22105,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"
@@ -22129,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"
},
@@ -22163,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"
},
@@ -22196,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"
},
@@ -22230,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": {
@@ -22288,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": {
@@ -22299,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"
},
@@ -22320,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",
@@ -22334,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": {
@@ -22369,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"
@@ -22400,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"
@@ -22431,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": {
@@ -22579,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",
@@ -22622,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"
@@ -22838,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": {
@@ -23084,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"
@@ -29377,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"
@@ -29582,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",
@@ -31528,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"
@@ -32807,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"
@@ -33633,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"
@@ -34355,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"
@@ -34493,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",
@@ -34993,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 68ef845be..ad861377e 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": {
@@ -126,6 +126,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/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/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/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/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/forms/simple-form.yaml b/src/server/forms/simple-form.yaml
index af1f92f38..06cc23655 100644
--- a/src/server/forms/simple-form.yaml
+++ b/src/server/forms/simple-form.yaml
@@ -3,6 +3,57 @@ 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
+ 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
+ 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
+ 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'
@@ -36,6 +87,21 @@ pages:
id: e18116e0-7c3e-416a-af42-6f229017c5b1
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'
@@ -46,7 +112,7 @@ pages:
shortDescription: Upload passport
hint: ''
options:
- required: true
+ required: false
schema: {}
id: 987c1234-56d7-89e0-1234-56789abcdef0
id: 23456789-0abc-def1-2345-67890abcdef1
@@ -60,7 +126,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
@@ -78,6 +144,29 @@ 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
- title: ''
path: '/notification-demo'
components:
@@ -103,4 +192,18 @@ pages:
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
diff --git a/src/server/index.test.ts b/src/server/index.test.ts
index 919d7a5d9..1c6a6f208 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/components/AutocompleteField.test.ts b/src/server/plugins/engine/components/AutocompleteField.test.ts
index a94f0574d..080f5a7dc 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,
@@ -20,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: {
@@ -206,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, translator)
+ const answer2 = getAnswer(field, state2, translator)
expect(answer1).toBe(item.text)
expect(answer2).toBe('')
@@ -265,7 +270,11 @@ describe.each([
it('sets Nunjucks component defaults', () => {
const item = options.examples[0]
- const viewModel = field.getViewModel(getFormData(item.value))
+ const viewModel = field.getViewModel({
+ payload: getFormData(item.value),
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -280,7 +289,11 @@ describe.each([
it.each([...options.examples])(
'sets Nunjucks component autocomplete suggestions',
(item) => {
- const viewModel = field.getViewModel(getFormData(item.value))
+ 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 2a9067ff6..312d38ba6 100644
--- a/src/server/plugins/engine/components/AutocompleteField.ts
+++ b/src/server/plugins/engine/components/AutocompleteField.ts
@@ -1,11 +1,8 @@
import { type AutocompleteFieldComponent } from '@defra/forms-model'
import { SelectField } from '~/src/server/plugins/engine/components/SelectField.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']
@@ -34,8 +31,8 @@ export class AutocompleteField extends SelectField {
this.formSchema = formSchema
}
- getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) {
- const viewModel = super.getViewModel(payload, errors)
+ 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 3b3b28189..f2b80974b 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,
@@ -22,6 +23,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: {
@@ -304,8 +309,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, translator)
+ const answer2 = getAnswer(field, state2, translator)
expect(answer1).toBe(outdent`
@@ -322,7 +327,7 @@ describe.each([
const item2 = options.examples[2]
const state = getFormState([item1.value, item2.value])
- const answer = getAnswer(field, state)
+ const answer = getAnswer(field, state, translator)
expect(answer).toBe(outdent`
@@ -384,7 +389,11 @@ describe.each([
it('sets Nunjucks component defaults', () => {
const item = options.examples[0]
- const viewModel = field.getViewModel(getFormData([item.value]))
+ const viewModel = field.getViewModel({
+ payload: getFormData([item.value]),
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -399,7 +408,11 @@ describe.each([
it.each([...options.examples])(
'sets Nunjucks component checkbox items',
(item) => {
- const viewModel = field.getViewModel(getFormData([item.value]))
+ const viewModel = field.getViewModel({
+ payload: getFormData([item.value]),
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel.items?.[0]).not.toMatchObject({
value: '' // First item is never empty
@@ -448,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)
+ 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([])
+ const result = checkboxField.getDisplayStringFromFormValue(
+ [],
+ translator
+ )
expect(result).toBe('')
})
@@ -462,9 +481,10 @@ describe.each([
'returns text for single selected value',
(item) => {
const checkboxField = field as CheckboxesField
- const result = checkboxField.getDisplayStringFromFormValue([
- item.value
- ])
+ const result = checkboxField.getDisplayStringFromFormValue(
+ [item.value],
+ translator
+ )
expect(result).toBe(item.text)
}
)
@@ -474,10 +494,10 @@ describe.each([
const item1 = options.examples[0]
const item2 = options.examples[2]
- const result = checkboxField.getDisplayStringFromFormValue([
- item1.value,
- item2.value
- ])
+ const result = checkboxField.getDisplayStringFromFormValue(
+ [item1.value, item2.value],
+ translator
+ )
expect(result).toBe(`${item1.text}, ${item2.text}`)
})
diff --git a/src/server/plugins/engine/components/CheckboxesField.ts b/src/server/plugins/engine/components/CheckboxesField.ts
index 399867ee3..f6bc84bdd 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 { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js'
@@ -41,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) {
@@ -86,7 +87,8 @@ export class CheckboxesField extends SelectionControlField {
}
getDisplayStringFromFormValue(
- selected: (string | number | boolean)[] | undefined
+ selected: (string | number | boolean)[] | undefined,
+ translator: Translator
) {
const { items } = this
@@ -94,10 +96,9 @@ 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.tListItem(item, 'text') || item.text)
.join(', ')
}
@@ -118,12 +119,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 df0ae3f48..2d42772d9 100644
--- a/src/server/plugins/engine/components/ComponentCollection.ts
+++ b/src/server/plugins/engine/components/ComponentCollection.ts
@@ -16,11 +16,18 @@ 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'
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,
@@ -28,7 +35,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
@@ -215,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(
+ translator: Translator,
+ errors?: FormSubmissionError[]
+ ): FormSubmissionError[] | undefined {
+ return this.getFieldErrors(
+ (field) => field.getErrors(translator, errors),
+ errors
+ )
}
/**
@@ -225,16 +237,16 @@ export class ComponentCollection {
* Composite fields like UKAddress can choose to return more than one error.
*/
getViewErrors(
+ translator: Translator,
errors?: FormSubmissionError[]
): FormSubmissionError[] | undefined {
- return this.getFieldErrors((field) => field.getViewErrors(errors), errors)
+ return this.getFieldErrors(
+ (field) => field.getViewErrors(translator, errors),
+ errors
+ )
}
- getViewModel(
- payload: FormPayload,
- errors?: FormSubmissionError[],
- query: FormQuery = {}
- ) {
+ getViewModel(context: RenderContext) {
const { components } = this
const result: ComponentViewModel[] = components.map((component) => {
@@ -242,7 +254,7 @@ export class ComponentCollection {
const model =
component instanceof FormComponent
- ? component.getViewModel(payload, errors, query)
+ ? component.getViewModel(context)
: component.getViewModel()
return { type, isFormComponent, model }
@@ -254,8 +266,67 @@ 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
+
+ // 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) {
+ // 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.tComponent(
+ field as unknown as ComponentDef,
+ 'shortDescription'
+ ) ||
+ translator.tComponent(field as unknown as ComponentDef, 'title')
+ const messagesOverride =
+ field.getValidationMessagesOverride(translator)
+ let patchedSchema = field.formSchema
+ if (translatedLabel && translatedLabel !== field.label) {
+ patchedSchema = patchedSchema.label(translatedLabel)
+ }
+ if (messagesOverride) {
+ patchedSchema = patchedSchema.messages(messagesOverride)
+ }
+ if (patchedSchema !== field.formSchema) {
+ labelOverrides[field.name] = patchedSchema
+ }
+ }
+ }
+
+ if (Object.keys(labelOverrides).length) {
+ schema = schema.keys(labelOverrides)
+ }
+ }
+
+ const result = schema.validate(value, {
+ ...opts,
+ ...(messages && { messages })
+ })
const details = result.error?.details
diff --git a/src/server/plugins/engine/components/DatePartsField.test.ts b/src/server/plugins/engine/components/DatePartsField.test.ts
index 3435cd5dd..464e9a11a 100644
--- a/src/server/plugins/engine/components/DatePartsField.test.ts
+++ b/src/server/plugins/engine/components/DatePartsField.test.ts
@@ -2,12 +2,14 @@ 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
} 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,
@@ -15,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
@@ -50,21 +56,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'
+ })
})
)
})
@@ -163,9 +175,11 @@ 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 month'
+ text: 'Example date parts field must include a components.dateField.month'
})
])
})
@@ -210,15 +224,16 @@ 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 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'
})
])
})
@@ -272,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, translator)
+ const answer2 = getAnswer(field, state2, translator)
expect(answer1).toBe('31 December 2024')
expect(answer2).toBe('')
@@ -351,7 +366,11 @@ describe('DatePartsField', () => {
it('sets Nunjucks component defaults', () => {
const payload = getFormData(date)
- const viewModel = field.getViewModel(payload)
+ const viewModel = field.getViewModel({
+ payload,
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -395,7 +414,11 @@ describe('DatePartsField', () => {
year: 'YYYY'
})
- const viewModel = field.getViewModel(payload)
+ const viewModel = field.getViewModel({
+ payload,
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -418,7 +441,11 @@ describe('DatePartsField', () => {
it('sets Nunjucks component fieldset', () => {
const payload = getFormData(date)
- const viewModel = field.getViewModel(payload)
+ const viewModel = field.getViewModel({
+ payload,
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel.fieldset).toEqual({
legend: {
@@ -921,6 +948,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 7ab4038ed..32a9245f1 100644
--- a/src/server/plugins/engine/components/DatePartsField.ts
+++ b/src/server/plugins/engine/components/DatePartsField.ts
@@ -11,16 +11,18 @@ 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'
+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,
type FormSubmissionState
} from '~/src/server/plugins/engine/types.js'
import { convertToLanguageMessages } from '~/src/server/utils/type-utils.js'
@@ -56,7 +58,7 @@ export class DatePartsField extends FormComponent {
{
type: ComponentType.NumberField,
name: `${name}__day`,
- title: 'Day',
+ title: 'components.dateField.day',
schema: { min: 1, max: 31, precision: 0 },
options: {
required: isRequired,
@@ -68,7 +70,7 @@ export class DatePartsField extends FormComponent {
{
type: ComponentType.NumberField,
name: `${name}__month`,
- title: 'Month',
+ title: 'components.dateField.month',
schema: { min: 1, max: 12, precision: 0 },
options: {
required: isRequired,
@@ -80,7 +82,7 @@ export class DatePartsField extends FormComponent {
{
type: ComponentType.NumberField,
name: `${name}__year`,
- title: 'Year',
+ title: 'components.dateField.year',
schema: { min: 1000, max: 3000, precision: 0 },
options: {
required: isRequired,
@@ -107,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 ''
}
@@ -118,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) {
@@ -148,10 +156,24 @@ export class DatePartsField extends FormComponent {
return this.getContextValueFromFormValue(value)
}
- getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) {
+ 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(context: RenderContext) {
+ const { errors } = context
const { collection, name } = this
- const viewModel = super.getViewModel(payload, errors)
+ const viewModel = super.getViewModel(context)
let { fieldset, label } = viewModel
// Check for component errors only
@@ -159,7 +181,7 @@ export class DatePartsField extends FormComponent {
// Use the component collection to generate the subitems
const items: DateInputItem[] = collection
- .getViewModel(payload, errors)
+ .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 c81a4ab79..1bdc92597 100644
--- a/src/server/plugins/engine/components/DeclarationField.test.ts
+++ b/src/server/plugins/engine/components/DeclarationField.test.ts
@@ -10,11 +10,16 @@ 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'
import { getFormData, getFormState } from '~/test/helpers/component-helpers.js'
+const translator = new FormModel(definition, {
+ basePath: '/'
+}).createTranslator()
+
describe('DeclarationField', () => {
let model: FormModel
@@ -184,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, translator)
+ const answer2 = getAnswer(field, state2, translator)
expect(answer1).toBe('I understand and agree')
expect(answer2).toBe('Not provided')
@@ -241,7 +246,11 @@ describe('DeclarationField', () => {
describe('View model', () => {
it('sets Nunjucks component defaults', () => {
- const viewModel = field.getViewModel(getFormData(undefined))
+ const viewModel = field.getViewModel({
+ payload: getFormData(undefined),
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -275,7 +284,11 @@ describe('DeclarationField', () => {
collection = new ComponentCollection([def], { model })
field = collection.fields[0]
- const viewModel = field.getViewModel(getFormData('unchecked'))
+ const viewModel = field.getViewModel({
+ payload: getFormData('unchecked'),
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -301,7 +314,11 @@ describe('DeclarationField', () => {
collection = new ComponentCollection([def], { model })
field = collection.fields[0]
- const viewModel = field.getViewModel(getFormData(['true', 'unchecked']))
+ const viewModel = field.getViewModel({
+ payload: getFormData(['true', 'unchecked']),
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -327,7 +344,11 @@ describe('DeclarationField', () => {
collection = new ComponentCollection([def], { model })
field = collection.fields[0]
- const viewModel = field.getViewModel(getFormData(['unchecked']))
+ const viewModel = field.getViewModel({
+ payload: getFormData(['unchecked']),
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -353,7 +374,11 @@ describe('DeclarationField', () => {
collection = new ComponentCollection([def], { model })
field = collection.fields[0]
- const viewModel = field.getViewModel(getFormData('true'))
+ const viewModel = field.getViewModel({
+ payload: getFormData('true'),
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -388,7 +413,11 @@ describe('DeclarationField', () => {
collection = new ComponentCollection([def], { model })
field = collection.fields[0]
- const viewModel = field.getViewModel(getFormData('true'))
+ const viewModel = field.getViewModel({
+ payload: getFormData('true'),
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -412,7 +441,11 @@ describe('DeclarationField', () => {
collection = new ComponentCollection([def], { model })
field = collection.fields[0]
- const viewModel = field.getViewModel(getFormData(undefined))
+ 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 dcaa7a1cb..709a68cc7 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,23 +16,23 @@ 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 { 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,
type FormSubmissionState,
type FormValue
} 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
+ declare declarationConfirmationLabel: string | undefined
declare formSchema: ArraySchema
declare stateSchema: BooleanSchema
@@ -69,8 +70,9 @@ export class DeclarationField extends FormComponent {
this.options = options
this.content = content
- this.declarationConfirmationLabel =
- options.declarationConfirmationLabel ?? this.DEFAULT_DECLARATION_LABEL
+ // 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
: []
@@ -114,20 +116,40 @@ export class DeclarationField extends FormComponent {
return this.isValue(value) ? value : undefined
}
- getDisplayStringFromFormValue(value: FormValue | FormPayload): string {
- return value === 'true' ? this.declarationConfirmationLabel : 'Not provided'
+ 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
+ ): string {
+ const { t } = translator
+ return value === 'true'
+ ? (this.declarationConfirmationLabel ??
+ t('components.declarationField.defaultLabel'))
+ : t('components.declarationField.notProvided')
}
- getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) {
- const defaultDeclarationConfirmationLabel =
- 'I confirm that I understand and accept this declaration'
+ getViewModel(context: RenderContext) {
+ const { payload } = context
+ const { t } = context.translator
+
const {
hint,
content,
- declarationConfirmationLabel = defaultDeclarationConfirmationLabel
+ declarationConfirmationLabel = t(
+ 'components.declarationField.defaultLabel'
+ )
} = this
- const viewModel = super.getViewModel(payload, errors)
+ 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 3f43699a4..fafa40e1f 100644
--- a/src/server/plugins/engine/components/EastingNorthingField.test.ts
+++ b/src/server/plugins/engine/components/EastingNorthingField.test.ts
@@ -10,9 +10,14 @@ 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'
+const translator = new FormModel(definition, {
+ basePath: '/'
+}).createTranslator()
+
describe('EastingNorthingField', () => {
let model: FormModel
@@ -49,14 +54,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).
})
)
})
@@ -202,8 +213,8 @@ describe('EastingNorthingField', () => {
})
const state2 = getFormState({})
- const answer1 = getAnswer(field, state1)
- const answer2 = getAnswer(field, state2)
+ const answer1 = getAnswer(field, state1, translator)
+ const answer2 = getAnswer(field, state2, translator)
expect(answer1).toBe('Easting: 12345 Northing: 1234567 ')
expect(answer2).toBe('')
@@ -286,7 +297,11 @@ describe('EastingNorthingField', () => {
easting: 12345,
northing: 1234567
})
- const viewModel = field.getViewModel(payload)
+ const viewModel = field.getViewModel({
+ payload,
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -298,13 +313,19 @@ describe('EastingNorthingField', () => {
},
items: [
expect.objectContaining({
- label: expect.objectContaining({ text: 'Easting' }),
+ label: expect.objectContaining({
+ text: '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: 'Northing'
+ }),
+ // Sub-field title is a key constant; resolved at request time (Task 8/9).
name: 'myComponent__northing',
id: 'myComponent__northing',
value: 1234567
@@ -323,12 +344,14 @@ describe('EastingNorthingField', () => {
{ model }
)
- const viewModel = componentWithInstruction.getViewModel(
- getFormData({
+ const viewModel = componentWithInstruction.getViewModel({
+ payload: getFormData({
easting: 12345,
northing: 1234567
- })
- )
+ }),
+ errors: undefined,
+ translator: stubTranslator
+ })
const instructionText =
'instructionText' in viewModel ? viewModel.instructionText : undefined
@@ -351,7 +374,11 @@ describe('EastingNorthingField', () => {
}
]
- const viewModel = field.getViewModel(payload, errors)
+ const viewModel = field.getViewModel({
+ payload,
+ errors,
+ translator: stubTranslator
+ })
// Check that error is passed to the viewModel
expect(viewModel.errors).toEqual(errors)
@@ -388,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)
@@ -448,6 +475,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 +557,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 +577,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 +614,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 +634,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 +666,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 +686,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 45f184c49..b4dd517e1 100644
--- a/src/server/plugins/engine/components/EastingNorthingField.ts
+++ b/src/server/plugins/engine/components/EastingNorthingField.ts
@@ -16,7 +16,12 @@ 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,
@@ -55,18 +60,47 @@ 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 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 = tPlugin(
+ 'components.eastingNorthingField.eastingDigits',
+ 'en-GB',
+ { fieldLabel }
+ )
+ const northingDigitsMessage = tPlugin(
+ 'components.eastingNorthingField.northingDigits',
+ 'en-GB',
+ { 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': tPlugin(
+ 'components.eastingNorthingField.eastingRequired',
+ 'en-GB'
+ ),
+ 'number.base': tPlugin(
+ 'components.eastingNorthingField.eastingRequired',
+ 'en-GB'
+ ),
+ 'number.min': tPlugin(
+ 'components.eastingNorthingField.eastingRange',
+ 'en-GB',
+ {
+ fieldLabel,
+ min: eastingMin,
+ max: eastingMax
+ }
+ ),
+ 'number.max': tPlugin(
+ 'components.eastingNorthingField.eastingRange',
+ 'en-GB',
+ {
+ fieldLabel,
+ min: eastingMin,
+ max: eastingMax
+ }
+ ),
'number.precision': eastingDigitsMessage,
'number.integer': eastingDigitsMessage,
'number.unsafe': eastingDigitsMessage
@@ -74,10 +108,32 @@ 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': tPlugin(
+ 'components.eastingNorthingField.northingRequired',
+ 'en-GB'
+ ),
+ 'number.base': tPlugin(
+ 'components.eastingNorthingField.northingRequired',
+ 'en-GB'
+ ),
+ 'number.min': tPlugin(
+ 'components.eastingNorthingField.northingRange',
+ 'en-GB',
+ {
+ fieldLabel,
+ min: northingMin,
+ max: northingMax
+ }
+ ),
+ 'number.max': tPlugin(
+ 'components.eastingNorthingField.northingRange',
+ 'en-GB',
+ {
+ fieldLabel,
+ min: northingMin,
+ max: northingMax
+ }
+ ),
'number.precision': northingDigitsMessage,
'number.integer': northingDigitsMessage,
'number.unsafe': northingDigitsMessage
@@ -88,7 +144,7 @@ export class EastingNorthingField extends FormComponent {
{
type: ComponentType.NumberField,
name: `${name}__easting`,
- title: 'Easting',
+ title: 'components.eastingNorthingField.easting',
schema: {
min: eastingMin,
max: eastingMax,
@@ -104,7 +160,7 @@ export class EastingNorthingField extends FormComponent {
{
type: ComponentType.NumberField,
name: `${name}__northing`,
- title: 'Northing',
+ title: 'components.eastingNorthingField.northing',
schema: {
min: northingMin,
max: northingMax,
@@ -135,7 +191,8 @@ export class EastingNorthingField extends FormComponent {
}
getDisplayStringFromFormValue(
- value: EastingNorthingState | undefined
+ value: EastingNorthingState | undefined,
+ _translator: Translator
): string {
if (!value) {
return ''
@@ -145,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(
@@ -167,15 +227,16 @@ 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(context: RenderContext) {
+ const viewModel = super.getViewModel(context)
+ return getLocationFieldViewModel(this, viewModel, context)
}
getViewErrors(
+ 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/EmailAddressField.test.ts b/src/server/plugins/engine/components/EmailAddressField.test.ts
index 580c1bac9..01284e965 100644
--- a/src/server/plugins/engine/components/EmailAddressField.test.ts
+++ b/src/server/plugins/engine/components/EmailAddressField.test.ts
@@ -9,9 +9,14 @@ 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'
+const translator = new FormModel(definition, {
+ basePath: '/'
+}).createTranslator()
+
describe('EmailAddressField', () => {
let model: FormModel
@@ -154,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, translator)
+ const answer2 = getAnswer(field, state2, translator)
expect(answer1).toBe('defra.helpline@defra.gov.uk')
expect(answer2).toBe('')
@@ -208,9 +213,11 @@ describe('EmailAddressField', () => {
describe('View model', () => {
it('sets Nunjucks component defaults', () => {
- const viewModel = field.getViewModel(
- getFormData('defra.helpline@defra.gov.uk')
- )
+ 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 795e4e9c1..4780f1919 100644
--- a/src/server/plugins/engine/components/EmailAddressField.ts
+++ b/src/server/plugins/engine/components/EmailAddressField.ts
@@ -5,12 +5,9 @@ import {
import joi, { type CustomHelpers } from 'joi'
import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.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']
@@ -54,8 +51,8 @@ export class EmailAddressField extends FormComponent {
this.options = options
}
- getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) {
- const viewModel = super.getViewModel(payload, errors)
+ 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 8d36772f3..2886b39bf 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,
@@ -324,10 +325,14 @@ 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, stubTranslator, {
+ format: 'summary'
+ })
+ const answer2 = getAnswer(field, state2, stubTranslator, {
+ format: 'summary'
+ })
- expect(answer1).toBe('Uploaded 3 files')
+ expect(answer1).toBe('3 files uploaded')
expect(answer2).toBe('')
})
@@ -382,7 +387,11 @@ describe('FileUploadField', () => {
describe('View model', () => {
it('sets Nunjucks component defaults', () => {
- const viewModel = field.getViewModel(getFormData(validState))
+ const viewModel = field.getViewModel({
+ payload: getFormData(validState),
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -461,13 +470,12 @@ describe('FileUploadField', () => {
})
it('sets Nunjucks component defaults (preview URL direct access)', () => {
- const viewModel = field.getViewModel(
- getFormData(validState),
- undefined,
-
- // Preview URL '?force'
- { force: '' }
- )
+ const viewModel = field.getViewModel({
+ payload: getFormData(validState),
+ errors: undefined,
+ translator: stubTranslator,
+ isForceAccess: true
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -521,7 +529,11 @@ describe('FileUploadField', () => {
})
it('sets Nunjucks component defaults with temp valid state', () => {
- const viewModel = field.getViewModel(getFormData(validTempState))
+ const viewModel = field.getViewModel({
+ payload: getFormData(validTempState),
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -562,7 +574,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({
+ payload: getFormData(validTempState),
+ errors: [],
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -892,13 +908,23 @@ describe('FileUploadField', () => {
// Mock request
mockRequest = {
+ server: { plugins: { 'forms-engine-plugin': {} } },
app: {
model: {
services: {
formSubmissionService: {
persistFiles: mockPersistFiles
}
- }
+ },
+ createTranslator: () => ({
+ t: jest.fn((k: string) => k),
+ tPage: jest.fn(),
+ tComponent: jest.fn(),
+ tSection: jest.fn(),
+ tListItem: jest.fn(),
+ tForm: jest.fn(),
+ language: 'en-GB'
+ })
}
}
} as unknown as FormRequestPayload
diff --git a/src/server/plugins/engine/components/FileUploadField.ts b/src/server/plugins/engine/components/FileUploadField.ts
index 3a5ce729a..e982bb2ed 100644
--- a/src/server/plugins/engine/components/FileUploadField.ts
+++ b/src/server/plugins/engine/components/FileUploadField.ts
@@ -9,6 +9,8 @@ import {
FormComponent,
isUploadState
} from '~/src/server/plugins/engine/components/FormComponent.js'
+import { type RenderContext } from '~/src/server/plugins/engine/components/types.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 {
@@ -19,10 +21,8 @@ import {
type FileUpload,
type FileUploadMetadata,
type FormContext,
- type FormPayload,
type FormState,
type FormStateValue,
- type FormSubmissionError,
type FormSubmissionState,
type SummaryList,
type SummaryListAction,
@@ -32,10 +32,8 @@ 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'
+import { resolveLanguage } from '~/src/server/utils/utils.js'
export const uploadIdSchema = joi.string().uuid().required()
@@ -163,19 +161,26 @@ 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
+ })
}
- getDisplayStringFromState(state: FormSubmissionState) {
+ getDisplayStringFromState(
+ state: FormSubmissionState,
+ translator: Translator
+ ) {
const files = this.getFormValueFromState(state)
- return this.getDisplayStringFromFormValue(files)
+ return this.getDisplayStringFromFormValue(files, translator)
}
getContextValueFromFormValue(
@@ -189,17 +194,12 @@ export class FileUploadField extends FormComponent {
return this.getContextValueFromFormValue(files)
}
- getViewModel(
- payload: FormPayload,
- errors?: FormSubmissionError[],
- query: FormQuery = {}
- ) {
+ getViewModel(context: RenderContext) {
+ const { errors, isForceAccess = false } = context
+ const { t } = context.translator
const { options, page, schema } = this
- // Allow preview URL direct access
- const isForceAccess = 'force' in query
-
- const viewModel = super.getViewModel(payload, errors)
+ const viewModel = super.getViewModel(context)
const { attributes, id, value } = viewModel
const files = this.getFormValue(value) ?? []
@@ -213,7 +213,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')
+ }
const valueHtml = render
.view('components/fileuploadfield-value.html', {
@@ -309,6 +312,9 @@ export class FileUploadField extends FormComponent {
metadata: FormMetadata,
context: FormContext
) {
+ const language = resolveLanguage(request, metadata)
+ const { t } = this.model.createTranslator(language)
+
const notificationEmail = metadata.notificationEmail
if (!notificationEmail) {
@@ -347,7 +353,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')
)
}
diff --git a/src/server/plugins/engine/components/FormComponent.ts b/src/server/plugins/engine/components/FormComponent.ts
index 3cf7c288f..19d2aafca 100644
--- a/src/server/plugins/engine/components/FormComponent.ts
+++ b/src/server/plugins/engine/components/FormComponent.ts
@@ -1,11 +1,14 @@
import {
+ type ComponentDef,
type FormComponentsDef,
type FormMetadata,
type Item
} from '@defra/forms-model'
+import { type LanguageMessages } from 'joi'
import { ComponentBase } from '~/src/server/plugins/engine/components/ComponentBase.js'
-import { optionalText } from '~/src/server/plugins/engine/components/constants.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,
type FormRequestPayload
@@ -115,7 +118,10 @@ export class FormComponent extends ComponentBase {
}
}
- getErrors(errors?: FormSubmissionError[]): FormSubmissionError[] | undefined {
+ getErrors(
+ _translator: Translator,
+ errors?: FormSubmissionError[]
+ ): FormSubmissionError[] | undefined {
const { name } = this
// Filter component and child errors only
@@ -134,34 +140,59 @@ export class FormComponent extends ComponentBase {
}
getFirstError(
+ translator: Translator,
errors?: FormSubmissionError[]
): FormSubmissionError | undefined {
- return this.getErrors(errors)?.[0]
+ return this.getErrors(translator, errors)?.[0]
}
getViewErrors(
+ translator: Translator,
errors?: FormSubmissionError[]
): FormSubmissionError[] | undefined {
- const firstError = this.getFirstError(errors)
+ const firstError = this.getFirstError(translator, errors)
return firstError && [firstError]
}
- getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) {
+ /**
+ * 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,
+ errors,
+ translator,
+ isForceAccess: _isForceAccess = false
+ }: RenderContext) {
const { hint, name, options = {}, title, viewModel } = this
+ const { t, tComponent } = translator
+
const isRequired = !('required' in options) || options.required !== false
const hideOptional = 'optionalText' in options && options.optionalText
- const label = `${title}${!isRequired && !hideOptional ? optionalText : ''}`
+
+ const resolvedTitle =
+ tComponent(this as unknown as ComponentDef, 'title') || title
+ const optionalTag =
+ !isRequired && !hideOptional ? ` ${t('common.optional')}` : ''
+ const label = `${resolvedTitle}${optionalTag}`
if (hint) {
viewModel.hint = {
- text: hint
+ text: tComponent(this as unknown as ComponentDef, 'hint') || hint
}
}
// 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
@@ -184,15 +215,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(
@@ -256,6 +293,19 @@ export class FormComponent extends ComponentBase {
}
}
+/**
+ * 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 {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ typeof (value as Record).t === 'function' &&
+ typeof (value as Record).tComponent === 'function'
+ )
+}
+
/**
* Check for form value
*/
diff --git a/src/server/plugins/engine/components/GeospatialField.test.ts b/src/server/plugins/engine/components/GeospatialField.test.ts
index 0a6c9e67e..cf1b3e184 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'
@@ -14,10 +15,15 @@ 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'
+const translator = new FormModel(definition, {
+ basePath: '/'
+}).createTranslator()
+
describe('GeospatialField', () => {
let model: FormModel
@@ -150,8 +156,12 @@ 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, stubTranslator, {
+ format: 'summary'
+ })
+ const answer2 = getAnswer(field, state2, stubTranslator, {
+ format: 'summary'
+ })
expect(answer1).toBe('Added 1 location')
expect(answer2).toBe('')
@@ -161,8 +171,12 @@ 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, stubTranslator, {
+ format: 'summary'
+ })
+ const answer2 = getAnswer(field, state2, stubTranslator, {
+ format: 'summary'
+ })
expect(answer1).toBe('Added 4 locations')
expect(answer2).toBe('')
@@ -220,7 +234,11 @@ describe('GeospatialField', () => {
describe('View model', () => {
it('sets Nunjucks component defaults', () => {
- const viewModel = field.getViewModel(getFormData('Geospatial'))
+ const viewModel = field.getViewModel({
+ payload: getFormData('Geospatial'),
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -261,7 +279,7 @@ describe('GeospatialField', () => {
value: getFormData([]),
errors: [
expect.objectContaining({
- text: 'Define at least 1 features'
+ text: 'validation.features.min' // 'Define at least 1 features'
})
]
}
@@ -311,7 +329,7 @@ describe('GeospatialField', () => {
value: getFormData([]),
errors: [
expect.objectContaining({
- text: 'Define at least 2 features'
+ text: 'validation.features.min' // 'Define at least 2 features'
})
]
}
@@ -333,7 +351,7 @@ describe('GeospatialField', () => {
value: getFormData(validSingleState),
errors: [
expect.objectContaining({
- text: 'Define at least 2 features'
+ text: 'validation.features.min' // 'Define at least 2 features'
})
]
}
@@ -366,7 +384,7 @@ describe('GeospatialField', () => {
value: getFormData([]),
errors: [
expect.objectContaining({
- text: 'Define at least 1 features'
+ text: 'validation.features.min' // 'Define at least 1 features'
})
]
}
@@ -394,7 +412,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'
})
]
}
@@ -421,7 +439,7 @@ describe('GeospatialField', () => {
value: getFormData([]),
errors: [
expect.objectContaining({
- text: 'Define exactly 1 features'
+ text: 'validation.features.length' // 'Define exactly 1 features'
})
]
}
@@ -449,7 +467,7 @@ describe('GeospatialField', () => {
value: getFormData(validState),
errors: [
expect.objectContaining({
- text: 'Define exactly 1 features'
+ text: 'validation.features.length' // 'Define exactly 1 features'
})
]
}
@@ -495,7 +513,7 @@ describe('GeospatialField', () => {
value: getFormData([]),
errors: [
expect.objectContaining({
- text: 'Define at least 2 features'
+ text: 'validation.features.min' // 'Define at least 2 features'
})
]
}
@@ -512,7 +530,7 @@ describe('GeospatialField', () => {
value: getFormData(validSingleState),
errors: [
expect.objectContaining({
- text: 'Define at least 2 features'
+ text: 'validation.features.min' // 'Define at least 2 features'
})
]
}
@@ -563,7 +581,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'
})
]
}
@@ -590,7 +608,7 @@ describe('GeospatialField', () => {
value: getFormData([]),
errors: [
expect.objectContaining({
- text: 'Define exactly 1 features'
+ text: 'validation.features.length' // 'Define exactly 1 features'
})
]
}
@@ -613,7 +631,7 @@ describe('GeospatialField', () => {
value: getFormData(validState),
errors: [
expect.objectContaining({
- text: 'Define exactly 1 features'
+ text: 'validation.features.length' // Define exactly 1 features
})
]
}
@@ -666,7 +684,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',
@@ -704,15 +722,73 @@ 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({
+ href: '#description_0',
+ text: 'translated country error'
+ })
+ ])
+ })
+
+ it('getViewErrors uses translator for description error text', () => {
+ const component = {
+ title: 'Example geospatial field',
+ name: 'myComponent',
+ type: ComponentType.GeospatialField,
+ options: { required: true }
+ } as unknown as ComponentDef
+
+ 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(
+ mockTranslator,
+ result.errors
+ )
+
+ expect(mockT).toHaveBeenCalledWith(
+ 'components.geospatialField.validation.descriptionRequired',
+ { count: 1 }
+ )
+ expect(viewErrors).toEqual([
expect.objectContaining({
- name: 0,
href: '#description_0',
- text: 'Location 1 must be in Scotland'
+ text: 'translated description error'
})
])
})
diff --git a/src/server/plugins/engine/components/GeospatialField.ts b/src/server/plugins/engine/components/GeospatialField.ts
index b7ea33415..c8a353b9b 100644
--- a/src/server/plugins/engine/components/GeospatialField.ts
+++ b/src/server/plugins/engine/components/GeospatialField.ts
@@ -7,10 +7,11 @@ import {
isGeospatialState
} from '~/src/server/plugins/engine/components/FormComponent.js'
import { getGeospatialSchema } from '~/src/server/plugins/engine/components/helpers/geospatial.js'
+import { type RenderContext } 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 FormState,
type FormStateValue,
type FormSubmissionError,
@@ -31,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)
@@ -58,20 +57,26 @@ 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 ''
}
- const unit = features.length === 1 ? 'location' : 'locations'
-
- return `Added ${features.length} ${unit}`
+ return translator.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(
@@ -86,8 +91,8 @@ export class GeospatialField extends FormComponent {
return this.getContextValueFromFormValue(features)
}
- getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) {
- const viewModel = super.getViewModel(payload, errors)
+ getViewModel(context: RenderContext) {
+ const viewModel = super.getViewModel(context)
const value =
typeof viewModel.value === 'string'
? viewModel.value
@@ -101,16 +106,25 @@ export class GeospatialField extends FormComponent {
}
}
- getErrors(errors?: FormSubmissionError[]): FormSubmissionError[] | undefined {
- const fieldErrors = super.getErrors(errors)
+ getErrors(
+ translator: Translator,
+ errors?: FormSubmissionError[]
+ ): FormSubmissionError[] | undefined {
+ const fieldErrors = super.getErrors(translator, errors)
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 }
+ )
}
})
@@ -118,9 +132,10 @@ export class GeospatialField extends FormComponent {
}
getViewErrors(
+ translator: Translator,
errors?: FormSubmissionError[]
): FormSubmissionError[] | undefined {
- return this.getErrors(errors)
+ return this.getErrors(translator, errors)
}
isValue(value?: FormStateValue | FormState): value is GeospatialState {
diff --git a/src/server/plugins/engine/components/HiddenField.test.ts b/src/server/plugins/engine/components/HiddenField.test.ts
index bf02c3802..77fa8cc17 100644
--- a/src/server/plugins/engine/components/HiddenField.test.ts
+++ b/src/server/plugins/engine/components/HiddenField.test.ts
@@ -6,9 +6,14 @@ 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'
+const translator = new FormModel(definition, {
+ basePath: '/'
+}).createTranslator()
+
describe('HiddenField', () => {
let model: FormModel
@@ -110,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, translator)
+ const answer2 = getAnswer(field, state2, translator)
expect(answer1).toBe('Hidden field')
expect(answer2).toBe('')
@@ -164,7 +169,11 @@ describe('HiddenField', () => {
describe('View model', () => {
it('sets Nunjucks component defaults', () => {
- const viewModel = field.getViewModel(getFormData('Hidden field'))
+ 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 70bb2e875..a4a56c807 100644
--- a/src/server/plugins/engine/components/LatLongField.test.ts
+++ b/src/server/plugins/engine/components/LatLongField.test.ts
@@ -7,9 +7,14 @@ 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'
+const translator = new FormModel(definition, {
+ basePath: '/'
+}).createTranslator()
+
describe('LatLongField', () => {
let model: FormModel
@@ -46,14 +51,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).
})
)
})
@@ -191,8 +202,8 @@ describe('LatLongField', () => {
})
const state2 = getFormState({})
- const answer1 = getAnswer(field, state1)
- const answer2 = getAnswer(field, state2)
+ const answer1 = getAnswer(field, state1, translator)
+ const answer2 = getAnswer(field, state2, translator)
expect(answer1).toBe('Latitude: 51.51945 Longitude: -0.127758 ')
expect(answer2).toBe('')
@@ -275,7 +286,11 @@ describe('LatLongField', () => {
latitude: 51.51945,
longitude: -0.127758
})
- const viewModel = field.getViewModel(payload)
+ const viewModel = field.getViewModel({
+ payload,
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -287,13 +302,19 @@ describe('LatLongField', () => {
},
items: [
expect.objectContaining({
- label: expect.objectContaining({ text: 'Latitude' }),
+ label: expect.objectContaining({
+ text: '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: 'Longitude'
+ }),
+ // Sub-field title is a key constant; resolved at request time (Task 8/9).
name: 'myComponent__longitude',
id: 'myComponent__longitude',
value: -0.127758
@@ -312,12 +333,14 @@ describe('LatLongField', () => {
{ model }
)
- const viewModel = componentWithInstruction.getViewModel(
- getFormData({
+ const viewModel = componentWithInstruction.getViewModel({
+ payload: getFormData({
latitude: 51.51945,
longitude: -0.127758
- })
- )
+ }),
+ errors: undefined,
+ translator: stubTranslator
+ })
const instructionText =
'instructionText' in viewModel ? viewModel.instructionText : undefined
@@ -340,7 +363,11 @@ describe('LatLongField', () => {
}
]
- const viewModel = field.getViewModel(payload, errors)
+ const viewModel = field.getViewModel({
+ payload,
+ errors,
+ translator: stubTranslator
+ })
// Check that error is passed to the viewModel
expect(viewModel.errors).toEqual(errors)
@@ -377,7 +404,7 @@ describe('LatLongField', () => {
}
]
- const viewErrors = field.getViewErrors(errors)
+ const viewErrors = field.getViewErrors(translator, errors)
expect(viewErrors).toHaveLength(2)
expect(viewErrors).toEqual([
@@ -436,6 +463,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 +639,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 +657,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 +736,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 +767,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 0fe4c6f14..91449777f 100644
--- a/src/server/plugins/engine/components/LatLongField.ts
+++ b/src/server/plugins/engine/components/LatLongField.ts
@@ -13,7 +13,12 @@ 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,
@@ -51,31 +56,50 @@ 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 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': tPlugin(
+ 'components.latLongField.precision',
+ 'en-GB'
+ ),
+ 'number.unsafe': tPlugin('components.latLongField.notANumber', 'en-GB')
})
- 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 = tPlugin(
+ 'components.latLongField.latitudeRange',
+ 'en-GB',
+ { fieldLabel, min: latitudeMin, max: latitudeMax }
+ )
+ const longitudeRangeMessage = tPlugin(
+ 'components.latLongField.longitudeRange',
+ 'en-GB',
+ { 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': tPlugin(
+ 'components.latLongField.latitudeRequired',
+ 'en-GB'
+ ),
+ 'number.base': tPlugin('components.latLongField.latitudeBase', 'en-GB', {
+ 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': tPlugin(
+ 'components.latLongField.longitudeRequired',
+ 'en-GB'
+ ),
+ 'number.base': tPlugin('components.latLongField.longitudeBase', 'en-GB', {
+ fieldLabel
+ }),
'number.min': longitudeRangeMessage,
'number.max': longitudeRangeMessage
})
@@ -85,7 +109,7 @@ export class LatLongField extends FormComponent {
{
type: ComponentType.NumberField,
name: `${name}__latitude`,
- title: 'Latitude',
+ title: 'components.latLongField.latitude',
schema: {
min: latitudeMin,
max: latitudeMax,
@@ -102,7 +126,7 @@ export class LatLongField extends FormComponent {
{
type: ComponentType.NumberField,
name: `${name}__longitude`,
- title: 'Longitude',
+ title: 'components.latLongField.longitude',
schema: {
min: longitudeMin,
max: longitudeMax,
@@ -133,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 ''
}
@@ -142,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 {
@@ -163,15 +193,16 @@ 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(context: RenderContext) {
+ const viewModel = super.getViewModel(context)
+ return getLocationFieldViewModel(this, viewModel, context)
}
getViewErrors(
+ 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/ListFormComponent.ts b/src/server/plugins/engine/components/ListFormComponent.ts
index 3d8b91db1..dd2ec323e 100644
--- a/src/server/plugins/engine/components/ListFormComponent.ts
+++ b/src/server/plugins/engine/components/ListFormComponent.ts
@@ -13,12 +13,14 @@ 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 { 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'
@@ -100,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
@@ -108,29 +111,35 @@ export class ListFormComponent extends FormComponent {
return items
.filter((item) => values.includes(item.value))
- .map((item) => item.text)
+ .map((item) => translator.tListItem(item, 'text') || 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(payload: FormPayload, errors?: FormSubmissionError[]) {
+ getViewModel(context: RenderContext) {
const { items: listItems } = this
- const viewModel = super.getViewModel(payload, errors)
+ const viewModel = super.getViewModel(context)
const { value } = viewModel
// Support multiple values for checkboxes
const values = this.isValue(value) ? [value].flat() : []
+ const { tListItem } = context.translator
+
const items = listItems.map((item) => {
const selected = values.includes(item.value)
- const itemModel: ListItem = { ...item, selected }
+ const resolvedText = tListItem(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/LocationFieldBase.test.ts b/src/server/plugins/engine/components/LocationFieldBase.test.ts
index f8f7970b5..fde054b5a 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'
@@ -82,7 +83,11 @@ describe('LocationFieldBase', () => {
} as ConstructorParameters[0]
const field = new TestLocationField(def, { model })
- const viewModel = field.getViewModel(getFormData('TEST1234'))
+ const viewModel = field.getViewModel({
+ payload: getFormData('TEST1234'),
+ errors: undefined,
+ translator: stubTranslator
+ })
const instructionText =
'instructionText' in viewModel ? viewModel.instructionText : undefined
@@ -100,7 +105,11 @@ describe('LocationFieldBase', () => {
} as ConstructorParameters[0]
const field = new TestLocationField(def, { model })
- const viewModel = field.getViewModel(getFormData('TEST1234'))
+ 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 ed1c943a1..c2b09465c 100644
--- a/src/server/plugins/engine/components/LocationFieldBase.ts
+++ b/src/server/plugins/engine/components/LocationFieldBase.ts
@@ -10,13 +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 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'
@@ -63,8 +62,7 @@ export abstract class LocationFieldBase extends FormComponent {
addClassOptionIfNone(locationOptions, 'govuk-input--width-10')
const config = this.getValidationConfig()
- const requiredMessage =
- config.requiredMessage ?? (messageTemplate.required as string)
+ const requiredMessage = config.requiredMessage ?? messageTemplate.required
const messages = convertToLanguageMessages({
'any.required': requiredMessage,
@@ -120,8 +118,8 @@ export abstract class LocationFieldBase extends FormComponent {
return LocationFieldBase.isText(value)
}
- getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) {
- const viewModel = super.getViewModel(payload, errors)
+ getViewModel(context: RenderContext) {
+ const viewModel = super.getViewModel(context)
if (this.instructionText) {
return {
@@ -140,8 +138,7 @@ export abstract class LocationFieldBase extends FormComponent {
baseErrors: [
{
type: 'required',
- template:
- config.requiredMessage ?? (messageTemplate.required as string)
+ template: config.requiredMessage ?? messageTemplate.required
},
...this.getErrorTemplates()
],
diff --git a/src/server/plugins/engine/components/LocationFieldHelpers.test.ts b/src/server/plugins/engine/components/LocationFieldHelpers.test.ts
index 92360edfe..b410e37f2 100644
--- a/src/server/plugins/engine/components/LocationFieldHelpers.test.ts
+++ b/src/server/plugins/engine/components/LocationFieldHelpers.test.ts
@@ -9,6 +9,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 +277,11 @@ describe('LocationFieldHelpers', () => {
myComponent__longitude: -0.1
}
- const viewModel = field.getViewModel(payload)
+ const viewModel = field.getViewModel({
+ payload,
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel.fieldset).toEqual({
legend: {
@@ -307,7 +312,11 @@ describe('LocationFieldHelpers', () => {
myComponent__longitude: -0.1
}
- const viewModel = field.getViewModel(payload)
+ const viewModel = field.getViewModel({
+ payload,
+ errors: undefined,
+ translator: stubTranslator
+ })
const instructionText =
'instructionText' in viewModel ? viewModel.instructionText : undefined
@@ -341,7 +350,11 @@ describe('LocationFieldHelpers', () => {
}
]
- const viewModel = field.getViewModel(payload, errors)
+ const viewModel = field.getViewModel({
+ payload,
+ errors,
+ translator: stubTranslator
+ })
// Check that errors are passed to the viewModel
expect(viewModel.errors).toEqual(errors)
@@ -389,7 +402,11 @@ describe('LocationFieldHelpers', () => {
}
]
- const viewModel = field.getViewModel(payload, errors)
+ const viewModel = field.getViewModel({
+ payload,
+ errors,
+ translator: stubTranslator
+ })
// Single errors should be displayed at fieldset level
expect(viewModel.items[0].errorMessage).toBeUndefined()
@@ -437,7 +454,11 @@ describe('LocationFieldHelpers', () => {
}
]
- const viewModel = field.getViewModel(payload, errors)
+ const viewModel = field.getViewModel({
+ payload,
+ errors,
+ translator: stubTranslator
+ })
expect(viewModel.items[0].errorMessage).toBeUndefined()
expect(viewModel.items[1].errorMessage).toBeUndefined()
@@ -469,7 +490,11 @@ describe('LocationFieldHelpers', () => {
}
// No errors passed in, but the subViewModels might have errors from elsewhere
- const viewModel = field.getViewModel(payload, [])
+ const viewModel = field.getViewModel({
+ payload,
+ errors: [],
+ translator: stubTranslator
+ })
// When no field errors, items should not have error messages
expect(viewModel.items[0].errorMessage).toBeUndefined()
@@ -511,7 +536,11 @@ describe('LocationFieldHelpers', () => {
}
]
- const viewModel = field.getViewModel(payload, errors)
+ const viewModel = field.getViewModel({
+ payload,
+ errors,
+ translator: stubTranslator
+ })
// No individual field errors
expect(viewModel.items[0].errorMessage).toBeUndefined()
@@ -545,7 +574,11 @@ describe('LocationFieldHelpers', () => {
myComponent__longitude: '-0.1'
}
- const viewModel = field.getViewModel(payload)
+ 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')
@@ -569,7 +602,11 @@ describe('LocationFieldHelpers', () => {
myComponent__longitude: '-0.1'
}
- const viewModel = field.getViewModel(payload)
+ const viewModel = field.getViewModel({
+ payload,
+ errors: undefined,
+ translator: stubTranslator
+ })
const label = viewModel.items[0].label
expect(label).toBeDefined()
@@ -599,7 +636,11 @@ describe('LocationFieldHelpers', () => {
myComponent__longitude: -0.1
}
- const viewModel = field.getViewModel(payload)
+ 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 50a891d03..206218a7f 100644
--- a/src/server/plugins/engine/components/LocationFieldHelpers.ts
+++ b/src/server/plugins/engine/components/LocationFieldHelpers.ts
@@ -6,6 +6,7 @@ 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 {
@@ -94,13 +95,12 @@ export function getLocationFieldViewModel(
name: string
value: FormValue
},
- payload: FormPayload,
- errors?: FormSubmissionError[]
+ context: RenderContext
) {
const { collection } = component
const { fieldset: existingFieldset, label } = viewModel
- const subViewModels = collection.getViewModel(payload, errors)
+ 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 582d1f4a4..075c57b90 100644
--- a/src/server/plugins/engine/components/MonthYearField.test.ts
+++ b/src/server/plugins/engine/components/MonthYearField.test.ts
@@ -2,18 +2,24 @@ import { ComponentType, type MonthYearFieldComponent } from '@defra/forms-model'
import { addMonths, format, startOfDay, startOfMonth } 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
} 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
} 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
@@ -49,10 +55,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'
+ })
})
})
)
@@ -135,9 +145,10 @@ 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 year'
+ text: 'Example month/year field must include a components.monthYearField.year'
})
])
})
@@ -169,12 +180,13 @@ 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 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'
})
])
})
@@ -195,12 +207,13 @@ 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 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'
})
])
})
@@ -250,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, translator)
+ const answer2 = getAnswer(field, state2, translator)
expect(answer1).toBe('December 2024')
expect(answer2).toBe('')
@@ -325,7 +338,11 @@ describe('MonthYearField', () => {
it('sets Nunjucks component defaults', () => {
const payload = getFormData(date)
- const viewModel = field.getViewModel(payload)
+ const viewModel = field.getViewModel({
+ payload,
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -360,7 +377,11 @@ describe('MonthYearField', () => {
year: 'YYYY'
})
- const viewModel = field.getViewModel(payload)
+ const viewModel = field.getViewModel({
+ payload,
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -379,7 +400,11 @@ describe('MonthYearField', () => {
it('sets Nunjucks component fieldset', () => {
const payload = getFormData(date)
- const viewModel = field.getViewModel(payload)
+ const viewModel = field.getViewModel({
+ payload,
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel.fieldset).toEqual({
legend: {
@@ -621,6 +646,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 e26b3b770..c0172e197 100644
--- a/src/server/plugins/engine/components/MonthYearField.ts
+++ b/src/server/plugins/engine/components/MonthYearField.ts
@@ -16,16 +16,18 @@ 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'
+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,
type FormSubmissionState
} from '~/src/server/plugins/engine/types.js'
import { convertToLanguageMessages } from '~/src/server/utils/type-utils.js'
@@ -62,7 +64,7 @@ export class MonthYearField extends FormComponent {
{
type: ComponentType.NumberField,
name: `${name}__month`,
- title: 'Month',
+ title: 'components.monthYearField.month',
schema: { min: 1, max: 12, precision: 0 },
options: {
required: isRequired,
@@ -74,7 +76,7 @@ export class MonthYearField extends FormComponent {
{
type: ComponentType.NumberField,
name: `${name}__year`,
- title: 'Year',
+ title: 'components.monthYearField.year',
schema: { min: 1000, max: 3000, precision: 0 },
options: {
required: isRequired,
@@ -101,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 ''
}
@@ -113,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(
@@ -145,10 +153,24 @@ export class MonthYearField extends FormComponent {
return this.getContextValueFromFormValue(value)
}
- getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) {
+ 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(context: RenderContext) {
+ const { errors } = context
const { collection, name } = this
- const viewModel = super.getViewModel(payload, errors)
+ const viewModel = super.getViewModel(context)
let { fieldset, label } = viewModel
// Check for component errors only
@@ -156,7 +178,7 @@ export class MonthYearField extends FormComponent {
// Use the component collection to generate the subitems
const items: DateInputItem[] = collection
- .getViewModel(payload, errors)
+ .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 9cbd20dc4..4c9fefe71 100644
--- a/src/server/plugins/engine/components/MultilineTextField.test.ts
+++ b/src/server/plugins/engine/components/MultilineTextField.test.ts
@@ -10,9 +10,14 @@ 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'
+const translator = new FormModel(definition, {
+ basePath: '/'
+}).createTranslator()
+
describe('MultilineTextField', () => {
let model: FormModel
@@ -154,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, translator)
+ const answer2 = getAnswer(field, state2, translator)
expect(answer1).toBe('Textarea')
expect(answer2).toBe('')
@@ -165,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, 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 ')
@@ -219,7 +224,11 @@ describe('MultilineTextField', () => {
describe('View model', () => {
it('sets Nunjucks component defaults', () => {
- const viewModel = field.getViewModel(getFormData('Textarea'))
+ const viewModel = field.getViewModel({
+ payload: getFormData('Textarea'),
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -242,15 +251,23 @@ describe('MultilineTextField', () => {
{ model }
)
- const viewModel = field.getViewModel(getFormData('Textarea'))
-
- const viewModel1 = componentCustom1.getViewModel(
- getFormData('Textarea custom #1')
- )
-
- const viewModel2 = componentCustom2.getViewModel(
- getFormData('Textarea custom #2')
- )
+ 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 37971b78d..c866108b4 100644
--- a/src/server/plugins/engine/components/MultilineTextField.ts
+++ b/src/server/plugins/engine/components/MultilineTextField.ts
@@ -3,12 +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 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']
@@ -94,10 +91,10 @@ export class MultilineTextField extends FormComponent {
this.schema = schema
}
- getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) {
+ getViewModel(context: RenderContext) {
const { schema, options, isCharacterOrWordCount } = this
- const viewModel = super.getViewModel(payload, errors)
+ const viewModel = super.getViewModel(context)
let { maxlength, maxwords, rows } = viewModel
if (schema.max) {
@@ -122,7 +119,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..d31972650 100644
--- a/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts
+++ b/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts
@@ -10,9 +10,14 @@ 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'
+const translator = new FormModel(definition, {
+ basePath: '/'
+}).createTranslator()
+
describe('NationalGridFieldNumberField', () => {
let model: FormModel
@@ -150,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, translator)
+ const answer2 = getAnswer(field, state2, translator)
expect(answer1).toBe('NG12345678')
expect(answer2).toBe('')
@@ -204,7 +209,11 @@ describe('NationalGridFieldNumberField', () => {
describe('View model', () => {
it('sets Nunjucks component defaults', () => {
- const viewModel = field.getViewModel(getFormData('NG12345678'))
+ const viewModel = field.getViewModel({
+ payload: getFormData('NG12345678'),
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -225,9 +234,11 @@ describe('NationalGridFieldNumberField', () => {
{ model }
)
- const viewModel = componentWithInstruction.getViewModel(
- getFormData('NG12345678')
- )
+ 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/NationalGridFieldNumberField.ts b/src/server/plugins/engine/components/NationalGridFieldNumberField.ts
index acf08c955..e3186c68e 100644
--- a/src/server/plugins/engine/components/NationalGridFieldNumberField.ts
+++ b/src/server/plugins/engine/components/NationalGridFieldNumberField.ts
@@ -2,6 +2,8 @@ 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']
@@ -13,14 +15,13 @@ export class NationalGridFieldNumberField extends LocationFieldBase {
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', 'en-GB')
+ ),
requiredMessage: createLowerFirstExpression(
- 'Enter {{lowerFirst(#title)}}'
+ t('components.nationalGridField.required', 'en-GB')
)
}
}
@@ -30,7 +31,7 @@ export class NationalGridFieldNumberField extends LocationFieldBase {
{
type: 'pattern',
template: createLowerFirstExpression(
- 'Enter a valid National Grid field number for {{lowerFirst(#title)}} like NG 1234 5678'
+ t('components.nationalGridField.pattern', 'en-GB')
)
}
]
@@ -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/NumberField.test.ts b/src/server/plugins/engine/components/NumberField.test.ts
index 28b266df0..f9de841ed 100644
--- a/src/server/plugins/engine/components/NumberField.test.ts
+++ b/src/server/plugins/engine/components/NumberField.test.ts
@@ -7,9 +7,14 @@ 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'
+const translator = new FormModel(definition, {
+ basePath: '/'
+}).createTranslator()
+
describe('NumberField', () => {
let model: FormModel
@@ -155,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, translator)
+ const answer2 = getAnswer(field, state2, translator)
expect(answer1).toBe('2024')
expect(answer2).toBe('')
@@ -209,7 +214,11 @@ describe('NumberField', () => {
describe('View model', () => {
it('sets Nunjucks component defaults', () => {
- const viewModel = field.getViewModel(getFormData(2024))
+ const viewModel = field.getViewModel({
+ payload: getFormData(2024),
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -227,7 +236,11 @@ describe('NumberField', () => {
{ model }
)
- const viewModel = componentCustom.getViewModel(getFormData(99.99))
+ const viewModel = componentCustom.getViewModel({
+ payload: getFormData(99.99),
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel.prefix).toEqual({ text: '£' })
expect(viewModel.suffix).toEqual({ text: 'per item' })
@@ -239,7 +252,11 @@ describe('NumberField', () => {
{ model }
)
- const viewModel = componentCustom.getViewModel(getFormData(99))
+ const viewModel = componentCustom.getViewModel({
+ payload: getFormData(99),
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel.attributes).toHaveProperty('inputmode', 'numeric')
})
@@ -250,7 +267,11 @@ describe('NumberField', () => {
{ model }
)
- const viewModel = componentCustom.getViewModel(getFormData(99))
+ const viewModel = componentCustom.getViewModel({
+ payload: getFormData(99),
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel.attributes).toHaveProperty('inputmode', 'numeric')
})
@@ -261,14 +282,22 @@ describe('NumberField', () => {
{ model }
)
- const viewModel = componentCustom.getViewModel(getFormData(99.99))
+ 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'))
+ 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 ec138d3dd..0b1e2b57a 100644
--- a/src/server/plugins/engine/components/NumberField.ts
+++ b/src/server/plugins/engine/components/NumberField.ts
@@ -5,13 +5,12 @@ import {
FormComponent,
isFormValue
} from '~/src/server/plugins/engine/components/FormComponent.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'
@@ -88,10 +87,10 @@ export class NumberField extends FormComponent {
return this.isValue(value) ? value : undefined
}
- getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) {
+ getViewModel(context: RenderContext) {
const { options, schema } = this
- const viewModel = super.getViewModel(payload, errors)
+ 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 9183fb081..0b6184b05 100644
--- a/src/server/plugins/engine/components/OsGridRefField.test.ts
+++ b/src/server/plugins/engine/components/OsGridRefField.test.ts
@@ -7,9 +7,14 @@ 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'
+const translator = new FormModel(definition, {
+ basePath: '/'
+}).createTranslator()
+
describe('OsGridRefField', () => {
let model: FormModel
@@ -166,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, translator)
+ const answer2 = getAnswer(field, state2, translator)
expect(answer1).toBe('TQ12345678')
expect(answer2).toBe('')
@@ -220,7 +225,11 @@ describe('OsGridRefField', () => {
describe('View model', () => {
it('sets Nunjucks component defaults', () => {
- const viewModel = field.getViewModel(getFormData('TQ12345678'))
+ const viewModel = field.getViewModel({
+ payload: getFormData('TQ12345678'),
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -241,9 +250,11 @@ describe('OsGridRefField', () => {
{ model }
)
- const viewModel = componentWithInstruction.getViewModel(
- getFormData('TQ12345678')
- )
+ 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/OsGridRefField.ts b/src/server/plugins/engine/components/OsGridRefField.ts
index f2561e7f6..dc61fc6dd 100644
--- a/src/server/plugins/engine/components/OsGridRefField.ts
+++ b/src/server/plugins/engine/components/OsGridRefField.ts
@@ -2,6 +2,8 @@ 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']
@@ -16,14 +18,13 @@ 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', 'en-GB')
+ ),
requiredMessage: createLowerFirstExpression(
- 'Enter {{lowerFirst(#title)}}'
+ t('components.osGridRefField.required', 'en-GB')
)
}
}
@@ -33,7 +34,7 @@ export class OsGridRefField extends LocationFieldBase {
{
type: 'pattern',
template: createLowerFirstExpression(
- 'Enter a valid OS grid reference for {{lowerFirst(#title)}} like TQ123456'
+ t('components.osGridRefField.pattern', 'en-GB')
)
}
]
@@ -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: []
+ }
}
}
diff --git a/src/server/plugins/engine/components/PaymentField.test.ts b/src/server/plugins/engine/components/PaymentField.test.ts
index 7ac98b949..30211787c 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,
@@ -30,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'
@@ -189,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, translator)
+ const answer2 = getAnswer(field, state2, translator)
expect(answer1).toBe('£150.00 - payment description')
expect(answer2).toBe('')
@@ -199,7 +220,11 @@ describe('PaymentField', () => {
describe('View model', () => {
it('sets Nunjucks component defaults', () => {
- const viewModel = field.getViewModel(getFormData(undefined))
+ const viewModel = field.getViewModel({
+ payload: getFormData(undefined),
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -223,7 +248,11 @@ describe('PaymentField', () => {
description: 'Test payment description',
isLivePayment: false
} as unknown as FormValue
- const viewModel = field.getViewModel(getFormData(paymentForViewModel))
+ const viewModel = field.getViewModel({
+ payload: getFormData(paymentForViewModel),
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -260,20 +289,16 @@ 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,
+ createTranslator: (lang: string) => model.createTranslator(lang)
+ } as unknown as FormModel
describe('dispatcher', () => {
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
}
@@ -289,7 +314,8 @@ describe('PaymentField', () => {
formId: 'formid',
basePath: 'base-path',
name: 'PaymentModel',
- services: mockServices
+ services: mockServices,
+ createTranslator: (lang: string) => model.createTranslator(lang)
},
getState: jest
.fn()
@@ -336,26 +362,15 @@ 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: {
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',
@@ -390,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
@@ -417,7 +425,8 @@ describe('PaymentField', () => {
formId: 'formid',
basePath: 'base-path',
name: 'PaymentModel',
- services: mockServices
+ services: mockServices,
+ createTranslator: (lang: string) => model.createTranslator(lang)
},
getState: jest
.fn()
@@ -451,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
@@ -478,7 +480,8 @@ describe('PaymentField', () => {
formId: 'formid',
basePath: 'base-path',
name: 'PaymentModel',
- services: mockServices
+ services: mockServices,
+ createTranslator: (lang: string) => model.createTranslator(lang)
},
getState: jest
.fn()
@@ -511,7 +514,7 @@ describe('PaymentField', () => {
describe('onSubmit', () => {
it('should throw if missing state', async () => {
- const mockRequest = {} as unknown as FormRequestPayload
+ const mockRequest = mockRequestBase
const error = await paymentField
.onSubmit(
@@ -529,7 +532,7 @@ describe('PaymentField', () => {
})
it('should ignore if our state says payment already captured', async () => {
- const mockRequest = {} as unknown as FormRequestPayload
+ const mockRequest = mockRequestBase
await paymentField.onSubmit(
mockRequest,
@@ -552,7 +555,7 @@ describe('PaymentField', () => {
})
it('should mark payment already captured according to gov pay', async () => {
- const mockRequest = {} as unknown as FormRequestPayload
+ const mockRequest = mockRequestBase
jest
.mocked(get)
// @ts-expect-error - partial mock
@@ -579,7 +582,7 @@ describe('PaymentField', () => {
})
it('should throw if bad status', async () => {
- const mockRequest = {} as unknown as FormRequestPayload
+ const mockRequest = mockRequestBase
jest
.mocked(get)
// @ts-expect-error - partial mock
@@ -612,7 +615,7 @@ describe('PaymentField', () => {
})
it('should throw if error during capture', async () => {
- const mockRequest = {} as unknown as FormRequestPayload
+ const mockRequest = mockRequestBase
jest
.mocked(get)
// @ts-expect-error - partial mock
@@ -647,7 +650,7 @@ describe('PaymentField', () => {
})
it('should throw if amount mismatch', async () => {
- const mockRequest = {} as unknown as FormRequestPayload
+ const mockRequest = mockRequestBase
jest
.mocked(get)
// @ts-expect-error - partial mock
@@ -682,7 +685,7 @@ describe('PaymentField', () => {
})
it('should capture payment if no errors', async () => {
- const mockRequest = {} as unknown as FormRequestPayload
+ const mockRequest = mockRequestBase
jest
.mocked(get)
// @ts-expect-error - partial mock
@@ -856,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: {
@@ -872,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'
@@ -893,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 = {
@@ -922,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'
@@ -963,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()
@@ -982,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',
@@ -1027,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()
@@ -1046,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'
@@ -1090,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()
@@ -1109,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',
@@ -1160,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 494995092..5de6a4751 100644
--- a/src/server/plugins/engine/components/PaymentField.ts
+++ b/src/server/plugins/engine/components/PaymentField.ts
@@ -11,10 +11,12 @@ import { logger } from '~/src/server/common/helpers/logging/logger.js'
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 { type FormModel } from '~/src/server/plugins/engine/models/index.js'
import {
PaymentErrorTypes,
@@ -28,10 +30,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'
@@ -39,6 +39,7 @@ import {
createPaymentService,
formatCurrency
} from '~/src/server/plugins/payment/helper.js'
+import { resolveLanguage } from '~/src/server/utils/utils.js'
export class PaymentField extends FormComponent {
declare options: PaymentFieldComponent['options']
@@ -93,7 +94,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) {
@@ -103,8 +107,9 @@ export class PaymentField extends FormComponent {
return `${formatCurrency(value.amount)} - ${value.description}`
}
- getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) {
- const viewModel = super.getViewModel(payload, errors)
+ 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)
@@ -229,7 +234,8 @@ export class PaymentField extends FormComponent {
): Promise {
const { options, name: componentName } = args.component
const { model } = args.controller
-
+ 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`
@@ -287,8 +293,8 @@ export class PaymentField extends FormComponent {
if (!payment) {
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')
+ : t('components.paymentField.testApiKey')
const govukError = createError(componentName, message)
request.yar.flash(COMPONENT_STATE_ERROR, govukError, true)
return h
@@ -320,9 +326,12 @@ export class PaymentField extends FormComponent {
*/
async onSubmit(
request: FormRequestPayload,
- _metadata: FormMetadata,
+ metadata: FormMetadata,
context: FormContext
): Promise {
+ const language = resolveLanguage(request, metadata)
+ const { t } = this.model.createTranslator(language)
+
// Zero-amount bypass — no capture needed
const resolvedAmount = PaymentField.resolveAmount(
this.options,
@@ -338,7 +347,7 @@ export class PaymentField extends FormComponent {
if (!paymentState) {
throw new PaymentPreAuthError(
this,
- 'Complete the payment to continue',
+ t('components.paymentField.completePayment'),
true,
PaymentErrorTypes.PaymentIncomplete
)
@@ -367,7 +376,8 @@ export class PaymentField extends FormComponent {
PaymentSubmissionError.checkPaymentAmount(
status.amount,
resolvedAmount,
- this
+ this,
+ t
)
if (status.state.status === 'success') {
@@ -378,7 +388,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'),
true,
PaymentErrorTypes.PaymentExpired
)
@@ -392,7 +402,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'),
false
)
}
diff --git a/src/server/plugins/engine/components/RadiosField.test.ts b/src/server/plugins/engine/components/RadiosField.test.ts
index 8ccc0ff9f..ef2238009 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,
@@ -17,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: {
@@ -174,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, translator)
+ const answer2 = getAnswer(field, state2, translator)
expect(answer1).toBe(item.text)
expect(answer2).toBe('')
@@ -233,7 +238,11 @@ describe.each([
it('sets Nunjucks component defaults', () => {
const item = options.examples[0]
- const viewModel = field.getViewModel(getFormData(item.value))
+ const viewModel = field.getViewModel({
+ payload: getFormData(item.value),
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -248,7 +257,11 @@ describe.each([
it.each([...options.examples])(
'sets Nunjucks component radio items',
(item) => {
- const viewModel = field.getViewModel(getFormData(item.value))
+ 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 19dad941d..e9d1e6a21 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,
@@ -17,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 +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, translator)
+ const answer2 = getAnswer(field, state2, translator)
expect(answer1).toBe(item.text)
expect(answer2).toBe('')
@@ -233,7 +238,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({
+ payload: getFormData(item.value),
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -248,7 +257,11 @@ describe.each([
it.each([...options.examples])(
'sets Nunjucks component select options',
(item) => {
- const viewModel = field.getViewModel(getFormData(item.value))
+ 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 2505f5447..175242d44 100644
--- a/src/server/plugins/engine/components/SelectField.ts
+++ b/src/server/plugins/engine/components/SelectField.ts
@@ -4,10 +4,7 @@ import {
} from '@defra/forms-model'
import { ListFormComponent } from '~/src/server/plugins/engine/components/ListFormComponent.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:
@@ -33,8 +30,8 @@ export class SelectField extends ListFormComponent {
this.options = options
}
- getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) {
- const viewModel = super.getViewModel(payload, errors)
+ 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 1f1070d79..b6f457245 100644
--- a/src/server/plugins/engine/components/SelectionControlField.ts
+++ b/src/server/plugins/engine/components/SelectionControlField.ts
@@ -1,20 +1,19 @@
import { ListFormComponent } from '~/src/server/plugins/engine/components/ListFormComponent.js'
-import { type ListItem } from '~/src/server/plugins/engine/components/types.js'
-import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js'
import {
- type ErrorMessageTemplateList,
- type FormPayload,
- type FormSubmissionError
-} from '~/src/server/plugins/engine/types.js'
+ 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[]) {
+ getViewModel(context: RenderContext) {
const { options } = this
- const viewModel = super.getViewModel(payload, errors)
+ 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 d3cc1b861..9c60f1f82 100644
--- a/src/server/plugins/engine/components/TelephoneNumberField.test.ts
+++ b/src/server/plugins/engine/components/TelephoneNumberField.test.ts
@@ -11,9 +11,14 @@ import {
} from '~/src/server/plugins/engine/components/helpers/components.js'
import { INVALID_ERROR_CODE } from '~/src/server/plugins/engine/components/helpers/telephone.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'
+const translator = new FormModel(definition, {
+ basePath: '/'
+}).createTranslator()
+
describe('TelephoneNumberField', () => {
let model: FormModel
@@ -167,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, translator)
+ const answer2 = getAnswer(field, state2, translator)
expect(answer1).toBe('+447900000000')
expect(answer2).toBe('')
@@ -221,9 +226,11 @@ describe('TelephoneNumberField', () => {
describe('View model', () => {
it('sets Nunjucks component defaults', () => {
- const viewModel = field.getViewModel(
- getFormData('Telephone number field')
- )
+ 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 22cafcbb0..31477e790 100644
--- a/src/server/plugins/engine/components/TelephoneNumberField.ts
+++ b/src/server/plugins/engine/components/TelephoneNumberField.ts
@@ -7,12 +7,9 @@ import {
INVALID_ERROR_CODE,
joi
} from '~/src/server/plugins/engine/components/helpers/telephone.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 TelephoneNumberField extends FormComponent {
declare options: TelephoneNumberFieldComponent['options']
@@ -59,8 +56,8 @@ export class TelephoneNumberField extends FormComponent {
this.options = options
}
- getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) {
- const viewModel = super.getViewModel(payload, errors)
+ 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 5b2d8a1e2..1223fdbbe 100644
--- a/src/server/plugins/engine/components/TextField.test.ts
+++ b/src/server/plugins/engine/components/TextField.test.ts
@@ -6,9 +6,14 @@ 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'
+const translator = new FormModel(definition, {
+ basePath: '/'
+}).createTranslator()
+
describe('TextField', () => {
let model: FormModel
@@ -123,6 +128,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', () => {
@@ -130,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, translator)
+ const answer2 = getAnswer(field, state2, translator)
expect(answer1).toBe('Text field')
expect(answer2).toBe('')
@@ -184,7 +238,11 @@ describe('TextField', () => {
describe('View model', () => {
it('sets Nunjucks component defaults', () => {
- const viewModel = field.getViewModel(getFormData('Text field'))
+ const viewModel = field.getViewModel({
+ payload: getFormData('Text field'),
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -197,6 +255,82 @@ describe('TextField', () => {
})
})
+ describe('getViewModel with Translator', () => {
+ 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,
+ tComponent,
+ tPage: jest.fn(),
+ tSection: jest.fn(),
+ tListItem: jest.fn(),
+ tForm: jest.fn(),
+ language: 'en-GB'
+ }
+ })
+ expect(tComponent).toHaveBeenCalledWith(field, 'title')
+ expect(viewModel.label.text).toBe('Translated title')
+ })
+
+ 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 tComponent = jest.fn().mockReturnValue('Translated hint')
+ const t = jest.fn().mockReturnValue('')
+ const viewModel = hintField.getViewModel({
+ payload: {},
+ errors: undefined,
+ translator: {
+ t,
+ tComponent,
+ tPage: jest.fn(),
+ tSection: jest.fn(),
+ tListItem: jest.fn(),
+ tForm: jest.fn(),
+ language: 'en-GB'
+ }
+ })
+ expect(tComponent).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 tComponent = jest.fn().mockReturnValue('Title')
+ const t = jest.fn().mockReturnValue('(optional)')
+ optField.getViewModel({
+ payload: {},
+ errors: undefined,
+ translator: {
+ t,
+ tComponent,
+ tPage: jest.fn(),
+ tSection: jest.fn(),
+ tListItem: jest.fn(),
+ tForm: jest.fn(),
+ language: 'en-GB'
+ }
+ })
+ expect(t).toHaveBeenCalledWith('common.optional')
+ })
+
+ it('falls back to English when no translator supplied', () => {
+ const viewModel = field.getViewModel({
+ payload: {},
+ errors: undefined,
+ translator: stubTranslator
+ })
+ 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/components/UkAddressField.test.ts b/src/server/plugins/engine/components/UkAddressField.test.ts
index aa5853958..cddc0c5eb 100644
--- a/src/server/plugins/engine/components/UkAddressField.test.ts
+++ b/src/server/plugins/engine/components/UkAddressField.test.ts
@@ -12,12 +12,17 @@ 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
} 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
@@ -52,35 +57,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 +264,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).
})
])
})
@@ -332,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, translator)
+ const answer2 = getAnswer(field, state2, translator)
expect(answer1).toBe(
'Richard Fairclough House Knutsford Road Warrington Cheshire WA4 1HT '
@@ -405,7 +428,11 @@ describe('UkAddressField', () => {
it('sets Nunjucks component defaults', () => {
const payload = getFormData(address)
- const viewModel = field.getViewModel(payload)
+ const viewModel = field.getViewModel({
+ payload,
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -417,6 +444,7 @@ describe('UkAddressField', () => {
expect.objectContaining({
model: getViewModel(address, 'addressLine1', {
label: { text: 'Address line 1' },
+ // Sub-field title is a key constant; resolved at request time (Task 8/9).
attributes: { autocomplete: 'address-line1' }
})
}),
@@ -424,6 +452,7 @@ describe('UkAddressField', () => {
expect.objectContaining({
model: getViewModel(address, 'addressLine2', {
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
})
@@ -432,6 +461,7 @@ describe('UkAddressField', () => {
expect.objectContaining({
model: getViewModel(address, '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' },
value: address.town
@@ -441,6 +471,7 @@ describe('UkAddressField', () => {
expect.objectContaining({
model: getViewModel(address, 'county', {
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
})
@@ -449,6 +480,7 @@ describe('UkAddressField', () => {
expect.objectContaining({
model: getViewModel(address, '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' },
value: address.postcode
@@ -461,7 +493,11 @@ describe('UkAddressField', () => {
it('sets Nunjucks component fieldset', () => {
const payload = getFormData(address)
- const viewModel = field.getViewModel(payload)
+ const viewModel = field.getViewModel({
+ payload,
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel.fieldset).toEqual({
legend: {
@@ -479,6 +515,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 +638,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 +664,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 +690,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 +716,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 +742,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 +768,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).
})
]
}
@@ -739,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/components/UkAddressField.ts b/src/server/plugins/engine/components/UkAddressField.ts
index 5b27d9b37..598185ae4 100644
--- a/src/server/plugins/engine/components/UkAddressField.ts
+++ b/src/server/plugins/engine/components/UkAddressField.ts
@@ -8,7 +8,8 @@ import {
isFormState
} from '~/src/server/plugins/engine/components/FormComponent.js'
import { TextField } from '~/src/server/plugins/engine/components/TextField.js'
-import { type QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.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 FormRequestPayload,
type FormResponseToolkit
@@ -23,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']
@@ -41,13 +43,12 @@ export class UkAddressField extends FormComponent {
const isRequired = options.required !== false
const hideOptional = !!options.optionalText
const hideTitle = !!options.hideTitle
-
this.collection = new ComponentCollection(
[
{
type: ComponentType.TextField,
name: `${name}__uprn`,
- title: 'UPRN',
+ title: 'components.addressField.uprn',
schema: {},
options: {
required: false,
@@ -57,7 +58,7 @@ export class UkAddressField extends FormComponent {
{
type: ComponentType.TextField,
name: `${name}__addressLine1`,
- title: 'Address line 1',
+ title: 'components.addressField.line1',
schema: { max: 100 },
options: {
autocomplete: 'address-line1',
@@ -68,7 +69,7 @@ export class UkAddressField extends FormComponent {
{
type: ComponentType.TextField,
name: `${name}__addressLine2`,
- title: 'Address line 2',
+ title: 'components.addressField.line2',
schema: { max: 100 },
options: {
autocomplete: 'address-line2',
@@ -79,7 +80,7 @@ export class UkAddressField extends FormComponent {
{
type: ComponentType.TextField,
name: `${name}__town`,
- title: 'Town or city',
+ title: 'components.addressField.town',
schema: { max: 100 },
options: {
autocomplete: 'address-level2',
@@ -91,7 +92,7 @@ export class UkAddressField extends FormComponent {
{
type: ComponentType.TextField,
name: `${name}__county`,
- title: 'County',
+ title: 'components.addressField.county',
schema: { max: 100 },
options: {
autocomplete: 'address-level1',
@@ -102,7 +103,7 @@ export class UkAddressField extends FormComponent {
{
type: ComponentType.TextField,
name: `${name}__postcode`,
- title: 'Postcode',
+ title: 'components.addressField.postcode',
schema: {
regex: '^[a-zA-Z]{1,2}\\d[a-zA-Z\\d]?\\s?\\d[a-zA-Z]{2}$'
},
@@ -143,23 +144,32 @@ 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)
}
/**
* Returns one error per child field
*/
getViewErrors(
+ translator: Translator,
errors?: FormSubmissionError[]
): FormSubmissionError[] | undefined {
- const uniqueErrors = this.getErrors(errors)?.filter(
+ const { t } = translator
+
+ const uniqueErrors = this.getErrors(translator, errors)?.filter(
(error, index, self) =>
index === self.findIndex((err) => err.name === error.name)
)
@@ -174,7 +184,9 @@ export class UkAddressField extends FormComponent {
name,
path: [name],
href: `#${name}`,
- text: `Enter ${lowerFirst(label)}`
+ text: t('components.addressField.enterAddress', {
+ shortDescription: lowerFirst(label)
+ })
}
]
}
@@ -182,10 +194,11 @@ export class UkAddressField extends FormComponent {
return uniqueErrors
}
- getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) {
+ getViewModel(context: RenderContext) {
+ const { payload, translator } = context
const { collection, name, options } = this
- const viewModel = super.getViewModel(payload, errors)
+ const viewModel = super.getViewModel(context)
let { fieldset, hint, label } = viewModel
fieldset ??= {
@@ -209,7 +222,7 @@ export class UkAddressField extends FormComponent {
}
}
- const components = collection.getViewModel(payload, errors)
+ const components = collection.getViewModel(context)
// Hide UPRN
const uprn = components.at(0)
@@ -224,7 +237,7 @@ export class UkAddressField extends FormComponent {
const usePostcodeLookup = this.shouldUsePostcodeLookup()
const value = usePostcodeLookup
- ? this.getDisplayStringFromState(payload)
+ ? this.getDisplayStringFromState(payload, translator)
: undefined
return {
@@ -284,6 +297,7 @@ export class UkAddressField extends FormComponent {
args: PostcodeLookupExternalArgs
) {
const { controller, component } = args
+ const language = resolveLanguage(request)
return dispatch(request, h, {
formName: controller.model.name,
@@ -291,7 +305,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
})
}
}
diff --git a/src/server/plugins/engine/components/YesNoField.test.ts b/src/server/plugins/engine/components/YesNoField.test.ts
index 1178c9f7a..279222c33 100644
--- a/src/server/plugins/engine/components/YesNoField.test.ts
+++ b/src/server/plugins/engine/components/YesNoField.test.ts
@@ -6,10 +6,15 @@ 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'
+const translator = new FormModel(definition, {
+ basePath: '/'
+}).createTranslator()
+
describe('YesNoField', () => {
let def: YesNoFieldComponent
let model: FormModel
@@ -143,9 +148,9 @@ describe('YesNoField', () => {
const state2 = getFormState(false)
const state3 = getFormState(null)
- const answer1 = getAnswer(field, state1)
- const answer2 = getAnswer(field, state2)
- const answer3 = getAnswer(field, state3)
+ 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')
@@ -215,7 +220,11 @@ describe('YesNoField', () => {
it('sets Nunjucks component defaults', () => {
const item = items[0]
- const viewModel = field.getViewModel(getFormData(item.value))
+ const viewModel = field.getViewModel({
+ payload: getFormData(item.value),
+ errors: undefined,
+ translator: stubTranslator
+ })
expect(viewModel).toEqual(
expect.objectContaining({
@@ -228,7 +237,11 @@ describe('YesNoField', () => {
})
it.each([...items])('sets Nunjucks component radio items', (item) => {
- const viewModel = field.getViewModel(getFormData(item.value))
+ 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/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/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/engine/components/helpers/components.test.ts b/src/server/plugins/engine/components/helpers/components.test.ts
index 87ae55e43..f0a2a20a9 100644
--- a/src/server/plugins/engine/components/helpers/components.test.ts
+++ b/src/server/plugins/engine/components/helpers/components.test.ts
@@ -22,8 +22,13 @@ 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'
+const translator = new FormModel(definition, {
+ basePath: '/'
+}).createTranslator()
+
describe('Location field formatting', () => {
let model: FormModel
@@ -52,7 +57,9 @@ describe('Location field formatting', () => {
locationEN__northing: 654321
}
- const answer = getAnswer(field, state, { format: 'email' })
+ const answer = getAnswer(field, state, stubTranslator, {
+ format: 'email'
+ })
expect(answer).toBe('Easting: 123456\nNorthing: 654321\n')
})
@@ -62,7 +69,7 @@ describe('Location field formatting', () => {
locationEN__northing: 654321
}
- const answer = getAnswer(field, state, { format: 'data' })
+ const answer = getAnswer(field, state, stubTranslator, { format: 'data' })
expect(answer).toBe('Easting: 123456\nNorthing: 654321')
})
@@ -72,7 +79,9 @@ describe('Location field formatting', () => {
locationEN__northing: 654321
}
- const answer = getAnswer(field, state, { format: 'summary' })
+ const answer = getAnswer(field, state, stubTranslator, {
+ format: 'summary'
+ })
// Should render as HTML from markdown
expect(answer).toContain('Easting: 123456')
expect(answer).toContain('Northing: 654321')
@@ -81,7 +90,9 @@ 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, stubTranslator, {
+ format: 'email'
+ })
expect(answer).toBe('')
})
})
@@ -105,7 +116,9 @@ describe('Location field formatting', () => {
locationLL__longitude: -0.127758
}
- const answer = getAnswer(field, state, { format: 'email' })
+ const answer = getAnswer(field, state, stubTranslator, {
+ format: 'email'
+ })
expect(answer).toBe('Latitude: 51.51945\nLongitude: -0.127758\n')
})
@@ -115,7 +128,7 @@ describe('Location field formatting', () => {
locationLL__longitude: -0.127758
}
- const answer = getAnswer(field, state, { format: 'data' })
+ const answer = getAnswer(field, state, stubTranslator, { format: 'data' })
expect(answer).toBe('Latitude: 51.51945\nLongitude: -0.127758')
})
@@ -125,7 +138,9 @@ describe('Location field formatting', () => {
locationLL__longitude: -0.127758
}
- const answer = getAnswer(field, state, { format: 'summary' })
+ 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')
@@ -134,7 +149,9 @@ 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, stubTranslator, {
+ format: 'email'
+ })
expect(answer).toBe('')
})
})
@@ -157,7 +174,9 @@ describe('Location field formatting', () => {
gridRef: 'TQ123456'
}
- const answer = getAnswer(field, state, { format: 'email' })
+ const answer = getAnswer(field, state, stubTranslator, {
+ format: 'email'
+ })
expect(answer).toBe('TQ123456\n')
})
@@ -166,7 +185,7 @@ describe('Location field formatting', () => {
gridRef: 'TQ123456'
}
- const answer = getAnswer(field, state, { format: 'data' })
+ const answer = getAnswer(field, state, stubTranslator, { format: 'data' })
expect(answer).toBe('TQ123456')
})
@@ -175,7 +194,9 @@ describe('Location field formatting', () => {
gridRef: 'TQ123456'
}
- const answer = getAnswer(field, state, { format: 'summary' })
+ const answer = getAnswer(field, state, stubTranslator, {
+ format: 'summary'
+ })
expect(answer).toBe('TQ123456')
})
})
@@ -198,7 +219,9 @@ describe('Location field formatting', () => {
ngField: 'NG12345678'
}
- const answer = getAnswer(field, state, { format: 'email' })
+ const answer = getAnswer(field, state, stubTranslator, {
+ format: 'email'
+ })
expect(answer).toBe('NG12345678\n')
})
@@ -207,7 +230,7 @@ describe('Location field formatting', () => {
ngField: 'NG12345678'
}
- const answer = getAnswer(field, state, { format: 'data' })
+ const answer = getAnswer(field, state, stubTranslator, { format: 'data' })
expect(answer).toBe('NG12345678')
})
@@ -216,7 +239,9 @@ describe('Location field formatting', () => {
ngField: 'NG12345678'
}
- const answer = getAnswer(field, state, { format: 'summary' })
+ const answer = getAnswer(field, state, stubTranslator, {
+ format: 'summary'
+ })
expect(answer).toBe('NG12345678')
})
})
@@ -239,7 +264,9 @@ describe('Location field formatting', () => {
geoField: validState
}
- const answer = getAnswer(field, state, { format: 'email' })
+ const answer = getAnswer(field, state, stubTranslator, {
+ format: 'email'
+ })
expect(answer).toBe('Added 4 locations\n')
})
@@ -248,7 +275,7 @@ describe('Location field formatting', () => {
geoField: validState
}
- const answer = getAnswer(field, state, { format: 'data' })
+ const answer = getAnswer(field, state, stubTranslator, { format: 'data' })
expect(answer).toBe('a,b,c,d')
})
@@ -257,7 +284,9 @@ describe('Location field formatting', () => {
geoField: validState
}
- const answer = getAnswer(field, state, { format: 'summary' })
+ const answer = getAnswer(field, state, stubTranslator, {
+ format: 'summary'
+ })
expect(answer).toBe('Added 4 locations')
})
})
@@ -281,7 +310,9 @@ describe('Location field formatting', () => {
declField: true
}
- const answer = getAnswer(field, state, { format: 'email' })
+ const answer = getAnswer(field, state, stubTranslator, {
+ format: 'email'
+ })
expect(answer).toBe('I understand and agree\n')
})
@@ -290,7 +321,9 @@ describe('Location field formatting', () => {
declField: false
}
- const answer = getAnswer(field, state, { format: 'email' })
+ const answer = getAnswer(field, state, stubTranslator, {
+ format: 'email'
+ })
expect(answer).toBe('Not provided\n')
})
@@ -299,7 +332,7 @@ describe('Location field formatting', () => {
declField: true
}
- const answer = getAnswer(field, state, { format: 'data' })
+ const answer = getAnswer(field, state, stubTranslator, { format: 'data' })
expect(answer).toBe('true')
})
@@ -308,14 +341,14 @@ describe('Location field formatting', () => {
declField: false
}
- const answer = getAnswer(field, state, { format: 'data' })
+ 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' })
+ const answer = getAnswer(field, state, stubTranslator, { format: 'data' })
expect(answer).toBe('false')
})
@@ -324,7 +357,9 @@ describe('Location field formatting', () => {
declField: true
}
- const answer = getAnswer(field, state, { format: 'summary' })
+ const answer = getAnswer(field, state, stubTranslator, {
+ format: 'summary'
+ })
expect(answer).toBe('I understand and agree')
})
@@ -333,14 +368,18 @@ describe('Location field formatting', () => {
declField: false
}
- const answer = getAnswer(field, state, { format: 'summary' })
+ 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' })
+ const answer = getAnswer(field, state, stubTranslator, {
+ format: 'summary'
+ })
expect(answer).toBe('Not provided')
})
})
@@ -359,7 +398,9 @@ describe('Location field formatting', () => {
locationEN__northing: 654321
}
- const answer = getAnswerMarkdown(field, state, { format: 'email' })
+ const answer = getAnswerMarkdown(field, state, translator, {
+ format: 'email'
+ })
expect(answer).toBe('Easting: 123456\nNorthing: 654321\n')
})
@@ -376,7 +417,9 @@ describe('Location field formatting', () => {
locationLL__longitude: -0.127758
}
- const answer = getAnswerMarkdown(field, state, { format: 'email' })
+ const answer = getAnswerMarkdown(field, state, translator, {
+ format: 'email'
+ })
expect(answer).toBe('Latitude: 51.51945\nLongitude: -0.127758\n')
})
@@ -392,7 +435,9 @@ describe('Location field formatting', () => {
gridRef: 'TQ123456'
}
- const answer = getAnswerMarkdown(field, state, { format: 'email' })
+ 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 fbda7be88..9d62de2ed 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,6 +222,7 @@ export function createComponent(
export function getAnswer(
field: Field,
state: FormState,
+ translator: Translator,
options: {
format:
| 'data' // Submission data
@@ -230,7 +232,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, translator, { format: 'email' })
}
// Use context value for submission data
@@ -248,12 +250,15 @@ export function getAnswer(
field instanceof Components.LatLongField
) {
return markdown
- .parse(getAnswerMarkdown(field, state), { async: false })
+ .parse(
+ getAnswerMarkdown(field, state, translator, { format: 'summary' }),
+ { async: false }
+ )
.trim()
}
// Use display text for check answers summary (single line)
- return field.getDisplayStringFromState(state)
+ return field.getDisplayStringFromState(state, translator)
}
/**
@@ -262,13 +267,14 @@ 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' }
) {
- const answer = field.getDisplayStringFromState(state)
+ const answer = field.getDisplayStringFromState(state, translator)
// Use escaped display text
let answerEscaped = `${escapeMarkdown(answer)}\n`
@@ -305,7 +311,8 @@ export function getAnswerMarkdown(
// Append bullet points
answerEscaped += items
.map((item) => {
- const label = escapeMarkdown(item.text)
+ const resolvedText = translator.tListItem(item, 'text') || item.text
+ const label = escapeMarkdown(resolvedText)
const value = escapeMarkdown(`(${item.value})`)
let line = label
@@ -344,7 +351,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/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/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts
index 127c57258..132bc617c 100644
--- a/src/server/plugins/engine/configureEnginePlugin.ts
+++ b/src/server/plugins/engine/configureEnginePlugin.ts
@@ -1,13 +1,16 @@
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'
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'
@@ -67,7 +70,15 @@ export const configureEnginePlugin = async (
baseUrl: 'http://localhost:3009', // always runs locally
saveAndExit,
ordnanceSurveyApiKey,
- ordnanceSurveyApiSecret
+ ordnanceSurveyApiSecret,
+ getLanguage: (request: AnyFormRequest, metadata?: FormMetadata) => {
+ if ('language' in request.query) {
+ request.yar.set('language', request.query.language)
+ }
+
+ // @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/form-context.test.ts b/src/server/plugins/engine/form-context.test.ts
index c6f4aa340..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: '' } } }
@@ -85,11 +85,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 })
@@ -131,10 +134,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/form-context.ts b/src/server/plugins/engine/form-context.ts
index c3c4f4786..579f08077 100644
--- a/src/server/plugins/engine/form-context.ts
+++ b/src/server/plugins/engine/form-context.ts
@@ -36,6 +36,7 @@ export interface FormModelOptions {
export interface FormContextOptions extends FormModelOptions {
errors?: FormSubmissionError[]
+ language?: string
}
type SummaryRequest = FormContextRequest & {
@@ -119,10 +120,15 @@ export async function getFormContext(
$$__referenceNumber: cachedState.$$__referenceNumber
} as unknown as FormSubmissionState
+ const language =
+ (yar.get('language') as string | undefined) ?? options.language ?? 'en-GB'
+ const translator = formModel.createTranslator(language)
+
return formModel.getFormContext(
summaryRequest,
formState,
- options.errors ?? []
+ options.errors ?? [],
+ translator
)
}
@@ -148,7 +154,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/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/helpers.ts b/src/server/plugins/engine/helpers.ts
index 3be9f98bd..9c103576b 100644
--- a/src/server/plugins/engine/helpers.ts
+++ b/src/server/plugins/engine/helpers.ts
@@ -109,7 +109,11 @@ 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, translator, {
+ format: 'summary'
+ })
return answer
})
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..f31505615
--- /dev/null
+++ b/src/server/plugins/engine/i18n/buildValidationMessages.ts
@@ -0,0 +1,69 @@
+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('components.declarationField.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(
+ 'components.yesNoField.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('components.dateField.validation.dateFormat'),
+ dateMin: t('components.dateField.validation.dateMin'),
+ dateMax: t('components.dateField.validation.dateMax'),
+
+ unicode: 'validation.unicode',
+ arrayMin: 'validation.array.min',
+ arrayMax: 'validation.array.max',
+ arrayLength: 'validation.array.length',
+ featuresMin: 'validation.features.min',
+ featuresMax: 'validation.features.max',
+ featuresLength: 'validation.features.length'
+ }
+}
+
+export type ValidationMessages = ReturnType
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..cfe18ed14
--- /dev/null
+++ b/src/server/plugins/engine/i18n/extractBaseTranslations.test.ts
@@ -0,0 +1,110 @@
+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 }
+}
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..030f4ea3a
--- /dev/null
+++ b/src/server/plugins/engine/i18n/index.test.ts
@@ -0,0 +1,76 @@
+import {
+ createFormI18nInstance,
+ 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', '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', () => {
+ 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'
+ )
+ })
+})
+
+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
new file mode 100644
index 000000000..96efc8edb
--- /dev/null
+++ b/src/server/plugins/engine/i18n/index.ts
@@ -0,0 +1,67 @@
+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'
+
+type BaseTranslations = FormDefinitionTranslations[string]
+
+i18next
+ .init({
+ resources: {
+ 'en-GB': { translation: enGB },
+ cy: { translation: cy },
+ 'x-pirate': { translation: xPirate }
+ },
+ fallbackLng: 'en-GB',
+ interpolation: {
+ prefix: '[[',
+ suffix: ']]',
+ escapeValue: false
+ }
+ })
+ .catch(() => {
+ // init with inline resources completes synchronously — this branch is unreachable
+ })
+
+export function t(
+ key: string,
+ language: string,
+ options?: Record
+): 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
+ },
+ cy: {
+ plugin: cy
+ },
+ '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
+}
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..a98cf4aaf
--- /dev/null
+++ b/src/server/plugins/engine/i18n/translations/cy.json
@@ -0,0 +1,269 @@
+{
+ "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",
+ "draft": "ddrafft",
+ "live": "fyw"
+ },
+
+ "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)",
+ "confirmationEmail": "E-bost cadarnhad (dewisol)",
+ "confirmationEmailHint": "Rhowch eich cyfeiriad e-bost i dderbyn e-bost cadarnhau a chopi o'ch atebion"
+ },
+
+ "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)"
+ },
+ "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."
+ }
+ },
+
+ "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",
+ "validation": {
+ "descriptionRequired": "Rhowch ddisgrifiad ar gyfer lleoliad [[count]]",
+ "wrongCountry": "Rhaid i leoliad [[count]] fod y tu mewn i [[country]]"
+ }
+ },
+
+ "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"
+ }
+ }
+}
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..26b0a8d97
--- /dev/null
+++ b/src/server/plugins/engine/i18n/translations/en-GB.json
@@ -0,0 +1,268 @@
+{
+ "validation": {
+ "required": "Enter {{lowerFirst(#label)}}",
+ "selectRequired": "Select {{lowerFirst(#label)}}",
+ "pattern": "Enter a valid {{lowerFirst(#label)}}",
+ "format": "Enter {{lowerFirst(#label)}} in the correct format",
+ "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"
+ },
+
+ "errors": {
+ "title": "There is a problem",
+ "optionsMismatch": "Options are different because you changed a previous answer"
+ },
+
+ "common": {
+ "optional": "(optional)",
+ "warningIconFallback": "Warning",
+ "back": "Back",
+ "continue": "Continue",
+ "saveAndExit": "Save and exit",
+ "draft": "draft",
+ "live": "live"
+ },
+
+ "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)",
+ "confirmationEmail": "Confirmation email (optional)",
+ "confirmationEmailHint": "Enter your email address to receive a confirmation email and a copy of your answers"
+ },
+
+ "repeater": {
+ "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",
+ "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."
+ },
+
+ "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)"
+ },
+ "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."
+ }
+ },
+
+ "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",
+ "month": "Month",
+ "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": {
+ "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"
+ },
+
+ "yesNoField": {
+ "yes": "Yes",
+ "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",
+ "validation": {
+ "declarationRequired": "You must confirm you understand and agree with the {{lowerFirst(#label)}} to continue"
+ }
+ },
+
+ "geospatialField": {
+ "added_one": "Added [[count]] location",
+ "added_other": "Added [[count]] locations",
+ "validation": {
+ "descriptionRequired": "Enter description for location [[count]]"
+ }
+ },
+
+ "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:",
+ "paymentAmountMismatch": "The pre-authorised payment amount is somehow different from that requested. Try adding payment details again."
+ },
+
+ "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"
+ }
+ }
+}
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..756df692b
--- /dev/null
+++ b/src/server/plugins/engine/i18n/translations/x-pirate.json
@@ -0,0 +1,227 @@
+{
+ "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": "Arrr! {{#title}} must include a {{lowerFirst(#label)}}",
+ "max": "{{#label}} must be {{#limit}} characters or fewer, ye blabberin' sea dog",
+ "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, arrr",
+ "numberInteger": "{{#label}} must be a whole number — no fractions on the high seas",
+ "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"
+ },
+
+ "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": "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 course",
+ "notProvided": "Not provided, ye forgetful landlubber",
+ "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",
+ "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": "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 scroll number to arrange a refund",
+ "contactUsLinkText": "contact the crew (opens in new tab)"
+ },
+
+ "repeater": {
+ "removeAnswer": "Are ye sure ye want to cast this answer overboard, arrr?",
+ "remove": "Cast overboard",
+ "cancel": "Belay that",
+ "continue": "Sail on",
+ "addAnother": "Add another, matey",
+ "saveAndExit": "Stow yer progress an' abandon ship",
+ "change": "Change course",
+ "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. Cast one overboard before ye hoist any more."
+ },
+
+ "exit": {
+ "defaultTitle": "Yer progress has been stowed in the hold",
+ "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 on yer voyage",
+ "referenceNumber": "Yer scroll number",
+ "feedbackLink": "What do ye think of this service? (takes 30 seconds, even for a pirate)"
+ },
+
+ "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 (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}}, ye time-traveller",
+ "dateMax": "{{#title}} must be the same as or before {{#limit}}, ye time-traveller"
+ }
+ },
+
+ "monthYearField": {
+ "month": "Month (of the raid)",
+ "year": "Year (of the raid)"
+ },
+
+ "addressField": {
+ "uprn": "Berth number (UPRN)",
+ "line1": "Port address line 1",
+ "line2": "Port address line 2",
+ "town": "Town or port",
+ "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, ye stubborn sea dog"
+ },
+
+ "latLongField": {
+ "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 (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 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 — even a parrot could do it",
+ "howToFind": "How to find yer location on the treasure map"
+ },
+
+ "locationFieldBase": {
+ "howToFind": "How to find yer location on the treasure map"
+ },
+
+ "yesNoField": {
+ "yes": "Aye",
+ "no": "Nay",
+ "validation": {
+ "selectYesNoRequired": "{{#label}} — say aye or nay, ye indecisive landlubber"
+ }
+ },
+
+ "declarationField": {
+ "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": {
+ "declarationRequired": "Ye must confirm ye understand and agree with the {{lowerFirst(#label)}} to continue, or walk the plank"
+ }
+ },
+
+ "geospatialField": {
+ "added_one": "Ye've marked [[count]] location 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": {
+ "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 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 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 aboard",
+ "uploadFailed": "There be a problem with yer hoisted files. Re-hoist them before submittin' the form again.",
+ "uploadedFilesHeading": "Files hoisted aboard",
+ "filesCount_one": "[[count]] file hoisted aboard",
+ "filesCount_other": "[[count]] files hoisted aboard"
+ }
+ }
+}
diff --git a/src/server/plugins/engine/i18n/types.ts b/src/server/plugins/engine/i18n/types.ts
new file mode 100644
index 000000000..c0927154b
--- /dev/null
+++ b/src/server/plugins/engine/i18n/types.ts
@@ -0,0 +1,60 @@
+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 the typed tX functions.
+ */
+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]
+
+/**
+ * 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
+ * tListItem — form namespace: list item text
+ */
+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,
+ prop: keyof EntityTranslations['components'][string]
+ ) => string
+ tSection: (
+ section: Section,
+ prop: keyof EntityTranslations['sections'][string]
+ ) => string
+ tListItem: (
+ item: Item,
+ prop: keyof EntityTranslations['listItems'][string]
+ ) => string
+ language: string
+}
diff --git a/src/server/plugins/engine/index.ts b/src/server/plugins/engine/index.ts
index fa0efdfaa..dca00da4b 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'
@@ -26,7 +27,8 @@ const globals = {
checkComponentTemplates,
checkErrorTemplates,
evaluate,
- govukRebrand
+ govukRebrand,
+ t: tGlobal
}
export const VIEW_PATH = 'src/server/plugins/engine/views'
diff --git a/src/server/plugins/engine/models/FormModel.test.ts b/src/server/plugins/engine/models/FormModel.test.ts
index 284fad39a..7a9cbccff 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'
@@ -145,6 +148,32 @@ describe('FormModel', () => {
expect(model.schemaVersion).toBe(SchemaVersion.V1)
})
+ 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')
+ })
+
+ it('translates a key using createTranslator', () => {
+ const model = new FormModel(definition, { basePath: '/test' })
+ const { t } = model.createTranslator('en-GB')
+ expect(t('errors.title')).toBe('There is a problem')
+ })
+
+ it('passes interpolation options through createTranslator', () => {
+ const model = new FormModel(definition, { basePath: '/test' })
+ const { t } = model.createTranslator('en-GB')
+ expect(t('pages.repeater.pageTitle', { count: 3 })).toBe(
+ 'You have added 3 answers'
+ )
+ })
+
+ it('creates translator that resolves plugin namespace keys', () => {
+ const model = new FormModel(definition, { basePath: 'test' })
+ const { t } = model.createTranslator('en-GB')
+ expect(t('common.continue')).toBe('Continue')
+ })
+
it.each([
{
input: undefined,
@@ -727,6 +756,99 @@ describe('FormModel - Joined Conditions', () => {
})
})
+ 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('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')).toBe('Sail on')
+ })
+
+ 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 { tComponent } = model.createTranslator('en-GB')
+ // pages[0].components[0] has id '717eb213-4e4b-4a2d-9cfd-2780f5e1e3e5'
+ // and title 'Have you previously been married?'
+ expect(tComponent(definitionV2.pages[0].components[0], 'title')).toBe(
+ 'Have you previously been married?'
+ )
+ })
+
+ 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 { tComponent } = model.createTranslator('cy')
+ // No Welsh translations registered → falls back to en-GB base form string
+ expect(tComponent(definitionV2.pages[0].components[0], 'title')).toBe(
+ '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: '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: 'Cymraeg' }
+ ])
+ })
+
+ it('should return empty list if no translations', () => {
+ const def = {
+ metadata: {}
+ } as unknown as FormDefinition
+ expect(getAvailableLanguages(def)).toEqual([])
+ })
+ })
+
describe('moreThanOnePaymentQuestion', () => {
it('should return false if no payment questions', () => {
const model = new FormModel(definition, { basePath: 'test' })
diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts
index 5520597ce..fb8fc2a2e 100644
--- a/src/server/plugins/engine/models/FormModel.ts
+++ b/src/server/plugins/engine/models/FormModel.ts
@@ -27,6 +27,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 { logger } from '~/src/server/common/helpers/logging/logger.js'
@@ -43,6 +44,12 @@ import {
getPage,
setPageTitles
} from '~/src/server/plugins/engine/helpers.js'
+import { extractBaseTranslations } from '~/src/server/plugins/engine/i18n/extractBaseTranslations.js'
+import { createFormI18nInstance } from '~/src/server/plugins/engine/i18n/index.js'
+import {
+ type FormDefinitionTranslations,
+ 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 {
@@ -81,6 +88,7 @@ export class FormModel {
conditions: Partial>
pages: PageControllerClass[]
services: Services
+ private readonly i18nInstance: i18n
controllers?: Record
pageDefMap: Map
@@ -123,7 +131,19 @@ export class FormModel {
// by joi so as not to change the source data.
def = structuredClone(result.value)
- // Add default lists
+ 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)
+ }
+ }
+
+ // 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',
@@ -132,12 +152,12 @@ export class FormModel {
items: [
{
id: '02900d42-83d1-4c72-a719-c4e8228952fa',
- text: 'Yes',
+ text: 'components.yesNoField.yes',
value: true
},
{
id: 'f39000eb-c51b-4019-8f82-bbda0423f04d',
- text: 'No',
+ text: 'components.yesNoField.no',
value: false
}
]
@@ -231,6 +251,62 @@ export class FormModel {
)
}
+ /** Returns a scoped translator pair for the given language. */
+ createTranslator(language = 'en-GB'): 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]
+ 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, {
+ lng: language,
+ 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]
+ if (typeof raw !== 'string') return ''
+ return t(raw)
+ }
+ 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),
+ language
+ }
+ }
+
/**
* 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
@@ -337,7 +413,8 @@ export class FormModel {
getFormContext(
request: FormContextRequest,
state: FormSubmissionState,
- errors?: FormSubmissionError[]
+ errors?: FormSubmissionError[],
+ translator?: Translator
): FormContext {
const { query } = request
@@ -365,11 +442,13 @@ 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
- context = validateFormPayload(request, page, context)
+ context = validateFormPayload(request, page, context, translator)
// Find start page
let nextPage = findPage(this, startPath)
@@ -387,7 +466,7 @@ export class FormModel {
// Stop at current page
if (
- this.pageStateIsInvalid(context, nextPage) ||
+ this.pageStateIsInvalid(context, nextPage, translator) ||
nextPage.path === currentPath
) {
break
@@ -448,7 +527,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)
@@ -464,7 +547,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)
}
}
}
@@ -473,7 +556,8 @@ export class FormModel {
private fieldStateIsInvalid(
context: FormContext,
field: ListFormComponent,
- list: List
+ list: List,
+ translator?: Translator
) {
const { evaluationState, state } = context
@@ -503,8 +587,9 @@ export class FormModel {
if (isInvalid) {
context.errors ??= []
- const text =
- 'Options are different because you changed a previous answer'
+ const text = (translator ?? this.createTranslator('en-GB')).t(
+ 'errors.optionsMismatch'
+ )
context.errors.push({
text,
@@ -571,7 +656,8 @@ export class FormModel {
function validateFormPayload(
request: FormContextRequest,
page: PageControllerClass,
- context: FormContext
+ context: FormContext,
+ translator?: Translator
): FormContext {
const { collection } = page
const { payload, state } = context
@@ -601,10 +687,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)
@@ -656,3 +742,28 @@ function getReferenceNumber(state: FormSubmissionState): string {
return state.$$__referenceNumber
}
+
+const EN_GB = 'en-GB'
+
+const allowedLanguages = {
+ [EN_GB]: 'English',
+ cy: 'Cymraeg'
+} as Record
+
+export 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 2fef463da..32964fdda 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
@@ -12,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
@@ -134,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', () => {
@@ -207,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)
@@ -252,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)
@@ -316,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)
@@ -340,6 +362,106 @@ 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'),
+ tPage: jest.fn().mockReturnValue('mocked-content') as Translator['tPage'],
+ tComponent: jest
+ .fn()
+ .mockReturnValue('mocked-content') as Translator['tComponent'],
+ tSection: jest
+ .fn()
+ .mockReturnValue('mocked-content') as Translator['tSection'],
+ tListItem: jest
+ .fn()
+ .mockReturnValue('mocked-content') as Translator['tListItem'],
+ tForm: jest.fn().mockReturnValue('mocked-content') as Translator['tForm'],
+ language: 'en-GB'
+ }
+ })
+
+ 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)
+ const viewModel = new SummaryViewModel(
+ request,
+ page,
+ context,
+ mockTranslator
+ )
+ expect(viewModel).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)
+ const viewModel = new SummaryViewModel(
+ request,
+ page,
+ context,
+ mockTranslator
+ )
+ expect(viewModel).toBeDefined()
+
+ expect(mockTranslator.t).toHaveBeenCalledWith('pages.summary.notProvided')
+ })
+
+ it('should use stubTranslator when no specific translation assertions needed', () => {
+ const state: FormState = {
+ $$__referenceNumber: 'foobar',
+ orderType: 'collection',
+ pizza: []
+ }
+
+ context = model.getFormContext(request, state)
+ const viewModel = new SummaryViewModel(
+ request,
+ page,
+ context,
+ stubTranslator
+ )
+ expect(viewModel).toBeDefined()
+ })
+})
+
describe('SummaryPageController', () => {
let model: FormModel
let controller: SummaryPageController
@@ -379,7 +501,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)
})
@@ -392,7 +518,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'
@@ -426,7 +556,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'
@@ -464,7 +598,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 2aaf95f89..846336301 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'
@@ -15,6 +19,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,
@@ -56,22 +61,29 @@ export class SummaryViewModel {
allowSaveAndExit = false
paymentState?: PaymentState
paymentDetails?: CheckAnswers
+ t: (key: string, opts?: Record) => string
+ private readonly _translator!: Translator
paymentRequired?: boolean
paymentPreAuthorized?: boolean
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
+ this.t = t
+ this._translator = translator
+
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')
}
this.serviceUrl = `/${basePath}`
@@ -85,7 +97,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 => {
@@ -98,7 +110,7 @@ export class SummaryViewModel {
if (!isForceAccess) {
items.push({
href: item.href,
- text: 'Change',
+ text: t('pages.summary.change'),
classes: 'govuk-link--no-visited-state',
visuallyHiddenText: item.label
})
@@ -110,7 +122,7 @@ export class SummaryViewModel {
},
value: {
classes: 'app-prose-scope',
- html: item.value || 'Not provided'
+ html: item.value || t('pages.summary.notProvided')
},
actions: {
items
@@ -125,7 +137,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
@@ -143,10 +159,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) {
@@ -154,15 +175,20 @@ 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)
+ )
}
}
})
if (items.length) {
+ const sectionTitle = section
+ ? translator.tSection(section, 'title') || section.title
+ : undefined
details.push({
name: section?.name,
- title: section?.title,
+ title: sectionTitle,
items
})
}
@@ -182,19 +208,22 @@ 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 === 0 ? '' : translator.t('pages.repeater.pageTitle', { count })
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())
}),
@@ -204,7 +233,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)
)
)
}
@@ -221,17 +250,31 @@ export function ItemField(
options: {
path: string
errors?: FormSubmissionError[]
- }
+ },
+ 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.
+ const fieldDef = {
+ id: field.id,
+ type: field.type,
+ shortDescription: field.label,
+ title: field.title
+ } as unknown as ComponentDef
+ const rawLabel = translator.tComponent(fieldDef, 'shortDescription')
+ const translatedLabel = rawLabel !== '' ? rawLabel : field.label
+ const rawTitle = translator.tComponent(fieldDef, 'title')
+ const translatedTitle = rawTitle !== '' ? rawTitle : field.title
+ const optional =
+ field.options.required === false
+ ? ` ${translator.t('common.optional')}`
+ : ''
return {
name: field.name,
- label: field.title,
- title:
- field.options.required === false
- ? `${field.summaryLabel} (optional)`
- : field.summaryLabel,
- error: field.getFirstError(options.errors),
- value: getAnswer(field, state),
+ label: translatedTitle,
+ title: `${translatedLabel}${optional}`,
+ error: field.getFirstError(translator, options.errors),
+ value: getAnswer(field, state, translator, { format: 'summary' }),
href: getPageHref(page, options.path, {
returnUrl: getPageHref(page, page.getSummaryPath())
}),
diff --git a/src/server/plugins/engine/options.js b/src/server/plugins/engine/options.js
index 2c7a73520..41d8bce44 100644
--- a/src/server/plugins/engine/options.js
+++ b/src/server/plugins/engine/options.js
@@ -25,7 +25,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()
})
/**
diff --git a/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts b/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts
index b1ee81ab0..937f47528 100644
--- a/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts
+++ b/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts
@@ -868,4 +868,33 @@ describe('Adapter v1 formatter', () => {
expect(parsedBody.meta.versionMetadata).toBeUndefined()
})
})
+
+ 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 eb9660b5c..772cc0e7c 100644
--- a/src/server/plugins/engine/outputFormatters/adapter/v1.ts
+++ b/src/server/plugins/engine/outputFormatters/adapter/v1.ts
@@ -49,6 +49,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/outputFormatters/human/v1.payment.test.ts b/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts
index 23b3f54ef..a714c4825 100644
--- a/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts
+++ b/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts
@@ -9,10 +9,15 @@ 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'
+const translator = new FormModel(definitionPayment, {
+ basePath: '/'
+}).createTranslator()
+
describe('v1 human formatter', () => {
describe('Payment', () => {
const modelPayment = new FormModel(definitionPayment, {
@@ -77,12 +82,14 @@ describe('v1 human formatter', () => {
)
const summaryViewModelPayment = controllerPayment.getSummaryViewModel(
requestPayment,
- contextPayment
+ contextPayment,
+ stubTranslator
)
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 4089bb5d7..b4ffbb8ef 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,11 +67,16 @@ 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,
summaryViewModel.details,
+ stubTranslator,
model
)
diff --git a/src/server/plugins/engine/outputFormatters/human/v1.ts b/src/server/plugins/engine/outputFormatters/human/v1.ts
index a2e6bef13..6d72202a7 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, translator, { format: 'email' })
)
}
diff --git a/src/server/plugins/engine/outputFormatters/machine/v1.ts b/src/server/plugins/engine/outputFormatters/machine/v1.ts
index 73907567e..c81b0b6ee 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,11 +79,11 @@ 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, {
+ output.main[item.name] = getAnswer(item.field, item.state, translator, {
format: 'data'
})
}
@@ -95,7 +97,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 +107,8 @@ function extractRepeaters(item: DetailItemRepeat) {
outputRepeaterItem[repeaterComponent.name] = getAnswer(
repeaterComponent.field,
repeaterComponent.state,
- {
- format: 'data'
- }
+ translator,
+ { format: 'data' }
)
})
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 35448bf2d..658584511 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,11 +76,16 @@ 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,
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 9ac6d8229..5320de027 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,
@@ -22,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: {
@@ -276,11 +281,16 @@ 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,
summaryViewModel.details,
+ translator,
model
)
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..3758220d5 100644
--- a/src/server/plugins/engine/pageControllers/FileUploadPageController.ts
+++ b/src/server/plugins/engine/pageControllers/FileUploadPageController.ts
@@ -14,6 +14,7 @@ import {
getError,
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'
import { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js'
import { getProxyUrlForLocalDevelopment } from '~/src/server/plugins/engine/pageControllers/helpers/index.js'
@@ -187,10 +188,12 @@ export class FileUploadPageController extends QuestionPageController {
const { filename } = fileToRemove.status.form.file
+ const { t } = this.getTranslator(request)
+
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 +261,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/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 426bd97d4..8925da579 100644
--- a/src/server/plugins/engine/pageControllers/PageController.ts
+++ b/src/server/plugins/engine/pageControllers/PageController.ts
@@ -15,9 +15,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'
@@ -28,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 {
/**
@@ -161,6 +164,10 @@ export class PageController {
return ControllerPath.Status.valueOf()
}
+ protected getTranslator(request: AnyFormRequest): Translator {
+ return this.model.createTranslator(resolveLanguage(request))
+ }
+
makeGetRouteHandler(): (
request: FormRequest,
context: FormContext,
@@ -168,7 +175,11 @@ export class PageController {
) => ReturnType> {
return (request, context, h) => {
const { viewModel, viewName } = this
- return h.view(viewName, viewModel)
+ const { t } = this.getTranslator(request)
+ return h.view(viewName, {
+ ...viewModel,
+ t
+ })
}
}
diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts
index 6dd4dcf22..d217231e0 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 {
@@ -8,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,
@@ -30,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
@@ -189,12 +195,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 +456,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 +516,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 +1104,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
)
})
@@ -1686,222 +1704,355 @@ describe('Save and Exit functionality', () => {
})
})
-describe('QuestionPageController V2 - PaymentField (DF-832)', () => {
+describe('QuestionPageController translator support', () => {
+ let page1: PageQuestion
+ let page1Url: URL
+
let model: FormModel
- let choicePage: QuestionPageController
- let paymentPage: QuestionPageController
+ let controller1: QuestionPageController
+ let requestPage1: FormRequest
beforeEach(() => {
- model = new FormModel(definitionPaymentV2Conditional, {
+ const { pages } = definitionConditionsBasic
+
+ page1 = pages[0]
+ page1Url = new URL('http://example.com/test/first-page')
+
+ model = new FormModel(definitionConditionsBasic, {
basePath: 'test'
})
- // Page order in fixture: choice, gated, payment, summary
- choicePage = /** @type {QuestionPageController} */ model.pages[0]
- paymentPage = /** @type {QuestionPageController} */ model.pages[2]
+ 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('getNextPath', () => {
- const buildRequest = (pathname: string) => {
- const url = new URL(`http://example.com/test${pathname}`)
- return buildFormRequest({
+ describe('getViewModel with translator', () => {
+ it('uses mock t from translator for plugin strings', () => {
+ const mockT = jest.fn((key: string) => `translated:${key}`)
+ const translator: Translator = {
+ t: mockT,
+ tPage: jest.fn((_, prop) => `content:${prop}`),
+ tComponent: jest.fn((_, prop) => `content:${prop}`),
+ tSection: 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
+ const requestWithReturn = buildFormRequest({
method: 'get',
- url,
- path: url.pathname,
- params: { path: pathname.replace('/', ''), slug: 'test' },
- query: {},
+ url: page1Url,
+ path: page1Url.pathname,
+ params: {
+ path: 'first-page',
+ slug: 'test'
+ },
+ query: { returnUrl: '/test/summary' },
app: { model }
} as FormRequest)
- }
- it('skips payment pages in forward navigation (users reach via CYA)', () => {
- // From the "choice" page with "yes" answered, the gated page's
- // condition evaluates true. Payment comes after gated, but must
- // be skipped so the walk lands on summary.
- const request = buildRequest('/choice')
- const context = model.getFormContext(request, {
- $$__referenceNumber: 'foobar',
- yesNoField: true
+ const context = model.getFormContext(requestWithReturn, {
+ $$__referenceNumber: 'foobar'
})
- const next = choicePage.getNextPath(context)
-
- expect(next).toBe('/gated')
+ controller1.getViewModel(requestWithReturn, context, translator)
- // From the gated page the next real page would be the payment page,
- // but forward navigation skips it and lands on summary.
- const gatedPage = /** @type {QuestionPageController} */ model.pages[1]
- expect(gatedPage.getNextPath(context)).toBe('/summary')
+ // The translator's t should have been called for back link text
+ expect(mockT).toHaveBeenCalledWith('pages.question.backToCheckAnswers')
})
- it('skips payment pages even when their condition would pass', () => {
- // The payment page has no page-level condition, so only the
- // isPaymentPage skip can cause it to be excluded.
- const request = buildRequest('/choice')
- const context = model.getFormContext(request, {
- $$__referenceNumber: 'foobar',
- yesNoField: false
+ it('uses mock tPage from translator for page entity', () => {
+ const mockT = jest.fn((key: string) => key)
+ const mockTPage = jest.fn(
+ (_entity: unknown, prop: string) => `content:${prop}`
+ )
+
+ const translator: Translator = {
+ t: mockT,
+ tPage: mockTPage as Translator['tPage'],
+ tComponent: jest.fn(() => ''),
+ tSection: jest.fn(() => ''),
+ tListItem: jest.fn(() => ''),
+ tForm: jest.fn(() => ''),
+ language: 'en-GB'
+ }
+
+ const context = model.getFormContext(requestPage1, {
+ $$__referenceNumber: 'foobar'
})
- // With "no", the gated page's condition fails so the walk from
- // /choice should skip both /gated (condition) and /payment (payment)
- // and land directly on the summary.
- expect(choicePage.getNextPath(context)).toBe('/summary')
+ controller1.getViewModel(requestPage1, context, translator)
+
+ expect(mockTPage).toHaveBeenCalledWith(
+ expect.objectContaining({ path: page1.path }),
+ 'title'
+ )
})
- })
- describe('getViewModel - resolved payment amount', () => {
- const buildPaymentRequest = () => {
- const url = new URL('http://example.com/test/payment')
- return buildFormRequest({
+ 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,
+ tPage: stubTranslator.tPage,
+ tComponent: stubTranslator.tComponent,
+ tSection: stubTranslator.tSection,
+ tListItem: stubTranslator.tListItem,
+ tForm: stubTranslator.tForm,
+ language: 'en-GB'
+ }
+
+ // Use a request with returnUrl so that getBackLink calls translator.t for back link text
+ const requestWithReturn = buildFormRequest({
method: 'get',
- url,
- path: url.pathname,
- params: { path: 'payment', slug: 'test' },
- query: {},
+ url: page1Url,
+ path: page1Url.pathname,
+ params: {
+ path: 'first-page',
+ slug: 'test'
+ },
+ query: { returnUrl: '/test/summary' },
app: { model }
} as FormRequest)
- }
- it('overrides displayed amount using resolveAmount with full form state', () => {
- // yesNoField = false selects the £99 conditional amount.
- // The payload for the payment page on its own would not know that
- // — the controller must reach into context.evaluationState.
- const request = buildPaymentRequest()
- const context = model.getFormContext(request, {
- $$__referenceNumber: 'foobar',
- yesNoField: false
+ const context = model.getFormContext(requestWithReturn, {
+ $$__referenceNumber: 'foobar'
})
- const viewModel = paymentPage.getViewModel(request, context)
- const paymentComp = viewModel.components.find(
- (c) => 'amount' in c.model && 'paymentState' in c.model
- )
+ controller1.getViewModel(requestWithReturn, context, mockTranslatorLocal)
- expect(paymentComp).toBeDefined()
- expect(paymentComp?.model).toHaveProperty('amount', '£99.00')
+ expect(mockT).toHaveBeenCalledWith('pages.question.backToCheckAnswers')
})
+ })
- it('overrides displayed amount to £0 when zero-amount condition matches', () => {
- // yesNoField = true selects the £0 conditional amount (first in list)
- const request = buildPaymentRequest()
- const context = model.getFormContext(request, {
- $$__referenceNumber: 'foobar',
- yesNoField: true
- })
+ describe('QuestionPageController V2 - PaymentField (DF-832)', () => {
+ let model: FormModel
+ let choicePage: QuestionPageController
+ let paymentPage: QuestionPageController
- const viewModel = paymentPage.getViewModel(request, context)
- const paymentComp = viewModel.components.find(
- (c) => 'amount' in c.model && 'paymentState' in c.model
- )
+ beforeEach(() => {
+ model = new FormModel(definitionPaymentV2Conditional, {
+ basePath: 'test'
+ })
- expect(paymentComp?.model).toHaveProperty('amount', '£0.00')
+ // Page order in fixture: choice, gated, payment, summary
+ choicePage = /** @type {QuestionPageController} */ model.pages[0]
+ paymentPage = /** @type {QuestionPageController} */ model.pages[2]
})
- it('falls back to base amount when no condition matches', () => {
- // Empty state → no conditions match → base £50
- const request = buildPaymentRequest()
- const context = model.getFormContext(request, {
- $$__referenceNumber: 'foobar'
- })
+ describe('getNextPath', () => {
+ const buildRequest = (pathname: string) => {
+ const url = new URL(`http://example.com/test${pathname}`)
+ return buildFormRequest({
+ method: 'get',
+ url,
+ path: url.pathname,
+ params: { path: pathname.replace('/', ''), slug: 'test' },
+ query: {},
+ app: { model }
+ } as FormRequest)
+ }
- const viewModel = paymentPage.getViewModel(request, context)
- const paymentComp = viewModel.components.find(
- (c) => 'amount' in c.model && 'paymentState' in c.model
- )
+ it('skips payment pages in forward navigation (users reach via CYA)', () => {
+ // From the "choice" page with "yes" answered, the gated page's
+ // condition evaluates true. Payment comes after gated, but must
+ // be skipped so the walk lands on summary.
+ const request = buildRequest('/choice')
+ const context = model.getFormContext(request, {
+ $$__referenceNumber: 'foobar',
+ yesNoField: true
+ })
- expect(paymentComp?.model).toHaveProperty('amount', '£50.00')
- })
+ const next = choicePage.getNextPath(context)
- it('suppresses Save & Exit on payment pages', () => {
- const request = buildPaymentRequest()
- const context = model.getFormContext(request, {
- $$__referenceNumber: 'foobar',
- yesNoField: false
+ expect(next).toBe('/gated')
+
+ // From the gated page the next real page would be the payment page,
+ // but forward navigation skips it and lands on summary.
+ const gatedPage = /** @type {QuestionPageController} */ model.pages[1]
+ expect(gatedPage.getNextPath(context)).toBe('/summary')
})
- const viewModel = paymentPage.getViewModel(request, context)
+ it('skips payment pages even when their condition would pass', () => {
+ // The payment page has no page-level condition, so only the
+ // isPaymentPage skip can cause it to be excluded.
+ const request = buildRequest('/choice')
+ const context = model.getFormContext(request, {
+ $$__referenceNumber: 'foobar',
+ yesNoField: false
+ })
- expect(viewModel).toHaveProperty('allowSaveAndExit', false)
+ // With "no", the gated page's condition fails so the walk from
+ // /choice should skip both /gated (condition) and /payment (payment)
+ // and land directly on the summary.
+ expect(choicePage.getNextPath(context)).toBe('/summary')
+ })
})
- it('allows Save & Exit on non-payment pages', () => {
- const url = new URL('http://example.com/test/choice')
- const request = buildFormRequest({
- method: 'get',
- url,
- path: url.pathname,
- params: { path: 'choice', slug: 'test' },
- query: {},
- app: { model }
- } as FormRequest)
+ describe('getViewModel - resolved payment amount', () => {
+ const buildPaymentRequest = () => {
+ const url = new URL('http://example.com/test/payment')
+ return buildFormRequest({
+ method: 'get',
+ url,
+ path: url.pathname,
+ params: { path: 'payment', slug: 'test' },
+ query: {},
+ app: { model }
+ } as FormRequest)
+ }
- const context = model.getFormContext(request, {
- $$__referenceNumber: 'foobar'
+ it('overrides displayed amount using resolveAmount with full form state', () => {
+ // yesNoField = false selects the £99 conditional amount.
+ // The payload for the payment page on its own would not know that
+ // — the controller must reach into context.evaluationState.
+ const request = buildPaymentRequest()
+ const context = model.getFormContext(request, {
+ $$__referenceNumber: 'foobar',
+ yesNoField: false
+ })
+
+ const viewModel = paymentPage.getViewModel(request, context, translator)
+ const paymentComp = viewModel.components.find(
+ (c) => 'amount' in c.model && 'paymentState' in c.model
+ )
+
+ expect(paymentComp).toBeDefined()
+ expect(paymentComp?.model).toHaveProperty('amount', '£99.00')
})
- // The default FormModel server may not have save-and-exit plugin
- // loaded, so we check via shouldShowSaveAndExit being the driver
- // rather than isPaymentPage for this page.
- jest.spyOn(choicePage, 'shouldShowSaveAndExit').mockReturnValue(true)
+ it('overrides displayed amount to £0 when zero-amount condition matches', () => {
+ // yesNoField = true selects the £0 conditional amount (first in list)
+ const request = buildPaymentRequest()
+ const context = model.getFormContext(request, {
+ $$__referenceNumber: 'foobar',
+ yesNoField: true
+ })
- const viewModel = choicePage.getViewModel(request, context)
+ const viewModel = paymentPage.getViewModel(request, context, translator)
+ const paymentComp = viewModel.components.find(
+ (c) => 'amount' in c.model && 'paymentState' in c.model
+ )
- expect(viewModel).toHaveProperty('allowSaveAndExit', true)
- })
- })
+ expect(paymentComp?.model).toHaveProperty('amount', '£0.00')
+ })
- describe('getViewModel - showSubmitButton based on paymentState', () => {
- const buildPaymentRequest = () => {
- const url = new URL('http://example.com/test/payment')
- return buildFormRequest({
- method: 'get',
- url,
- path: url.pathname,
- params: { path: 'payment', slug: 'test' },
- query: {},
- app: { model }
- } as FormRequest)
- }
+ it('falls back to base amount when no condition matches', () => {
+ // Empty state → no conditions match → base £50
+ const request = buildPaymentRequest()
+ const context = model.getFormContext(request, {
+ $$__referenceNumber: 'foobar'
+ })
- it('shows submit button when no payment pre-auth captured yet', () => {
- // No paymentState on form state → hasIncompletePayment is true
- // → showSubmitButton is false.
- const request = buildPaymentRequest()
- const context = model.getFormContext(request, {
- $$__referenceNumber: 'foobar',
- yesNoField: false
+ const viewModel = paymentPage.getViewModel(request, context, translator)
+ const paymentComp = viewModel.components.find(
+ (c) => 'amount' in c.model && 'paymentState' in c.model
+ )
+
+ expect(paymentComp?.model).toHaveProperty('amount', '£50.00')
+ })
+
+ it('suppresses Save & Exit on payment pages', () => {
+ const request = buildPaymentRequest()
+ const context = model.getFormContext(request, {
+ $$__referenceNumber: 'foobar',
+ yesNoField: false
+ })
+
+ const viewModel = paymentPage.getViewModel(request, context, translator)
+
+ expect(viewModel).toHaveProperty('allowSaveAndExit', false)
})
- const viewModel = paymentPage.getViewModel(request, context)
+ it('allows Save & Exit on non-payment pages', () => {
+ const url = new URL('http://example.com/test/choice')
+ const request = buildFormRequest({
+ method: 'get',
+ url,
+ path: url.pathname,
+ params: { path: 'choice', slug: 'test' },
+ query: {},
+ app: { model }
+ } as FormRequest)
+
+ const context = model.getFormContext(request, {
+ $$__referenceNumber: 'foobar'
+ })
+
+ // The default FormModel server may not have save-and-exit plugin
+ // loaded, so we check via shouldShowSaveAndExit being the driver
+ // rather than isPaymentPage for this page.
+ jest.spyOn(choicePage, 'shouldShowSaveAndExit').mockReturnValue(true)
+
+ const viewModel = choicePage.getViewModel(request, context, translator)
- expect(viewModel).toHaveProperty('showSubmitButton', false)
+ expect(viewModel).toHaveProperty('allowSaveAndExit', true)
+ })
})
- it('shows submit button when pre-auth has a captured status', () => {
- // The PaymentField persists paymentId/amount/description at the top
- // of its state slot, with preAuth nested under it. preAuth.status
- // being present means pre-auth was captured → hasIncompletePayment
- // is false → showSubmitButton is true.
- const request = buildPaymentRequest()
- const context = model.getFormContext(request, {
- $$__referenceNumber: 'foobar',
- yesNoField: false,
- paymentField: {
- paymentId: 'p-1',
- amount: 99,
- description: 'Test payment',
- preAuth: {
- status: 'success',
+ describe('getViewModel - showSubmitButton based on paymentState', () => {
+ const buildPaymentRequest = () => {
+ const url = new URL('http://example.com/test/payment')
+ return buildFormRequest({
+ method: 'get',
+ url,
+ path: url.pathname,
+ params: { path: 'payment', slug: 'test' },
+ query: {},
+ app: { model }
+ } as FormRequest)
+ }
+
+ it('shows submit button when no payment pre-auth captured yet', () => {
+ // No paymentState on form state → hasIncompletePayment is true
+ // → showSubmitButton is false.
+ const request = buildPaymentRequest()
+ const context = model.getFormContext(request, {
+ $$__referenceNumber: 'foobar',
+ yesNoField: false
+ })
+
+ const viewModel = paymentPage.getViewModel(request, context, translator)
+
+ expect(viewModel).toHaveProperty('showSubmitButton', false)
+ })
+
+ it('shows submit button when pre-auth has a captured status', () => {
+ // The PaymentField persists paymentId/amount/description at the top
+ // of its state slot, with preAuth nested under it. preAuth.status
+ // being present means pre-auth was captured → hasIncompletePayment
+ // is false → showSubmitButton is true.
+ const request = buildPaymentRequest()
+ const context = model.getFormContext(request, {
+ $$__referenceNumber: 'foobar',
+ yesNoField: false,
+ paymentField: {
paymentId: 'p-1',
- amount: 99
+ amount: 99,
+ description: 'Test payment',
+ preAuth: {
+ status: 'success',
+ paymentId: 'p-1',
+ amount: 99
+ }
}
- }
- } as unknown as FormSubmissionState)
+ } as unknown as FormSubmissionState)
- const viewModel = paymentPage.getViewModel(request, context)
+ const viewModel = paymentPage.getViewModel(request, context, translator)
- expect(viewModel).toHaveProperty('showSubmitButton', true)
+ expect(viewModel).toHaveProperty('showSubmitButton', true)
+ })
})
})
})
diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts
index 1b2398001..543650718 100644
--- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts
+++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts
@@ -21,7 +21,6 @@ import {
} from '~/src/server/constants.js'
import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js'
import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js'
-import { optionalText } from '~/src/server/plugins/engine/components/constants.js'
import { type BackLink } from '~/src/server/plugins/engine/components/types.js'
import {
checkFormStatus,
@@ -31,6 +30,7 @@ import {
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'
@@ -118,18 +118,28 @@ 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, tPage, tSection } = translator
+
+ let { showTitle, sectionTitle } = viewModel
+
+ let pageTitle = tPage(this.pageDef, 'title') || viewModel.pageTitle
+
+ if (this.section) {
+ sectionTitle =
+ this.section.hideTitle !== true ? tSection(this.section, 'title') : ''
+ }
- const components = collection.getViewModel(payload, errors, query)
+ const components = collection.getViewModel({ payload, errors, translator })
const formComponents = components.filter(
({ isFormComponent }) => isFormComponent
)
@@ -163,7 +173,7 @@ export class QuestionPageController extends PageController {
if (pageTitle) {
labelOrLegend.text = isOptional
- ? `${pageTitle}${optionalText}`
+ ? `${pageTitle} ${t('common.optional')}`
: pageTitle
}
@@ -216,8 +226,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,
@@ -450,14 +462,17 @@ export class QuestionPageController extends PageController {
const { collection, model, viewName } = this
const { evaluationState } = context
+ const translator = this.getTranslator(request)
+ 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.pathname)
}
- const viewModel = this.getViewModel(request, context)
- viewModel.errors = collection.getViewErrors(viewModel.errors)
+ const viewModel = this.getViewModel(request, context, translator)
+ viewModel.errors = collection.getViewErrors(translator, viewModel.errors)
const flashedError = request.yar.flash(COMPONENT_STATE_ERROR)
const flashedErrors = !Array.isArray(flashedError) ? [flashedError] : []
@@ -484,7 +499,10 @@ export class QuestionPageController extends PageController {
viewModel.hasMissingNotificationEmail =
await this.hasMissingNotificationEmail(request, context)
- return h.view(viewName, viewModel)
+ return h.view(viewName, {
+ ...viewModel,
+ t
+ })
}
}
@@ -515,7 +533,8 @@ export class QuestionPageController extends PageController {
*/
protected getBackLink(
request: FormContextRequest,
- context: FormContext
+ context: FormContext,
+ t: (key: string, opts?: Record) => string
): BackLink | undefined {
const { pageDef } = this
const { path, query } = request
@@ -529,8 +548,8 @@ export class QuestionPageController extends PageController {
return {
text:
hasRepeater(pageDef) && itemId
- ? 'Go back to add another'
- : 'Go back to check answers',
+ ? t('pages.question.backToAddAnother')
+ : t('pages.question.backToCheckAnswers'),
href: returnUrl
}
}
@@ -548,7 +567,7 @@ export class QuestionPageController extends PageController {
// Default back link
return {
- text: 'Back',
+ text: t('common.back'),
href: this.getHref(backPath)
}
}
@@ -563,6 +582,9 @@ export class QuestionPageController extends PageController {
const { isForceAccess, state, evaluationState } = context
const action = request.payload.action
+ const translator = this.getTranslator(request)
+ const { t } = translator
+
if (action?.startsWith(FormAction.External)) {
return await this.dispatchExternal(request, h, context)
}
@@ -572,8 +594,11 @@ export class QuestionPageController extends PageController {
* @todo Refactor to match POST REDIRECT GET pattern
*/
if (context.errors || isForceAccess) {
- const viewModel = this.getViewModel(request, context)
- viewModel.errors = collection.getViewErrors(viewModel.errors)
+ const viewModel = this.getViewModel(request, context, translator)
+ viewModel.errors = collection.getViewErrors(
+ translator,
+ viewModel.errors
+ )
// Filter our components based on their conditions using our evaluated state
viewModel.components = this.filterConditionalComponents(
@@ -582,7 +607,10 @@ export class QuestionPageController extends PageController {
evaluationState
)
- return h.view(viewName, viewModel)
+ return h.view(viewName, {
+ ...viewModel,
+ t
+ })
}
// Save state
diff --git a/src/server/plugins/engine/pageControllers/RepeatPageController.test.ts b/src/server/plugins/engine/pageControllers/RepeatPageController.test.ts
index b66f91e5f..d2a85c7d2 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'
@@ -5,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,
@@ -135,7 +137,10 @@ describe('RepeatPageController', () => {
beforeEach(() => {
viewModel = controller.getViewModel(
requestPageItem,
- model.getFormContext(requestPageItem, { $$__referenceNumber: 'foobar' })
+ model.getFormContext(requestPageItem, {
+ $$__referenceNumber: 'foobar'
+ }),
+ stubTranslator
)
})
@@ -200,7 +205,8 @@ describe('RepeatPageController', () => {
model.getFormContext(requestPageSummary, {
$$__referenceNumber: 'foobar'
}),
- list
+ list,
+ stubTranslator
)
})
@@ -276,4 +282,110 @@ 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 translator: Translator = {
+ t: mockT,
+ tPage: jest.fn(() => ''),
+ tComponent: jest.fn(() => ''),
+ tSection: jest.fn(() => ''),
+ tListItem: jest.fn(() => ''),
+ tForm: jest.fn(() => ''),
+ language: 'en-GB'
+ }
+
+ 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('uses translator.tPage for page title when translator is supplied', () => {
+ const mockT = jest.fn((key: string) => `translated:${key}`)
+ const mockTPage = jest.fn(() => 'translated-title')
+ const translator: Translator = {
+ t: mockT,
+ tPage: mockTPage as Translator['tPage'],
+ tComponent: jest.fn(() => ''),
+ tSection: jest.fn(() => ''),
+ tListItem: jest.fn(() => ''),
+ tForm: jest.fn(() => ''),
+ language: 'en-GB'
+ }
+
+ const context = model.getFormContext(requestPageItem, {
+ $$__referenceNumber: 'foobar'
+ })
+
+ const viewModel = controller.getViewModel(
+ requestPageItem,
+ context,
+ translator
+ )
+
+ expect(mockTPage).toHaveBeenCalled()
+ expect(viewModel).toHaveProperty('pageTitle', 'translated-title')
+ })
+ })
+
+ 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 translator: Translator = {
+ t: mockT,
+ tPage: jest.fn(() => ''),
+ tComponent: jest.fn(() => ''),
+ tSection: jest.fn(() => ''),
+ tListItem: jest.fn(() => ''),
+ tForm: jest.fn(() => ''),
+ language: 'en-GB'
+ }
+
+ 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('uses stubTranslator when no specific translation assertions needed', () => {
+ const context = model.getFormContext(requestPageSummary, {
+ $$__referenceNumber: 'foobar'
+ })
+
+ const viewModel = controller.getListSummaryViewModel(
+ requestPageSummary,
+ context,
+ list,
+ stubTranslator
+ )
+
+ expect(viewModel).toHaveProperty('pageTitle')
+ })
+ })
})
diff --git a/src/server/plugins/engine/pageControllers/RepeatPageController.ts b/src/server/plugins/engine/pageControllers/RepeatPageController.ts
index e9656249c..76c5fd5de 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 { 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,9 +194,20 @@ export class RepeatPageController extends QuestionPageController {
return super.proceed(request, h, nextPath)
}
- const viewModel = this.getListSummaryViewModel(request, context, list)
+ const translator = this.getTranslator(request)
+ const { t } = translator
- return h.view(this.listSummaryViewName, viewModel)
+ const viewModel = this.getListSummaryViewModel(
+ request,
+ context,
+ list,
+ translator
+ )
+
+ return h.view(this.listSummaryViewName, {
+ ...viewModel,
+ t
+ })
}
}
@@ -220,6 +232,9 @@ export class RepeatPageController extends QuestionPageController {
return super.proceed(request, h, nextPath)
}
+ const translator = this.getTranslator(request)
+ const { t } = translator
+
const { action } = this.getFormParams(request)
const hasErrorMin =
@@ -232,7 +247,6 @@ 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'}`
context.errors = [
{
@@ -240,14 +254,22 @@ 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', { 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)
+ return h.view(this.listSummaryViewName, {
+ ...viewModel,
+ t
+ })
}
if (action === FormAction.AddAnother) {
@@ -281,11 +303,14 @@ export class RepeatPageController extends QuestionPageController {
const itemId = this.getItemId(request)
const item = this.getItemFromList(list, itemId)
+ const translator = this.getTranslator(request)
+ const { t } = translator
+
if (!item || list.length === 1) {
throw Boom.notFound(
item
- ? 'Last list item cannot be removed'
- : 'List item to remove not found'
+ ? t('pages.repeater.lastItemCannotBeRemoved')
+ : t('pages.repeater.itemToRemoveNotFound')
)
}
@@ -294,11 +319,12 @@ export class RepeatPageController extends QuestionPageController {
return h.view(this.listDeleteViewName, {
...viewModel,
context,
- backLink: this.getBackLink(request, context),
- pageTitle: 'Are you sure you want to remove this answer?',
+ backLink: this.getBackLink(request, context, t),
+ pageTitle: t('pages.repeater.removeAnswer'),
itemTitle: `${title} ${list.indexOf(item) + 1}`,
- buttonConfirm: { text: 'Remove' },
- buttonCancel: { text: 'Cancel' }
+ buttonConfirm: { text: t('pages.repeater.remove') },
+ buttonCancel: { text: t('pages.repeater.cancel') },
+ t
} satisfies ItemDeletePageViewModel)
}
}
@@ -318,11 +344,13 @@ export class RepeatPageController extends QuestionPageController {
const itemId = this.getItemId(request)
const item = this.getItemFromList(list, itemId)
+ const { t } = this.getTranslator(request)
+
if (!item || list.length === 1) {
throw Boom.notFound(
item
- ? 'Last list item cannot be removed'
- : 'List item to remove not found'
+ ? t('pages.repeater.lastItemCannotBeRemoved')
+ : t('pages.repeater.itemToRemoveNotFound')
)
}
@@ -343,7 +371,8 @@ export class RepeatPageController extends QuestionPageController {
getViewModel(
request: FormContextRequest,
- context: FormContext
+ context: FormContext,
+ translator: Translator
): FormPageViewModel {
const { state } = context
@@ -351,7 +380,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}`
@@ -367,12 +396,15 @@ 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
+
const { title } = repeat.options
const summaryList: SummaryList = {
@@ -396,9 +428,11 @@ export class RepeatPageController extends QuestionPageController {
href: redirectPath(`${href}/${item.itemId}`, {
returnUrl: query.returnUrl ?? this.getHref(summaryPath)
}),
- text: 'Change',
+ text: t('pages.repeater.change'),
classes: 'govuk-link--no-visited-state',
- visuallyHiddenText: `item ${index + 1}`
+ visuallyHiddenText: t('pages.repeater.visuallyHiddenItem', {
+ index: index + 1
+ })
})
if (count > 1) {
@@ -406,15 +440,17 @@ export class RepeatPageController extends QuestionPageController {
href: redirectPath(`${href}/${item.itemId}/confirm-delete`, {
returnUrl: query.returnUrl
}),
- text: 'Remove',
+ text: t('pages.repeater.remove'),
classes: 'govuk-link--no-visited-state',
- visuallyHiddenText: `item ${index + 1}`
+ visuallyHiddenText: t('pages.repeater.visuallyHiddenItem', {
+ index: index + 1
+ })
})
}
}
const itemDisplayText = collection.fields.length
- ? collection.fields[0].getDisplayStringFromState(item)
+ ? collection.fields[0].getDisplayStringFromState(item, translator)
: ''
summaryList.rows.push({
@@ -422,7 +458,7 @@ export class RepeatPageController extends QuestionPageController {
text: `${title} ${index + 1}`
},
value: {
- text: itemDisplayText || 'Not provided'
+ text: itemDisplayText || t('pages.repeater.notProvided')
},
actions: {
items
@@ -431,13 +467,11 @@ export class RepeatPageController extends QuestionPageController {
})
}
- const unit = count === 1 ? 'answer' : 'answers'
-
return {
...this.viewModel,
- backLink: this.getBackLink(request, context),
+ backLink: this.getBackLink(request, context, t),
repeatTitle: title,
- pageTitle: `You have added ${count} ${unit}`,
+ pageTitle: t('pages.repeater.pageTitle', { count }),
showTitle: true,
context,
errors,
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/StatusPageController.ts b/src/server/plugins/engine/pageControllers/StatusPageController.ts
index fddee7f67..f9e6e4c7e 100644
--- a/src/server/plugins/engine/pageControllers/StatusPageController.ts
+++ b/src/server/plugins/engine/pageControllers/StatusPageController.ts
@@ -8,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
@@ -32,6 +33,10 @@ export class StatusPageController extends QuestionPageController {
) => {
const { viewModel, viewName } = this
+ const language = resolveLanguage(request)
+ const translator = this.model.createTranslator(language)
+ const { t } = translator
+
const cacheService = getCacheService(request.server)
const confirmationState = await cacheService.getConfirmationState(request)
@@ -58,7 +63,8 @@ export class StatusPageController extends QuestionPageController {
submissionGuidance,
formName,
showReferenceNumber: this.showReferenceNumber,
- referenceNumber: confirmationState.referenceNumber
+ referenceNumber: confirmationState.referenceNumber,
+ t
})
}
}
diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts
index 86677bb59..d7d9df8bc 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,
@@ -14,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
@@ -90,6 +95,52 @@ 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'),
+ 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'],
+ tForm: jest.fn().mockReturnValue('content') as Translator['tForm'],
+ language: 'en-GB'
+ }
+
+ 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 })
+ )
+ })
+ })
})
describe('SummaryPageController - Payment (DF-832)', () => {
@@ -375,7 +426,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
@@ -413,7 +464,8 @@ describe('SummaryPageController - Payment (DF-832)', () => {
request,
viewModel,
model,
- 'notify@example.com'
+ 'notify@example.com',
+ translator
)
).rejects.toMatchObject({
name: 'PaymentSubmissionError'
@@ -434,7 +486,8 @@ describe('SummaryPageController - Payment (DF-832)', () => {
request,
viewModel,
model,
- 'notify@example.com'
+ 'notify@example.com',
+ translator
)
).rejects.toBe(err)
})
@@ -454,7 +507,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/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts
index fdd088fee..6ebbac02e 100644
--- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts
+++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts
@@ -20,6 +20,7 @@ import {
createError,
getCacheService
} from '~/src/server/plugins/engine/helpers.js'
+import { type Translator } from '~/src/server/plugins/engine/i18n/types.js'
import {
SummaryViewModel,
type FormModel
@@ -89,11 +90,13 @@ 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
+
+ const viewModel = new SummaryViewModel(request, this, context, translator)
- const { query } = request
const { payload, errors, state } = context
const paymentField = this.findPaymentField()
@@ -110,7 +113,8 @@ export class SummaryPageController extends QuestionPageController {
viewModel.paymentState = paymentState
viewModel.paymentDetails = this.buildPaymentDetails(
paymentField,
- paymentState
+ paymentState,
+ t
)
}
@@ -126,9 +130,13 @@ 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)
+ viewModel.backLink = this.getBackLink(request, context, t)
viewModel.feedbackLink = this.feedbackLink
viewModel.phaseTag = this.phaseTag
viewModel.components = components
@@ -142,32 +150,33 @@ export class SummaryPageController extends QuestionPageController {
paymentField: PaymentField,
paymentState: NonNullable<
ReturnType
- >
+ >,
+ t: (key: string, opts?: Record) => string
) {
const rows = [
{
- key: { text: 'Payment for' },
+ key: { text: t('pages.summary.paymentFor') },
value: { text: paymentState.description }
},
{
- key: { text: 'Total amount' },
+ key: { text: t('pages.summary.totalAmount') },
value: { text: formatCurrency(paymentState.amount) }
},
{
- key: { text: 'Reference' },
+ key: { text: t('pages.summary.reference') },
value: { text: paymentState.reference }
}
]
if (paymentState.preAuth?.createdAt) {
rows.push({
- key: { text: 'Date of payment' },
+ key: { text: t('pages.summary.dateOfPayment') },
value: { text: formatPaymentDate(paymentState.preAuth.createdAt) }
})
}
return {
- title: { text: 'Payment details' },
+ title: { text: t('pages.summary.paymentDetailsTitle') },
summaryList: { rows }
}
}
@@ -183,6 +192,9 @@ export class SummaryPageController extends QuestionPageController {
) => {
const { viewName } = this
+ const translator = this.getTranslator(request)
+ const { t } = translator
+
// After GOV.UK Pay callback, auto-submit the form instead of
// showing CYA again. The payment is already pre-authorized.
if (request.query.paymentComplete === 'true') {
@@ -195,11 +207,12 @@ export class SummaryPageController extends QuestionPageController {
await this.reconcilePaymentState(request, context)
- const viewModel = this.getSummaryViewModel(request, context)
+ const viewModel = this.getSummaryViewModel(request, context, translator)
viewModel.hasMissingNotificationEmail =
await this.hasMissingNotificationEmail(request, context)
+ viewModel.t = t
return h.view(viewName, viewModel)
}
}
@@ -270,7 +283,8 @@ export class SummaryPageController extends QuestionPageController {
checkEmailAddressForLiveFormSubmission(notificationEmail, isPreview)
if (notificationEmail) {
- const viewModel = this.getSummaryViewModel(request, context)
+ const translator = this.getTranslator(request)
+ const viewModel = this.getSummaryViewModel(request, context, translator)
try {
await submitForm(
@@ -279,7 +293,8 @@ export class SummaryPageController extends QuestionPageController {
request,
viewModel,
model,
- notificationEmail
+ notificationEmail,
+ translator
)
} catch (error) {
return this.handleSubmissionError(error, request, h)
@@ -388,12 +403,17 @@ export class SummaryPageController extends QuestionPageController {
request: FormRequestPayload,
h: FormResponseToolkit
) {
+ const { t } = this.getTranslator(request)
+
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')} `
+ const helpLinkHtml = t('pages.summary.submissionFailedContactSuffix', {
+ 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')}${helpLinkHtml}.`
)
request.yar.flash(COMPONENT_STATE_ERROR, govukError, true)
@@ -420,7 +440,8 @@ export async function submitForm(
request: FormRequestPayload,
summaryViewModel: SummaryViewModel,
model: FormModel,
- emailAddress: string
+ emailAddress: string,
+ translator: Translator
) {
await finaliseComponents(request, formMetadata, context, model)
@@ -434,6 +455,7 @@ export async function submitForm(
const items = getFormSubmissionData(
summaryViewModel.context,
summaryViewModel.details,
+ translator,
model
)
@@ -443,7 +465,8 @@ export async function submitForm(
model,
items,
emailAddress,
- request.yar.id
+ request.yar.id,
+ translator
)
if (submitResponse === undefined) {
@@ -521,7 +544,8 @@ function submitData(
model: FormModel,
items: DetailItem[],
retrievalKey: string,
- sessionId: string
+ sessionId: string,
+ translator: Translator
) {
const { formSubmissionService } = model.services
const { submit } = formSubmissionService
@@ -529,8 +553,8 @@ function submitData(
const payload: SubmitPayload = {
sessionId,
retrievalKey,
- main: buildMainRecords(items),
- repeaters: buildRepeaterRecords(items)
+ main: buildMainRecords(items, translator),
+ repeaters: buildRepeaterRecords(items, translator)
}
return submit(payload)
}
@@ -538,6 +562,7 @@ function submitData(
export function getFormSubmissionData(
context: FormContext,
details: Detail[],
+ translator: Translator,
model: FormModel
) {
const items = context.relevantPages
@@ -548,7 +573,7 @@ export function getFormSubmissionData(
)
.flat()
- const paymentItems = getPaymentFieldItems(context, model)
+ const paymentItems = getPaymentFieldItems(context, translator, model)
return [...items, ...paymentItems]
}
@@ -559,6 +584,7 @@ export function getFormSubmissionData(
*/
function getPaymentFieldItems(
context: FormContext,
+ translator: Translator,
model: FormModel
): DetailItemField[] {
const items: DetailItemField[] = []
@@ -574,7 +600,7 @@ function getPaymentFieldItems(
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/__stubs__/translator.ts b/src/server/plugins/engine/pageControllers/__stubs__/translator.ts
new file mode 100644
index 000000000..585f79fdf
--- /dev/null
+++ b/src/server/plugins/engine/pageControllers/__stubs__/translator.ts
@@ -0,0 +1,28 @@
+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.
+ * 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),
+ 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),
+ language: 'en-GB'
+}
diff --git a/src/server/plugins/engine/pageControllers/errors.ts b/src/server/plugins/engine/pageControllers/errors.ts
index ef9986cac..eb072e747 100644
--- a/src/server/plugins/engine/pageControllers/errors.ts
+++ b/src/server/plugins/engine/pageControllers/errors.ts
@@ -65,12 +65,14 @@ export class PaymentSubmissionError extends Error {
static checkPaymentAmount(
stateAmount: number,
expectedAmount: number | undefined,
- component: FormComponent
+ component: FormComponent,
+ t: (key: string) => string
) {
if (stateAmount / 100 !== expectedAmount) {
+ const message = t('components.paymentField.paymentAmountMismatch')
throw new PaymentPreAuthError(
component,
- 'The pre-authorised payment amount is somehow different from that requested. Try adding payment details again.',
+ message,
true,
PaymentErrorTypes.PaymentIncomplete
)
diff --git a/src/server/plugins/engine/pageControllers/helpers/submission.test.ts b/src/server/plugins/engine/pageControllers/helpers/submission.test.ts
index c17a7aacc..db605875e 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([
@@ -303,7 +310,7 @@ describe('Submission helpers', () => {
}
] as unknown as DetailItemField[]
- const result = buildMainRecords(items)
+ const result = buildMainRecords(items, translator)
expect(result).toHaveLength(1)
expect(result).toEqual([
@@ -329,7 +336,10 @@ describe('Submission helpers', () => {
}
]
- const result = buildRepeaterRecords(items as unknown as DetailItemField[])
+ const result = buildRepeaterRecords(
+ items as unknown as DetailItemField[],
+ translator
+ )
expect(result).toEqual([])
})
@@ -360,7 +370,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')
@@ -393,7 +406,7 @@ describe('Submission helpers', () => {
}
] as unknown as DetailItemField[]
- const result = buildRepeaterRecords(items)
+ const result = buildRepeaterRecords(items, translator)
expect(result).toHaveLength(1)
expect(result).toEqual([
@@ -440,7 +453,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/engine/pageControllers/helpers/submission.ts b/src/server/plugins/engine/pageControllers/helpers/submission.ts
index f2974d2ec..0a080ee60 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
)
@@ -46,7 +50,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, translator, { format: 'data' })
})
}
}
@@ -102,7 +106,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,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' })
+ value = getAnswer(subItem.field, subItem.state, translator, {
+ format: 'data'
+ })
}
return {
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 c2fb9e482..b2c521808 100644
--- a/src/server/plugins/engine/pageControllers/validationOptions.ts
+++ b/src/server/plugins/engine/pageControllers/validationOptions.ts
@@ -1,7 +1,6 @@
// Declaration above is needed for: https://github.com/hapijs/joi/issues/3064
-import joi, {
- type JoiExpression,
+import {
type LanguageMessages,
type LanguageMessagesExt,
type ReferenceOptions,
@@ -9,6 +8,11 @@ import joi, {
} from 'joi'
import { lowerFirstPreserveProperNouns } from '~/src/server/plugins/engine/components/helpers/index.js'
+import {
+ buildValidationMessages,
+ type ValidationMessages
+} from '~/src/server/plugins/engine/i18n/buildValidationMessages.js'
+import { t } from '~/src/server/plugins/engine/i18n/index.js'
export const opts = {
functions: {
@@ -17,58 +21,13 @@ export const opts = {
} as ReferenceOptions
/**
- * 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,
- unicode: '{{#label}} includes invalid characters, for example, long dashes',
- 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}}',
- arrayMin: 'Select at least {{#limit}} options from the list',
- arrayMax: 'Only {{#limit}} can be selected from the list',
- arrayLength: 'Select only {{#limit}} options from the list',
- featuresMin: 'Define at least {{#limit}} features',
- featuresMax: 'Only {{#limit}} features can be defined',
- featuresLength: 'Define exactly {{#limit}} features'
-}
+export const messageTemplate: ValidationMessages = buildValidationMessages(
+ (key) => t(key, 'en-GB')
+)
export const messages: LanguageMessagesExt = {
'string.base': messageTemplate.required,
@@ -105,6 +64,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,
diff --git a/src/server/plugins/engine/plugin.ts b/src/server/plugins/engine/plugin.ts
index fb715d23d..941f63bd6 100644
--- a/src/server/plugins/engine/plugin.ts
+++ b/src/server/plugins/engine/plugin.ts
@@ -36,6 +36,7 @@ export const plugin = {
model,
cache,
saveAndExit,
+ getLanguage,
nunjucks: nunjucksOptions,
viewContext,
preparePageEventRequestOptions,
@@ -78,6 +79,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/server/plugins/engine/routes/index.test.ts b/src/server/plugins/engine/routes/index.test.ts
index fedbfeb83..1dde54696 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,24 @@ function buildMockModel(
isForceAccess: false,
data: {}
}),
+ createTranslator: jest.fn().mockReturnValue({
+ t: jest.fn(),
+ tPage: jest.fn(),
+ tComponent: jest.fn(),
+ tSection: jest.fn(),
+ tListItem: jest.fn(),
+ tForm: jest.fn(),
+ language: 'en-GB'
+ }),
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 +104,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 de05dcb5b..e39d5e555 100644
--- a/src/server/plugins/engine/routes/index.ts
+++ b/src/server/plugins/engine/routes/index.ts
@@ -43,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,
@@ -82,7 +83,14 @@ export async function redirectOrMakeHandler(
state = await importExternalComponentState(request, page, state)
const flash = cacheService.getFlash(request)
- const context = model.getFormContext(request, state, flash?.errors)
+ const language = resolveLanguage(request)
+ const translator = model.createTranslator(language)
+ const context = model.getFormContext(
+ request,
+ state,
+ flash?.errors,
+ translator
+ )
await copyNotYetValidatedState(request, context)
diff --git a/src/server/plugins/engine/routes/questions.test.ts b/src/server/plugins/engine/routes/questions.test.ts
index 53737db67..43de7de81 100644
--- a/src/server/plugins/engine/routes/questions.test.ts
+++ b/src/server/plugins/engine/routes/questions.test.ts
@@ -65,8 +65,17 @@ 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(),
+ tPage: jest.fn(),
+ tComponent: jest.fn(),
+ tSection: jest.fn(),
+ tListItem: jest.fn(),
+ tForm: jest.fn(),
+ language: 'en-GB'
+ })
+ } as unknown as FormModel
const pageMock = createMockPageController(
modelMock,
@@ -83,6 +92,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
@@ -106,8 +116,17 @@ 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(),
+ tPage: jest.fn(),
+ tComponent: jest.fn(),
+ tSection: jest.fn(),
+ tListItem: jest.fn(),
+ tForm: jest.fn(),
+ language: 'en-GB'
+ })
+ } as unknown as FormModel
const pageMock = createMockPageController(
modelMock,
@@ -126,6 +145,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
@@ -147,8 +167,17 @@ 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(),
+ tPage: jest.fn(),
+ tComponent: jest.fn(),
+ tSection: jest.fn(),
+ tListItem: jest.fn(),
+ tForm: jest.fn(),
+ language: 'en-GB'
+ })
+ } as unknown as FormModel
const pageMock = createMockPageController(
modelMock,
@@ -164,6 +193,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
@@ -196,8 +226,17 @@ 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(),
+ tPage: jest.fn(),
+ tComponent: jest.fn(),
+ tSection: jest.fn(),
+ tListItem: jest.fn(),
+ tForm: jest.fn(),
+ language: 'en-GB'
+ })
+ } as unknown as FormModel
const pageMock = createMockPageController(
modelMock,
@@ -213,6 +252,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
@@ -262,8 +302,17 @@ 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(),
+ tPage: jest.fn(),
+ tComponent: jest.fn(),
+ tSection: jest.fn(),
+ tListItem: jest.fn(),
+ tForm: jest.fn(),
+ language: 'en-GB'
+ })
+ } as unknown as FormModel
const pageMock = createMockPageController(
modelMock,
@@ -280,6 +329,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' }
@@ -301,8 +351,17 @@ 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(),
+ tPage: jest.fn(),
+ tComponent: jest.fn(),
+ tSection: jest.fn(),
+ tListItem: jest.fn(),
+ tForm: jest.fn(),
+ language: 'en-GB'
+ })
+ } as unknown as FormModel
const pageMock = createMockPageController(
modelMock,
@@ -320,6 +379,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' }
@@ -344,8 +404,17 @@ 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(),
+ tPage: jest.fn(),
+ tComponent: jest.fn(),
+ tSection: jest.fn(),
+ tListItem: jest.fn(),
+ tForm: jest.fn(),
+ language: 'en-GB'
+ })
+ } as unknown as FormModel
const pageMock = createMockPageController(
modelMock,
@@ -362,6 +431,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' }
@@ -384,8 +454,17 @@ 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(),
+ tPage: jest.fn(),
+ tComponent: jest.fn(),
+ tSection: jest.fn(),
+ tListItem: jest.fn(),
+ tForm: jest.fn(),
+ language: 'en-GB'
+ })
+ } as unknown as FormModel
const pageMock = createMockPageController(
modelMock,
@@ -401,6 +480,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' }
@@ -434,8 +514,17 @@ 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(),
+ tPage: jest.fn(),
+ tComponent: jest.fn(),
+ tSection: jest.fn(),
+ tListItem: jest.fn(),
+ tForm: jest.fn(),
+ language: 'en-GB'
+ })
+ } as unknown as FormModel
const pageMock = createMockPageController(
modelMock,
@@ -451,6 +540,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 374e471d9..07283ad76 100644
--- a/src/server/plugins/engine/routes/questions.ts
+++ b/src/server/plugins/engine/routes/questions.ts
@@ -44,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,
@@ -58,10 +59,13 @@ 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 language = resolveLanguage(request)
+ const translator = model.createTranslator(language)
+ const viewModel = new SummaryViewModel(request, page, context, translator)
const items = getFormSubmissionData(
viewModel.context,
viewModel.details,
+ translator,
model
)
diff --git a/src/server/plugins/engine/services/localFormsService.js b/src/server/plugins/engine/services/localFormsService.js
index 8bf8c6680..3915952ce 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',
diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts
index 9d9fecd75..0870502f2 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,
@@ -31,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'
@@ -199,6 +201,8 @@ export interface FormContext {
pageMap: Map
componentMap: Map
referenceNumber: string
+ languages: { name: string; code: string }[]
+ translator: Translator | undefined
}
export type FormContextRequest = (
@@ -394,6 +398,7 @@ export interface PageViewModelBase extends Partial {
feedbackLink?: string
serviceUrl: string
phaseTag?: string
+ t?: (key: string, opts?: Record) => string
}
export interface ItemDeletePageViewModel extends PageViewModelBase {
@@ -510,6 +515,7 @@ export interface PluginOptions {
viewContext: PluginProperties['forms-engine-plugin']['viewContext']
preparePageEventRequestOptions?: PreparePageEventRequestOptions
onRequest?: OnRequestCallback
+ 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
@@ -526,6 +532,7 @@ export interface FormAdapterSubmissionMessageMeta {
isPreview: boolean
notificationEmail: string
versionMetadata?: FormVersionMetadata
+ language?: string
custom?: Record
}
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()
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 %}
-
{{upload.count}} file{{ "s" if upload.count > 1}} uploaded
+
{{ t('components.fileUploadField.filesCount', { count: 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 }}
Use a different address
+ value="external-{{component.model.name}}">{{ t('components.addressField.useDifferentAddress') }}
{% endset %}
@@ -54,14 +54,14 @@
{% else %}
{# 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 ac3658d66..8ef4f30d2 100644
--- a/src/server/plugins/engine/views/file-upload.html
+++ b/src/server/plugins/engine/views/file-upload.html
@@ -14,15 +14,15 @@
{% if formAction %}
{{ govukButton({
- text: "Upload file",
+ text: t('pages.fileUpload.upload'),
classes: "govuk-button--secondary upload-file-button",
preventDoubleClick: true,
disabled: context.isForceAccess
}) }}
{% 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 a2ad2d2a5..c656aec7f 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/form.html b/src/server/plugins/engine/views/partials/form.html
index 95a0d95ad..cdeaf8976 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/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 %}
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..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,10 +1,9 @@
{% from "govuk/components/warning-text/macro.njk" import govukWarningText %}
-{% 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.
-{% endset %}
+{% set linkText %}{{ t('pages.preview.warnMissingNotificationEmail.linkText') }}{% endset %}
+{% set linkHtml %}
{{ linkText }} {% endset %}
{{ govukWarningText({
- html: html,
- iconFallbackText: "Warning"
+ html: t('pages.preview.warnMissingNotificationEmail.body', { link: linkHtml }),
+ 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 81315fdbd..a6fcf4bbe 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 @@