diff --git a/docs/features/code-based/PRE_POPULATE_STATE.md b/docs/features/code-based/PRE_POPULATE_STATE.md new file mode 100644 index 000000000..658e1afcc --- /dev/null +++ b/docs/features/code-based/PRE_POPULATE_STATE.md @@ -0,0 +1,21 @@ +--- +layout: default +title: Pre-populate state +parent: Code-based Features +grand_parent: Features +render_with_liquid: false +--- + +# Pre-populate state + +The forms engine supports the ability to pre-populate form state using query string parameters. This feature enables applications to support passing specific parameter values through the form and on to the submission without the user having to enter these values. + +The feature uses the HiddenField component to prevent against rogue state injection. Only query string parameters whose names exist as HiddenField components will be copied into state. + +The parameter values get copied on first load of the form, and are simple key/value parameters e.g.: + +``` +?paramname1=paramval1,paramname2=paramname2 +``` + +There is no limit set on the number of parameters. The keys and values get copied as-is (no case changes get applied). diff --git a/package-lock.json b/package-lock.json index 91ce2ce21..e9c5fa0ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2012,28 +2012,15 @@ "keyv": "^5.5.4" } }, - "node_modules/@cacheable/memory/node_modules/@cacheable/utils": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.3.0.tgz", - "integrity": "sha512-qznqu6bpEei96zojGW+/IX1VXTOihznnVOK/kzyQWcqgn7SqkC3216nsX7M4BQfGwQgnxUXZ1xX7xiUoedqLPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hashery": "^1.2.0" - }, - "peerDependencies": { - "keyv": "^5.5.4" - } - }, "node_modules/@cacheable/memory/node_modules/@keyv/bigmap": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.2.0.tgz", - "integrity": "sha512-4Lme8NejkyetZ9oJ6u8NSf0iJEFFt7I+tyDI48wZlaFmbhDEh4nZg7bEPFPwCWkpIuL50/ukWBC9AHQTmdJLUA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.0.tgz", + "integrity": "sha512-KT01GjzV6AQD5+IYrcpoYLkCu1Jod3nau1Z7EsEuViO3TZGRacSbO9MfHmbJ1WaOXFtWLxPVj169cn2WNKPkIg==", "dev": true, "license": "MIT", "dependencies": { "hashery": "^1.2.0", - "hookified": "^1.12.2" + "hookified": "^1.13.0" }, "engines": { "node": ">= 18" @@ -2053,6 +2040,27 @@ "@keyv/serialize": "^1.1.1" } }, + "node_modules/@cacheable/utils": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.3.1.tgz", + "integrity": "sha512-38NJXjIr4W1Sghun8ju+uYWD8h2c61B4dKwfnQHVDFpAJ9oS28RpfqZQJ6Dgd3RceGkILDY9YT+72HJR3LoeSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.2.0", + "keyv": "^5.5.4" + } + }, + "node_modules/@cacheable/utils/node_modules/keyv": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.4.tgz", + "integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -2218,9 +2226,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.584", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.584.tgz", - "integrity": "sha512-hghONjBY8ho7Z7I/QRwYT70Ot5BStPO5rbtaSsGMMhjOsiXM93+jGmclr8rw1A3w5DTiqmaagbtX1lgDojcSjg==", + "version": "3.0.585", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.585.tgz", + "integrity": "sha512-lSJzQu0xTk+VqSjLcjt7zwPS88J7MVYl5kdKfKCQsqbnVTmBHi9a3MHvCa6xlZRu1GZgoEjwQWY+QKzTKfN8FA==", "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", @@ -3934,9 +3942,9 @@ } }, "node_modules/@jest/reporters/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { @@ -5095,9 +5103,9 @@ "license": "MIT" }, "node_modules/@types/lodash": { - "version": "4.17.20", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", - "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==", "dev": true, "license": "MIT" }, @@ -5238,18 +5246,18 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.4.tgz", - "integrity": "sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz", + "integrity": "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/type-utils": "8.46.4", - "@typescript-eslint/utils": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/type-utils": "8.48.0", + "@typescript-eslint/utils": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -5263,23 +5271,23 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.46.4", + "@typescript-eslint/parser": "^8.48.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.4.tgz", - "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.0.tgz", + "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", "debug": "^4.3.4" }, "engines": { @@ -5295,14 +5303,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.4.tgz", - "integrity": "sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.0.tgz", + "integrity": "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.4", - "@typescript-eslint/types": "^8.46.4", + "@typescript-eslint/tsconfig-utils": "^8.48.0", + "@typescript-eslint/types": "^8.48.0", "debug": "^4.3.4" }, "engines": { @@ -5317,14 +5325,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.4.tgz", - "integrity": "sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.0.tgz", + "integrity": "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4" + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5335,9 +5343,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.4.tgz", - "integrity": "sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.0.tgz", + "integrity": "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==", "dev": true, "license": "MIT", "engines": { @@ -5352,15 +5360,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.4.tgz", - "integrity": "sha512-V4QC8h3fdT5Wro6vANk6eojqfbv5bpwHuMsBcJUJkqs2z5XnYhJzyz9Y02eUmF9u3PgXEUiOt4w4KHR3P+z0PQ==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.0.tgz", + "integrity": "sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4", - "@typescript-eslint/utils": "8.46.4", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/utils": "8.48.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -5377,9 +5385,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.4.tgz", - "integrity": "sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz", + "integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==", "dev": true, "license": "MIT", "engines": { @@ -5391,21 +5399,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.4.tgz", - "integrity": "sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.0.tgz", + "integrity": "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.46.4", - "@typescript-eslint/tsconfig-utils": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", + "@typescript-eslint/project-service": "8.48.0", + "@typescript-eslint/tsconfig-utils": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", + "tinyglobby": "^0.2.15", "ts-api-utils": "^2.1.0" }, "engines": { @@ -5433,16 +5440,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.4.tgz", - "integrity": "sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.0.tgz", + "integrity": "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4" + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5457,13 +5464,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.4.tgz", - "integrity": "sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.0.tgz", + "integrity": "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/types": "8.48.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -6723,9 +6730,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.29", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.29.tgz", - "integrity": "sha512-sXdt2elaVnhpDNRDz+1BDx1JQoJRuNk7oVlAlbGiFkLikHCAQiccexF/9e91zVi6RCgqspl04aP+6Cnl9zRLrA==", + "version": "2.8.31", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz", + "integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -6940,26 +6947,12 @@ "qified": "^0.5.2" } }, - "node_modules/cacheable/node_modules/@cacheable/utils": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.3.0.tgz", - "integrity": "sha512-qznqu6bpEei96zojGW+/IX1VXTOihznnVOK/kzyQWcqgn7SqkC3216nsX7M4BQfGwQgnxUXZ1xX7xiUoedqLPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hashery": "^1.2.0" - }, - "peerDependencies": { - "keyv": "^5.5.4" - } - }, "node_modules/cacheable/node_modules/keyv": { "version": "5.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.4.tgz", "integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -7048,9 +7041,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001755", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001755.tgz", - "integrity": "sha512-44V+Jm6ctPj7R52Na4TLi3Zri4dWUljJd+RDm+j8LtNCc/ihLCT+X1TzoOAkRETEWqjuLnh9581Tl80FvK7jVA==", + "version": "1.0.30001757", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", + "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", "dev": true, "funding": [ { @@ -7505,9 +7498,9 @@ } }, "node_modules/core-js": { - "version": "3.46.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.46.0.tgz", - "integrity": "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==", + "version": "3.47.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz", + "integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -7517,13 +7510,13 @@ } }, "node_modules/core-js-compat": { - "version": "3.46.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.46.0.tgz", - "integrity": "sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law==", + "version": "3.47.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.47.0.tgz", + "integrity": "sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==", "dev": true, "license": "MIT", "dependencies": { - "browserslist": "^4.26.3" + "browserslist": "^4.28.0" }, "funding": { "type": "opencollective", @@ -8244,9 +8237,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.254", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.254.tgz", - "integrity": "sha512-DcUsWpVhv9svsKRxnSCZ86SjD+sp32SGidNB37KpqXJncp1mfUgKbHvBomE89WJDbfVKw1mdv5+ikrvd43r+Bg==", + "version": "1.5.260", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.260.tgz", + "integrity": "sha512-ov8rBoOBhVawpzdre+Cmz4FB+y66Eqrk6Gwqd8NGxuhv99GQ8XqMAr351KEkOt7gukXWDg6gJWEMKgL2RLMPtA==", "dev": true, "license": "ISC" }, @@ -11406,9 +11399,9 @@ } }, "node_modules/jest-config/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { @@ -11930,17 +11923,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-runner/node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, "node_modules/jest-runtime": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", @@ -11976,9 +11958,9 @@ } }, "node_modules/jest-runtime/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { @@ -16344,9 +16326,9 @@ } }, "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", "dev": true, "license": "MIT", "dependencies": { @@ -16706,9 +16688,9 @@ } }, "node_modules/stylelint": { - "version": "16.25.0", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.25.0.tgz", - "integrity": "sha512-Li0avYWV4nfv1zPbdnxLYBGq4z8DVZxbRgx4Kn6V+Uftz1rMoF1qiEI3oL4kgWqyYgCgs7gT5maHNZ82Gk03vQ==", + "version": "16.26.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.26.0.tgz", + "integrity": "sha512-Y/3AVBefrkqqapVYH3LBF5TSDZ1kw+0XpdKN2KchfuhMK6lQ85S4XOG4lIZLcrcS4PWBmvcY6eS2kCQFz0jukQ==", "dev": true, "funding": [ { @@ -16736,7 +16718,7 @@ "debug": "^4.4.3", "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", - "file-entry-cache": "^10.1.4", + "file-entry-cache": "^11.1.0", "global-modules": "^2.0.0", "globby": "^11.1.0", "globjoin": "^0.1.4", @@ -16941,13 +16923,13 @@ "license": "MIT" }, "node_modules/stylelint/node_modules/file-entry-cache": { - "version": "10.1.4", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-10.1.4.tgz", - "integrity": "sha512-5XRUFc0WTtUbjfGzEwXc42tiGxQHBmtbUG1h9L2apu4SulCGN3Hqm//9D6FAolf8MYNL7f/YlJl9vy08pj5JuA==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.1.tgz", + "integrity": "sha512-TPVFSDE7q91Dlk1xpFLvFllf8r0HyOMOlnWy7Z2HBku5H3KhIeOGInexrIeg2D64DosVB/JXkrrk6N/7Wriq4A==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^6.1.13" + "flat-cache": "^6.1.19" } }, "node_modules/stylelint/node_modules/flat-cache": { @@ -17019,10 +17001,6 @@ "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true, "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, "engines": { "node": ">=8" } @@ -17368,6 +17346,17 @@ "dev": true, "license": "MIT" }, + "node_modules/terser/node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -17998,9 +17987,9 @@ } }, "node_modules/webpack": { - "version": "5.102.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz", - "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", + "version": "5.103.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.103.0.tgz", + "integrity": "sha512-HU1JOuV1OavsZ+mfigY0j8d1TgQgbZ6M+J75zDkpEAwYeXjWSqrGJtgnPblJjd/mAyTNQ7ygw0MiKOn6etz8yw==", "dev": true, "license": "MIT", "peer": true, @@ -18022,7 +18011,7 @@ "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", + "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", diff --git a/src/server/plugins/engine/components/HiddenField.test.ts b/src/server/plugins/engine/components/HiddenField.test.ts new file mode 100644 index 000000000..bf02c3802 --- /dev/null +++ b/src/server/plugins/engine/components/HiddenField.test.ts @@ -0,0 +1,188 @@ +import { ComponentType, type HiddenFieldComponent } from '@defra/forms-model' + +import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' +import { + getAnswer, + type Field +} from '~/src/server/plugins/engine/components/helpers/components.js' +import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import definition from '~/test/form/definitions/blank.js' +import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' + +describe('HiddenField', () => { + let model: FormModel + + beforeEach(() => { + model = new FormModel(definition, { + basePath: 'test' + }) + }) + + describe('Defaults', () => { + let def: HiddenFieldComponent + let collection: ComponentCollection + let field: Field + + beforeEach(() => { + def = { + title: 'Hidden field', + name: 'myComponent', + type: ComponentType.HiddenField, + options: {} + } satisfies HiddenFieldComponent + + collection = new ComponentCollection([def], { model }) + field = collection.fields[0] + }) + + describe('Schema', () => { + it('uses component title as label as default', () => { + const { formSchema } = collection + const { keys } = formSchema.describe() + + expect(keys).toHaveProperty( + 'myComponent', + expect.objectContaining({ + flags: expect.objectContaining({ + label: 'Hidden field' + }) + }) + ) + }) + + it('uses component name as keys', () => { + const { formSchema } = collection + const { keys } = formSchema.describe() + + expect(field.keys).toEqual(['myComponent']) + expect(field.collection).toBeUndefined() + + for (const key of field.keys) { + expect(keys).toHaveProperty(key) + } + }) + + it('is required by default', () => { + const { formSchema } = collection + const { keys } = formSchema.describe() + + expect(keys).toHaveProperty( + 'myComponent', + expect.objectContaining({ + flags: expect.objectContaining({ + presence: 'required' + }) + }) + ) + }) + it('accepts valid values', () => { + const result1 = collection.validate(getFormData('Hidden value')) + const result2 = collection.validate(getFormData('Hidden value 2')) + + expect(result1.errors).toBeUndefined() + expect(result2.errors).toBeUndefined() + }) + + it('adds errors for empty value', () => { + const result = collection.validate(getFormData('')) + + expect(result.errors).toEqual([ + expect.objectContaining({ + text: 'Enter hidden field' + }) + ]) + }) + + it('adds errors for invalid values', () => { + const result1 = collection.validate(getFormData(['invalid'])) + const result2 = collection.validate( + // @ts-expect-error - Allow invalid param for test + getFormData({ unknown: 'invalid' }) + ) + + expect(result1.errors).toBeTruthy() + expect(result2.errors).toBeTruthy() + }) + }) + + describe('State', () => { + it('returns text from state', () => { + const state1 = getFormState('Hidden field') + const state2 = getFormState(null) + + const answer1 = getAnswer(field, state1) + const answer2 = getAnswer(field, state2) + + expect(answer1).toBe('Hidden field') + expect(answer2).toBe('') + }) + + it('returns payload from state', () => { + const state1 = getFormState('Hidden field') + const state2 = getFormState(null) + + const payload1 = field.getFormDataFromState(state1) + const payload2 = field.getFormDataFromState(state2) + + expect(payload1).toEqual(getFormData('Hidden field')) + expect(payload2).toEqual(getFormData()) + }) + + it('returns value from state', () => { + const state1 = getFormState('Hidden field') + const state2 = getFormState(null) + + const value1 = field.getFormValueFromState(state1) + const value2 = field.getFormValueFromState(state2) + + expect(value1).toBe('Hidden field') + expect(value2).toBeUndefined() + }) + + it('returns context for conditions and form submission', () => { + const state1 = getFormState('Hidden field') + const state2 = getFormState(null) + + const value1 = field.getContextValueFromState(state1) + const value2 = field.getContextValueFromState(state2) + + expect(value1).toBe('Hidden field') + expect(value2).toBeNull() + }) + + it('returns state from payload', () => { + const payload1 = getFormData('Hidden field') + const payload2 = getFormData() + + const value1 = field.getStateFromValidForm(payload1) + const value2 = field.getStateFromValidForm(payload2) + + expect(value1).toEqual(getFormState('Hidden field')) + expect(value2).toEqual(getFormState(null)) + }) + }) + + describe('View model', () => { + it('sets Nunjucks component defaults', () => { + const viewModel = field.getViewModel(getFormData('Hidden field')) + + expect(viewModel).toEqual( + expect.objectContaining({ + label: { text: def.title }, + name: 'myComponent', + id: 'myComponent', + value: 'Hidden field' + }) + ) + }) + }) + + describe('AllPossibleErrors', () => { + it('should return errors', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).toBeEmpty() + }) + }) + }) +}) diff --git a/src/server/plugins/engine/components/HiddenField.ts b/src/server/plugins/engine/components/HiddenField.ts new file mode 100644 index 000000000..227652ab8 --- /dev/null +++ b/src/server/plugins/engine/components/HiddenField.ts @@ -0,0 +1,62 @@ +import { + type HiddenFieldComponent, + type TextFieldComponent +} from '@defra/forms-model' +import joi, { type StringSchema } from 'joi' + +import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' +import { TextField } from '~/src/server/plugins/engine/components/TextField.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' +import { + type ErrorMessageTemplateList, + type FormState, + type FormStateValue, + type FormSubmissionState +} from '~/src/server/plugins/engine/types.js' + +export class HiddenField extends FormComponent { + declare formSchema: StringSchema + declare stateSchema: StringSchema + declare schema: TextFieldComponent['schema'] + declare options: TextFieldComponent['options'] + + constructor( + def: HiddenFieldComponent, + props: ConstructorParameters[1] + ) { + super(def, props) + + const formSchema = joi.string().trim().label(this.label).required() + + this.formSchema = formSchema.default('') + this.stateSchema = formSchema.default(null).allow(null) + this.schema = {} + this.options = {} + } + + getFormValueFromState(state: FormSubmissionState) { + const { name } = this + return this.getFormValue(state[name]) + } + + isValue(value?: FormStateValue | FormState): value is string { + return TextField.isText(value) + } + + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return HiddenField.getAllPossibleErrors() + } + + /** + * Static version of getAllPossibleErrors that doesn't require a component instance. + */ + static getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [{ type: 'required', template: messageTemplate.required }], + advancedSettingsErrors: [] + } + } +} diff --git a/src/server/plugins/engine/components/helpers/components.ts b/src/server/plugins/engine/components/helpers/components.ts index 880df9812..d307e6517 100644 --- a/src/server/plugins/engine/components/helpers/components.ts +++ b/src/server/plugins/engine/components/helpers/components.ts @@ -34,6 +34,7 @@ export type Field = InstanceType< | typeof Components.TextField | typeof Components.UkAddressField | typeof Components.FileUploadField + | typeof Components.HiddenField > // Guidance component instances only @@ -186,6 +187,10 @@ export function createComponent( case ComponentType.LatLongField: component = new Components.LatLongField(def, options) break + + case ComponentType.HiddenField: + component = new Components.HiddenField(def, options) + break } if (typeof component === 'undefined') { diff --git a/src/server/plugins/engine/components/helpers/helpers.test.ts b/src/server/plugins/engine/components/helpers/helpers.test.ts index de49574a3..e597fa0d1 100644 --- a/src/server/plugins/engine/components/helpers/helpers.test.ts +++ b/src/server/plugins/engine/components/helpers/helpers.test.ts @@ -2,6 +2,7 @@ import { ComponentType, type ComponentDef } from '@defra/forms-model' import { ComponentBase } from '~/src/server/plugins/engine/components/ComponentBase.js' import { EastingNorthingField } from '~/src/server/plugins/engine/components/EastingNorthingField.js' +import { HiddenField } from '~/src/server/plugins/engine/components/HiddenField.js' import { LatLongField } from '~/src/server/plugins/engine/components/LatLongField.js' import { NationalGridFieldNumberField } from '~/src/server/plugins/engine/components/NationalGridFieldNumberField.js' import { OsGridRefField } from '~/src/server/plugins/engine/components/OsGridRefField.js' @@ -96,6 +97,22 @@ describe('helpers tests', () => { expect(component.name).toBe('testField') expect(component.title).toBe('Test National Grid') }) + + test('should create HiddenField component', () => { + const component = createComponent( + { + type: ComponentType.HiddenField, + name: 'hiddenField', + title: 'Hidden field', + options: {} + }, + { model: formModel } + ) + + expect(component).toBeInstanceOf(HiddenField) + expect(component.name).toBe('hiddenField') + expect(component.title).toBe('Hidden field') + }) }) describe('ComponentBase tests', () => { diff --git a/src/server/plugins/engine/components/index.ts b/src/server/plugins/engine/components/index.ts index 43c342e26..da04c5a62 100644 --- a/src/server/plugins/engine/components/index.ts +++ b/src/server/plugins/engine/components/index.ts @@ -28,3 +28,4 @@ export { EastingNorthingField } from '~/src/server/plugins/engine/components/Eas export { OsGridRefField } from '~/src/server/plugins/engine/components/OsGridRefField.js' export { NationalGridFieldNumberField } from '~/src/server/plugins/engine/components/NationalGridFieldNumberField.js' export { LatLongField } from '~/src/server/plugins/engine/components/LatLongField.js' +export { HiddenField } from '~/src/server/plugins/engine/components/HiddenField.js' diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index 936aa6568..42cfcad80 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -74,6 +74,7 @@ export class FormModel { lists: FormDefinition['lists'] sections: FormDefinition['sections'] = [] name: string + formId: string values: FormDefinition basePath: string versionNumber?: number @@ -100,6 +101,7 @@ export class FormModel { basePath: string versionNumber?: number ordnanceSurveyApiKey?: string + formId?: string }, services: Services = defaultServices, controllers?: Record @@ -152,6 +154,7 @@ export class FormModel { this.lists = def.lists this.sections = def.sections this.name = def.name ?? '' + this.formId = options.formId ?? '' this.values = result.value this.basePath = options.basePath this.versionNumber = options.versionNumber diff --git a/src/server/plugins/engine/pageControllers/PageController.test.ts b/src/server/plugins/engine/pageControllers/PageController.test.ts index 10d5c52c8..13d867d90 100644 --- a/src/server/plugins/engine/pageControllers/PageController.test.ts +++ b/src/server/plugins/engine/pageControllers/PageController.test.ts @@ -24,7 +24,8 @@ describe('PageController', () => { const page2 = pages[1] model = new FormModel(definition, { - basePath: testBasePath + basePath: testBasePath, + formId: 'form-id' }) controller1 = new PageController(model, page1) @@ -61,8 +62,11 @@ describe('PageController', () => { }) }) - it('returns feedback link (from form definition)', () => { - expect(controller1).toHaveProperty('feedbackLink', undefined) + it('returns feedback link default', () => { + expect(controller1).toHaveProperty( + 'feedbackLink', + '/form/csat?formId=form-id' + ) const emailAddress = 'test@feedback.cat' @@ -77,7 +81,7 @@ describe('PageController', () => { }) it('returns phase tag (from form definition)', () => { - expect(controller1).toHaveProperty('phaseTag', undefined) + expect(controller1).toHaveProperty('phaseTag', 'beta') model.def.phaseBanner = { phase: 'alpha' diff --git a/src/server/plugins/engine/pageControllers/PageController.ts b/src/server/plugins/engine/pageControllers/PageController.ts index 0ba533749..73a0052c5 100644 --- a/src/server/plugins/engine/pageControllers/PageController.ts +++ b/src/server/plugins/engine/pageControllers/PageController.ts @@ -8,6 +8,7 @@ import { import Boom from '@hapi/boom' import { type Lifecycle, type RouteOptions, type Server } from '@hapi/hapi' +import { config } from '~/src/config/index.js' import { type ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { encodeUrl, @@ -121,17 +122,20 @@ export class PageController { get feedbackLink() { const { def } = this - // setting the feedbackLink to undefined here for feedback forms prevents the feedback link from being shown + // Use the feedbackLink if defined, otherwise use default CSAT link const feedbackLink = def.feedback?.emailAddress ? `mailto:${def.feedback.emailAddress}` : def.feedback?.url + if (!feedbackLink) { + return `/form/csat?formId=${this.model.formId}` + } return encodeUrl(feedbackLink) } get phaseTag() { const { def } = this - return def.phaseBanner?.phase + return def.phaseBanner?.phase ?? config.get('phaseTag') } getHref(path: string): string { diff --git a/src/server/plugins/engine/pageControllers/StatusPageController.ts b/src/server/plugins/engine/pageControllers/StatusPageController.ts index 9f2f72d7e..08a57fac5 100644 --- a/src/server/plugins/engine/pageControllers/StatusPageController.ts +++ b/src/server/plugins/engine/pageControllers/StatusPageController.ts @@ -41,13 +41,20 @@ export class StatusPageController extends QuestionPageController { const slug = request.params.slug const { formsService } = this.model.services - const { getFormMetadata } = formsService + const { getFormMetadata, getFormMetadataById } = formsService const { submissionGuidance } = await getFormMetadata(slug) + // Re-read form name if overriding display (for example, in a feedback form) + const storedFormId = confirmationState.formId + const formName = storedFormId + ? (await getFormMetadataById(storedFormId)).title + : undefined + return h.view(viewName, { ...viewModel, - submissionGuidance + submissionGuidance, + formName }) } } diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index aac9d3b55..0da4396eb 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -152,7 +152,10 @@ export class SummaryPageController extends QuestionPageController { ) } - await cacheService.setConfirmationState(request, { confirmed: true }) + await cacheService.setConfirmationState(request, { + confirmed: true, + formId: context.state.formId as string | undefined + }) // Clear all form data await cacheService.clearState(request) diff --git a/src/server/plugins/engine/routes/index.test.ts b/src/server/plugins/engine/routes/index.test.ts index 45cb49128..a5b0f01ab 100644 --- a/src/server/plugins/engine/routes/index.test.ts +++ b/src/server/plugins/engine/routes/index.test.ts @@ -1,3 +1,4 @@ +import { ComponentType, type Page } from '@defra/forms-model' import Boom from '@hapi/boom' import { type ResponseObject, type ResponseToolkit } from '@hapi/hapi' @@ -9,15 +10,40 @@ import { } from '~/src/server/plugins/engine/helpers.js' import { type FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js' -import { redirectOrMakeHandler } from '~/src/server/plugins/engine/routes/index.js' +import { + prefillStateFromQueryParameters, + redirectOrMakeHandler +} from '~/src/server/plugins/engine/routes/index.js' import { type AnyFormRequest, type OnRequestCallback } from '~/src/server/plugins/engine/types.js' import { type FormResponseToolkit } from '~/src/server/routes/types.js' +import { type FormsService, type Services } from '~/src/server/types.js' jest.mock('~/src/server/plugins/engine/helpers') +function buildMockModel( + pagesOverride = [] as Page[], + pagesControllerOverride = [] as PageControllerClass[], + servicesOverride = {} as Services +) { + return { + def: { + metadata: { + submission: { code: 'TEST-CODE' } + } as { submission: { code: string } }, + pages: pagesOverride + }, + getFormContext: jest.fn().mockReturnValue({ + isForceAccess: false, + data: {} + }), + pages: pagesControllerOverride, + services: servicesOverride + } as unknown as FormModel +} + describe('redirectOrMakeHandler', () => { const mockServer = {} as unknown as Parameters< typeof redirectOrMakeHandler @@ -38,17 +64,7 @@ describe('redirectOrMakeHandler', () => { let mockPage: PageControllerClass - const mockModel: FormModel = { - def: { - metadata: { - submission: { code: 'TEST-CODE' } - } as { submission: { code: string } } - }, - getFormContext: jest.fn().mockReturnValue({ - isForceAccess: false, - data: {} - }) - } as unknown as FormModel + const mockModel = buildMockModel() const mockMakeHandler = jest .fn() @@ -314,4 +330,128 @@ describe('redirectOrMakeHandler', () => { expect(proceed).toHaveBeenCalledWith(mockRequest, mockH, '/test-href') }) }) + + describe('prefillStateFromQueryParameters', () => { + const mockGetState = jest.fn() + const mockMergeState = jest.fn() + const mockRequestPrefill: AnyFormRequest = { + server: mockServer, + app: {}, + yar: { flash: () => [] }, + params: { path: 'test-path' }, + query: {} + } as unknown as AnyFormRequest + + it('should not add any state if no params', async () => { + const mockModelPrefill = buildMockModel( + [], + [ + { + getState: mockGetState, + mergeState: mockMergeState + } as unknown as PageControllerClass + ] + ) + + await prefillStateFromQueryParameters( + mockRequestPrefill, + mockModelPrefill + ) + expect(mockMergeState).not.toHaveBeenCalled() + }) + + it('should only add state where param names match hidden field names', async () => { + const mockRequest2 = { + ...mockRequest, + query: { + param1: 'val1', + param2: 'val2', + param3: 'val3', + param4: 'val4' + } + } as unknown as AnyFormRequest + + const mockModel = buildMockModel( + [ + { + components: [ + { + type: ComponentType.HiddenField, + name: 'param2' + }, + { + type: ComponentType.HiddenField, + name: 'param4' + } + ], + next: [] + } as unknown as Page + ], + [ + { + getState: mockGetState.mockResolvedValue({}), + mergeState: mockMergeState + } as unknown as PageControllerClass + ] + ) + + await prefillStateFromQueryParameters(mockRequest2, mockModel) + expect(mockMergeState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + { + param2: 'val2', + param4: 'val4' + } + ) + }) + + it('should call lookup function for formId', async () => { + const mockRequest3 = { + ...mockRequest, + query: { + formId: 'c644804b-2f23-4c96-a2fc-ad4975974723' + } + } as unknown as AnyFormRequest + + const mockModel = buildMockModel( + [ + { + components: [ + { + type: ComponentType.HiddenField, + name: 'formId' + } + ], + next: [] + } as unknown as Page + ], + [ + { + getState: mockGetState.mockResolvedValue({}), + mergeState: mockMergeState + } as unknown as PageControllerClass + ], + { + formsService: { + getFormMetadata: jest.fn(), + getFormMetadataById: jest + .fn() + .mockResolvedValue({ title: 'My looked-up form name' }), + getFormDefinition: jest.fn() + } as unknown as FormsService + } as Services + ) + + await prefillStateFromQueryParameters(mockRequest3, mockModel) + expect(mockMergeState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + { + formId: 'c644804b-2f23-4c96-a2fc-ad4975974723', + formName: 'My looked-up form name' + } + ) + }) + }) }) diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index 9cb681c2c..7abb98a2b 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -1,3 +1,4 @@ +import { getHiddenFields } from '@defra/forms-model' import Boom from '@hapi/boom' import { type ResponseObject, @@ -33,6 +34,7 @@ import { type ExternalStateAppendage, type FormContext, type FormPayload, + type FormStateValue, type FormSubmissionState, type OnRequestCallback, type PluginOptions @@ -41,6 +43,7 @@ import { type FormRequest, type FormResponseToolkit } from '~/src/server/routes/types.js' +import { type Services } from '~/src/server/types.js' export async function redirectOrMakeHandler( request: AnyFormRequest, @@ -108,6 +111,71 @@ export async function redirectOrMakeHandler( return proceed(request, h, page.getHref(relevantPath)) } +/** + * A series of functions that can transform a pre-fill input parameter e.g lookup a form title based on firm id + */ +const paramLookupFunctions = { + formId: async (val: string, services: Services) => { + const meta = await services.formsService.getFormMetadataById(val) + return { + key: 'formName', + value: meta.title + } + } +} as Partial< + Record< + string, + ( + val: string, + services: Services + ) => Promise<{ key: string; value: string | undefined }> + > +> + +/** + * Any hidden parameters defined in the FormDefinition may be pre-filled by URL parameter values. + * Other parameters are ignored for security reasons. + * @param request + * @param model + */ +export async function prefillStateFromQueryParameters( + request: AnyFormRequest, + model: FormModel +): Promise { + const hiddenFieldNames = new Set( + getHiddenFields(model.def).map((field) => field.name) + ) + const query = Object.keys(request.query).length ? request.query : undefined + + if (!query) { + return + } + + const params = {} as Record + + for (const [key, value = ''] of Object.entries(query)) { + if (hiddenFieldNames.has(key)) { + const lookupFunc = paramLookupFunctions[key] + if (lookupFunc) { + const res = await lookupFunc(value, model.services) + // Store original value and result + params[key] = value + params[res.key] = res.value + } else { + params[key] = value + } + } + } + + if (!Object.keys(params).length) { + return + } + + const page = model.pages[0] // Any page will do so just take the first one + const formData = await page.getState(request) + await page.mergeState(request, formData, params) +} + async function importExternalComponentState( request: AnyFormRequest, page: PageControllerClass, @@ -185,6 +253,9 @@ export function makeLoadFormPreHandler(server: Server, options: PluginOptions) { if (server.app.model) { request.app.model = server.app.model + // Copy any URL params into the form state + await prefillStateFromQueryParameters(request, request.app.model) + return h.continue } @@ -244,7 +315,7 @@ export function makeLoadFormPreHandler(server: Server, options: PluginOptions) { // Construct the form model const model = new FormModel( definition, - { basePath, versionNumber, ordnanceSurveyApiKey }, + { basePath, versionNumber, ordnanceSurveyApiKey, formId: id }, services, controllers ) @@ -254,6 +325,9 @@ export function makeLoadFormPreHandler(server: Server, options: PluginOptions) { server.app.models.set(key, item) } + // Copy any URL params into the form state + await prefillStateFromQueryParameters(request, item.model) + // Assign the model to the request data // for use in the downstream handler request.app.model = item.model diff --git a/src/server/plugins/engine/services/formsService.js b/src/server/plugins/engine/services/formsService.js index cb766daa7..79a538d2e 100644 --- a/src/server/plugins/engine/services/formsService.js +++ b/src/server/plugins/engine/services/formsService.js @@ -12,6 +12,16 @@ export function getFormMetadata(_slug) { throw error } +// eslint-disable-next-line jsdoc/require-returns-check +/** + * Dummy function to get form metadata. + * @param {string} _id - the id of the form + * @returns {Promise} + */ +export function getFormMetadataById(_id) { + throw error +} + // eslint-disable-next-line jsdoc/require-returns-check /** * Dummy function to get form metadata. diff --git a/src/server/plugins/engine/services/formsService.test.ts b/src/server/plugins/engine/services/formsService.test.ts new file mode 100644 index 000000000..e7ae06ccc --- /dev/null +++ b/src/server/plugins/engine/services/formsService.test.ts @@ -0,0 +1,21 @@ +import { FormStatus } from '@defra/forms-model' + +import { + getFormDefinition, + getFormMetadata, + getFormMetadataById +} from '~/src/server/plugins/engine/services/formsService.js' + +describe('formsService', () => { + it('getFormMetadata should throw error', () => { + expect(() => getFormMetadata('slug')).toThrow() + }) + + it('getFormMetadataById should throw error', () => { + expect(() => getFormMetadataById('id')).toThrow() + }) + + it('getFormDefinition should throw error', () => { + expect(() => getFormDefinition('id', FormStatus.Draft)).toThrow() + }) +}) diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 61af993be..01f0f00b3 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -299,6 +299,7 @@ export type SummaryListAction = ComponentText & { export interface PageViewModelBase extends Partial { page: PageController name?: string + formId?: string pageTitle: string sectionTitle?: string showTitle: boolean diff --git a/src/server/plugins/engine/views/components/hiddenfield.html b/src/server/plugins/engine/views/components/hiddenfield.html new file mode 100644 index 000000000..88f5f864f --- /dev/null +++ b/src/server/plugins/engine/views/components/hiddenfield.html @@ -0,0 +1,3 @@ +{% macro HiddenField(component) %} + +{% endmacro %} diff --git a/src/server/plugins/engine/views/confirmation.html b/src/server/plugins/engine/views/confirmation.html index 82b8c3cbf..bcdc50720 100644 --- a/src/server/plugins/engine/views/confirmation.html +++ b/src/server/plugins/engine/views/confirmation.html @@ -14,6 +14,11 @@

What happens next

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

+ What do you think of this service? (takes 30 seconds) +

+ {% endif %} {% endblock %} diff --git a/src/server/plugins/engine/views/partials/form.html b/src/server/plugins/engine/views/partials/form.html index c7a68d32e..a2732ba39 100644 --- a/src/server/plugins/engine/views/partials/form.html +++ b/src/server/plugins/engine/views/partials/form.html @@ -6,9 +6,17 @@ {{ componentList(components) }} + {% if isStartPage %} + {% set buttonText = "Start now" %} + {% elif submitButtonText %} + {% set buttonText = submitButtonText %} + {% else %} + {% set buttonText = "Continue" %} + {% endif %} +
{{ govukButton({ - text: "Start now" if isStartPage else "Continue", + text: buttonText, isStartButton: isStartPage, preventDoubleClick: true }) }} diff --git a/src/server/services/cacheService.ts b/src/server/services/cacheService.ts index 42d5c818d..63ac6b08c 100644 --- a/src/server/services/cacheService.ts +++ b/src/server/services/cacheService.ts @@ -55,7 +55,7 @@ export class CacheService { async getConfirmationState( request: AnyFormRequest - ): Promise<{ confirmed?: true }> { + ): Promise<{ confirmed?: true; formId?: string }> { const key = this.Key(request, ADDITIONAL_IDENTIFIER.Confirmation) const value = await this.cache.get(key) @@ -64,7 +64,7 @@ export class CacheService { async setConfirmationState( request: AnyFormRequest, - confirmationState: { confirmed?: true } + confirmationState: { confirmed?: true; formId?: string } ) { const key = this.Key(request, ADDITIONAL_IDENTIFIER.Confirmation) const ttl = config.get('confirmationSessionTimeout') diff --git a/src/server/types.ts b/src/server/types.ts index b20881f28..87823c0cd 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -23,6 +23,7 @@ import { type CacheService } from '~/src/server/services/cacheService.js' export interface FormsService { getFormMetadata: (slug: string) => Promise + getFormMetadataById: (id: string) => Promise getFormDefinition: ( id: string, state: FormStatus diff --git a/src/server/utils/file-form-service.js b/src/server/utils/file-form-service.js index f8306bc4d..fa58f22f8 100644 --- a/src/server/utils/file-form-service.js +++ b/src/server/utils/file-form-service.js @@ -97,6 +97,23 @@ export class FileFormService { return metadata } + /** + * Get the form metadata by form id + * @param {string} id - the form id + * @returns {FormMetadata} + */ + getFormMetadataById(id) { + const metadata = Array.from(this.#metadata.values()).find( + (form) => form.id === id + ) + + if (!metadata) { + throw new Error(`Form metadata id '${id}' not found`) + } + + return metadata + } + /** * Get the form defintion by id * @param {string} id - the form id @@ -127,12 +144,22 @@ export class FileFormService { return Promise.resolve(this.getFormMetadata(slug)) }, + /** + * Get the form metadata by form id + * @param {string} id + * @returns {Promise} + */ + getFormMetadataById: (id) => { + return Promise.resolve(this.getFormMetadataById(id)) + }, + /** * Get the form defintion by id * @param {string} id + * @param {FormStatus} _state * @returns {Promise} */ - getFormDefinition: (id) => { + getFormDefinition: (id, _state) => { return Promise.resolve(this.getFormDefinition(id)) } } @@ -140,5 +167,5 @@ export class FileFormService { } /** - * @import { FormMetadata, FormDefinition } from '@defra/forms-model' + * @import { FormMetadata, FormDefinition, FormStatus } from '@defra/forms-model' */ diff --git a/src/server/utils/file-form-service.test.js b/src/server/utils/file-form-service.test.js new file mode 100644 index 000000000..266a00288 --- /dev/null +++ b/src/server/utils/file-form-service.test.js @@ -0,0 +1,114 @@ +import { join } from 'node:path' + +import { FormStatus } from '~/src/server/routes/types.js' +import { FileFormService } from '~/src/server/utils/file-form-service.js' + +describe('File-form-service', () => { + /** @type {FileFormService} */ + let service + beforeEach(async () => { + const now = new Date() + const user = { id: 'user', displayName: 'Username' } + const author = { + createdAt: now, + createdBy: user, + updatedAt: now, + updatedBy: user + } + service = new FileFormService() + const metadata = { + organisation: 'Defra', + teamName: 'Team name', + teamEmail: 'team@defra.gov.uk', + submissionGuidance: "Thanks for your submission, we'll be in touch", + notificationEmail: 'email@domain.com', + ...author, + live: author + } + await service.addForm( + `${join(import.meta.dirname, '../../../test/form/definitions')}/components.json`, + { + ...metadata, + id: '95e92559-968d-44ae-8666-2b1ad3dffd31', + title: 'Form test', + slug: 'form-test' + } + ) + }) + + describe('metadata by slug', () => { + it('should get form metadata by slug', () => { + const meta = service.getFormMetadata('form-test') + expect(meta.id).toBe('95e92559-968d-44ae-8666-2b1ad3dffd31') + expect(meta.title).toBe('Form test') + }) + + it('should throw if not found', () => { + expect(() => service.getFormMetadata('form-test-missing')).toThrow( + "Form metadata 'form-test-missing' not found" + ) + }) + }) + + describe('metadata by id', () => { + it('should get form metadata by id', () => { + const meta = service.getFormMetadataById( + '95e92559-968d-44ae-8666-2b1ad3dffd31' + ) + expect(meta.id).toBe('95e92559-968d-44ae-8666-2b1ad3dffd31') + expect(meta.title).toBe('Form test') + }) + + it('should throw if not found', () => { + expect(() => service.getFormMetadataById('id-missing')).toThrow( + "Form metadata id 'id-missing' not found" + ) + }) + }) + + describe('definition by id', () => { + it('should get form definition by id', () => { + const form = service.getFormDefinition( + '95e92559-968d-44ae-8666-2b1ad3dffd31' + ) + expect(form.name).toBe('All components') + expect(form.startPage).toBe('/all-components') + }) + + it('should throw if not found', () => { + expect(() => service.getFormDefinition('id-missing')).toThrow( + "Form definition 'id-missing' not found" + ) + }) + }) + + describe('toFormsService', () => { + it('should create interface', async () => { + const interfaceImpl = service.toFormsService() + const res1 = await interfaceImpl.getFormMetadata('form-test') + expect(res1.id).toBe('95e92559-968d-44ae-8666-2b1ad3dffd31') + expect(res1.title).toBe('Form test') + + const res2 = await interfaceImpl.getFormMetadataById( + '95e92559-968d-44ae-8666-2b1ad3dffd31' + ) + expect(res2.id).toBe('95e92559-968d-44ae-8666-2b1ad3dffd31') + expect(res2.title).toBe('Form test') + + const res3 = await interfaceImpl.getFormDefinition( + '95e92559-968d-44ae-8666-2b1ad3dffd31', + FormStatus.Draft + ) + expect(res3?.name).toBe('All components') + expect(res3?.startPage).toBe('/all-components') + }) + }) + + describe('readForm', () => { + it('should throw if invalid extension', async () => { + await expect( + service.readForm('/some-folder/some-file.bad') + ).rejects.toThrow("Invalid file extension '.bad'") + }) + }) +})