diff --git a/jest.config.cjs b/jest.config.cjs index 69c910764..d48093fc7 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -9,7 +9,7 @@ module.exports = { resetModules: true, restoreMocks: true, clearMocks: true, - silent: true, + silent: false, testMatch: [ '/src/**/*.test.{cjs,js,mjs,ts}', '/test/**/*.test.{cjs,js,mjs,ts}', diff --git a/package-lock.json b/package-lock.json index f932f839e..9b5fbd97b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.438", + "@defra/forms-model": "^3.0.441", "@defra/hapi-tracing": "^1.0.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", @@ -125,7 +125,7 @@ "stylelint": "^16.12.0", "stylelint-config-gds": "^2.0.0", "terser-webpack-plugin": "^5.3.11", - "tsx": "^4.19.2", + "tsx": "^4.19.3", "typescript": "^5.7.2", "webpack": "^5.97.1", "webpack-assets-manifest": "^5.2.1", @@ -2039,9 +2039,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.438", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.438.tgz", - "integrity": "sha512-3Vn6ZeTy/Oajx5t5vG5/xTn0UPnas0fdtTYOYTfNQuco12RJRtmLeXaOcHyzJqZpxvAmqWL0AKKEUv4iSSMoZg==", + "version": "3.0.441", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.441.tgz", + "integrity": "sha512-j3PvXk4Ms/aiONJd/MA52sLnprMDNtRiJEXu0o6RipkX5EUBd8wh/MCemnisWbL2QTUwTWeroMAbhBJB1SD/Dw==", "license": "OGL-UK-3.0", "dependencies": { "marked": "^15.0.7", @@ -2138,13 +2138,14 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.0.tgz", - "integrity": "sha512-3sG8Zwa5fMcA9bgqB8AfWPQ+HFke6uD3h1s3RIwUNK8EG7a4buxvuFTs3j1IMs2NXAk9F30C/FF4vxRgQCcmoQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", + "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "aix" @@ -2154,13 +2155,14 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.0.tgz", - "integrity": "sha512-+KuOHTKKyIKgEEqKbGTK8W7mPp+hKinbMBeEnNzjJGyFcWsfrXjSTNluJHCY1RqhxFurdD8uNXQDei7qDlR6+g==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", + "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -2170,13 +2172,14 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.0.tgz", - "integrity": "sha512-EuHFUYkAVfU4qBdyivULuu03FhJO4IJN9PGuABGrFy4vUuzk91P2d+npxHcFdpUnfYKy0PuV+n6bKIpHOB3prQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", + "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -2186,13 +2189,14 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.0.tgz", - "integrity": "sha512-WRrmKidLoKDl56LsbBMhzTTBxrsVwTKdNbKDalbEZr0tcsBgCLbEtoNthOW6PX942YiYq8HzEnb4yWQMLQuipQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", + "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -2202,13 +2206,14 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.0.tgz", - "integrity": "sha512-YLntie/IdS31H54Ogdn+v50NuoWF5BDkEUFpiOChVa9UnKpftgwzZRrI4J132ETIi+D8n6xh9IviFV3eXdxfow==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", + "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -2218,13 +2223,14 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.0.tgz", - "integrity": "sha512-IMQ6eme4AfznElesHUPDZ+teuGwoRmVuuixu7sv92ZkdQcPbsNHzutd+rAfaBKo8YK3IrBEi9SLLKWJdEvJniQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", + "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -2234,13 +2240,14 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.0.tgz", - "integrity": "sha512-0muYWCng5vqaxobq6LB3YNtevDFSAZGlgtLoAc81PjUfiFz36n4KMpwhtAd4he8ToSI3TGyuhyx5xmiWNYZFyw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", + "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -2250,13 +2257,14 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.0.tgz", - "integrity": "sha512-XKDVu8IsD0/q3foBzsXGt/KjD/yTKBCIwOHE1XwiXmrRwrX6Hbnd5Eqn/WvDekddK21tfszBSrE/WMaZh+1buQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", + "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -2266,13 +2274,14 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.0.tgz", - "integrity": "sha512-SEELSTEtOFu5LPykzA395Mc+54RMg1EUgXP+iw2SJ72+ooMwVsgfuwXo5Fn0wXNgWZsTVHwY2cg4Vi/bOD88qw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", + "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2282,13 +2291,14 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.0.tgz", - "integrity": "sha512-j1t5iG8jE7BhonbsEg5d9qOYcVZv/Rv6tghaXM/Ug9xahM0nX/H2gfu6X6z11QRTMT6+aywOMA8TDkhPo8aCGw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", + "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2298,13 +2308,14 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.0.tgz", - "integrity": "sha512-P7O5Tkh2NbgIm2R6x1zGJJsnacDzTFcRWZyTTMgFdVit6E98LTxO+v8LCCLWRvPrjdzXHx9FEOA8oAZPyApWUA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", + "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2314,13 +2325,14 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.0.tgz", - "integrity": "sha512-InQwepswq6urikQiIC/kkx412fqUZudBO4SYKu0N+tGhXRWUqAx+Q+341tFV6QdBifpjYgUndV1hhMq3WeJi7A==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", + "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", "cpu": [ "loong64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2330,13 +2342,14 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.0.tgz", - "integrity": "sha512-J9rflLtqdYrxHv2FqXE2i1ELgNjT+JFURt/uDMoPQLcjWQA5wDKgQA4t/dTqGa88ZVECKaD0TctwsUfHbVoi4w==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", + "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", "cpu": [ "mips64el" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2346,13 +2359,14 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.0.tgz", - "integrity": "sha512-cShCXtEOVc5GxU0fM+dsFD10qZ5UpcQ8AM22bYj0u/yaAykWnqXJDpd77ublcX6vdDsWLuweeuSNZk4yUxZwtw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", + "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2362,13 +2376,14 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.0.tgz", - "integrity": "sha512-HEtaN7Y5UB4tZPeQmgz/UhzoEyYftbMXrBCUjINGjh3uil+rB/QzzpMshz3cNUxqXN7Vr93zzVtpIDL99t9aRw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", + "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2378,13 +2393,14 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.0.tgz", - "integrity": "sha512-WDi3+NVAuyjg/Wxi+o5KPqRbZY0QhI9TjrEEm+8dmpY9Xir8+HE/HNx2JoLckhKbFopW0RdO2D72w8trZOV+Wg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", + "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2394,13 +2410,14 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.0.tgz", - "integrity": "sha512-a3pMQhUEJkITgAw6e0bWA+F+vFtCciMjW/LPtoj99MhVt+Mfb6bbL9hu2wmTZgNd994qTAEw+U/r6k3qHWWaOQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", + "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -2409,14 +2426,32 @@ "node": ">=18" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", + "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.0.tgz", - "integrity": "sha512-cRK+YDem7lFTs2Q5nEv/HHc4LnrfBCbH5+JHu6wm2eP+d8OZNoSMYgPZJq78vqQ9g+9+nMuIsAO7skzphRXHyw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", + "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" @@ -2426,13 +2461,14 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.0.tgz", - "integrity": "sha512-suXjq53gERueVWu0OKxzWqk7NxiUWSUlrxoZK7usiF50C6ipColGR5qie2496iKGYNLhDZkPxBI3erbnYkU0rQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", + "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -2442,13 +2478,14 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.0.tgz", - "integrity": "sha512-6p3nHpby0DM/v15IFKMjAaayFhqnXV52aEmv1whZHX56pdkK+MEaLoQWj+H42ssFarP1PcomVhbsR4pkz09qBg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", + "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -2458,13 +2495,14 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.0.tgz", - "integrity": "sha512-BFelBGfrBwk6LVrmFzCq1u1dZbG4zy/Kp93w2+y83Q5UGYF1d8sCzeLI9NXjKyujjBBniQa8R8PzLFAUrSM9OA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", + "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "sunos" @@ -2474,13 +2512,14 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.0.tgz", - "integrity": "sha512-lY6AC8p4Cnb7xYHuIxQ6iYPe6MfO2CC43XXKo9nBXDb35krYt7KGhQnOkRGar5psxYkircpCqfbNDB4uJbS2jQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", + "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -2490,13 +2529,14 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.0.tgz", - "integrity": "sha512-7L1bHlOTcO4ByvI7OXVI5pNN6HSu6pUQq9yodga8izeuB1KcT2UkHaH6118QJwopExPn0rMHIseCTx1CRo/uNA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", + "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -2506,13 +2546,14 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.0.tgz", - "integrity": "sha512-Arm+WgUFLUATuoxCJcahGuk6Yj9Pzxd6l11Zb/2aAuv5kWWvvfhLFo2fni4uSK5vzlUdCGZ/BdV5tH8klj8p8g==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", + "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -7515,11 +7556,12 @@ } }, "node_modules/esbuild": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.0.tgz", - "integrity": "sha512-1lvV17H2bMYda/WaFb2jLPeHU3zml2k4/yagNMG8Q/YtfMjCwEUZa2eXXMgZTVSL5q1n4H7sQ0X6CdJDqqeCFA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", + "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -7527,30 +7569,31 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.23.0", - "@esbuild/android-arm": "0.23.0", - "@esbuild/android-arm64": "0.23.0", - "@esbuild/android-x64": "0.23.0", - "@esbuild/darwin-arm64": "0.23.0", - "@esbuild/darwin-x64": "0.23.0", - "@esbuild/freebsd-arm64": "0.23.0", - "@esbuild/freebsd-x64": "0.23.0", - "@esbuild/linux-arm": "0.23.0", - "@esbuild/linux-arm64": "0.23.0", - "@esbuild/linux-ia32": "0.23.0", - "@esbuild/linux-loong64": "0.23.0", - "@esbuild/linux-mips64el": "0.23.0", - "@esbuild/linux-ppc64": "0.23.0", - "@esbuild/linux-riscv64": "0.23.0", - "@esbuild/linux-s390x": "0.23.0", - "@esbuild/linux-x64": "0.23.0", - "@esbuild/netbsd-x64": "0.23.0", - "@esbuild/openbsd-arm64": "0.23.0", - "@esbuild/openbsd-x64": "0.23.0", - "@esbuild/sunos-x64": "0.23.0", - "@esbuild/win32-arm64": "0.23.0", - "@esbuild/win32-ia32": "0.23.0", - "@esbuild/win32-x64": "0.23.0" + "@esbuild/aix-ppc64": "0.25.0", + "@esbuild/android-arm": "0.25.0", + "@esbuild/android-arm64": "0.25.0", + "@esbuild/android-x64": "0.25.0", + "@esbuild/darwin-arm64": "0.25.0", + "@esbuild/darwin-x64": "0.25.0", + "@esbuild/freebsd-arm64": "0.25.0", + "@esbuild/freebsd-x64": "0.25.0", + "@esbuild/linux-arm": "0.25.0", + "@esbuild/linux-arm64": "0.25.0", + "@esbuild/linux-ia32": "0.25.0", + "@esbuild/linux-loong64": "0.25.0", + "@esbuild/linux-mips64el": "0.25.0", + "@esbuild/linux-ppc64": "0.25.0", + "@esbuild/linux-riscv64": "0.25.0", + "@esbuild/linux-s390x": "0.25.0", + "@esbuild/linux-x64": "0.25.0", + "@esbuild/netbsd-arm64": "0.25.0", + "@esbuild/netbsd-x64": "0.25.0", + "@esbuild/openbsd-arm64": "0.25.0", + "@esbuild/openbsd-x64": "0.25.0", + "@esbuild/sunos-x64": "0.25.0", + "@esbuild/win32-arm64": "0.25.0", + "@esbuild/win32-ia32": "0.25.0", + "@esbuild/win32-x64": "0.25.0" } }, "node_modules/escalade": { @@ -12039,9 +12082,9 @@ } }, "node_modules/marked": { - "version": "15.0.7", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.7.tgz", - "integrity": "sha512-dgLIeKGLx5FwziAnsk4ONoGwHwGPJzselimvlVskE9XLN4Orv9u2VA3GWw/lYUqjfA0rUT/6fqKwfZJapP9BEg==", + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.8.tgz", + "integrity": "sha512-rli4l2LyZqpQuRve5C0rkn6pj3hT8EWPC+zkAxFTAJLxRbENfTAhEQq9itrmf1Y81QtAX5D/MYlGlIomNgj9lA==", "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -16090,12 +16133,13 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/tsx": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.2.tgz", - "integrity": "sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==", + "version": "4.19.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.3.tgz", + "integrity": "sha512-4H8vUNGNjQ4V2EOoGw005+c+dGuPSnhpPBPHBtsZdGZBk/iJb4kguGlPWaZTZ3q5nMtFOEsY0nRDlh9PJyd6SQ==", "dev": true, + "license": "MIT", "dependencies": { - "esbuild": "~0.23.0", + "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "bin": { diff --git a/package.json b/package.json index 952a66593..30ce40988 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ }, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.438", + "@defra/forms-model": "^3.0.441", "@defra/hapi-tracing": "^1.0.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", @@ -178,7 +178,7 @@ "stylelint": "^16.12.0", "stylelint-config-gds": "^2.0.0", "terser-webpack-plugin": "^5.3.11", - "tsx": "^4.19.2", + "tsx": "^4.19.3", "typescript": "^5.7.2", "webpack": "^5.97.1", "webpack-assets-manifest": "^5.2.1", diff --git a/src/client/javascripts/file-upload.js b/src/client/javascripts/file-upload.js index 89c3d01e9..47cc5077d 100644 --- a/src/client/javascripts/file-upload.js +++ b/src/client/javascripts/file-upload.js @@ -1,4 +1,6 @@ export const MAX_POLLING_DURATION = 300 // 5 minutes +const ARIA_DESCRIBEDBY = 'aria-describedby' +const ERROR_SUMMARY_TITLE_ID = 'error-summary-title' /** * Creates or updates status announcer for screen readers @@ -127,7 +129,7 @@ function renderSummary(selectedFile, statusText, form) { const fileInput = form.querySelector('input[type="file"]') if (fileInput) { - fileInput.setAttribute('aria-describedby', 'statusInformation') + fileInput.setAttribute(ARIA_DESCRIBEDBY, 'statusInformation') } const summaryList = findOrCreateSummaryList( @@ -150,17 +152,30 @@ function renderSummary(selectedFile, statusText, form) { /** * Shows an error message using the GOV.UK error summary component + * and adds inline error styling to the file input * @param {string} message - The error message to display * @param {HTMLElement | null} errorSummary - The error summary container * @param {HTMLInputElement} fileInput - The file input element * @returns {void} */ function showError(message, errorSummary, fileInput) { + const topErrorSummary = document.querySelector('.govuk-error-summary') + + if (topErrorSummary) { + const titleElement = document.getElementById(ERROR_SUMMARY_TITLE_ID) + if (titleElement) { + fileInput.setAttribute(ARIA_DESCRIBEDBY, ERROR_SUMMARY_TITLE_ID) + } else { + fileInput.removeAttribute(ARIA_DESCRIBEDBY) + } + return + } + if (errorSummary) { errorSummary.innerHTML = `
-

+

There is a problem

@@ -173,7 +188,30 @@ function showError(message, errorSummary, fileInput) {
` - fileInput.setAttribute('aria-describedby', 'error-summary-title') + + fileInput.setAttribute(ARIA_DESCRIBEDBY, ERROR_SUMMARY_TITLE_ID) + } + + const formGroup = fileInput.closest('.govuk-form-group') + if (formGroup) { + formGroup.classList.add('govuk-form-group--error') + fileInput.classList.add('govuk-file-upload--error') + + const inputId = fileInput.id + let errorMessage = document.getElementById(`${inputId}-error`) + + if (!errorMessage) { + errorMessage = document.createElement('p') + errorMessage.id = `${inputId}-error` + errorMessage.className = 'govuk-error-message' + errorMessage.innerHTML = `Error: ${message}` + formGroup.insertBefore(errorMessage, fileInput) + } + + fileInput.setAttribute( + ARIA_DESCRIBEDBY, + `error-summary-title ${inputId}-error` + ) } } @@ -190,6 +228,18 @@ function reloadPage() { window.location.href = window.location.pathname } +/** + * Build the upload status URL given the current pathname and the upload ID. + * @param {string} pathname – e.g. window.location.pathname + * @param {string} uploadId + * @returns {string} e.g. "/form/upload-status/abc123" + */ +export function buildUploadStatusUrl(pathname, uploadId) { + const pathSegments = pathname.split('/').filter((segment) => segment) + const prefix = pathSegments.length > 0 ? `/${pathSegments[0]}` : '' + return `${prefix}/upload-status/${uploadId}` +} + /** * Polls the upload status endpoint until the file is ready or timeout occurs * @param {string} uploadId - The upload ID to check @@ -205,7 +255,12 @@ function pollUploadStatus(uploadId) { return } - fetch(`/upload-status/${uploadId}`, { + const uploadStatusUrl = buildUploadStatusUrl( + window.location.pathname, + uploadId + ) + + fetch(uploadStatusUrl, { headers: { Accept: 'application/json' } @@ -343,6 +398,7 @@ export function initFileUpload() { if (errorSummary) { errorSummary.innerHTML = '' } + if (fileInput.files && fileInput.files.length > 0) { selectedFile = fileInput.files[0] } diff --git a/src/server/constants.js b/src/server/constants.js index 1c7789b66..70112a3d9 100644 --- a/src/server/constants.js +++ b/src/server/constants.js @@ -1 +1,3 @@ export const PREVIEW_PATH_PREFIX = '/preview' +export const ERROR_PREVIEW_PATH_PREFIX = '/error-preview' +export const FORM_PREFIX = '' diff --git a/src/server/index.test.ts b/src/server/index.test.ts index 520a8b8c4..e89fda7be 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -1,6 +1,7 @@ import { type Server } from '@hapi/hapi' import { StatusCodes } from 'http-status-codes' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { getFormDefinition, @@ -57,13 +58,13 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/slug' + url: `${FORM_PREFIX}/slug` } const res = await server.inject(options) expect(res.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) - expect(res.headers.location).toBe('/slug/page-one') + expect(res.headers.location).toBe(`${FORM_PREFIX}/slug/page-one`) expect(getCacheSize()).toBe(1) }) @@ -77,13 +78,15 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/preview/live/slug' + url: `${FORM_PREFIX}/preview/live/slug` } const res = await server.inject(options) expect(res.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) - expect(res.headers.location).toBe('/preview/live/slug/page-one') + expect(res.headers.location).toBe( + `${FORM_PREFIX}/preview/live/slug/page-one` + ) expect(getCacheSize()).toBe(1) }) @@ -97,13 +100,15 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/preview/draft/slug' + url: `${FORM_PREFIX}/preview/draft/slug` } const res = await server.inject(options) expect(res.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) - expect(res.headers.location).toBe('/preview/draft/slug/page-one') + expect(res.headers.location).toBe( + `${FORM_PREFIX}/preview/draft/slug/page-one` + ) expect(getCacheSize()).toBe(1) }) @@ -117,7 +122,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/slug/page-one' + url: `${FORM_PREFIX}/slug/page-one` } const res = await server.inject(options) @@ -136,7 +141,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/preview/live/slug/page-one' + url: `${FORM_PREFIX}/preview/live/slug/page-one` } const res = await server.inject(options) @@ -155,7 +160,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/preview/draft/slug/page-one' + url: `${FORM_PREFIX}/preview/draft/slug/page-one` } const res = await server.inject(options) @@ -174,7 +179,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/slug/page-one' + url: `${FORM_PREFIX}/slug/page-one` } const res = await server.inject(options) @@ -195,7 +200,7 @@ describe('Model cache', () => { // Populate live/live cache item const options1 = { method: 'GET', - url: '/slug/page-one' + url: `${FORM_PREFIX}/slug/page-one` } const res1 = await server.inject(options1) @@ -206,7 +211,7 @@ describe('Model cache', () => { // Populate live/preview cache item const options2 = { method: 'GET', - url: '/preview/live/slug/page-one' + url: `${FORM_PREFIX}/preview/live/slug/page-one` } const res2 = await server.inject(options2) @@ -217,7 +222,7 @@ describe('Model cache', () => { // Populate draft/preview cache item const options3 = { method: 'GET', - url: '/preview/draft/slug/page-one' + url: `${FORM_PREFIX}/preview/draft/slug/page-one` } const res3 = await server.inject(options3) @@ -269,7 +274,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/slug' + url: `${FORM_PREFIX}/slug` } const res = await server.inject(options) @@ -283,7 +288,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/preview/draft/slug' + url: `${FORM_PREFIX}/preview/draft/slug` } const res = await server.inject(options) @@ -297,7 +302,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/preview/live/slug' + url: `${FORM_PREFIX}/preview/live/slug` } const res = await server.inject(options) @@ -315,7 +320,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/slug' + url: `${FORM_PREFIX}/slug` } const res = await server.inject(options) @@ -333,7 +338,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/preview/draft/slug' + url: `${FORM_PREFIX}/preview/draft/slug` } const res = await server.inject(options) @@ -351,7 +356,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/preview/live/slug' + url: `${FORM_PREFIX}/preview/live/slug` } const res = await server.inject(options) @@ -365,7 +370,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/slug/page-one' + url: `${FORM_PREFIX}/slug/page-one` } const res = await server.inject(options) @@ -379,7 +384,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/preview/draft/slug/page-one' + url: `${FORM_PREFIX}/preview/draft/slug/page-one` } const res = await server.inject(options) @@ -393,7 +398,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/preview/live/slug/page-one' + url: `${FORM_PREFIX}/preview/live/slug/page-one` } const res = await server.inject(options) @@ -411,7 +416,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/slug/page-one' + url: `${FORM_PREFIX}/slug/page-one` } const res = await server.inject(options) @@ -429,7 +434,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/preview/draft/slug/page-one' + url: `${FORM_PREFIX}/preview/draft/slug/page-one` } const res = await server.inject(options) @@ -447,7 +452,7 @@ describe('Model cache', () => { const options = { method: 'GET', - url: '/preview/live/slug/page-one' + url: `${FORM_PREFIX}/preview/live/slug/page-one` } const res = await server.inject(options) @@ -494,7 +499,7 @@ describe('Upload status route', () => { const options = { method: 'GET', - url: '/upload-status/123e4567-e89b-12d3-a456-426614174000' + url: `${FORM_PREFIX}/upload-status/123e4567-e89b-12d3-a456-426614174000` } const res = await server.inject(options) @@ -511,7 +516,7 @@ describe('Upload status route', () => { const options = { method: 'GET', - url: '/upload-status/123e4567-e89b-12d3-a456-426614174000' + url: `${FORM_PREFIX}/upload-status/123e4567-e89b-12d3-a456-426614174000` } const res = await server.inject(options) @@ -527,7 +532,7 @@ describe('Upload status route', () => { const options = { method: 'GET', - url: '/upload-status/123e4567-e89b-12d3-a456-426614174000' + url: `${FORM_PREFIX}/upload-status/123e4567-e89b-12d3-a456-426614174000` } const res = await server.inject(options) @@ -539,7 +544,7 @@ describe('Upload status route', () => { test('GET /upload-status/{uploadId} returns 400 for invalid uploadId format', async () => { const options = { method: 'GET', - url: '/upload-status/not-a-valid-guid' + url: `${FORM_PREFIX}/upload-status/not-a-valid-guid` } const res = await server.inject(options) diff --git a/src/server/index.ts b/src/server/index.ts index 8c40298ce..3ee1e2cf4 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -90,6 +90,8 @@ export async function createServer(routeConfig?: RouteConfig) { await server.register(Scooter) await server.register(pluginCrumb) + await server.register(pluginEngine) + server.ext('onPreResponse', (request: Request, h: ResponseToolkit) => { const { response } = request @@ -113,7 +115,6 @@ export async function createServer(routeConfig?: RouteConfig) { }) await server.register(pluginViews) - await server.register(pluginEngine) await server.register({ plugin: { diff --git a/src/server/plugins/engine/components/AutocompleteField.test.ts b/src/server/plugins/engine/components/AutocompleteField.test.ts index eebb4a3dd..8c38e772d 100644 --- a/src/server/plugins/engine/components/AutocompleteField.test.ts +++ b/src/server/plugins/engine/components/AutocompleteField.test.ts @@ -2,6 +2,7 @@ import { ComponentType, type AutocompleteFieldComponent } from '@defra/forms-model' +import lowerFirst from 'lodash/lowerFirst.js' import { AutocompleteField } from '~/src/server/plugins/engine/components/AutocompleteField.js' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' @@ -32,7 +33,8 @@ describe.each([ options: { list: listString, examples: listStringExamples, - allow: ['1', '2', '3', '4'] + allow: ['1', '2', '3', '4'], + shortDescription: 'My string list' } }, { @@ -47,7 +49,8 @@ describe.each([ options: { list: listNumber, examples: listNumberExamples, - allow: [1, 2, 3, 4] + allow: [1, 2, 3, 4], + shortDescription: 'My number list' } } ])('AutocompleteField: $component.title', ({ component: def, options }) => { @@ -153,7 +156,35 @@ describe.each([ expect(result.errors).toEqual([ expect.objectContaining({ - text: `Enter ${def.title.toLowerCase()}` + text: `Enter ${lowerFirst(def.title)}` + }) + ]) + }) + + it('adds errors for empty value if shortDescription exists', () => { + collection = new ComponentCollection( + [{ ...def, shortDescription: options.shortDescription }], + { model } + ) + const result = collection.validate(getFormData('')) + + expect(result.errors).toEqual([ + expect.objectContaining({ + text: `Enter ${lowerFirst(options.shortDescription)}` + }) + ]) + }) + + it('adds errors for empty value if shortDescription exists but is empty', () => { + collection = new ComponentCollection( + [{ ...def, shortDescription: '' }], + { model } + ) + const result = collection.validate(getFormData('')) + + expect(result.errors).toEqual([ + expect.objectContaining({ + text: `Enter ${lowerFirst(def.title)}` }) ]) }) @@ -290,5 +321,42 @@ describe.each([ expect(items).toEqual([]) }) }) + + describe('AllPossibleErrors', () => { + it('should return errors', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).toBeEmpty() + }) + }) + + describe('Validation', () => { + describe.each([ + { + description: 'Use short description if it exists', + component: { + title: 'What is your example text?', + shortDescription: 'Your example text', + name: 'myComponent', + type: ComponentType.AutocompleteField, + list: 'ABCE', + options: {} + } satisfies AutocompleteFieldComponent, + assertions: [ + { + input: getFormData(''), + output: { + value: getFormData(''), + errors: [ + expect.objectContaining({ + text: 'Enter your example text' + }) + ] + } + } + ] + } + ]) + }) }) }) diff --git a/src/server/plugins/engine/components/AutocompleteField.ts b/src/server/plugins/engine/components/AutocompleteField.ts index 3ad7ede7e..a1a21c591 100644 --- a/src/server/plugins/engine/components/AutocompleteField.ts +++ b/src/server/plugins/engine/components/AutocompleteField.ts @@ -23,8 +23,12 @@ export class AutocompleteField extends SelectField { const messages = options.customValidationMessages formSchema = formSchema.messages({ - 'any.only': messages?.['any.only'] ?? messageTemplate.required, - 'any.required': messages?.['any.required'] ?? messageTemplate.required + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + 'any.only': + messages?.['any.only'] ?? (messageTemplate.required as string), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + 'any.required': + messages?.['any.required'] ?? (messageTemplate.required as string) }) } diff --git a/src/server/plugins/engine/components/CheckboxesField.test.ts b/src/server/plugins/engine/components/CheckboxesField.test.ts index 9c33708cf..e4cbc675d 100644 --- a/src/server/plugins/engine/components/CheckboxesField.test.ts +++ b/src/server/plugins/engine/components/CheckboxesField.test.ts @@ -2,6 +2,8 @@ import { ComponentType, type CheckboxesFieldComponent } from '@defra/forms-model' +import { toLower } from 'lodash' +import lowerFirst from 'lodash/lowerFirst.js' import { outdent } from 'outdent' import { CheckboxesField } from '~/src/server/plugins/engine/components/CheckboxesField.js' @@ -23,7 +25,8 @@ import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' describe.each([ { component: { - title: 'String list', + title: 'String list title', + shortDescription: 'String list', name: 'myComponent', type: ComponentType.CheckboxesField, list: 'listString', @@ -31,6 +34,7 @@ describe.each([ } satisfies CheckboxesFieldComponent, options: { + label: 'string list', list: listString, examples: listStringExamples, allow: ['1', '2', '3', '4'], @@ -39,14 +43,34 @@ describe.each([ }, { component: { - title: 'Number list', + title: 'String list title', + shortDescription: 'String list', name: 'myComponent', type: ComponentType.CheckboxesField, + list: 'listString', + options: {} + } satisfies CheckboxesFieldComponent, + + options: { + label: 'string list', + list: listString, + examples: listStringExamples, + allow: ['1', '2', '3', '4'], + deny: ['5', '6', '7', '8'] + } + }, + { + component: { + title: 'Number list title', + name: 'myComponent', + shortDescription: 'Number list', + type: ComponentType.CheckboxesField, list: 'listNumber', options: {} } satisfies CheckboxesFieldComponent, options: { + label: 'number list', list: listNumber, examples: listNumberExamples, allow: [1, 2, 3, 4], @@ -72,14 +96,14 @@ describe.each([ describe('Defaults', () => { describe('Schema', () => { - it('uses component title as label', () => { + it('uses component short description as label', () => { const { formSchema } = collection const { keys } = formSchema.describe() expect(keys).toHaveProperty( 'myComponent', expect.objectContaining({ - flags: expect.objectContaining({ label: def.title }) + flags: expect.objectContaining({ label: def.shortDescription }) }) ) }) @@ -157,7 +181,7 @@ describe.each([ { allow: options.allow, flags: { - label: def.title, + label: def.shortDescription, only: true }, type: options.list.type @@ -172,7 +196,7 @@ describe.each([ expect(result.errors).toEqual([ expect.objectContaining({ - text: `Select ${def.title.toLowerCase()}` + text: `Select ${lowerFirst(options.label)}` }) ]) }) @@ -200,7 +224,7 @@ describe.each([ expect(result.errors).toEqual([ expect.objectContaining({ - text: `Select ${def.title.toLowerCase()}` + text: `Select ${toLower(def.shortDescription)}` }) ]) } @@ -213,7 +237,7 @@ describe.each([ expect(result.errors).toEqual([ expect.objectContaining({ - text: `Select ${def.title.toLowerCase()}` + text: `Select ${lowerFirst(options.label)}` }) ]) } @@ -375,5 +399,13 @@ describe.each([ expect(items).toEqual([]) }) }) + + describe('AllPossibleErrors', () => { + it('should return errors', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).toBeEmpty() + }) + }) }) }) diff --git a/src/server/plugins/engine/components/CheckboxesField.ts b/src/server/plugins/engine/components/CheckboxesField.ts index 484225af4..30707c3d1 100644 --- a/src/server/plugins/engine/components/CheckboxesField.ts +++ b/src/server/plugins/engine/components/CheckboxesField.ts @@ -23,16 +23,20 @@ export class CheckboxesField extends SelectionControlField { super(def, props) const { listType: type } = this - const { options, title } = def + const { options } = def let formSchema = type === 'string' ? joi.array() : joi.array() const itemsSchema = joi[type]() .valid(...this.values) - .label(title) + .label(this.label) - formSchema = formSchema.items(itemsSchema).single().label(title).required() + formSchema = formSchema + .items(itemsSchema) + .single() + .label(this.label) + .required() if (options.required === false) { formSchema = formSchema.optional() diff --git a/src/server/plugins/engine/components/ComponentCollection.ts b/src/server/plugins/engine/components/ComponentCollection.ts index a0c04fb1c..d09ee1230 100644 --- a/src/server/plugins/engine/components/ComponentCollection.ts +++ b/src/server/plugins/engine/components/ComponentCollection.ts @@ -124,7 +124,7 @@ export class ComponentCollection { } // Update error with parent title - error.local.title ??= parent?.title + error.local.title ??= parent?.label return error }) @@ -212,25 +212,22 @@ export class ComponentCollection { return context } + /** + * Get all errors for all fields in this collection + */ getErrors(errors?: FormSubmissionError[]): FormSubmissionError[] | undefined { - const { fields } = this - - const list: FormSubmissionError[] = [] - - // Add only one error per field - for (const field of fields) { - const error = field.getError(errors) - - if (error) { - list.push(error) - } - } - - if (!list.length) { - return - } + return this.getFieldErrors((field) => field.getErrors(errors), errors) + } - return list + /** + * Get view errors for all fields in this collection. + * For most fields this means filtering to the first error in the list. + * Composite fields like UKAddress can choose to return more than one error. + */ + getViewErrors( + errors?: FormSubmissionError[] + ): FormSubmissionError[] | undefined { + return this.getFieldErrors((field) => field.getViewErrors(errors), errors) } getViewModel( @@ -266,6 +263,36 @@ export class ComponentCollection { errors: this.page?.getErrors(details) ?? getErrors(details) } } + + /** + * Helper to get errors from all fields + */ + private getFieldErrors( + callback: (field: Field) => FormSubmissionError[] | undefined, + errors?: FormSubmissionError[] + ): FormSubmissionError[] | undefined { + const { fields } = this + + if (!errors?.length) { + return + } + + const list: FormSubmissionError[] = [] + + for (const field of fields) { + const fieldErrors = callback(field) + + if (fieldErrors?.length) { + list.push(...fieldErrors) + } + } + + if (!list.length) { + return + } + + return list + } } /** diff --git a/src/server/plugins/engine/components/DatePartsField.test.ts b/src/server/plugins/engine/components/DatePartsField.test.ts index c82a20074..4a69579df 100644 --- a/src/server/plugins/engine/components/DatePartsField.test.ts +++ b/src/server/plugins/engine/components/DatePartsField.test.ts @@ -31,6 +31,7 @@ describe('DatePartsField', () => { beforeEach(() => { def = { title: 'Example date parts field', + shortDescription: 'Example date parts', name: 'myComponent', type: ComponentType.DatePartsField, options: {} @@ -199,7 +200,7 @@ describe('DatePartsField', () => { expect(result3.errors).toBeUndefined() }) - it('adds errors for empty value', () => { + it('adds errors for empty value when short description exists', () => { const result = collection.validate( getFormData({ day: '', @@ -210,13 +211,13 @@ describe('DatePartsField', () => { expect(result.errors).toEqual([ expect.objectContaining({ - text: 'Example date parts field must include a day' + text: 'Example date parts must include a day' }), expect.objectContaining({ - text: 'Example date parts field must include a month' + text: 'Example date parts must include a month' }), expect.objectContaining({ - text: 'Example date parts field must include a year' + text: 'Example date parts must include a year' }) ]) }) @@ -409,6 +410,14 @@ describe('DatePartsField', () => { }) }) }) + + describe('AllPossibleErrors', () => { + it('should return errors', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).not.toBeEmpty() + }) + }) }) describe('Validation', () => { diff --git a/src/server/plugins/engine/components/DatePartsField.ts b/src/server/plugins/engine/components/DatePartsField.ts index d88b3a434..30692d8ed 100644 --- a/src/server/plugins/engine/components/DatePartsField.ts +++ b/src/server/plugins/engine/components/DatePartsField.ts @@ -1,11 +1,6 @@ import { ComponentType, type DatePartsFieldComponent } from '@defra/forms-model' import { add, format, isValid, parse, startOfToday, sub } from 'date-fns' -import { - type Context, - type CustomValidator, - type LanguageMessages, - type ObjectSchema -} from 'joi' +import { type Context, type CustomValidator, type ObjectSchema } from 'joi' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { @@ -17,12 +12,14 @@ import { NumberField } from '~/src/server/plugins/engine/components/NumberField. import { type DateInputItem } from '~/src/server/plugins/engine/components/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { + type ErrorMessageTemplateList, type FormPayload, type FormState, type FormStateValue, type FormSubmissionError, type FormSubmissionState } from '~/src/server/plugins/engine/types.js' +import { convertToLanguageMessages } from '~/src/server/utils/type-utils.js' export class DatePartsField extends FormComponent { declare options: DatePartsFieldComponent['options'] @@ -40,15 +37,17 @@ export class DatePartsField extends FormComponent { const isRequired = options.required !== false - const customValidationMessages: LanguageMessages = { + const customValidationMessages = convertToLanguageMessages({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 'any.required': messageTemplate.objectMissing, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 'number.base': messageTemplate.objectMissing, 'number.precision': messageTemplate.dateFormat, 'number.integer': messageTemplate.dateFormat, 'number.unsafe': messageTemplate.dateFormat, 'number.min': messageTemplate.dateFormat, 'number.max': messageTemplate.dateFormat - } + }) this.collection = new ComponentCollection( [ @@ -192,6 +191,28 @@ export class DatePartsField extends FormComponent { return DatePartsField.isDateParts(value) } + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { type: 'required', template: messageTemplate.required }, + { type: 'dateFormat', template: messageTemplate.dateFormat }, + { type: 'dateFormatDay', template: '{{#label}} must include a day' }, + { + type: 'dateFormatMonth', + template: '{{#label}} must include a month' + }, + { type: 'dateFormatYear', template: '{{#label}} must include a year' } + ], + advancedSettingsErrors: [ + { type: 'dateMin', template: messageTemplate.dateMin }, + { type: 'dateMax', template: messageTemplate.dateMax } + ] + } + } + static isDateParts( value?: FormStateValue | FormState ): value is DatePartsState { diff --git a/src/server/plugins/engine/components/EmailAddressField.test.ts b/src/server/plugins/engine/components/EmailAddressField.test.ts index 5deae9507..b9d12c269 100644 --- a/src/server/plugins/engine/components/EmailAddressField.test.ts +++ b/src/server/plugins/engine/components/EmailAddressField.test.ts @@ -29,6 +29,7 @@ describe('EmailAddressField', () => { beforeEach(() => { def = { title: 'Example email address field', + shortDescription: 'Example email address', name: 'myComponent', type: ComponentType.EmailAddressField, options: {} @@ -47,7 +48,7 @@ describe('EmailAddressField', () => { 'myComponent', expect.objectContaining({ flags: expect.objectContaining({ - label: 'Example email address field' + label: 'Example email address' }) }) ) @@ -111,6 +112,24 @@ describe('EmailAddressField', () => { it('adds errors for empty value', () => { const result = collection.validate(getFormData('')) + expect(result.errors).toEqual([ + expect.objectContaining({ + text: 'Enter example email address' + }) + ]) + }) + + it('adds errors for empty value given no shortDescription', () => { + def = { + title: 'Example email address field', + name: 'myComponent', + type: ComponentType.EmailAddressField, + options: {} + } satisfies EmailAddressFieldComponent + + collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData('')) + expect(result.errors).toEqual([ expect.objectContaining({ text: 'Enter example email address field' @@ -205,6 +224,14 @@ describe('EmailAddressField', () => { ) }) }) + + describe('AllPossibleErrors', () => { + it('should return errors', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).toBeEmpty() + }) + }) }) describe('Validation', () => { @@ -269,6 +296,29 @@ describe('EmailAddressField', () => { } ] }, + { + description: 'Email address validation', + component: { + title: 'Example email address field', + shortDescription: 'Example email address', + name: 'myComponent', + type: ComponentType.EmailAddressField, + options: {} + } satisfies EmailAddressFieldComponent, + assertions: [ + { + input: getFormData('defra.helpline'), + output: { + value: getFormData('defra.helpline'), + errors: [ + expect.objectContaining({ + text: 'Enter example email address in the correct format' + }) + ] + } + } + ] + }, { description: 'Custom validation message', component: { diff --git a/src/server/plugins/engine/components/EmailAddressField.ts b/src/server/plugins/engine/components/EmailAddressField.ts index 493c3fbc4..44995ac18 100644 --- a/src/server/plugins/engine/components/EmailAddressField.ts +++ b/src/server/plugins/engine/components/EmailAddressField.ts @@ -2,7 +2,9 @@ import { type EmailAddressFieldComponent } from '@defra/forms-model' import joi from 'joi' import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { + type ErrorMessageTemplateList, type FormPayload, type FormSubmissionError } from '~/src/server/plugins/engine/types.js' @@ -16,9 +18,9 @@ export class EmailAddressField extends FormComponent { ) { super(def, props) - const { options, title } = def + const { options } = def - let formSchema = joi.string().email().trim().label(title).required() + let formSchema = joi.string().email().trim().label(this.label).required() if (options.required === false) { formSchema = formSchema.allow('') @@ -52,4 +54,17 @@ export class EmailAddressField extends FormComponent { type: 'email' } } + + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { type: 'required', template: messageTemplate.required }, + { type: 'format', template: messageTemplate.format } + ], + advancedSettingsErrors: [] + } + } } diff --git a/src/server/plugins/engine/components/FileUploadField.test.ts b/src/server/plugins/engine/components/FileUploadField.test.ts index d96a294fd..5aa24bab0 100644 --- a/src/server/plugins/engine/components/FileUploadField.test.ts +++ b/src/server/plugins/engine/components/FileUploadField.test.ts @@ -157,6 +157,7 @@ describe('FileUploadField', () => { beforeEach(() => { def = { title: 'Example file upload field', + shortDescription: 'Example file upload', name: 'myComponent', type: ComponentType.FileUploadField, options: {}, @@ -169,7 +170,32 @@ describe('FileUploadField', () => { }) describe('Schema', () => { + it('uses component short description as label', () => { + const { formSchema } = collection + const { keys } = formSchema.describe() + + expect(keys).toHaveProperty( + 'myComponent', + expect.objectContaining({ + flags: expect.objectContaining({ + label: 'Example file upload' + }) + }) + ) + }) + it('uses component title as label', () => { + def = { + title: 'Example file upload field', + name: 'myComponent', + type: ComponentType.FileUploadField, + options: {}, + schema: {} + } satisfies FileUploadFieldComponent + + page = createPage(model, definition.pages[0]) + collection = new ComponentCollection([def], { page, model }) + const { formSchema } = collection const { keys } = formSchema.describe() @@ -246,6 +272,25 @@ describe('FileUploadField', () => { it('adds errors for empty value', () => { const result = collection.validate(getFormData()) + expect(result.errors).toEqual([ + expect.objectContaining({ + text: 'Select example file upload' + }) + ]) + }) + + it('adds errors for empty value with no shortDescription', () => { + def = { + title: 'Example file upload field', + name: 'myComponent', + type: ComponentType.FileUploadField, + options: {}, + schema: {} + } satisfies FileUploadFieldComponent + + collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData()) + expect(result.errors).toEqual([ expect.objectContaining({ text: 'Select example file upload field' @@ -545,6 +590,14 @@ describe('FileUploadField', () => { ) }) }) + + describe('AllPossibleErrors', () => { + it('should return errors', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).not.toBeEmpty() + }) + }) }) describe('Validation', () => { diff --git a/src/server/plugins/engine/components/FileUploadField.ts b/src/server/plugins/engine/components/FileUploadField.ts index bc285a4de..04caed0cc 100644 --- a/src/server/plugins/engine/components/FileUploadField.ts +++ b/src/server/plugins/engine/components/FileUploadField.ts @@ -5,9 +5,11 @@ import { FormComponent, isUploadState } from '~/src/server/plugins/engine/components/FormComponent.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { FileStatus, UploadStatus, + type ErrorMessageTemplateList, type FileState, type FileUpload, type FileUploadMetadata, @@ -104,9 +106,13 @@ export class FileUploadField extends FormComponent { ) { super(def, props) - const { options, schema, title } = def + const { options, schema } = def - let formSchema = joi.array().label(title).single().required() + let formSchema = joi + .array() + .label(this.label) + .single() + .required() if (options.required === false) { formSchema = formSchema.optional() @@ -231,7 +237,7 @@ export class FileUploadField extends FormComponent { }) // Set up the `accept` attribute - if ('accept' in options) { + if ('accept' in options && options.accept) { attributes.accept = options.accept } @@ -259,4 +265,47 @@ export class FileUploadField extends FormComponent { isValue(value?: FormStateValue | FormState): value is UploadState { return isUploadState(value) } + + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { type: 'selectRequired', template: messageTemplate.selectRequired }, + { + type: 'filesMimes', + template: 'The selected file must be a {{#limit}}' + }, + { + type: 'filesSize', + template: 'The selected file must be smaller than 100MB' + }, + { type: 'filesEmpty', template: 'The selected file is empty' }, + { type: 'filesVirus', template: 'The selected file contains a virus' }, + { + type: 'filesPartial', + template: 'The selected file has not fully uploaded' + }, + { + type: 'filesError', + template: 'The selected file could not be uploaded – try again' + } + ], + advancedSettingsErrors: [ + { + type: 'filesMin', + template: 'You must upload {{#limit}} files or more' + }, + { + type: 'filesMax', + template: 'You can only upload {{#limit}} files or less' + }, + { + type: 'filesExact', + template: 'You must upload exactly {{#limit}} files' + } + ] + } + } } diff --git a/src/server/plugins/engine/components/FormComponent.ts b/src/server/plugins/engine/components/FormComponent.ts index 0b8302c1a..c25a5a07c 100644 --- a/src/server/plugins/engine/components/FormComponent.ts +++ b/src/server/plugins/engine/components/FormComponent.ts @@ -3,6 +3,7 @@ import { type FormComponentsDef, type Item } from '@defra/forms-model' import { ComponentBase } from '~/src/server/plugins/engine/components/ComponentBase.js' import { optionalText } from '~/src/server/plugins/engine/components/constants.js' import { + type ErrorMessageTemplateList, type FileState, type FormPayload, type FormState, @@ -18,6 +19,7 @@ import { export class FormComponent extends ComponentBase { type: FormComponentsDef['type'] hint: FormComponentsDef['hint'] + label: string isFormComponent = true @@ -31,6 +33,10 @@ export class FormComponent extends ComponentBase { this.type = type this.hint = hint + this.label = + 'shortDescription' in def && def.shortDescription + ? def.shortDescription + : def.title } get keys() { @@ -100,10 +106,19 @@ export class FormComponent extends ComponentBase { return list } - getError(errors?: FormSubmissionError[]): FormSubmissionError | undefined { + getFirstError( + errors?: FormSubmissionError[] + ): FormSubmissionError | undefined { return this.getErrors(errors)?.[0] } + getViewErrors( + errors?: FormSubmissionError[] + ): FormSubmissionError[] | undefined { + const firstError = this.getFirstError(errors) + return firstError && [firstError] + } + getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { const { hint, name, options = {}, title, viewModel } = this @@ -119,7 +134,7 @@ export class FormComponent extends ComponentBase { // Filter component errors only const componentErrors = this.getErrors(errors) - const componentError = this.getError(componentErrors) + const componentError = this.getFirstError(componentErrors) if (componentErrors) { viewModel.errors = componentErrors @@ -175,6 +190,13 @@ export class FormComponent extends ComponentBase { isState(value?: FormStateValue | FormState): value is FormState { return isFormState(value) } + + getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [], + advancedSettingsErrors: [] + } + } } /** diff --git a/src/server/plugins/engine/components/ListFormComponent.ts b/src/server/plugins/engine/components/ListFormComponent.ts index 4fedab1ec..5488e84d4 100644 --- a/src/server/plugins/engine/components/ListFormComponent.ts +++ b/src/server/plugins/engine/components/ListFormComponent.ts @@ -14,7 +14,9 @@ import joi, { import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' import { type ListItem } from '~/src/server/plugins/engine/components/types.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { + type ErrorMessageTemplateList, type FormPayload, type FormSubmissionError, type FormSubmissionState @@ -61,7 +63,7 @@ export class ListFormComponent extends FormComponent { ) { super(def, props) - const { options, title } = def + const { options } = def const { model } = props if ('list' in def) { @@ -71,7 +73,7 @@ export class ListFormComponent extends FormComponent { let formSchema = joi[this.listType]() .valid(...this.values) - .label(title) + .label(this.label) .required() if (options.customValidationMessages) { @@ -137,4 +139,16 @@ export class ListFormComponent extends FormComponent { items } } + + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { type: 'selectRequired', template: messageTemplate.selectRequired } + ], + advancedSettingsErrors: [] + } + } } diff --git a/src/server/plugins/engine/components/Markdown.test.ts b/src/server/plugins/engine/components/Markdown.test.ts new file mode 100644 index 000000000..6756e9bd2 --- /dev/null +++ b/src/server/plugins/engine/components/Markdown.test.ts @@ -0,0 +1,48 @@ +import { ComponentType, type MarkdownComponent } from '@defra/forms-model' + +import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' +import { type Guidance } from '~/src/server/plugins/engine/components/helpers.js' +import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import definition from '~/test/form/definitions/basic.js' + +describe('Markdown', () => { + let model: FormModel + + beforeEach(() => { + model = new FormModel(definition, { + basePath: 'test' + }) + }) + + describe('Defaults', () => { + let def: MarkdownComponent + let collection: ComponentCollection + let guidance: Guidance + + beforeEach(() => { + def = { + title: 'Markdown guidance', + name: 'myComponent', + type: ComponentType.Markdown, + content: '# Heading 1 ## Heading 2', + options: {} + } satisfies MarkdownComponent + + collection = new ComponentCollection([def], { model }) + guidance = collection.guidance[0] + }) + + describe('View model', () => { + it('sets Nunjucks component defaults', () => { + const viewModel = guidance.getViewModel() + + expect(viewModel).toEqual( + expect.objectContaining({ + attributes: {}, + content: def.content + }) + ) + }) + }) + }) +}) diff --git a/src/server/plugins/engine/components/Markdown.ts b/src/server/plugins/engine/components/Markdown.ts new file mode 100644 index 000000000..c97208d7a --- /dev/null +++ b/src/server/plugins/engine/components/Markdown.ts @@ -0,0 +1,29 @@ +import { type MarkdownComponent } from '@defra/forms-model' + +import { ComponentBase } from '~/src/server/plugins/engine/components/ComponentBase.js' + +export class Markdown extends ComponentBase { + declare options: MarkdownComponent['options'] + content: MarkdownComponent['content'] + + constructor( + def: MarkdownComponent, + props: ConstructorParameters[1] + ) { + super(def, props) + + const { content, options } = def + + this.content = content + this.options = options + } + + getViewModel() { + const { content, viewModel } = this + + return { + ...viewModel, + content + } + } +} diff --git a/src/server/plugins/engine/components/MonthYearField.test.ts b/src/server/plugins/engine/components/MonthYearField.test.ts index ed5e449ad..28d70bd67 100644 --- a/src/server/plugins/engine/components/MonthYearField.test.ts +++ b/src/server/plugins/engine/components/MonthYearField.test.ts @@ -31,6 +31,7 @@ describe('MonthYearField', () => { beforeEach(() => { def = { title: 'Example month/year field', + shortDescription: 'Example month/year', name: 'myComponent', type: ComponentType.MonthYearField, options: {} @@ -168,6 +169,32 @@ describe('MonthYearField', () => { }) ) + expect(result.errors).toEqual([ + expect.objectContaining({ + text: 'Example month/year must include a month' + }), + expect.objectContaining({ + text: 'Example month/year must include a year' + }) + ]) + }) + + it('adds errors for empty value given no short desc exists', () => { + def = { + title: 'Example month/year field', + name: 'myComponent', + type: ComponentType.MonthYearField, + options: {} + } satisfies MonthYearFieldComponent + + collection = new ComponentCollection([def], { model }) + const result = collection.validate( + getFormData({ + month: '', + year: '' + }) + ) + expect(result.errors).toEqual([ expect.objectContaining({ text: 'Example month/year field must include a month' @@ -347,6 +374,14 @@ describe('MonthYearField', () => { }) }) }) + + describe('AllPossibleErrors', () => { + it('should return errors', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).not.toBeEmpty() + }) + }) }) describe('Validation', () => { diff --git a/src/server/plugins/engine/components/MonthYearField.ts b/src/server/plugins/engine/components/MonthYearField.ts index 7e9519eb0..8040d9b14 100644 --- a/src/server/plugins/engine/components/MonthYearField.ts +++ b/src/server/plugins/engine/components/MonthYearField.ts @@ -17,12 +17,14 @@ import { NumberField } from '~/src/server/plugins/engine/components/NumberField. import { type DateInputItem } from '~/src/server/plugins/engine/components/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { + type ErrorMessageTemplateList, type FormPayload, type FormState, type FormStateValue, type FormSubmissionError, type FormSubmissionState } from '~/src/server/plugins/engine/types.js' +import { convertToLanguageMessages } from '~/src/server/utils/type-utils.js' export class MonthYearField extends FormComponent { declare options: MonthYearFieldComponent['options'] @@ -40,15 +42,18 @@ export class MonthYearField extends FormComponent { const isRequired = options.required !== false - const customValidationMessages: LanguageMessages = { - 'any.required': messageTemplate.objectMissing, - 'number.base': messageTemplate.objectMissing, - 'number.precision': messageTemplate.dateFormat, - 'number.integer': messageTemplate.dateFormat, - 'number.unsafe': messageTemplate.dateFormat, - 'number.min': messageTemplate.dateFormat, - 'number.max': messageTemplate.dateFormat - } + const customValidationMessages: LanguageMessages = + convertToLanguageMessages({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + 'any.required': messageTemplate.objectMissing, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + 'number.base': messageTemplate.objectMissing, + 'number.precision': messageTemplate.dateFormat, + 'number.integer': messageTemplate.dateFormat, + 'number.unsafe': messageTemplate.dateFormat, + 'number.min': messageTemplate.dateFormat, + 'number.max': messageTemplate.dateFormat + }) this.collection = new ComponentCollection( [ @@ -180,6 +185,26 @@ export class MonthYearField extends FormComponent { return MonthYearField.isMonthYear(value) } + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { type: 'required', template: messageTemplate.required }, + { + type: 'dateFormatMonth', + template: '{{#label}} must include a month' + }, + { type: 'dateFormatYear', template: '{{#label}} must include a year' } + ], + advancedSettingsErrors: [ + { type: 'dateMin', template: messageTemplate.dateMin }, + { type: 'dateMax', template: messageTemplate.dateMax } + ] + } + } + static isMonthYear( value?: FormStateValue | FormState ): value is MonthYearState { diff --git a/src/server/plugins/engine/components/MultilineTextField.test.ts b/src/server/plugins/engine/components/MultilineTextField.test.ts index e0c09a652..092e37b35 100644 --- a/src/server/plugins/engine/components/MultilineTextField.test.ts +++ b/src/server/plugins/engine/components/MultilineTextField.test.ts @@ -29,7 +29,8 @@ describe('MultilineTextField', () => { beforeEach(() => { def = { - title: 'Example textarea', + title: 'Example textarea title', + shortDescription: 'Example textarea', name: 'myComponent', type: ComponentType.MultilineTextField, options: {}, @@ -41,7 +42,7 @@ describe('MultilineTextField', () => { }) describe('Schema', () => { - it('uses component title as label', () => { + it('uses component short description as label', () => { const { formSchema } = collection const { keys } = formSchema.describe() @@ -117,6 +118,25 @@ describe('MultilineTextField', () => { ]) }) + it('adds errors for empty value given no short description', () => { + def = { + title: 'Example textarea title', + name: 'myComponent', + type: ComponentType.MultilineTextField, + options: {}, + schema: {} + } satisfies MultilineTextFieldComponent + + collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData('')) + + expect(result.errors).toEqual([ + expect.objectContaining({ + text: 'Enter example textarea title' + }) + ]) + }) + it('adds errors for invalid values', () => { const result1 = collection.validate(getFormData(['invalid'])) const result2 = collection.validate( @@ -234,6 +254,14 @@ describe('MultilineTextField', () => { ) }) }) + + describe('AllPossibleErrors', () => { + it('should return errors', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).not.toBeEmpty() + }) + }) }) describe('Validation', () => { @@ -294,7 +322,57 @@ describe('MultilineTextField', () => { ] }, { - description: 'Schema min and max', + description: 'Schema min', + component: { + title: 'Example textarea', + name: 'myComponent', + type: ComponentType.MultilineTextField, + options: {}, + schema: { + min: 5 + } + } satisfies MultilineTextFieldComponent, + assertions: [ + { + input: getFormData('Text'), + output: { + value: getFormData('Text'), + errors: [ + expect.objectContaining({ + text: 'Example textarea must be 5 characters or more' + }) + ] + } + } + ] + }, + { + description: 'Schema max', + component: { + title: 'Example textarea', + name: 'myComponent', + type: ComponentType.MultilineTextField, + options: {}, + schema: { + max: 8 + } + } satisfies MultilineTextFieldComponent, + assertions: [ + { + input: getFormData('Text too long'), + output: { + value: getFormData('Text too long'), + errors: [ + expect.objectContaining({ + text: 'Example textarea must be 8 characters or less' + }) + ] + } + } + ] + }, + { + description: 'Schema min and max together', component: { title: 'Example textarea', name: 'myComponent', @@ -312,7 +390,7 @@ describe('MultilineTextField', () => { value: getFormData('Text'), errors: [ expect.objectContaining({ - text: 'Example textarea must be 5 characters or more' + text: 'Example textarea must be between 5 and 8 characters' }) ] } @@ -323,7 +401,7 @@ describe('MultilineTextField', () => { value: getFormData('Textarea too long'), errors: [ expect.objectContaining({ - text: 'Example textarea must be 8 characters or less' + text: 'Example textarea must be between 5 and 8 characters' }) ] } diff --git a/src/server/plugins/engine/components/MultilineTextField.ts b/src/server/plugins/engine/components/MultilineTextField.ts index a0f501440..b0ec0ab6c 100644 --- a/src/server/plugins/engine/components/MultilineTextField.ts +++ b/src/server/plugins/engine/components/MultilineTextField.ts @@ -3,7 +3,9 @@ import Joi, { type CustomValidator, type StringSchema } from 'joi' import { type ComponentBase } from '~/src/server/plugins/engine/components/ComponentBase.js' import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { + type ErrorMessageTemplateList, type FormPayload, type FormSubmissionError } from '~/src/server/plugins/engine/types.js' @@ -22,9 +24,9 @@ export class MultilineTextField extends FormComponent { ) { super(def, props) - const { schema, options, title } = def + const { schema, options } = def - let formSchema = Joi.string().trim().label(title).required() + let formSchema = Joi.string().trim().label(this.label).required() if (options.required === false) { formSchema = formSchema.allow('') @@ -71,6 +73,15 @@ export class MultilineTextField extends FormComponent { }) } else if (options.customValidationMessages) { formSchema = formSchema.messages(options.customValidationMessages) + } else if ( + typeof schema.max === 'number' && + typeof schema.min === 'number' + ) { + const minMaxErrorText = this.buildMinMaxText(schema.min, schema.max) + formSchema = formSchema.ruleset + .min(schema.min) + .max(schema.max) + .message(minMaxErrorText) } this.formSchema = formSchema.default('') @@ -105,6 +116,30 @@ export class MultilineTextField extends FormComponent { rows } } + + buildMinMaxText(min?: number, max?: number): string { + const minMaxError = messageTemplate.minMax as string + return minMaxError + .replace('{{#min}}', min ? min.toString() : '[min length]') + .replace('{{#max}}', max ? max.toString() : '[max length]') + } + + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [{ type: 'required', template: messageTemplate.required }], + advancedSettingsErrors: [ + { type: 'min', template: messageTemplate.min }, + { type: 'max', template: messageTemplate.max }, + { + type: 'minMax', + template: this.buildMinMaxText(this.schema.min, this.schema.max) + } + ] + } + } } function getValidatorMaxWords(component: MultilineTextField) { diff --git a/src/server/plugins/engine/components/NumberField.test.ts b/src/server/plugins/engine/components/NumberField.test.ts index 1e3c40b11..afe6d5a70 100644 --- a/src/server/plugins/engine/components/NumberField.test.ts +++ b/src/server/plugins/engine/components/NumberField.test.ts @@ -27,6 +27,7 @@ describe('NumberField', () => { beforeEach(() => { def = { title: 'Example number field', + shortDescription: 'Example number', name: 'myComponent', type: ComponentType.NumberField, options: {}, @@ -46,7 +47,7 @@ describe('NumberField', () => { 'myComponent', expect.objectContaining({ flags: expect.objectContaining({ - label: 'Example number field' + label: 'Example number' }) }) ) @@ -113,9 +114,22 @@ describe('NumberField', () => { expect(result.errors).toEqual([ expect.objectContaining({ - text: 'Enter example number field' + text: 'Enter example number' }) ]) + }) + + it('adds errors for empty value given no short description exists', () => { + def = { + title: 'Example number field', + name: 'myComponent', + type: ComponentType.NumberField, + options: {}, + schema: {} + } satisfies NumberFieldComponent + + collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData('')) expect(result.errors).toEqual([ expect.objectContaining({ @@ -258,6 +272,14 @@ describe('NumberField', () => { expect(viewModel).toHaveProperty('value', 'AA') }) + + describe('AllPossibleErrors', () => { + it('should return errors', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).not.toBeEmpty() + }) + }) }) describe('Validation', () => { diff --git a/src/server/plugins/engine/components/NumberField.ts b/src/server/plugins/engine/components/NumberField.ts index dc8dd069c..5433d1d60 100644 --- a/src/server/plugins/engine/components/NumberField.ts +++ b/src/server/plugins/engine/components/NumberField.ts @@ -7,6 +7,7 @@ import { } from '~/src/server/plugins/engine/components/FormComponent.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { + type ErrorMessageTemplateList, type FormPayload, type FormState, type FormStateValue, @@ -26,12 +27,12 @@ export class NumberField extends FormComponent { ) { super(def, props) - const { options, schema, title } = def + const { options, schema } = def let formSchema = joi .number() .custom(getValidatorPrecision(this)) - .label(title) + .label(this.label) .required() if (options.required === false) { @@ -40,7 +41,9 @@ export class NumberField extends FormComponent { const messages = options.customValidationMessages formSchema = formSchema.empty('').messages({ - 'any.required': messages?.['any.required'] ?? messageTemplate.required + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + 'any.required': + messages?.['any.required'] ?? (messageTemplate.required as string) }) } @@ -129,6 +132,23 @@ export class NumberField extends FormComponent { return NumberField.isNumber(value) } + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { type: 'required', template: messageTemplate.required }, + { type: 'numberInteger', template: messageTemplate.numberInteger } + ], + advancedSettingsErrors: [ + { type: 'numberMin', template: messageTemplate.numberMin }, + { type: 'numberMax', template: messageTemplate.numberMax }, + { type: 'numberPrecision', template: messageTemplate.numberPrecision } + ] + } + } + static isNumber(value?: FormStateValue | FormState): value is number { return typeof value === 'number' } diff --git a/src/server/plugins/engine/components/RadiosField.test.ts b/src/server/plugins/engine/components/RadiosField.test.ts index f0bb586c0..d071cb3a8 100644 --- a/src/server/plugins/engine/components/RadiosField.test.ts +++ b/src/server/plugins/engine/components/RadiosField.test.ts @@ -1,4 +1,5 @@ import { ComponentType, type RadiosFieldComponent } from '@defra/forms-model' +import lowerFirst from 'lodash/lowerFirst.js' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { RadiosField } from '~/src/server/plugins/engine/components/RadiosField.js' @@ -151,7 +152,7 @@ describe.each([ expect(result.errors).toEqual([ expect.objectContaining({ - text: `Select ${def.title.toLowerCase()}` + text: `Select ${lowerFirst(def.title)}` }) ]) }) @@ -284,5 +285,13 @@ describe.each([ expect(items).toEqual([]) }) }) + + describe('AllPossibleErrors', () => { + it('should return errors', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).toBeEmpty() + }) + }) }) }) diff --git a/src/server/plugins/engine/components/SelectField.test.ts b/src/server/plugins/engine/components/SelectField.test.ts index c6a2cda9b..585ebbda8 100644 --- a/src/server/plugins/engine/components/SelectField.test.ts +++ b/src/server/plugins/engine/components/SelectField.test.ts @@ -1,4 +1,5 @@ import { ComponentType, type SelectFieldComponent } from '@defra/forms-model' +import lowerFirst from 'lodash/lowerFirst.js' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { SelectField } from '~/src/server/plugins/engine/components/SelectField.js' @@ -152,7 +153,7 @@ describe.each([ expect(result.errors).toEqual([ expect.objectContaining({ - text: `Select ${def.title.toLowerCase()}` + text: `Select ${lowerFirst(def.title)}` }) ]) }) diff --git a/src/server/plugins/engine/components/SelectionControlField.ts b/src/server/plugins/engine/components/SelectionControlField.ts index 75a086ffb..348c12961 100644 --- a/src/server/plugins/engine/components/SelectionControlField.ts +++ b/src/server/plugins/engine/components/SelectionControlField.ts @@ -1,6 +1,8 @@ import { ListFormComponent } from '~/src/server/plugins/engine/components/ListFormComponent.js' import { type ListItem } from '~/src/server/plugins/engine/components/types.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { + type ErrorMessageTemplateList, type FormPayload, type FormSubmissionError } from '~/src/server/plugins/engine/types.js' @@ -40,4 +42,16 @@ export class SelectionControlField extends ListFormComponent { items } } + + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { type: 'selectRequired', template: messageTemplate.selectRequired } + ], + advancedSettingsErrors: [] + } + } } diff --git a/src/server/plugins/engine/components/TelephoneNumberField.test.ts b/src/server/plugins/engine/components/TelephoneNumberField.test.ts index 97d713695..0c828e9a7 100644 --- a/src/server/plugins/engine/components/TelephoneNumberField.test.ts +++ b/src/server/plugins/engine/components/TelephoneNumberField.test.ts @@ -29,6 +29,7 @@ describe('TelephoneNumberField', () => { beforeEach(() => { def = { title: 'Example telephone number field', + shortDescription: 'Example telephone number', name: 'myComponent', type: ComponentType.TelephoneNumberField, options: {} @@ -39,7 +40,7 @@ describe('TelephoneNumberField', () => { }) describe('Schema', () => { - it('uses component title as label', () => { + it('uses component short description as label', () => { const { formSchema } = collection const { keys } = formSchema.describe() @@ -47,7 +48,7 @@ describe('TelephoneNumberField', () => { 'myComponent', expect.objectContaining({ flags: expect.objectContaining({ - label: 'Example telephone number field' + label: 'Example telephone number' }) }) ) @@ -121,6 +122,25 @@ describe('TelephoneNumberField', () => { it('adds errors for empty value', () => { const result = collection.validate(getFormData('')) + expect(result.errors).toEqual([ + expect.objectContaining({ + text: 'Enter example telephone number' + }) + ]) + }) + + it('adds errors for empty value given no short description exists', () => { + def = { + title: 'Example telephone number field', + name: 'myComponent', + type: ComponentType.TelephoneNumberField, + options: {} + } satisfies TelephoneNumberFieldComponent + + collection = new ComponentCollection([def], { model }) + + const result = collection.validate(getFormData('')) + expect(result.errors).toEqual([ expect.objectContaining({ text: 'Enter example telephone number field' @@ -215,6 +235,14 @@ describe('TelephoneNumberField', () => { ) }) }) + + describe('AllPossibleErrors', () => { + it('should return errors', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).toBeEmpty() + }) + }) }) describe('Validation', () => { diff --git a/src/server/plugins/engine/components/TelephoneNumberField.ts b/src/server/plugins/engine/components/TelephoneNumberField.ts index e33919c3c..c8059b73a 100644 --- a/src/server/plugins/engine/components/TelephoneNumberField.ts +++ b/src/server/plugins/engine/components/TelephoneNumberField.ts @@ -3,7 +3,9 @@ import joi, { type StringSchema } from 'joi' import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' import { addClassOptionIfNone } from '~/src/server/plugins/engine/components/helpers.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { + type ErrorMessageTemplateList, type FormPayload, type FormSubmissionError } from '~/src/server/plugins/engine/types.js' @@ -21,13 +23,13 @@ export class TelephoneNumberField extends FormComponent { ) { super(def, props) - const { options, title } = def + const { options } = def let formSchema = joi .string() .trim() .pattern(PATTERN) - .label(title) + .label(this.label) .required() if (options.required === false) { @@ -64,4 +66,17 @@ export class TelephoneNumberField extends FormComponent { type: 'tel' } } + + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { type: 'required', template: messageTemplate.required }, + { type: 'format', template: messageTemplate.format } + ], + advancedSettingsErrors: [] + } + } } diff --git a/src/server/plugins/engine/components/TextField.test.ts b/src/server/plugins/engine/components/TextField.test.ts index c3f5c7220..f2c0bb26b 100644 --- a/src/server/plugins/engine/components/TextField.test.ts +++ b/src/server/plugins/engine/components/TextField.test.ts @@ -37,7 +37,7 @@ describe('TextField', () => { }) describe('Schema', () => { - it('uses component title as label', () => { + it('uses component title as label as default', () => { const { formSchema } = collection const { keys } = formSchema.describe() @@ -196,10 +196,42 @@ describe('TextField', () => { ) }) }) + + describe('AllPossibleErrors', () => { + it('should return errors', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).not.toBeEmpty() + }) + }) }) describe('Validation', () => { describe.each([ + { + description: 'Use short description if it exists', + component: { + title: 'What is your example text?', + shortDescription: 'Your example text', + name: 'myComponent', + type: ComponentType.TextField, + options: {}, + schema: {} + } satisfies TextFieldComponent, + assertions: [ + { + input: getFormData(''), + output: { + value: getFormData(''), + errors: [ + expect.objectContaining({ + text: 'Enter your example text' + }) + ] + } + } + ] + }, { description: 'Trim empty spaces', component: { diff --git a/src/server/plugins/engine/components/TextField.ts b/src/server/plugins/engine/components/TextField.ts index 04bcfab7a..6a4866cae 100644 --- a/src/server/plugins/engine/components/TextField.ts +++ b/src/server/plugins/engine/components/TextField.ts @@ -8,7 +8,9 @@ import { FormComponent, isFormValue } from '~/src/server/plugins/engine/components/FormComponent.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { + type ErrorMessageTemplateList, type FormState, type FormStateValue, type FormSubmissionState @@ -30,10 +32,10 @@ export class TextField extends FormComponent { ) { super(def, props) - const { options, title } = def + const { options } = def const schema = 'schema' in def ? def.schema : {} - let formSchema = joi.string().trim().label(title).required() + let formSchema = joi.string().trim().label(this.label).required() if (options.required === false) { formSchema = formSchema.allow('') @@ -90,6 +92,19 @@ export class TextField extends FormComponent { return TextField.isText(value) } + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [{ type: 'required', template: messageTemplate.required }], + advancedSettingsErrors: [ + { type: 'min', template: messageTemplate.min }, + { type: 'max', template: messageTemplate.max } + ] + } + } + static isText(value?: FormStateValue | FormState): value is string { return isFormValue(value) && typeof value === 'string' } diff --git a/src/server/plugins/engine/components/UkAddressField.test.ts b/src/server/plugins/engine/components/UkAddressField.test.ts index c84dbc2e9..a00ba3690 100644 --- a/src/server/plugins/engine/components/UkAddressField.test.ts +++ b/src/server/plugins/engine/components/UkAddressField.test.ts @@ -465,6 +465,14 @@ describe('UkAddressField', () => { }) }) }) + + describe('AllPossibleErrors', () => { + it('should return errors', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).toBeEmpty() + }) + }) }) describe('Validation', () => { @@ -509,7 +517,8 @@ describe('UkAddressField', () => { postcode: ' WA4 1HT' }), output: { - value: getFormData(address) + value: getFormData(address), + errors: undefined } }, { @@ -521,7 +530,8 @@ describe('UkAddressField', () => { postcode: 'WA4 1HT ' }), output: { - value: getFormData(address) + value: getFormData(address), + errors: undefined } }, { @@ -533,7 +543,8 @@ describe('UkAddressField', () => { postcode: ' WA4 1HT \n\n' }), output: { - value: getFormData(address) + value: getFormData(address), + errors: undefined } } ] @@ -661,6 +672,35 @@ describe('UkAddressField', () => { }) ] } + }, + { + input: getFormData({ + addressLine1: '', + addressLine2: '', + town: '', + county: '', + postcode: postcodeInvalid + }), + output: { + value: getFormData({ + addressLine1: '', + addressLine2: '', + town: '', + county: '', + postcode: postcodeInvalid + }), + errors: [ + expect.objectContaining({ + text: 'Enter address line 1' + }), + expect.objectContaining({ + text: 'Enter town or city' + }), + expect.objectContaining({ + text: 'Enter a valid postcode' + }) + ] + } } ] } @@ -676,6 +716,9 @@ describe('UkAddressField', () => { ({ input, output }) => { const result = collection.validate(input) expect(result).toEqual(output) + + const errors = collection.getErrors(result.errors) + expect(errors).toEqual(output.errors) } ) }) diff --git a/src/server/plugins/engine/components/UkAddressField.ts b/src/server/plugins/engine/components/UkAddressField.ts index 14901677a..d436d3feb 100644 --- a/src/server/plugins/engine/components/UkAddressField.ts +++ b/src/server/plugins/engine/components/UkAddressField.ts @@ -9,6 +9,7 @@ import { import { TextField } from '~/src/server/plugins/engine/components/TextField.js' import { type QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' import { + type ErrorMessageTemplateList, type FormPayload, type FormState, type FormStateValue, @@ -123,6 +124,18 @@ export class UkAddressField extends FormComponent { return Object.values(value).filter(Boolean) } + /** + * Returns one error per child field + */ + getViewErrors( + errors?: FormSubmissionError[] + ): FormSubmissionError[] | undefined { + return this.getErrors(errors)?.filter( + (error, index, self) => + index === self.findIndex((err) => err.name === error.name) + ) + } + getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { const { collection, name, options } = this @@ -163,6 +176,21 @@ export class UkAddressField extends FormComponent { return UkAddressField.isUkAddress(value) } + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { type: 'required', template: 'Enter address line 1' }, + { type: 'required', template: 'Enter town or city' }, + { type: 'required', template: 'Enter postcode' }, + { type: 'format', template: 'Enter valid postcode' } + ], + advancedSettingsErrors: [] + } + } + static isUkAddress( value?: FormStateValue | FormState ): value is UkAddressState { diff --git a/src/server/plugins/engine/components/YesNoField.test.ts b/src/server/plugins/engine/components/YesNoField.test.ts index 7debf9eee..e00498d38 100644 --- a/src/server/plugins/engine/components/YesNoField.test.ts +++ b/src/server/plugins/engine/components/YesNoField.test.ts @@ -121,7 +121,7 @@ describe('YesNoField', () => { expect(result.errors).toEqual([ expect.objectContaining({ - text: 'Select example yes/no' + text: 'Example yes/no - select yes or no' }) ]) }) @@ -245,4 +245,12 @@ describe('YesNoField', () => { ) }) }) + + describe('AllPossibleErrors', () => { + it('should return errors', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).toBeEmpty() + }) + }) }) diff --git a/src/server/plugins/engine/components/YesNoField.ts b/src/server/plugins/engine/components/YesNoField.ts index 6079d827b..547c0744f 100644 --- a/src/server/plugins/engine/components/YesNoField.ts +++ b/src/server/plugins/engine/components/YesNoField.ts @@ -2,6 +2,9 @@ import { type YesNoFieldComponent } from '@defra/forms-model' import { SelectionControlField } from '~/src/server/plugins/engine/components/SelectionControlField.js' import { addClassOptionIfNone } from '~/src/server/plugins/engine/components/helpers.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' +import { type ErrorMessageTemplateList } from '~/src/server/plugins/engine/types.js' +import { convertToLanguageMessages } from '~/src/server/utils/type-utils.js' /** * @description @@ -25,7 +28,28 @@ export class YesNoField extends SelectionControlField { formSchema = formSchema.optional() } + formSchema = formSchema.messages( + convertToLanguageMessages({ + 'any.required': messageTemplate.selectYesNoRequired + }) + ) + this.formSchema = formSchema this.options = options } + + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { + type: 'selectYesNoRequired', + template: messageTemplate.selectYesNoRequired + } + ], + advancedSettingsErrors: [] + } + } } diff --git a/src/server/plugins/engine/components/helpers.test.ts b/src/server/plugins/engine/components/helpers.test.ts new file mode 100644 index 000000000..c7397a322 --- /dev/null +++ b/src/server/plugins/engine/components/helpers.test.ts @@ -0,0 +1,24 @@ +import { type ComponentDef } from '@defra/forms-model' + +import { createComponent } from '~/src/server/plugins/engine/components/helpers.js' +import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import definition from '~/test/form/definitions/basic.js' + +const formModel = new FormModel(definition, { + basePath: 'test' +}) + +describe('helpers tests', () => { + test('should throw if invalid type', () => { + expect(() => + createComponent( + { + type: 'invalid-type' + } as unknown as ComponentDef, + { + model: formModel + } + ) + ).toThrow('Component type invalid-type does not exist') + }) +}) diff --git a/src/server/plugins/engine/components/helpers.ts b/src/server/plugins/engine/components/helpers.ts index a1b0dd50d..6f92be56e 100644 --- a/src/server/plugins/engine/components/helpers.ts +++ b/src/server/plugins/engine/components/helpers.ts @@ -55,6 +55,8 @@ export type Component = InstanceType< // Field component instances only export type Field = InstanceType< | typeof Components.AutocompleteField + | typeof Components.RadiosField + | typeof Components.YesNoField | typeof Components.CheckboxesField | typeof Components.DatePartsField | typeof Components.EmailAddressField @@ -72,10 +74,43 @@ export type Field = InstanceType< export type Guidance = InstanceType< | typeof Components.Details | typeof Components.Html + | typeof Components.Markdown | typeof Components.InsetText | typeof Components.List > +// List component instances only +export type ListField = InstanceType< + | typeof Components.AutocompleteField + | typeof Components.CheckboxesField + | typeof Components.RadiosField + | typeof Components.SelectField + | typeof Components.YesNoField +> + +/** + * Filter known components with lists + */ +export function hasListFormField( + field?: Partial +): field is ListFormComponent { + return !!field && isListFieldType(field.type) +} + +export function isListFieldType( + type?: ComponentType +): type is ListField['type'] { + const allowedTypes = [ + ComponentType.AutocompleteField, + ComponentType.CheckboxesField, + ComponentType.RadiosField, + ComponentType.SelectField, + ComponentType.YesNoField + ] + + return !!type && allowedTypes.includes(type) +} + /** * Create field instance for each {@link ComponentDef} type */ @@ -118,6 +153,10 @@ export function createComponent( component = new Components.List(def, options) break + case ComponentType.Markdown: + component = new Components.Markdown(def, options) + break + case ComponentType.MultilineTextField: component = new Components.MultilineTextField(def, options) break diff --git a/src/server/plugins/engine/components/index.ts b/src/server/plugins/engine/components/index.ts index 545ef55d0..d4a1065b4 100644 --- a/src/server/plugins/engine/components/index.ts +++ b/src/server/plugins/engine/components/index.ts @@ -12,6 +12,7 @@ export { EmailAddressField } from '~/src/server/plugins/engine/components/EmailA export { Html } from '~/src/server/plugins/engine/components/Html.js' export { InsetText } from '~/src/server/plugins/engine/components/InsetText.js' export { List } from '~/src/server/plugins/engine/components/List.js' +export { Markdown } from '~/src/server/plugins/engine/components/Markdown.js' export { MultilineTextField } from '~/src/server/plugins/engine/components/MultilineTextField.js' export { NumberField } from '~/src/server/plugins/engine/components/NumberField.js' export { RadiosField } from '~/src/server/plugins/engine/components/RadiosField.js' diff --git a/src/server/plugins/engine/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts index c4d08bbdc..59163dc78 100644 --- a/src/server/plugins/engine/configureEnginePlugin.ts +++ b/src/server/plugins/engine/configureEnginePlugin.ts @@ -1,8 +1,8 @@ import { join, parse } from 'node:path' import { type FormDefinition } from '@defra/forms-model' -import { type ServerRegisterPluginObject } from '@hapi/hapi' +import { FORM_PREFIX } from '~/src/server/constants.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { plugin, @@ -19,14 +19,24 @@ export const configureEnginePlugin = async ({ formFilePath, services, controllers -}: RouteConfig = {}): Promise> => { +}: RouteConfig = {}): Promise<{ + plugin: typeof plugin + options: PluginOptions +}> => { let model: FormModel | undefined if (formFileName && formFilePath) { const definition = await getForm(join(formFilePath, formFileName)) const { name } = parse(formFileName) - model = new FormModel(definition, { basePath: name }, services, controllers) + const initialBasePath = `${FORM_PREFIX}${name}` + + model = new FormModel( + definition, + { basePath: initialBasePath }, + services, + controllers + ) } return { diff --git a/src/server/plugins/engine/helpers.test.ts b/src/server/plugins/engine/helpers.test.ts index 7e0ab88da..ae90fd1e4 100644 --- a/src/server/plugins/engine/helpers.test.ts +++ b/src/server/plugins/engine/helpers.test.ts @@ -3,7 +3,6 @@ import { type ResponseObject, type ResponseToolkit } from '@hapi/hapi' import { StatusCodes } from 'http-status-codes' import { ValidationError } from 'joi' -import { PREVIEW_PATH_PREFIX } from '~/src/server/constants.js' import { checkEmailAddressForLiveFormSubmission, checkFormStatus, @@ -17,6 +16,7 @@ import { safeGenerateCrumb, type GlobalScope } from '~/src/server/plugins/engine/helpers.js' +import { handleLegacyRedirect } from '~/src/server/plugins/engine/helpers.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { createPage, @@ -311,35 +311,42 @@ describe('Helpers', () => { }) describe('checkFormStatus', () => { - it('should return true/live for paths starting with PREVIEW_PATH_PREFIX and form is live', () => { - const path = `${PREVIEW_PATH_PREFIX}/live/another/segment` - expect(checkFormStatus(path)).toStrictEqual({ + it('should return true/live for params that include live state segment', () => { + expect( + checkFormStatus({ + state: FormStatus.Live, + slug: 'another', + path: 'segment' + }) + ).toStrictEqual({ state: FormStatus.Live, isPreview: true }) }) - it('should return false for paths not starting with PREVIEW_PATH_PREFIX', () => { - const path = '/some/other/path' - expect(checkFormStatus(path)).toStrictEqual({ - state: FormStatus.Live, - isPreview: false - }) - }) - - it('should be case insensitive and return draft when form is draft', () => { - const path = `${PREVIEW_PATH_PREFIX.toUpperCase()}/draft/path` - expect(checkFormStatus(path)).toStrictEqual({ + it('should return true/draft for params that include draft state segment', () => { + expect( + checkFormStatus({ + state: FormStatus.Draft, + slug: 'another', + path: 'segment' + }) + ).toStrictEqual({ state: FormStatus.Draft, isPreview: true }) }) - it('should throw an error for invalid form state', () => { - const path = `${PREVIEW_PATH_PREFIX}/invalid-state` - expect(() => checkFormStatus(path)).toThrow( - 'Invalid form state: invalid-state' - ) + it('should return false/live for paths without a state segment', () => { + expect( + checkFormStatus({ + slug: 'some', + path: 'other' + }) + ).toStrictEqual({ + state: FormStatus.Live, + isPreview: false + }) }) }) @@ -789,4 +796,48 @@ describe('Helpers', () => { }) }) }) + + describe('handleLegacyRedirect', () => { + let mockH: jest.Mocked> + let mockRedirectResponse: jest.Mocked< + ReturnType + > + + beforeEach(() => { + mockRedirectResponse = { + permanent: jest.fn().mockReturnThis(), + takeover: jest.fn().mockReturnThis() + } as unknown as jest.Mocked> + + mockH = { + redirect: jest.fn().mockReturnValue(mockRedirectResponse) + } + }) + + it('should call h.redirect with the target URL', () => { + const targetUrl = '/another/target' + handleLegacyRedirect(mockH as unknown as ResponseToolkit, targetUrl) + + expect(mockH.redirect).toHaveBeenCalledTimes(1) + expect(mockH.redirect).toHaveBeenCalledWith(targetUrl) + }) + + it('should call permanent() and takeover() on the redirect response', () => { + const targetUrl = '/final/destination' + handleLegacyRedirect(mockH as unknown as ResponseToolkit, targetUrl) + + expect(mockRedirectResponse.permanent).toHaveBeenCalledTimes(1) + expect(mockRedirectResponse.takeover).toHaveBeenCalledTimes(1) + }) + + it('should return the final response object from takeover()', () => { + const targetUrl = '/the/end' + const response = handleLegacyRedirect( + mockH as unknown as ResponseToolkit, + targetUrl + ) + + expect(response).toBe(mockRedirectResponse) + }) + }) }) diff --git a/src/server/plugins/engine/helpers.ts b/src/server/plugins/engine/helpers.ts index d5c704df6..8c4112891 100644 --- a/src/server/plugins/engine/helpers.ts +++ b/src/server/plugins/engine/helpers.ts @@ -1,7 +1,10 @@ import { ControllerPath, Engine, + hasComponents, + isFormType, type ComponentDef, + type FormDefinition, type Page } from '@defra/forms-model' import Boom from '@hapi/boom' @@ -12,7 +15,6 @@ import { type Schema, type ValidationErrorItem } from 'joi' import { Liquid } from 'liquidjs' import { createLogger } from '~/src/server/common/helpers/logging/logger.js' -import { PREVIEW_PATH_PREFIX } from '~/src/server/constants.js' import { getAnswer, type Field @@ -27,6 +29,7 @@ import { import { FormAction, FormStatus, + type FormParams, type FormQuery, type FormRequest, type FormRequestPayload @@ -253,29 +256,18 @@ export function getStartPath(model?: FormModel) { return startPath ? `/${startPath}` : ControllerPath.Start } -export function checkFormStatus(path: string) { - const isPreview = path.toLowerCase().startsWith(PREVIEW_PATH_PREFIX) +export function checkFormStatus(params?: FormParams) { + const isPreview = !!params?.state - let state: FormStatus | undefined + let state = FormStatus.Live - if (isPreview) { - const previewState = path.split('/')[2] - - for (const formState of Object.values(FormStatus)) { - if (previewState === formState.toString()) { - state = formState - break - } - } - - if (!state) { - throw new Error(`Invalid form state: ${previewState}`) - } + if (isPreview && params.state === FormStatus.Draft) { + state = FormStatus.Draft } return { isPreview, - state: state ?? FormStatus.Live + state } } @@ -337,7 +329,7 @@ export function safeGenerateCrumb( return undefined } - // crumb plugin or its generate method doesn’t exist + // crumb plugin or its generate method doesn't exist if (!request.server.plugins.crumb.generate) { return undefined } @@ -382,3 +374,38 @@ export function evaluateTemplate( export function getCacheService(server: Server) { return server.plugins['forms-engine-plugin'].cacheService } + +/** + * Handles logging and issuing a permanent redirect for legacy routes. + * @param h - The Hapi response toolkit. + * @param targetUrl - The URL to redirect to. + * @returns The Hapi response object configured for permanent redirect. + */ +export function handleLegacyRedirect(h: ResponseToolkit, targetUrl: string) { + return h.redirect(targetUrl).permanent().takeover() +} + +/** + * If the page doesn't have a title, set it from the title of the first form component + * @param def - the form definition + */ +export function setPageTitles(def: FormDefinition) { + def.pages.forEach((page) => { + if (!page.title) { + if (hasComponents(page)) { + // Set the page title from the first form component + const firstFormComponent = page.components.find((component) => + isFormType(component.type) + ) + + page.title = firstFormComponent?.title ?? '' + } + + if (!page.title) { + const formNameMsg = def.name ? ` in form '${def.name}'` : '' + + logger.warn(`Page '${page.path}' has no title${formNameMsg}`) + } + } + }) +} diff --git a/src/server/plugins/engine/models/FormModel.test.ts b/src/server/plugins/engine/models/FormModel.test.ts index 3c02a0668..8f5304662 100644 --- a/src/server/plugins/engine/models/FormModel.test.ts +++ b/src/server/plugins/engine/models/FormModel.test.ts @@ -1,6 +1,8 @@ import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { type FormContextRequest } from '~/src/server/plugins/engine/types.js' +import { V2 as definitionV2 } from '~/test/form/definitions/conditions-basic.js' import definition from '~/test/form/definitions/conditions-escaping.js' +import conditionsListDefinition from '~/test/form/definitions/conditions-list.js' import fieldsRequiredDefinition from '~/test/form/definitions/fields-required.js' describe('FormModel', () => { @@ -10,6 +12,20 @@ describe('FormModel', () => { () => new FormModel(definition, { basePath: 'test' }) ).not.toThrow() }) + + it('Sets the page title from first form component when empty (V2 only)', () => { + const noTitlesDefinition = { + ...definitionV2, + pages: definitionV2.pages.map((page) => ({ ...page, title: '' })) + } + + const model = new FormModel(noTitlesDefinition, { basePath: 'test' }) + + expect(model.def.pages.at(0)?.title).toBe( + 'Have you previously been married?' + ) + expect(model.def.pages.at(1)?.title).toBe('Date of marriage') + }) }) describe('getFormContext', () => { @@ -74,7 +90,7 @@ describe('FormModel', () => { }) const state = { - $$__referenceNumber: 1232456, + $$__referenceNumber: 123456789, checkboxesSingle: ['Arabian', 'Shetland'] } const pageUrl = new URL('http://example.com/components/fields-required') @@ -93,5 +109,79 @@ describe('FormModel', () => { 'Reference number not found in form state' ) }) + + it('redirects to the page if the list field (radio) is invalidated due to list item conditions', () => { + const formModel = new FormModel(conditionsListDefinition, { + basePath: '/conditional-list-items' + }) + + const state = { + $$__referenceNumber: 'foobar', + gXsqLq: true, + QwcNsc: 'meat', + zeQDES: ['peppers', 'cheese', 'ham'] + } + const pageUrl = new URL( + 'http://example.com/conditional-list-items/summary' + ) + + const request: FormContextRequest = { + method: 'get', + query: {}, + path: pageUrl.pathname, + params: { path: 'summary', slug: 'conditional-list-items' }, + url: pageUrl, + app: { model: formModel } + } + + const context = formModel.getFormContext(request, state) + + expect(context.errors).toHaveLength(1) + expect(context.errors?.at(0)?.text).toBe( + 'Options are different because you changed a previous answer' + ) + expect(context.relevantPages).toHaveLength(2) + expect(context.paths).toHaveLength(2) + expect(context.relevantState).toEqual({ gXsqLq: true, QwcNsc: 'meat' }) + }) + + it('redirects to the page if the list field (check) is invalidated due to list item conditions', () => { + const formModel = new FormModel(conditionsListDefinition, { + basePath: '/conditional-list-items' + }) + + const state = { + $$__referenceNumber: 'foobar', + gXsqLq: true, + QwcNsc: 'vegan', + zeQDES: ['peppers', 'cheese', 'ham'] + } + const pageUrl = new URL( + 'http://example.com/conditional-list-items/summary' + ) + + const request: FormContextRequest = { + method: 'get', + query: {}, + path: pageUrl.pathname, + params: { path: 'summary', slug: 'conditional-list-items' }, + url: pageUrl, + app: { model: formModel } + } + + const context = formModel.getFormContext(request, state) + + expect(context.errors).toHaveLength(1) + expect(context.errors?.at(0)?.text).toBe( + 'Options are different because you changed a previous answer' + ) + expect(context.relevantPages).toHaveLength(3) + expect(context.paths).toHaveLength(3) + expect(context.relevantState).toEqual({ + gXsqLq: true, + QwcNsc: 'vegan', + zeQDES: ['peppers', 'cheese', 'ham'] + }) + }) }) }) diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index d11b11242..0b512e411 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -19,11 +19,16 @@ import { add } from 'date-fns' import { Parser, type Value } from 'expr-eval' import joi from 'joi' -import { type Component } from '~/src/server/plugins/engine/components/helpers.js' +import { type ListFormComponent } from '~/src/server/plugins/engine/components/ListFormComponent.js' +import { + hasListFormField, + type Component +} from '~/src/server/plugins/engine/components/helpers.js' import { findPage, getError, - getPage + getPage, + setPageTitles } from '~/src/server/plugins/engine/helpers.js' import { type ExecutableCondition } from '~/src/server/plugins/engine/models/types.js' import { type PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js' @@ -100,6 +105,9 @@ export class FormModel { ] }) + // Fix up page titles + setPageTitles(def) + this.engine = def.engine this.def = def this.lists = def.lists @@ -297,7 +305,10 @@ export class FormModel { this.assignRelevantState(context, nextPage) // Stop at current page - if (nextPage.path === currentPath) { + if ( + this.pageStateIsInvalid(context, nextPage) || + nextPage.path === currentPath + ) { break } @@ -351,6 +362,78 @@ export class FormModel { } } + private pageStateIsInvalid(context: FormContext, page: PageControllerClass) { + // Get any list-bound fields on the page + const listFields = page.collection.fields.filter(hasListFormField) + + // For each list field that is bound to a list that contains any conditional items, + // we need to check any answers are still valid. Do this by evaluating the conditions + // and ensuring any current answers are all included in the set of valid answers + for (const field of listFields) { + const list = field.list + + // Filter out YesNo as they can't be conditional + if (list !== undefined && field.type !== ComponentType.YesNoField) { + const hasOptionalItems = + list.items.filter((item) => item.condition).length > 0 + + if (hasOptionalItems) { + return this.fieldStateIsInvalid(context, field, list) + } + } + } + } + + private fieldStateIsInvalid( + context: FormContext, + field: ListFormComponent, + list: List + ) { + const { evaluationState, state } = context + + const validValues = list.items + .filter((item) => + item.condition + ? this.conditions[item.condition]?.fn(evaluationState) + : true + ) + .map((item) => item.value) + + // Get the field state + const fieldState = field.getFormValueFromState(state) + + if (fieldState !== undefined) { + let isInvalid = false + const isArray = Array.isArray(fieldState) + + // Check if any saved state value(s) are still valid + // and return true if any are invalid + if (isArray) { + isInvalid = !fieldState.every((item) => validValues.includes(item)) + } else { + isInvalid = !validValues.includes(fieldState) + } + + if (isInvalid) { + if (!context.errors) { + context.errors = [] + } + + const text = + 'Options are different because you changed a previous answer' + + context.errors.push({ + text, + name: field.name, + href: `#${field.name}`, + path: [`#${field.name}`] + }) + } + + return isInvalid + } + } + private assignPaths(context: FormContext) { for (const { keys, path } of context.relevantPages) { context.paths.push(path) diff --git a/src/server/plugins/engine/models/SummaryViewModel.test.ts b/src/server/plugins/engine/models/SummaryViewModel.test.ts index e8360be32..b686db7f3 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.test.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.test.ts @@ -1,3 +1,4 @@ +import { FORM_PREFIX } from '~/src/server/constants.js' import { FormModel, SummaryViewModel @@ -13,7 +14,7 @@ import { } from '~/src/server/plugins/engine/types.js' import definition from '~/test/form/definitions/repeat-mixed.js' -const basePath = '/test' +const basePath = `${FORM_PREFIX}/test` describe('SummaryViewModel', () => { const itemId1 = 'abc-123' @@ -28,7 +29,7 @@ describe('SummaryViewModel', () => { beforeEach(() => { model = new FormModel(definition, { - basePath: 'test' + basePath: `${FORM_PREFIX}/test` }) page = createPage(model, definition.pages[2]) @@ -55,7 +56,13 @@ describe('SummaryViewModel', () => { orderType: 'collection', pizza: [] } satisfies FormState, - keys: ['How would you like to receive your pizza?', 'Pizzas'], + keys: [ + 'How would you like to receive your pizza?', + 'Pizzas', + 'How you would like to receive your pizza', + 'Pizzas', + 'Pizza' + ], values: ['Collection', 'Not supplied'] }, { @@ -71,7 +78,13 @@ describe('SummaryViewModel', () => { } ] } satisfies FormState, - keys: ['How would you like to receive your pizza?', 'Pizza added'], + keys: [ + 'How would you like to receive your pizza?', + 'Pizza added', + 'How you would like to receive your pizza', + 'Pizzas', + 'Pizza' + ], values: ['Delivery', 'You added 1 Pizza'] }, { @@ -92,7 +105,13 @@ describe('SummaryViewModel', () => { } ] } satisfies FormState, - keys: ['How would you like to receive your pizza?', 'Pizzas added'], + keys: [ + 'How would you like to receive your pizza?', + 'Pizzas added', + 'How you would like to receive your pizza', + 'Pizzas', + 'Pizza' + ], values: ['Delivery', 'You added 2 Pizzas'] } ])('Check answers ($description)', ({ state, keys, values }) => { @@ -124,7 +143,7 @@ describe('SummaryViewModel', () => { expect(summaryList1).toHaveProperty('rows', [ { key: { - text: keys[0] + text: keys[2] }, value: { classes: 'app-prose-scope', @@ -181,7 +200,7 @@ describe('SummaryViewModel', () => { expect(summaryList1).toHaveProperty('rows', [ { key: { - text: keys[0] + text: keys[2] }, value: { classes: 'app-prose-scope', @@ -208,5 +227,25 @@ describe('SummaryViewModel', () => { } ]) }) + + it('should use correct summary labels', () => { + request.query.force = '' // Preview URL '?force' + context = model.getFormContext(request, state) + summaryViewModel = new SummaryViewModel(request, page, context) + + expect(summaryViewModel.details).toHaveLength(2) + + const [details1, details2] = summaryViewModel.details + + expect(details1.items[0]).toMatchObject({ + title: keys[2], + label: keys[0] + }) + + expect(details2.items[0]).toMatchObject({ + title: keys[1], + label: keys[4] + }) + }) }) }) diff --git a/src/server/plugins/engine/models/SummaryViewModel.ts b/src/server/plugins/engine/models/SummaryViewModel.ts index 9198aae01..8b0bccb10 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.ts @@ -4,7 +4,10 @@ import { getAnswer, type Field } from '~/src/server/plugins/engine/components/helpers.js' -import { type BackLink } from '~/src/server/plugins/engine/components/types.js' +import { + type BackLink, + type ComponentViewModel +} from '~/src/server/plugins/engine/components/types.js' import { evaluateTemplate, getError, @@ -47,6 +50,7 @@ export class SummaryViewModel { errors?: FormSubmissionError[] serviceUrl: string hasMissingNotificationEmail?: boolean + components?: ComponentViewModel[] constructor( request: FormContextRequest, @@ -207,8 +211,8 @@ function ItemField( return { name: field.name, label: field.title, - title: field.title, - error: field.getError(options.errors), + title: field.label, + error: field.getFirstError(options.errors), value: getAnswer(field, state), href: getPageHref(page, options.path, { returnUrl: getPageHref(page, page.getSummaryPath()) diff --git a/src/server/plugins/engine/outputFormatters/human/v1.test.ts b/src/server/plugins/engine/outputFormatters/human/v1.test.ts index ff0bfd426..069965ef2 100644 --- a/src/server/plugins/engine/outputFormatters/human/v1.test.ts +++ b/src/server/plugins/engine/outputFormatters/human/v1.test.ts @@ -93,10 +93,9 @@ describe('getPersonalisation', () => { `^ For security reasons, the links in this email expire at ${dateFormat(dateExpiry, 'h:mmaaa')} on ${dateFormat(dateExpiry, 'eeee d MMMM yyyy')}` ) - // Check for form answers expect(body).toContain( outdent` - Form submitted at ${dateFormat(dateNow, 'h:mmaaa')} on ${dateFormat(dateNow, 'd MMMM yyyy')}. + ${definition.name} form received at ${dateFormat(dateNow, 'h:mmaaa')} on ${dateFormat(dateNow, 'd MMMM yyyy')}. --- diff --git a/src/server/plugins/engine/outputFormatters/human/v1.ts b/src/server/plugins/engine/outputFormatters/human/v1.ts index ca670b7dd..79cd0f8cb 100644 --- a/src/server/plugins/engine/outputFormatters/human/v1.ts +++ b/src/server/plugins/engine/outputFormatters/human/v1.ts @@ -42,7 +42,7 @@ export function format( lines.push(`This is a test of the ${formName} ${formStatus.state} form.\n`) } - lines.push(`Form submitted at ${escapeMarkdown(formattedNow)}.\n`) + lines.push(`${formName} form received at ${escapeMarkdown(formattedNow)}.\n`) lines.push('---\n') items.forEach((item) => { diff --git a/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts b/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts index 9c853ae66..7694d24bb 100644 --- a/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts +++ b/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts @@ -210,6 +210,7 @@ describe('FileUploadPageController', () => { expect(getUploadStatusSpy).toHaveBeenCalledTimes(2) expect(request.logger.info).toHaveBeenCalled() + /* eslint-disable-next-line @typescript-eslint/no-unsafe-member-access */ const logMsg = (request.logger.info as jest.Mock).mock.calls[0][0] expect(logMsg).toEqual(expect.stringContaining('Waiting')) expect(logMsg).toEqual(expect.stringContaining('some-id')) diff --git a/src/server/plugins/engine/pageControllers/PageController.test.ts b/src/server/plugins/engine/pageControllers/PageController.test.ts index 175f2252c..1fa8c57ef 100644 --- a/src/server/plugins/engine/pageControllers/PageController.test.ts +++ b/src/server/plugins/engine/pageControllers/PageController.test.ts @@ -1,5 +1,6 @@ import { type ResponseToolkit } from '@hapi/hapi' +import { FORM_PREFIX } from '~/src/server/constants.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js' import { type FormRequest } from '~/src/server/routes/types.js' @@ -10,6 +11,8 @@ describe('PageController', () => { let controller1: PageController let controller2: PageController + const testBasePath = `${FORM_PREFIX}/test` + beforeEach(() => { const { pages } = definition @@ -17,7 +20,7 @@ describe('PageController', () => { const page2 = pages[1] model = new FormModel(definition, { - basePath: 'test' + basePath: testBasePath }) controller1 = new PageController(model, page1) @@ -31,8 +34,8 @@ describe('PageController', () => { }) it('returns href', () => { - expect(controller1).toHaveProperty('href', '/test/licence') - expect(controller2).toHaveProperty('href', '/test/full-name') + expect(controller1).toHaveProperty('href', `${testBasePath}/licence`) + expect(controller2).toHaveProperty('href', `${testBasePath}/full-name`) }) it('returns keys (empty)', () => { @@ -99,11 +102,11 @@ describe('PageController', () => { describe('Path methods', () => { describe('Link href', () => { it('prefixes paths into link hrefs', () => { - const href1 = controller1.getHref('/') + const href1 = controller1.getHref('') const href2 = controller1.getHref('/page-one') - expect(href1).toBe('/test') - expect(href2).toBe('/test/page-one') + expect(href1).toBe(testBasePath) + expect(href2).toBe(`${testBasePath}/page-one`) }) }) diff --git a/src/server/plugins/engine/pageControllers/PageController.ts b/src/server/plugins/engine/pageControllers/PageController.ts index 52180f168..93925cecb 100644 --- a/src/server/plugins/engine/pageControllers/PageController.ts +++ b/src/server/plugins/engine/pageControllers/PageController.ts @@ -135,12 +135,22 @@ export class PageController { return def.phaseBanner?.phase } - getHref(path: string) { - const { model } = this + getHref(path: string): string { + const basePath = this.model.basePath - return path === '/' - ? `/${model.basePath}` // Strip trailing slash - : `/${model.basePath}${path}` + if (path === '/') { + return `/${basePath}` + } + + // if ever the path is not prefixed with a slash, add it + const relativeTargetPath = path.startsWith('/') ? path.substring(1) : path + let finalPath = `/${basePath}` + if (relativeTargetPath) { + finalPath += `/${relativeTargetPath}` + } + finalPath = finalPath.replace(/\/{2,}/g, '/') + + return finalPath } getStartPath() { diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index 6c1673d3c..a6f2e7a36 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -398,7 +398,7 @@ export class QuestionPageController extends PageController { const { evaluationState } = context const viewModel = this.getViewModel(request, context) - viewModel.errors = collection.getErrors(viewModel.errors) + viewModel.errors = collection.getViewErrors(viewModel.errors) /** * Content components can be hidden based on a condition. If the condition evaluates to true, it is safe to be kept, otherwise discard it @@ -498,7 +498,7 @@ export class QuestionPageController extends PageController { */ if (context.errors || isForceAccess) { const viewModel = this.getViewModel(request, context) - viewModel.errors = collection.getErrors(viewModel.errors) + viewModel.errors = collection.getViewErrors(viewModel.errors) // Filter our components based on their conditions using our evaluated state viewModel.components = this.filterConditionalComponents( diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index 04ea3a2f0..5696aab8e 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -1,7 +1,12 @@ -import { type PageSummary, type SubmitPayload } from '@defra/forms-model' +import { + hasComponentsEvenIfNoNext, + type Page, + type SubmitPayload +} from '@defra/forms-model' import Boom from '@hapi/boom' import { type ResponseToolkit, type RouteOptions } from '@hapi/hapi' +import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { FileUploadField } from '~/src/server/plugins/engine/components/FileUploadField.js' import { getAnswer } from '~/src/server/plugins/engine/components/helpers.js' import { @@ -30,15 +35,21 @@ import { } from '~/src/server/routes/types.js' export class SummaryPageController extends QuestionPageController { - declare pageDef: PageSummary + declare pageDef: Page /** * The controller which is used when Page["controller"] is defined as "./pages/summary.js" */ - constructor(model: FormModel, pageDef: PageSummary) { + constructor(model: FormModel, pageDef: Page) { super(model, pageDef) this.viewName = 'summary' + + // Components collection + this.collection = new ComponentCollection( + hasComponentsEvenIfNoNext(pageDef) ? pageDef.components : [], + { model, page: this } + ) } getSummaryViewModel( @@ -47,11 +58,16 @@ export class SummaryPageController extends QuestionPageController { ): SummaryViewModel { const viewModel = new SummaryViewModel(request, this, context) + const { query } = request + const { payload, errors } = context + const components = this.collection.getViewModel(payload, errors, query) + // We already figure these out in the base page controller. Take them and apply them to our page-specific model. // This is a stop-gap until we can add proper inheritance in place. viewModel.backLink = this.getBackLink(request, context) viewModel.feedbackLink = this.feedbackLink viewModel.phaseTag = this.phaseTag + viewModel.components = components return viewModel } @@ -96,7 +112,7 @@ export class SummaryPageController extends QuestionPageController { // Get the form metadata using the `slug` param const { notificationEmail } = await getFormMetadata(params.slug) - const { isPreview } = checkFormStatus(request.path) + const { isPreview } = checkFormStatus(request.params) const emailAddress = notificationEmail ?? this.model.def.outputEmail checkEmailAddressForLiveFormSubmission(emailAddress, isPreview) @@ -138,8 +154,7 @@ async function submitForm( ) { await extendFileRetention(model, state, emailAddress) - const { path } = request - const formStatus = checkFormStatus(path) + const formStatus = checkFormStatus(request.params) const logTags = ['submit', 'submissionApi'] request.logger.info(logTags, 'Preparing email', formStatus) diff --git a/src/server/plugins/engine/pageControllers/validationOptions.ts b/src/server/plugins/engine/pageControllers/validationOptions.ts index a088c7f7e..4c4575c58 100644 --- a/src/server/plugins/engine/pageControllers/validationOptions.ts +++ b/src/server/plugins/engine/pageControllers/validationOptions.ts @@ -1,32 +1,45 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ // Declaration above is needed for: https://github.com/hapijs/joi/issues/3064 -import joi, { type LanguageMessages, type ValidationOptions } from 'joi' +import joi, { + type JoiExpression, + type LanguageMessages, + type LanguageMessagesExt, + type ReferenceOptions, + type ValidationOptions +} from 'joi' import lowerFirst from 'lodash/lowerFirst.js' const opts = { functions: { lowerFirst } -} +} as ReferenceOptions /** * see @link https://joi.dev/api/?v=17.4.2#template-syntax for template syntax */ -export const messageTemplate = { - // @ts-expect-error - joi.expression options type issue - required: joi.expression('Enter {{lowerFirst(#label)}}', opts), - // @ts-expect-error - joi.expression options type issue - selectRequired: joi.expression('Select {{lowerFirst(#label)}}', opts), +export const messageTemplate: Record = { + required: joi.expression( + 'Enter {{lowerFirst(#label)}}', + opts + ) as JoiExpression, + selectRequired: joi.expression( + 'Select {{lowerFirst(#label)}}', + opts + ) as JoiExpression, + selectYesNoRequired: '{{#label}} - select yes or no', max: '{{#label}} must be {{#limit}} characters or less', min: '{{#label}} must be {{#limit}} characters or more', - // @ts-expect-error - joi.expression options type issue - pattern: joi.expression('Enter a valid {{lowerFirst(#label)}}', opts), + 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', - // @ts-expect-error - joi.expression options type issue opts - ), + ) as JoiExpression, number: '{{#label}} must be a number', numberPrecision: '{{#label}} must have {{#limit}} or fewer decimal places', numberInteger: '{{#label}} must be a whole number', @@ -36,19 +49,17 @@ export const messageTemplate = { // Nested fields use component title - // @ts-expect-error - joi.expression options type issue - objectRequired: joi.expression('Enter {{#label}}', opts), + objectRequired: joi.expression('Enter {{#label}}', opts) as JoiExpression, objectMissing: joi.expression( '{{#title}} must include a {{lowerFirst(#label)}}', - // @ts-expect-error - joi.expression options type issue opts - ), + ) as JoiExpression, dateFormat: '{{#title}} must be a real date', dateMin: '{{#title}} must be the same as or after {{#limit}}', dateMax: '{{#title}} must be the same as or before {{#limit}}' } -export const messages: LanguageMessages = { +export const messages: LanguageMessagesExt = { 'string.base': messageTemplate.required, 'string.min': messageTemplate.min, 'string.empty': messageTemplate.required, @@ -77,9 +88,12 @@ export const messages: LanguageMessages = { 'date.max': messageTemplate.dateMax } +export const messagesPre: LanguageMessages = + messages as unknown as LanguageMessages + export const validationOptions: ValidationOptions = { abortEarly: false, - messages, + messages: messagesPre, errors: { wrap: { array: false, diff --git a/src/server/plugins/engine/plugin.ts b/src/server/plugins/engine/plugin.ts index 310dca477..8952b9be2 100644 --- a/src/server/plugins/engine/plugin.ts +++ b/src/server/plugins/engine/plugin.ts @@ -105,6 +105,8 @@ export const plugin = { dependencies: ['@hapi/crumb', '@hapi/yar', 'hapi-pino'], multiple: true, async register(server: Server, options: PluginOptions) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- hapi types are wrong + const prefix = server.realm.modifiers.route.prefix ?? '' const { model, services = defaultServices, @@ -193,9 +195,9 @@ export const plugin = { return h.continue } - const { params, path } = request + const { params } = request const { slug } = params - const { isPreview, state: formState } = checkFormStatus(path) + const { isPreview, state: formState } = checkFormStatus(params) // Get the form metadata using the `slug` param const metadata = await formsService.getFormMetadata(slug) @@ -241,9 +243,11 @@ export const plugin = { ) // Set up the basePath for the model - const basePath = isPreview - ? `${PREVIEW_PATH_PREFIX.substring(1)}/${formState}/${slug}` - : slug + const basePath = ( + isPreview + ? `${prefix}${PREVIEW_PATH_PREFIX}/${formState}/${slug}` + : `${prefix}/${slug}` + ).substring(1) // Construct the form model const model = new FormModel( diff --git a/src/server/plugins/engine/services/notifyService.ts b/src/server/plugins/engine/services/notifyService.ts index 85c07651c..0feb665b4 100644 --- a/src/server/plugins/engine/services/notifyService.ts +++ b/src/server/plugins/engine/services/notifyService.ts @@ -19,8 +19,7 @@ export async function submit( submitResponse: SubmitResponsePayload ) { const logTags = ['submit', 'email'] - const { path } = request - const formStatus = checkFormStatus(path) + const formStatus = checkFormStatus(request.params) // Get submission email personalisation request.logger.info(logTags, 'Getting personalisation data') diff --git a/src/server/plugins/engine/services/uploadService.js b/src/server/plugins/engine/services/uploadService.js index 2d4b43b93..44d63c124 100644 --- a/src/server/plugins/engine/services/uploadService.js +++ b/src/server/plugins/engine/services/uploadService.js @@ -10,12 +10,19 @@ const stagingPrefix = config.get('stagingPrefix') * Initiates a CDP file upload * @param {string} path - the path of the page in the form * @param {string} retrievalKey - the retrieval key for the files - * @param {string} [mimeTypes] - the csv string of accepted mimeTypes + * @param {string} [mimeTypesCsv] - the csv string of accepted mimeTypes */ -export async function initiateUpload(path, retrievalKey, mimeTypes) { +export async function initiateUpload(path, retrievalKey, mimeTypesCsv) { const postJsonByType = /** @type {typeof postJson} */ (postJson) + const mimeTypesList = mimeTypesCsv + ?.split(',') + .map((type) => type.trim()) + .filter((type) => type !== '') + + const mimeTypes = mimeTypesList?.length ? mimeTypesList : undefined + const payload = { redirect: path, callback: `${submissionUrl}/file`, @@ -24,10 +31,7 @@ export async function initiateUpload(path, retrievalKey, mimeTypes) { metadata: { retrievalKey }, - mimeTypes: mimeTypes - ?.split(',') - .map((type) => type.trim()) - .filter((type) => type !== '') + mimeTypes // maxFileSize: 25 * 1000 * 1000 } diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 87214fc32..e1aed6ef2 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -4,7 +4,7 @@ import { type List, type Page } from '@defra/forms-model' -import { type ValidationErrorItem } from 'joi' +import { type JoiExpression, type ValidationErrorItem } from 'joi' import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' import { type Component } from '~/src/server/plugins/engine/components/helpers.js' @@ -316,3 +316,12 @@ export type PageViewModel = | FeaturedFormPageViewModel export type FilterFunction = (value: unknown) => unknown +export interface ErrorMessageTemplate { + type: string + template: JoiExpression +} + +export interface ErrorMessageTemplateList { + baseErrors: ErrorMessageTemplate[] + advancedSettingsErrors: ErrorMessageTemplate[] +} diff --git a/src/server/plugins/engine/views/components/html.html b/src/server/plugins/engine/views/components/html.html index 0456cae4f..349b5b459 100644 --- a/src/server/plugins/engine/views/components/html.html +++ b/src/server/plugins/engine/views/components/html.html @@ -1,3 +1,3 @@ {% macro Html(component) %} - {{ component.model.content | safe }} + {{ component.model.content | safe }} {% endmacro %} diff --git a/src/server/plugins/engine/views/components/markdown.html b/src/server/plugins/engine/views/components/markdown.html new file mode 100644 index 000000000..fb014fa5d --- /dev/null +++ b/src/server/plugins/engine/views/components/markdown.html @@ -0,0 +1,5 @@ +{% macro Markdown(component) %} +
+ {{ component.model.content | markdown | safe }} +
+{% endmacro %} diff --git a/src/server/plugins/engine/views/summary.html b/src/server/plugins/engine/views/summary.html index 30c4e96dc..f6dfc4218 100644 --- a/src/server/plugins/engine/views/summary.html +++ b/src/server/plugins/engine/views/summary.html @@ -2,6 +2,7 @@ {% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} {% from "govuk/components/summary-list/macro.njk" import govukSummaryList %} +{% from "partials/components.html" import componentList with context %} {% block content %}
@@ -34,11 +35,16 @@

{% if declaration %}

Declaration

+
{{ declaration | safe }} +
{% endif %} + {{ componentList(components) }} + + {% set isDeclaration = declaration or components | length %}
diff --git a/src/server/plugins/nunjucks/context.js b/src/server/plugins/nunjucks/context.js index bd8b4d29d..1bd2780ee 100644 --- a/src/server/plugins/nunjucks/context.js +++ b/src/server/plugins/nunjucks/context.js @@ -7,8 +7,8 @@ import { StatusCodes } from 'http-status-codes' import pkg from '~/package.json' with { type: 'json' } import { config } from '~/src/config/index.js' import { createLogger } from '~/src/server/common/helpers/logging/logger.js' -import { PREVIEW_PATH_PREFIX } from '~/src/server/constants.js' import { + checkFormStatus, encodeUrl, safeGenerateCrumb } from '~/src/server/plugins/engine/helpers.js' @@ -22,9 +22,9 @@ let webpackManifest * @param {FormRequest | FormRequestPayload | null} request */ export function context(request) { - const { params, path, response } = request ?? {} + const { params, response } = request ?? {} - const isPreviewMode = path?.startsWith(PREVIEW_PATH_PREFIX) + const { isPreview: isPreviewMode, state: formState } = checkFormStatus(params) // Only add the slug in to the context if the response is OK. // Footer meta links are not rendered when the slug is missing. @@ -62,7 +62,7 @@ export function context(request) { }, crumb: safeGenerateCrumb(request), currentPath: `${request.path}${request.url.search}`, - previewMode: isPreviewMode ? params?.state : undefined, + previewMode: isPreviewMode ? formState : undefined, slug: isResponseOK ? params?.slug : undefined } diff --git a/src/server/plugins/nunjucks/enviroment.test.js b/src/server/plugins/nunjucks/enviroment.test.js index a6f1cbf14..4bdb62cda 100644 --- a/src/server/plugins/nunjucks/enviroment.test.js +++ b/src/server/plugins/nunjucks/enviroment.test.js @@ -88,7 +88,9 @@ describe('Nunjucks environment', () => { } } - const result = checkComponentTemplates.call(nunjucksCtx, component) + const result = /** @type {{ model: { content: string } }} */ ( + checkComponentTemplates.call(nunjucksCtx, component) + ) expect(helpers.evaluateTemplate).toHaveBeenCalledWith( 'Some {{ context.someData }} content', @@ -114,7 +116,9 @@ describe('Nunjucks environment', () => { } } - const result = checkComponentTemplates.call(nunjucksCtx, component) + const result = /** @type {{ model: { content: string } }} */ ( + checkComponentTemplates.call(nunjucksCtx, component) + ) expect(helpers.evaluateTemplate).not.toHaveBeenCalled() @@ -136,7 +140,9 @@ describe('Nunjucks environment', () => { } } - const result = checkComponentTemplates.call(nunjucksCtx, component) + const result = /** @type {{ model: { label?: { text: string } } }} */ ( + checkComponentTemplates.call(nunjucksCtx, component) + ) expect(helpers.evaluateTemplate).toHaveBeenCalledWith( 'Label with {{ context.someData }}', diff --git a/src/server/utils/type-utils.ts b/src/server/utils/type-utils.ts new file mode 100644 index 000000000..80fe3f0cf --- /dev/null +++ b/src/server/utils/type-utils.ts @@ -0,0 +1,15 @@ +import Joi, { + type JoiExpression, + type LanguageMessages, + type LanguageMessagesExt +} from 'joi' + +export function convertToLanguageMessages( + extLanguageMessages: LanguageMessagesExt +): LanguageMessages { + return extLanguageMessages as unknown as LanguageMessages +} + +export function createJoiExpression(expr: string): JoiExpression { + return Joi.expression(expr) as unknown as JoiExpression +} diff --git a/src/typings/joi/index.d.ts b/src/typings/joi/index.d.ts index 37dcdacb8..f57e62dd3 100644 --- a/src/typings/joi/index.d.ts +++ b/src/typings/joi/index.d.ts @@ -19,4 +19,12 @@ declare module 'joi' { title?: string } } + + interface JoiExpressionReturn { + render: (p1, p2, p3, p4, p5) => string + } + + type JoiExpression = JoiExpressionReturn | string + + type LanguageMessagesExt = Record } diff --git a/test/client/javascripts/file-upload.test.js b/test/client/javascripts/file-upload.test.js index fbc9905c9..1070f4bc6 100644 --- a/test/client/javascripts/file-upload.test.js +++ b/test/client/javascripts/file-upload.test.js @@ -1,4 +1,7 @@ -import { initFileUpload } from '~/src/client/javascripts/file-upload.js' +import { + buildUploadStatusUrl, + initFileUpload +} from '~/src/client/javascripts/file-upload.js' describe('File Upload Client JS', () => { beforeEach(() => { @@ -1194,4 +1197,127 @@ describe('File Upload Client JS', () => { global.fetch = originalFetch global.FormData = originalFormData }) + + test('does not add new error summary if one already exists', () => { + document.body.innerHTML = ` +
+
+

Existing error

+
+
+
+
+ + +
+ ` + + const { triggerClick } = setupTestableComponent() + const errorSummary = document.querySelector( + '.govuk-error-summary-container' + ) + + triggerClick({ preventDefault: jest.fn() }) + + expect(document.querySelectorAll('.govuk-error-summary')).toHaveLength(1) + expect(errorSummary?.innerHTML).toBe('') + }) + + test('adds inline error styling to file input field', () => { + document.body.innerHTML = ` +
+
+
+ +
+ +
+ ` + + const { triggerClick } = setupTestableComponent() + + triggerClick({ preventDefault: jest.fn() }) + + const fileInput = document.querySelector('input[type="file"]') + const formGroup = fileInput?.closest('.govuk-form-group') + + expect(formGroup?.classList.contains('govuk-form-group--error')).toBe(true) + + expect(fileInput?.classList.contains('govuk-file-upload--error')).toBe(true) + + const errorMessage = document.getElementById(`${fileInput?.id}-error`) + expect(errorMessage).not.toBeNull() + expect(errorMessage?.textContent).toContain('Select a file') + + const hiddenSpan = errorMessage?.querySelector('.govuk-visually-hidden') + expect(hiddenSpan).not.toBeNull() + expect(hiddenSpan?.textContent).toBe('Error:') + + expect(fileInput?.getAttribute('aria-describedby')).toContain( + `${fileInput?.id}-error` + ) + }) + + test('sets aria-describedby when error summary exists with title element', () => { + document.body.innerHTML = ` +
+
+

Existing error

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

Existing error without ID

+
+
+
+
+ + +
+ ` + + const { triggerClick, fileInput } = setupTestableComponent() + + fileInput?.setAttribute('aria-describedby', 'some-value') + triggerClick({ preventDefault: jest.fn() }) + + expect(fileInput?.hasAttribute('aria-describedby')).toBe(false) + }) +}) + +describe('buildUploadStatusUrl()', () => { + it('builds URL with no prefix for root paths', () => { + expect(buildUploadStatusUrl('/', 'abc')).toBe('/upload-status/abc') + expect(buildUploadStatusUrl('', 'xyz')).toBe('/upload-status/xyz') + }) + + it('uses the first segment as prefix', () => { + expect(buildUploadStatusUrl('/form/mypage', 'id1')).toBe( + '/form/upload-status/id1' + ) + }) + + it('trims nested segments and trailing slashes', () => { + expect(buildUploadStatusUrl('/one/two/three/', 'id2')).toBe( + '/one/upload-status/id2' + ) + }) }) diff --git a/test/condition/checkboxes.test.js b/test/condition/checkboxes.test.js index dfcbf4eda..4e3f943f3 100644 --- a/test/condition/checkboxes.test.js +++ b/test/condition/checkboxes.test.js @@ -2,12 +2,13 @@ import { resolve } from 'node:path' import { StatusCodes } from 'http-status-codes' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' -const basePath = '/checkboxes' +const basePath = `${FORM_PREFIX}/checkboxes` const key = 'wqJmSf' jest.mock('~/src/server/plugins/engine/services/formsService.js') diff --git a/test/condition/radios.test.js b/test/condition/radios.test.js index 3730aecd9..4290eab42 100644 --- a/test/condition/radios.test.js +++ b/test/condition/radios.test.js @@ -2,12 +2,13 @@ import { resolve } from 'node:path' import { StatusCodes } from 'http-status-codes' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' -const basePath = '/radios' +const basePath = `${FORM_PREFIX}/radios` const key = 'wqJmSf' jest.mock('~/src/server/plugins/engine/services/formsService.js') diff --git a/test/condition/text.test.js b/test/condition/text.test.js index e7ded99be..473895757 100644 --- a/test/condition/text.test.js +++ b/test/condition/text.test.js @@ -2,12 +2,13 @@ import { resolve } from 'node:path' import { StatusCodes } from 'http-status-codes' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' -const basePath = '/text' +const basePath = `${FORM_PREFIX}/text` const key = 'wqJmSf' jest.mock('~/src/server/plugins/engine/services/formsService.js') diff --git a/test/fixtures/form.js b/test/fixtures/form.js index ca7b4fc93..ef4e5e66c 100644 --- a/test/fixtures/form.js +++ b/test/fixtures/form.js @@ -81,6 +81,50 @@ export const definition = { outputEmail: 'enrique.chase@defra.gov.uk' } +export const componentId = '1491981d-99cd-485e-ab4a-f88275edeadc' + +/** + * @satisfies {FormDefinition} + */ +export const definitionWithComponentId = { + name: '', + startPage: '/page-one', + pages: [ + { + path: '/page-one', + title: 'Page one', + section: 'section', + components: [ + { + id: componentId, + type: ComponentType.TextField, + name: 'textField', + title: 'This is your first field', + hint: 'Help text', + options: {}, + schema: {} + } + ], + next: [{ path: ControllerPath.Summary }] + }, + { + title: 'Summary', + path: ControllerPath.Summary, + controller: ControllerType.Summary + } + ], + sections: [ + { + name: 'section', + title: 'Section title', + hideTitle: false + } + ], + conditions: [], + lists: [], + outputEmail: 'enrique.chase@defra.gov.uk' +} + /** * @import { FormDefinition, FormMetadata, FormMetadataAuthor, FormMetadataState } from '@defra/forms-model' */ diff --git a/test/form/csrf.test.js b/test/form/csrf.test.js index 24078407e..3ab3fd959 100644 --- a/test/form/csrf.test.js +++ b/test/form/csrf.test.js @@ -2,13 +2,14 @@ import { join } from 'node:path' import { StatusCodes } from 'http-status-codes' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' import { getCookie } from '~/test/utils/get-cookie.js' -const basePath = '/basic' +const basePath = `${FORM_PREFIX}/basic` jest.mock('~/src/server/plugins/engine/services/formsService.js') diff --git a/test/form/definitions/conditions-list.js b/test/form/definitions/conditions-list.js new file mode 100644 index 000000000..5021beeb9 --- /dev/null +++ b/test/form/definitions/conditions-list.js @@ -0,0 +1,215 @@ +import { + ComponentType, + ConditionType, + ControllerType, + Engine, + OperatorName +} from '@defra/forms-model' + +export default /** @satisfies {FormDefinition} */ ({ + name: 'Conditional list items', + pages: [ + { + id: '449a45f6-4541-4a46-91bd-8b8931b07b50', + title: 'Summary', + path: '/summary', + controller: ControllerType.Summary + }, + { + title: 'Are you veggie?', + path: '/are-you-veggie', + next: [ + { + path: '/type' + } + ], + components: [ + { + name: 'gXsqLq', + title: 'Are you veggie?', + type: ComponentType.YesNoField, + options: {} + } + ] + }, + { + title: 'Type', + path: '/type', + next: [ + { + path: '/toppings' + } + ], + components: [ + { + name: 'QwcNsc', + title: 'Type', + type: ComponentType.RadiosField, + list: 'hIYxMw', + options: {} + } + ] + }, + { + title: 'Toppings', + path: '/toppings', + next: [ + { + path: '/summary' + } + ], + components: [ + { + name: 'zeQDES', + title: 'Toppings', + type: ComponentType.CheckboxesField, + list: 'pMdDIh', + options: {} + } + ] + } + ], + conditions: [ + { + name: 'sieBra', + displayName: 'isVeggie', + value: { + name: 'isVeggie', + conditions: [ + { + field: { + name: 'gXsqLq', + type: ComponentType.YesNoField, + display: 'Are you veggie?' + }, + operator: OperatorName.Is, + value: { + type: ConditionType.Value, + value: 'true', + display: 'Yes' + } + } + ] + } + }, + { + name: 'naJibN', + displayName: 'isNotVeggie', + value: { + name: 'isNotVeggie', + conditions: [ + { + field: { + name: 'gXsqLq', + type: ComponentType.YesNoField, + display: 'Are you veggie?' + }, + operator: OperatorName.Is, + value: { + type: ConditionType.Value, + value: 'false', + display: 'No' + } + } + ] + } + }, + { + name: 'NcPUbs', + displayName: 'isNotVegan', + value: { + name: 'isNotVegan', + conditions: [ + { + field: { + name: 'QwcNsc', + type: ComponentType.RadiosField, + display: 'Type' + }, + operator: OperatorName.IsNot, + value: { + type: ConditionType.Value, + value: 'vegan', + display: 'Vegan' + } + } + ] + } + } + ], + sections: [], + lists: [ + { + title: 'Type', + name: 'hIYxMw', + type: 'string', + items: [ + { + text: 'Vegetarian', + value: 'vegetarian', + condition: 'sieBra' + }, + { + text: 'Vegan', + value: 'vegan', + condition: 'sieBra' + }, + { + text: 'Meat eater', + value: 'meat', + condition: 'naJibN' + } + ] + }, + { + title: 'Topping', + name: 'pMdDIh', + type: 'string', + items: [ + { + text: 'Onions', + value: 'onions' + }, + { + text: 'Peppers', + value: 'peppers' + }, + { + text: 'Mushrooms', + value: 'mushrooms' + }, + { + text: 'Cheese', + value: 'cheese', + condition: 'NcPUbs' + }, + { + text: 'Ham', + value: 'ham', + condition: 'naJibN' + }, + { + text: 'Chicken', + value: 'chicken', + condition: 'naJibN' + }, + { + text: 'Pepperoni', + value: 'pepperoni', + condition: 'naJibN' + }, + { + text: 'Tofu', + value: 'tofu', + condition: 'sieBra' + } + ] + } + ], + engine: Engine.V1, + startPage: '/are-you-veggie' +}) + +/** + * @import { FormDefinition } from '@defra/forms-model' + */ diff --git a/test/form/definitions/repeat-mixed.js b/test/form/definitions/repeat-mixed.js index 7a7ecc292..3a8d266c0 100644 --- a/test/form/definitions/repeat-mixed.js +++ b/test/form/definitions/repeat-mixed.js @@ -15,6 +15,7 @@ export default /** @satisfies {FormDefinition} */ ({ { name: 'orderType', title: 'How would you like to receive your pizza?', + shortDescription: 'How you would like to receive your pizza', type: ComponentType.RadiosField, list: 'orderTypeOption', options: {} diff --git a/test/form/exit-page.test.js b/test/form/exit-page.test.js index d37707d75..f37c76ca7 100644 --- a/test/form/exit-page.test.js +++ b/test/form/exit-page.test.js @@ -2,13 +2,14 @@ import { join } from 'node:path' import { StatusCodes } from 'http-status-codes' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' import { getCookie, getCookieHeader } from '~/test/utils/get-cookie.js' -const basePath = '/demo-cph-number' +const basePath = `${FORM_PREFIX}/demo-cph-number` jest.mock('~/src/server/utils/notify.ts') jest.mock('~/src/server/plugins/engine/services/formsService.js') diff --git a/test/form/fields-optional.test.js b/test/form/fields-optional.test.js index dc8bbb411..8df8bd1ef 100644 --- a/test/form/fields-optional.test.js +++ b/test/form/fields-optional.test.js @@ -2,13 +2,14 @@ import { join } from 'node:path' import { StatusCodes } from 'http-status-codes' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' import { getCookie, getCookieHeader } from '~/test/utils/get-cookie.js' -const basePath = '/fields-optional' +const basePath = `${FORM_PREFIX}/fields-optional` jest.mock('~/src/server/plugins/engine/services/formsService.js') diff --git a/test/form/fields-required.test.js b/test/form/fields-required.test.js index 5f126a596..acf08d397 100644 --- a/test/form/fields-required.test.js +++ b/test/form/fields-required.test.js @@ -3,13 +3,14 @@ import { join } from 'node:path' import { within } from '@testing-library/dom' import { StatusCodes } from 'http-status-codes' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' import { getCookie, getCookieHeader } from '~/test/utils/get-cookie.js' -const basePath = '/fields-required' +const basePath = `${FORM_PREFIX}/fields-required` jest.mock('~/src/server/plugins/engine/services/formsService.js') diff --git a/test/form/file-upload.test.js b/test/form/file-upload.test.js index 4e5b35330..3402877ef 100644 --- a/test/form/file-upload.test.js +++ b/test/form/file-upload.test.js @@ -3,6 +3,7 @@ import { resolve } from 'node:path' import { within } from '@testing-library/dom' import { StatusCodes } from 'http-status-codes' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' import { @@ -14,7 +15,7 @@ import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' import { getCookieHeader } from '~/test/utils/get-cookie.js' -const basePath = '/file-upload' +const basePath = `${FORM_PREFIX}/file-upload` jest.mock('~/src/server/plugins/engine/services/uploadService.js') jest.mock('~/src/server/plugins/engine/services/formsService.js') diff --git a/test/form/govuk-notify.test.js b/test/form/govuk-notify.test.js index 7ea26e42a..4faa98f55 100644 --- a/test/form/govuk-notify.test.js +++ b/test/form/govuk-notify.test.js @@ -3,6 +3,7 @@ import { join } from 'node:path' import { StatusCodes } from 'http-status-codes' import { outdent } from 'outdent' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { persistFiles, @@ -19,7 +20,7 @@ import { sendNotification } from '~/src/server/utils/notify.js' import * as fixtures from '~/test/fixtures/index.js' import { getCookieHeader } from '~/test/utils/get-cookie.js' -const basePath = '/components' +const basePath = `${FORM_PREFIX}/components` jest.mock('~/src/server/utils/notify.ts') jest.mock('~/src/server/plugins/engine/services/uploadService.js') diff --git a/test/form/journey-basic.test.js b/test/form/journey-basic.test.js index 81eabac36..b1801ac3a 100644 --- a/test/form/journey-basic.test.js +++ b/test/form/journey-basic.test.js @@ -3,6 +3,7 @@ import { join } from 'node:path' import { within } from '@testing-library/dom' import { StatusCodes } from 'http-status-codes' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { submit } from '~/src/server/plugins/engine/services/formSubmissionService.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' @@ -11,7 +12,7 @@ import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' import { getCookie, getCookieHeader } from '~/test/utils/get-cookie.js' -const basePath = '/basic' +const basePath = `${FORM_PREFIX}/basic` jest.mock('~/src/server/utils/notify.ts') jest.mock('~/src/server/plugins/engine/services/formsService.js') @@ -122,6 +123,8 @@ describe('Form journey', () => { }) beforeEach(() => { + // server.app.models.clear() + jest.clearAllMocks() jest.mocked(getFormMetadata).mockResolvedValue(fixtures.form.metadata) }) diff --git a/test/form/persist-files.test.js b/test/form/persist-files.test.js index da4a70566..134a84703 100644 --- a/test/form/persist-files.test.js +++ b/test/form/persist-files.test.js @@ -2,6 +2,7 @@ import { join } from 'node:path' import { StatusCodes } from 'http-status-codes' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { persistFiles, @@ -15,7 +16,7 @@ import { CacheService } from '~/src/server/services/cacheService.js' import * as fixtures from '~/test/fixtures/index.js' import { getCookieHeader } from '~/test/utils/get-cookie.js' -const basePath = '/file-upload-basic' +const basePath = `${FORM_PREFIX}/file-upload-basic` jest.mock('~/src/server/utils/notify.ts') jest.mock('~/src/server/plugins/engine/services/formsService.js') diff --git a/test/form/repeat.test.js b/test/form/repeat.test.js index 94f7b069c..045ecfe4b 100644 --- a/test/form/repeat.test.js +++ b/test/form/repeat.test.js @@ -5,6 +5,7 @@ import { hasRepeater } from '@defra/forms-model' import { within } from '@testing-library/dom' import { StatusCodes } from 'http-status-codes' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { isRepeatState } from '~/src/server/plugins/engine/components/FormComponent.js' import { getCacheService } from '~/src/server/plugins/engine/helpers.js' @@ -19,7 +20,7 @@ jest.mock('~/src/server/utils/notify.ts') jest.mock('~/src/server/plugins/engine/services/formsService.js') jest.mock('~/src/server/plugins/engine/services/formSubmissionService.js') -const basePath = '/repeat' +const basePath = `${FORM_PREFIX}/repeat` /** * POST a new repeat item @@ -118,7 +119,9 @@ describe('Repeat GET tests', () => { }) expect(res.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) - expect(res.headers.location).toMatch(/^\/repeat\/pizza-order\/[0-9a-f-]+$/) + expect(res.headers.location).toMatch( + new RegExp(`^${FORM_PREFIX}/repeat/pizza-order/[0-9a-f-]+$`) + ) }) test('GET /pizza-order with 1 item returns 302 to repeater summary', async () => { @@ -168,7 +171,9 @@ describe('Repeat GET tests', () => { }) expect(res.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY) - expect(res.headers.location).toMatch(/^\/repeat\/pizza-order\/[0-9a-f-]+$/) + expect(res.headers.location).toMatch( + new RegExp(`^${FORM_PREFIX}/repeat/pizza-order/[0-9a-f-]+$`) + ) }) test('GET /pizza-order/{id} returns 200', async () => { @@ -394,7 +399,10 @@ describe('Repeat POST tests', () => { }) expect(res.statusCode).toBe(StatusCodes.SEE_OTHER) - expect(res.headers.location).toMatch(/^\/repeat\/pizza-order\/summary?/) + const expectedPathRegex = new RegExp( + `^${FORM_PREFIX}/repeat/pizza-order/summary$` + ) + expect(res.headers.location).toMatch(expectedPathRegex) }) test('POST /pizza-order/{id}/confirm-delete with 1 item returns 404', async () => { @@ -426,7 +434,9 @@ describe('Repeat POST tests', () => { }) expect(res.statusCode).toBe(StatusCodes.SEE_OTHER) - expect(res.headers.location).toMatch(/^\/repeat\/pizza-order\/summary/) + expect(res.headers.location).toMatch( + new RegExp(`^${FORM_PREFIX}/repeat/pizza-order/summary$`) + ) }) test('POST /pizza-order/summary ADD_ANOTHER returns 303', async () => { diff --git a/test/form/summary-submission-email.test.js b/test/form/summary-submission-email.test.js index 9ee6f0357..295f7b501 100644 --- a/test/form/summary-submission-email.test.js +++ b/test/form/summary-submission-email.test.js @@ -1,12 +1,13 @@ import { join } from 'node:path' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' import { getCookie, getCookieHeader } from '~/test/utils/get-cookie.js' -const basePath = '/minimal' +const basePath = `${FORM_PREFIX}/minimal` jest.mock('~/src/server/plugins/engine/services/formsService.js') jest.mock('~/src/server/plugins/engine/services/formSubmissionService.js') diff --git a/test/form/template.test.js b/test/form/template.test.js index 1507f7f8e..009cb2501 100644 --- a/test/form/template.test.js +++ b/test/form/template.test.js @@ -3,13 +3,14 @@ import { join } from 'node:path' import { within } from '@testing-library/dom' import { StatusCodes } from 'http-status-codes' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' import { getCookie, getCookieHeader } from '~/test/utils/get-cookie.js' -const basePath = '/templates' +const basePath = `${FORM_PREFIX}/templates` jest.mock('~/src/server/plugins/engine/services/formsService.js') @@ -138,7 +139,7 @@ describe('Form template journey', () => { const $errorItems = within($errorSummary).getAllByRole('listitem') expect($errorItems[0]).toHaveTextContent( - 'Select are you in England, Enrique Chase?' + 'Are you in England, Enrique Chase? - select yes or no' ) }) @@ -250,7 +251,9 @@ describe('Form template journey', () => { const $output4 = container.getByTestId('output-4') expect($output4).toBeInTheDocument() - expect($output4.textContent).toBe('/templates/are-you-in-england') + expect($output4.textContent).toBe( + `${FORM_PREFIX}/templates/are-you-in-england` + ) }) test('POST /information', async () => { diff --git a/test/form/titles.test.js b/test/form/titles.test.js index 1d47af533..db9e3a1fc 100644 --- a/test/form/titles.test.js +++ b/test/form/titles.test.js @@ -1,12 +1,13 @@ import { join } from 'node:path' +import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' import { getCookie, getCookieHeader } from '~/test/utils/get-cookie.js' -const basePath = '/titles' +const basePath = `${FORM_PREFIX}/titles` jest.mock('~/src/server/plugins/engine/services/formsService.js') diff --git a/tsconfig.json b/tsconfig.json index 62935cac4..664f61292 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,7 @@ "paths": { "~/*": ["./*"] }, - "types": ["@testing-library/jest-dom"] + "types": ["@testing-library/jest-dom", "jest"] }, "include": [ "**/*.cjs", @@ -22,7 +22,8 @@ "**/*.ts", ".eslintrc.*", ".prettierrc.*", - ".lintstagedrc.*" + ".lintstagedrc.*", + "node_modules/@types/jest/index.d.ts" ], "exclude": ["coverage", "node_modules", ".public", ".server"] }