diff --git a/.env.example b/.env.example index 19be47ec..6e253a49 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,4 @@ -SESSION_SECRET=your_secret \ No newline at end of file +SESSION_SECRET=your_secret +SENTRY_DSN=You_DSN +GOVUK_NOTIFY_API_KEY=your_api_key +DATA_MANAGEMENT_EMAIL=data_management_team_email_address diff --git a/Procfile b/Procfile new file mode 100644 index 00000000..5ec9cc2c --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: node index.js \ No newline at end of file diff --git a/config/default.yaml b/config/default.yaml index ac496aa6..1668607d 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -15,6 +15,15 @@ aws: { s3ForcePathStyle: false, } redis: false -url: 'https://publish-planning.data.gov.uk' -serviceName: 'Check planning and housing data for England' -feedbackLink: 'https://docs.google.com/forms/d/e/1FAIpQLSdYXqY0Aaket9XJBiGDhSL_CD_cxHZxgvQCFZZtdURdvvIY5A/viewform' \ No newline at end of file +# ToDo: url and name might need updating +url: 'https://check-planning.data.gov.uk' +serviceName: 'Provide planning and housing data for England' +feedbackLink: 'https://docs.google.com/forms/d/e/1FAIpQLSdYXqY0Aaket9XJBiGDhSL_CD_cxHZxgvQCFZZtdURdvvIY5A/viewform' +email: { + templates: { + RequestTemplateId: 'fa1c2b51-3c91-4f9d-9a18-83639164d552', + AcknowledgementTemplateId: '2a4dff6d-78c4-4fea-a489-c97485453807' + }, + dataManagementEmail: 'fakeemail@fakemail.com' +} + diff --git a/config/development.yaml b/config/development.yaml index d3642e6c..a3e1f38a 100644 --- a/config/development.yaml +++ b/config/development.yaml @@ -1,5 +1,5 @@ asyncRequestApi: { - url: http://development-pub-async-api-lb-2117113766.eu-west-2.elb.amazonaws.com + url: http://development-pub-async-api-lb-69142969.eu-west-2.elb.amazonaws.com } aws: { region: eu-west-2, diff --git a/index.js b/index.js index be5feec1..c9ccbd9a 100644 --- a/index.js +++ b/index.js @@ -12,13 +12,15 @@ import { setupSession } from './src/serverSetup/session.js' import { setupNunjucks } from './src/serverSetup/nunjucks.js' import { setupSentry } from './src/serverSetup/sentry.js' +import { dataSubjects } from './src/utils/utils.js' + dotenv.config() const app = express() setupMiddlewares(app) setupSession(app) -setupNunjucks(app) +setupNunjucks({ app, dataSubjects }) setupRoutes(app) setupSentry(app) setupErrorHandlers(app) diff --git a/package-lock.json b/package-lock.json index 5e773626..9087002a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,34 +1,37 @@ { - "name": "data-validation-frontend", + "name": "provide-service", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "data-validation-frontend", + "name": "provide-service", "version": "1.0.0", "license": "ISC", "dependencies": { "@aws-sdk/client-s3": "^3.537.0", "@sentry/node": "^8.7.0", "@sentry/profiling-node": "^8.7.0", - "@x-govuk/govuk-prototype-components": "^2.0.3", - "@x-govuk/govuk-prototype-filters": "^1.2.0", + "@x-govuk/govuk-prototype-components": "^3.0.5", + "@x-govuk/govuk-prototype-filters": "^1.4.0", "aws-sdk": "^2.1581.0", "axios": "^1.6.2", + "body-parser": "^1.20.2", "connect-redis": "^7.1.1", + "cookie-parser": "^1.4.6", "dotenv": "^16.4.5", - "email-validator": "^2.0.4", - "express": "^4.18.2", + "express": "^4.19.2", "express-session": "^1.18.0", "fs": "^0.0.1-security", - "govuk-frontend": "^4.7.0", + "govuk-frontend": "^5.4.0", "hmpo-config": "^3.0.0", "hmpo-form-wizard": "^13.0.0", "hmpo-i18n": "^6.0.1", "js-yaml": "^4.1.0", + "lodash": "^4.17.21", "maplibre-gl": "^4.1.0", "multer": "^1.4.5-lts.1", + "notifications-node-client": "^8.2.0", "nunjucks": "^3.2.4", "redis": "^4.6.13", "sass": "^1.69.4", @@ -40,10 +43,10 @@ "@playwright/test": "^1.39.0", "@testcontainers/localstack": "^10.7.2", "@types/node": "^20.8.9", - "@vitest/coverage-v8": "^0.34.6", "@wiremock/wiremock-testcontainers-node": "^0.0.1", "concurrently": "^8.2.2", - "husky": "^8.0.0", + "husky": "^9.0.11", + "jsdom": "^24.1.0", "nodemon": "^3.0.1", "standard": "^17.1.0", "supertest": "^7.0.0", @@ -65,19 +68,6 @@ "node": ">=0.10.0" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@aws-crypto/crc32": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-3.0.0.tgz", @@ -895,12 +885,6 @@ "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==", "dev": true }, - "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true - }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -929,9 +913,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", "cpu": [ "ppc64" ], @@ -945,9 +929,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", "cpu": [ "arm" ], @@ -961,9 +945,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", "cpu": [ "arm64" ], @@ -977,9 +961,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", "cpu": [ "x64" ], @@ -993,9 +977,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", "cpu": [ "arm64" ], @@ -1009,9 +993,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", "cpu": [ "x64" ], @@ -1025,9 +1009,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", "cpu": [ "arm64" ], @@ -1041,9 +1025,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", "cpu": [ "x64" ], @@ -1057,9 +1041,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", "cpu": [ "arm" ], @@ -1073,9 +1057,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", "cpu": [ "arm64" ], @@ -1089,9 +1073,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", "cpu": [ "ia32" ], @@ -1105,9 +1089,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", "cpu": [ "loong64" ], @@ -1121,9 +1105,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", "cpu": [ "mips64el" ], @@ -1137,9 +1121,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", "cpu": [ "ppc64" ], @@ -1153,9 +1137,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", "cpu": [ "riscv64" ], @@ -1169,9 +1153,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", "cpu": [ "s390x" ], @@ -1185,9 +1169,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", "cpu": [ "x64" ], @@ -1201,9 +1185,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", "cpu": [ "x64" ], @@ -1217,9 +1201,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", "cpu": [ "x64" ], @@ -1233,9 +1217,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", "cpu": [ "x64" ], @@ -1249,9 +1233,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", "cpu": [ "arm64" ], @@ -1265,9 +1249,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", "cpu": [ "ia32" ], @@ -1281,9 +1265,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", "cpu": [ "x64" ], @@ -1564,15 +1548,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -2224,9 +2199,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.14.1.tgz", - "integrity": "sha512-fH8/o8nSUek8ceQnT7K4EQbSiV7jgkHq81m9lWZFIXjJ7lJzpWXbQFpT/Zh6OZYnpFykvzC3fbEvEAFZu03dPA==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.1.tgz", + "integrity": "sha512-lncuC4aHicncmbORnx+dUaAgzee9cm/PbIqgWz1PpXuwc+sa1Ct83tnqUDy/GFKleLiN7ZIeytM6KJ4cAn1SxA==", "cpu": [ "arm" ], @@ -2237,9 +2212,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.14.1.tgz", - "integrity": "sha512-Y/9OHLjzkunF+KGEoJr3heiD5X9OLa8sbT1lm0NYeKyaM3oMhhQFvPB0bNZYJwlq93j8Z6wSxh9+cyKQaxS7PQ==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.18.1.tgz", + "integrity": "sha512-F/tkdw0WSs4ojqz5Ovrw5r9odqzFjb5LIgHdHZG65dFI1lWTWRVy32KDJLKRISHgJvqUeUhdIvy43fX41znyDg==", "cpu": [ "arm64" ], @@ -2250,9 +2225,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.14.1.tgz", - "integrity": "sha512-+kecg3FY84WadgcuSVm6llrABOdQAEbNdnpi5X3UwWiFVhZIZvKgGrF7kmLguvxHNQy+UuRV66cLVl3S+Rkt+Q==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.18.1.tgz", + "integrity": "sha512-vk+ma8iC1ebje/ahpxpnrfVQJibTMyHdWpOGZ3JpQ7Mgn/3QNHmPq7YwjZbIE7km73dH5M1e6MRRsnEBW7v5CQ==", "cpu": [ "arm64" ], @@ -2263,9 +2238,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.14.1.tgz", - "integrity": "sha512-2pYRzEjVqq2TB/UNv47BV/8vQiXkFGVmPFwJb+1E0IFFZbIX8/jo1olxqqMbo6xCXf8kabANhp5bzCij2tFLUA==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.18.1.tgz", + "integrity": "sha512-IgpzXKauRe1Tafcej9STjSSuG0Ghu/xGYH+qG6JwsAUxXrnkvNHcq/NL6nz1+jzvWAnQkuAJ4uIwGB48K9OCGA==", "cpu": [ "x64" ], @@ -2276,9 +2251,22 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.14.1.tgz", - "integrity": "sha512-mS6wQ6Do6/wmrF9aTFVpIJ3/IDXhg1EZcQFYHZLHqw6AzMBjTHWnCG35HxSqUNphh0EHqSM6wRTT8HsL1C0x5g==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.18.1.tgz", + "integrity": "sha512-P9bSiAUnSSM7EmyRK+e5wgpqai86QOSv8BwvkGjLwYuOpaeomiZWifEos517CwbG+aZl1T4clSE1YqqH2JRs+g==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.18.1.tgz", + "integrity": "sha512-5RnjpACoxtS+aWOI1dURKno11d7krfpGDEn19jI8BuWmSBbUC4ytIADfROM1FZrFhQPSoP+KEa3NlEScznBTyQ==", "cpu": [ "arm" ], @@ -2289,9 +2277,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.14.1.tgz", - "integrity": "sha512-p9rGKYkHdFMzhckOTFubfxgyIO1vw//7IIjBBRVzyZebWlzRLeNhqxuSaZ7kCEKVkm/kuC9fVRW9HkC/zNRG2w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.18.1.tgz", + "integrity": "sha512-8mwmGD668m8WaGbthrEYZ9CBmPug2QPGWxhJxh/vCgBjro5o96gL04WLlg5BA233OCWLqERy4YUzX3bJGXaJgQ==", "cpu": [ "arm64" ], @@ -2302,9 +2290,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.14.1.tgz", - "integrity": "sha512-nDY6Yz5xS/Y4M2i9JLQd3Rofh5OR8Bn8qe3Mv/qCVpHFlwtZSBYSPaU4mrGazWkXrdQ98GB//H0BirGR/SKFSw==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.18.1.tgz", + "integrity": "sha512-dJX9u4r4bqInMGOAQoGYdwDP8lQiisWb9et+T84l2WXk41yEej8v2iGKodmdKimT8cTAYt0jFb+UEBxnPkbXEQ==", "cpu": [ "arm64" ], @@ -2315,11 +2303,11 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.14.1.tgz", - "integrity": "sha512-im7HE4VBL+aDswvcmfx88Mp1soqL9OBsdDBU8NqDEYtkri0qV0THhQsvZtZeNNlLeCUQ16PZyv7cqutjDF35qw==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.18.1.tgz", + "integrity": "sha512-V72cXdTl4EI0x6FNmho4D502sy7ed+LuVW6Ym8aI6DRQ9hQZdp5sj0a2usYOlqvFBNKQnLQGwmYnujo2HvjCxQ==", "cpu": [ - "ppc64le" + "ppc64" ], "dev": true, "optional": true, @@ -2328,9 +2316,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.14.1.tgz", - "integrity": "sha512-RWdiHuAxWmzPJgaHJdpvUUlDz8sdQz4P2uv367T2JocdDa98iRw2UjIJ4QxSyt077mXZT2X6pKfT2iYtVEvOFw==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.18.1.tgz", + "integrity": "sha512-f+pJih7sxoKmbjghrM2RkWo2WHUW8UbfxIQiWo5yeCaCM0TveMEuAzKJte4QskBp1TIinpnRcxkquY+4WuY/tg==", "cpu": [ "riscv64" ], @@ -2341,9 +2329,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.14.1.tgz", - "integrity": "sha512-VMgaGQ5zRX6ZqV/fas65/sUGc9cPmsntq2FiGmayW9KMNfWVG/j0BAqImvU4KTeOOgYSf1F+k6at1UfNONuNjA==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.18.1.tgz", + "integrity": "sha512-qb1hMMT3Fr/Qz1OKovCuUM11MUNLUuHeBC2DPPAWUYYUAOFWaxInaTwTQmc7Fl5La7DShTEpmYwgdt2hG+4TEg==", "cpu": [ "s390x" ], @@ -2354,9 +2342,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.14.1.tgz", - "integrity": "sha512-9Q7DGjZN+hTdJomaQ3Iub4m6VPu1r94bmK2z3UeWP3dGUecRC54tmVu9vKHTm1bOt3ASoYtEz6JSRLFzrysKlA==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.18.1.tgz", + "integrity": "sha512-7O5u/p6oKUFYjRbZkL2FLbwsyoJAjyeXHCU3O4ndvzg2OFO2GinFPSJFGbiwFDaCFc+k7gs9CF243PwdPQFh5g==", "cpu": [ "x64" ], @@ -2367,9 +2355,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.14.1.tgz", - "integrity": "sha512-JNEG/Ti55413SsreTguSx0LOVKX902OfXIKVg+TCXO6Gjans/k9O6ww9q3oLGjNDaTLxM+IHFMeXy/0RXL5R/g==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.18.1.tgz", + "integrity": "sha512-pDLkYITdYrH/9Cv/Vlj8HppDuLMDUBmgsM0+N+xLtFd18aXgM9Nyqupb/Uw+HeidhfYg2lD6CXvz6CjoVOaKjQ==", "cpu": [ "x64" ], @@ -2380,9 +2368,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.14.1.tgz", - "integrity": "sha512-ryS22I9y0mumlLNwDFYZRDFLwWh3aKaC72CWjFcFvxK0U6v/mOkM5Up1bTbCRAhv3kEIwW2ajROegCIQViUCeA==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.18.1.tgz", + "integrity": "sha512-W2ZNI323O/8pJdBGil1oCauuCzmVd9lDmWBBqxYZcOqWD6aWqJtVBQ1dFrF4dYpZPks6F+xCZHfzG5hYlSHZ6g==", "cpu": [ "arm64" ], @@ -2393,9 +2381,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.14.1.tgz", - "integrity": "sha512-TdloItiGk+T0mTxKx7Hp279xy30LspMso+GzQvV2maYePMAWdmrzqSNZhUpPj3CGw12aGj57I026PgLCTu8CGg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.18.1.tgz", + "integrity": "sha512-ELfEX1/+eGZYMaCIbK4jqLxO1gyTSOIlZr6pbC4SRYFaSIDVKOnZNMdoZ+ON0mrFDp4+H5MhwNC1H/AhE3zQLg==", "cpu": [ "ia32" ], @@ -2406,9 +2394,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.14.1.tgz", - "integrity": "sha512-wQGI+LY/Py20zdUPq+XCem7JcPOyzIJBm3dli+56DJsQOHbnXZFEwgmnC6el1TPAfC8lBT3m+z69RmLykNUbew==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.18.1.tgz", + "integrity": "sha512-yjk2MAkQmoaPYCSu35RLJ62+dz358nE83VfTePJRp8CG7aMg25mEJYpXFiD+NcevhX8LxD5OP5tktPXnXN7GDw==", "cpu": [ "x64" ], @@ -3253,9 +3241,9 @@ } }, "node_modules/@types/chai": { - "version": "4.3.14", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.14.tgz", - "integrity": "sha512-Wj71sXE4Q4AkGdG9Tvq1u/fquNz9EdG4LIJMwVVII7ashjD/8cf8fyIfJAjRr6YcsXnSE8cOGQPq1gqeR8z+3w==", + "version": "4.3.16", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.16.tgz", + "integrity": "sha512-PatH4iOdyh3MyWtmHVFXLWCCIhUbopaltqddG9BzB+gMIzee2MJrvd+jouii9Z3wzQJruGWAm7WOMjgfG8hQlQ==", "dev": true }, "node_modules/@types/chai-subset": { @@ -3436,12 +3424,6 @@ "@types/node": "*" } }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3687,31 +3669,6 @@ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", "dev": true }, - "node_modules/@vitest/coverage-v8": { - "version": "0.34.6", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-0.34.6.tgz", - "integrity": "sha512-fivy/OK2d/EsJFoEoxHFEnNGTg+MmdZBAVK9Ka4qhXR2K3J0DS08vcGVwzDtXSuUMabLv4KtPcpSKkcMXFDViw==", - "dev": true, - "dependencies": { - "@ampproject/remapping": "^2.2.1", - "@bcoe/v8-coverage": "^0.2.3", - "istanbul-lib-coverage": "^3.2.0", - "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^4.0.1", - "istanbul-reports": "^3.1.5", - "magic-string": "^0.30.1", - "picocolors": "^1.0.0", - "std-env": "^3.3.3", - "test-exclude": "^6.0.0", - "v8-to-istanbul": "^9.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "vitest": ">=0.32.0 <1" - } - }, "node_modules/@vitest/expect": { "version": "0.34.6", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.34.6.tgz", @@ -3756,9 +3713,9 @@ } }, "node_modules/@vitest/runner/node_modules/yocto-queue": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", - "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz", + "integrity": "sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==", "dev": true, "engines": { "node": ">=12.20" @@ -4007,27 +3964,27 @@ } }, "node_modules/@x-govuk/govuk-prototype-components": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/@x-govuk/govuk-prototype-components/-/govuk-prototype-components-2.2.2.tgz", - "integrity": "sha512-QZryE59lmhexVyUmnuw9Dx4YCXHTla8+KgAP+kBq1aDtOlvPf5ta0M4SqjTs7LxA9c31KijR4+7TszTOOwkwaA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@x-govuk/govuk-prototype-components/-/govuk-prototype-components-3.0.5.tgz", + "integrity": "sha512-K55FWYfkDiNBtpKBcbNHpcrcvLbQMkq8hjCqirj18X45gtcSu4whrFfCrvlkoE7WDwK7+f0flR5Tc+UdzJYv5A==", "dependencies": { - "accessible-autocomplete": "^2.0.4", + "accessible-autocomplete": "^3.0.0", "eventslibjs": "^1.2.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=18" }, "optionalDependencies": { "govuk-prototype-kit": "^13.14.1" }, "peerDependencies": { - "govuk-frontend": "^4.0.0 || ^5.0.0" + "govuk-frontend": "^5.0.0" } }, "node_modules/@x-govuk/govuk-prototype-filters": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@x-govuk/govuk-prototype-filters/-/govuk-prototype-filters-1.3.1.tgz", - "integrity": "sha512-6Gi8k04aZL8CCXu9rxOzwNGZ5d2qTixkZn4Itmx9ZKp3ypT5hu0FDPcFgX3sys1n3yIM4RfcHqn8tIW5Ygj1+Q==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@x-govuk/govuk-prototype-filters/-/govuk-prototype-filters-1.4.0.tgz", + "integrity": "sha512-kFMypSr6jYlet5K1qKfLKrSASCPZlxhEYU7jv64zQc+48F+0a+fwgrKn1/FD/ocX1euzAoTOihYVnZYCFeDhAQ==", "dependencies": { "govuk-markdown": "^0.7.0", "lodash": "^4.17.21", @@ -4078,11 +4035,16 @@ } }, "node_modules/accessible-autocomplete": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/accessible-autocomplete/-/accessible-autocomplete-2.0.4.tgz", - "integrity": "sha512-2p0txrSpvs5wXFUeQJHMheDPTZVSEmiUHWlEPb7vJnv2Dd1xPfoLnBQQMfNbTSit2pL/9sSQYESuD2Yyohd4Yw==", - "dependencies": { - "preact": "^8.3.1" + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/accessible-autocomplete/-/accessible-autocomplete-3.0.0.tgz", + "integrity": "sha512-Kpm6EX+jjD0AurWfzSP4EVLEKsLUWCazZwidjum+8FCRtSINeaPzVa3ElKVGWvSqVZN9zjeSBF8cirhYEZjW1A==", + "peerDependencies": { + "preact": "^8.0.0" + }, + "peerDependenciesMeta": { + "preact": { + "optional": true + } } }, "node_modules/acorn": { @@ -4123,10 +4085,13 @@ } }, "node_modules/acorn-walk": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", - "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.3.tgz", + "integrity": "sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==", "dev": true, + "dependencies": { + "acorn": "^8.11.0" + }, "engines": { "node": ">=0.4.0" } @@ -5175,6 +5140,11 @@ "node": "*" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -5774,6 +5744,12 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/confbox": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.7.tgz", + "integrity": "sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==", + "dev": true + }, "node_modules/connect": { "version": "3.6.6", "resolved": "https://registry.npmjs.org/connect/-/connect-3.6.6.tgz", @@ -5862,12 +5838,6 @@ "node": ">= 0.6" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true - }, "node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", @@ -6012,6 +5982,71 @@ "http-errors": "^2.0.0" } }, + "node_modules/cssstyle": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.0.1.tgz", + "integrity": "sha512-8ZYiJ3A/3OkDd093CBT/0UKDWry7ak4BdPTFP2+QEP7cmhouyq/Up709ASSj2cK02BbZiMgk7kYjZNS4QP5qrQ==", + "dev": true, + "dependencies": { + "rrweb-cssom": "^0.6.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", + "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", + "dev": true + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.0.0.tgz", + "integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==", + "dev": true, + "dependencies": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/data-view-buffer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", @@ -6087,6 +6122,12 @@ "ms": "2.0.0" } }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", + "dev": true + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -6118,9 +6159,9 @@ "integrity": "sha512-ldHDqbpMP5VTZ/QrIQ6ikzYy4fWh8WPIfqEwetN/4Pxq/xPnWlnESGN41oam5DEwI+acHznEm1bp5Rn4bp4c0w==" }, "node_modules/deep-eql": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", - "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", "dev": true, "dependencies": { "type-detect": "^4.0.0" @@ -6527,6 +6568,14 @@ "node": ">= 0.8.0" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -6538,14 +6587,6 @@ "integrity": "sha512-oJRPo82XEqtQAobHpJIR3zW5YO3sSRRkPz2an4yxi1UvqhsGm54vR/wzTFV74a3soDOJ8CKW7ajOOX5ESzddwg==", "dev": true }, - "node_modules/email-validator": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/email-validator/-/email-validator-2.0.4.tgz", - "integrity": "sha512-gYCwo7kh5S3IDyZPLZf6hSS0MnZT8QmJFqYvbqlDZSbwdZlY6QZWxJ4i/6UhITOJ4XzyI647Bm2MXKCLqnJ4nQ==", - "engines": { - "node": ">4.0" - } - }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -6675,6 +6716,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/envinfo": { "version": "7.12.0", "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.12.0.tgz", @@ -6859,9 +6912,9 @@ } }, "node_modules/esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, "hasInstallScript": true, "bin": { @@ -6871,29 +6924,29 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" } }, "node_modules/escalade": { @@ -8355,9 +8408,9 @@ } }, "node_modules/govuk-frontend": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/govuk-frontend/-/govuk-frontend-4.8.0.tgz", - "integrity": "sha512-NOmPJxL8IYq1HSNHYKx9XY2LLTxuwb+IFASiGQO4sgJ8K7AG66SlSeqARrcetevV8zOf+i1z+MbJJ2O7//OxAw==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/govuk-frontend/-/govuk-frontend-5.4.0.tgz", + "integrity": "sha512-F3YwQYrYQqIPfNxsoph6O78Ey1unCB6cy6omx8KeWY9G504lWZFBSIaiUCma1jNLw9bOUU7Ui+tXG09jjqy0Mw==", "engines": { "node": ">= 4.2.0" } @@ -8695,6 +8748,18 @@ "wbuf": "^1.1.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-entities": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.2.tgz", @@ -8711,12 +8776,6 @@ } ] }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true - }, "node_modules/http-basic": { "version": "8.1.3", "resolved": "https://registry.npmjs.org/http-basic/-/http-basic-8.1.3.tgz", @@ -8902,15 +8961,15 @@ } }, "node_modules/husky": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.3.tgz", - "integrity": "sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==", + "version": "9.0.11", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.0.11.tgz", + "integrity": "sha512-AB6lFlbwwyIqMdHYhwPe+kjOC3Oc5P3nThEoW/AaO2BX3vJDjWPFxYLxokUZOo6RNX20He3AaT8sESs9NJcmEw==", "dev": true, "bin": { - "husky": "lib/bin.js" + "husky": "bin.mjs" }, "engines": { - "node": ">=14" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/typicode" @@ -9442,6 +9501,12 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, "node_modules/is-regex": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", @@ -9617,91 +9682,6 @@ "node": ">=0.10.0" } }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-report/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "dev": true, - "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/istanbul-lib-source-maps/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "node_modules/istanbul-reports": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", - "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", - "dev": true, - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/iterator.prototype": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.2.tgz", @@ -9771,14 +9751,170 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" + "node_modules/jsdom": { + "version": "24.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.0.tgz", + "integrity": "sha512-6gpM7pRXCwIOKxX47cgOyvyQDN/Eh0f1MeKySBV2xGdKtqJBLj8P25eY3EVCWo2mglDDzozR2r2MW4T+JiNUZA==", + "dev": true, + "dependencies": { + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.4", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.10", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.4", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.17.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } }, - "node_modules/json-parse-better-errors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dev": true, + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/debug": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", + "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", + "dev": true, + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.0.0.tgz", + "integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==", + "dev": true, + "dependencies": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", "dev": true }, @@ -9816,12 +9952,6 @@ "node": ">=6" } }, - "node_modules/jsonc-parser": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", - "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", - "dev": true - }, "node_modules/jsonfile": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", @@ -9833,6 +9963,32 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -9848,6 +10004,25 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kdbush": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", @@ -9998,16 +10173,40 @@ "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "dev": true }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, "node_modules/lodash.isfinite": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/lodash.isfinite/-/lodash.isfinite-3.3.2.tgz", "integrity": "sha512-7FGG40uhC8Mm633uKW1r58aElFlBlxCrg9JfSi3P6aYiWmfiWF0PgMd86ZUsxE5GwWPdHoS2+48bwTh2VPkIQA==" }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" }, "node_modules/lodash.kebabcase": { "version": "4.1.1", @@ -10019,6 +10218,11 @@ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "node_modules/lodash.union": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", @@ -10118,21 +10322,6 @@ "node": ">=12" } }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/maplibre-gl": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.1.2.tgz", @@ -10363,15 +10552,15 @@ "dev": true }, "node_modules/mlly": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.6.1.tgz", - "integrity": "sha512-vLgaHvaeunuOXHSmEbZ9izxPx3USsk8KCQ8iC+aTlp5sKRSoZvwhHh5L9VbKSaVC6sJDqbyohIS76E2VmHIPAA==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.1.tgz", + "integrity": "sha512-rrVRZRELyQzrIUAVMHxP97kv+G786pHmOKzuFII8zDYahFBS7qnHh2AlYSl1GAHhaMPCz6/oHjVMcfFYgFYHgA==", "dev": true, "dependencies": { "acorn": "^8.11.3", "pathe": "^1.1.2", - "pkg-types": "^1.0.3", - "ufo": "^1.3.2" + "pkg-types": "^1.1.1", + "ufo": "^1.5.3" } }, "node_modules/module-details-from-path": { @@ -10643,6 +10832,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/notifications-node-client": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/notifications-node-client/-/notifications-node-client-8.2.0.tgz", + "integrity": "sha512-XGmW2f2CroEwIUrPaTyShpF8pLlu79rBnwWns1uPGs27LbZdzNPJF1BzPl3cG3Tsu3nVlaWeXJJYAE+ALryalA==", + "dependencies": { + "axios": "^1.6.1", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=14.17.3", + "npm": ">=6.14.13" + } + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -10687,6 +10889,12 @@ "node": ">= 6" } }, + "node_modules/nwsapi": { + "version": "2.2.10", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.10.tgz", + "integrity": "sha512-QK0sRs7MKv0tKe1+5uZIQk/C8XGza4DAnztJG8iD+TpJIORARrCxczA738awHrZoHeTjSSoHqao2teO0dC/gFQ==", + "dev": true + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -11118,6 +11326,18 @@ "node": ">=4" } }, + "node_modules/parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "dev": true, + "dependencies": { + "entities": "^4.4.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -11240,9 +11460,9 @@ } }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", "dev": true }, "node_modules/picomatch": { @@ -11404,14 +11624,14 @@ } }, "node_modules/pkg-types": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.0.3.tgz", - "integrity": "sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.1.3.tgz", + "integrity": "sha512-+JrgthZG6m3ckicaOB74TwQ+tBWsFl3qVQg7mN8ulwSOElJ7gBhKzj2VkCPnZ4NlF6kEquYU+RIYNVAvzd54UA==", "dev": true, "dependencies": { - "jsonc-parser": "^3.2.0", - "mlly": "^1.2.0", - "pathe": "^1.1.0" + "confbox": "^0.1.7", + "mlly": "^1.7.1", + "pathe": "^1.1.2" } }, "node_modules/playwright": { @@ -11496,9 +11716,9 @@ } }, "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.39", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz", + "integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==", "dev": true, "funding": [ { @@ -11516,7 +11736,7 @@ ], "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", + "picocolors": "^1.0.1", "source-map-js": "^1.2.0" }, "engines": { @@ -11563,12 +11783,6 @@ "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.0.0.tgz", "integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==" }, - "node_modules/preact": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/preact/-/preact-8.5.3.tgz", - "integrity": "sha512-O3kKP+1YdgqHOFsZF2a9JVdtqD+RPzCQc3rP+Ualf7V6rmRDchZ9MJbiGTT7LuyqFKZqlHSOyO/oMFmI2lVTsw==", - "hasInstallScript": true - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -11605,9 +11819,9 @@ } }, "node_modules/pretty-format/node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true }, "node_modules/process-nextick-args": { @@ -11701,6 +11915,12 @@ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, + "node_modules/psl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", + "dev": true + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -11747,6 +11967,12 @@ "node": ">=0.4.x" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -12228,9 +12454,9 @@ "integrity": "sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==" }, "node_modules/rollup": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.14.1.tgz", - "integrity": "sha512-4LnHSdd3QK2pa1J6dFbfm1HN0D7vSK/ZuZTsdyUAlA6Rr1yTouUTL13HaDOGJVgby461AhrNGBS7sCGXXtT+SA==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.18.1.tgz", + "integrity": "sha512-Elx2UT8lzxxOXMpy5HWQGZqkrQOtrVDDa/bm9l10+U4rQnVzbL/LgZ4NOM1MPIDyHk69W4InuYDF5dzRh4Kw1A==", "dev": true, "dependencies": { "@types/estree": "1.0.5" @@ -12243,24 +12469,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.14.1", - "@rollup/rollup-android-arm64": "4.14.1", - "@rollup/rollup-darwin-arm64": "4.14.1", - "@rollup/rollup-darwin-x64": "4.14.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.14.1", - "@rollup/rollup-linux-arm64-gnu": "4.14.1", - "@rollup/rollup-linux-arm64-musl": "4.14.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.14.1", - "@rollup/rollup-linux-riscv64-gnu": "4.14.1", - "@rollup/rollup-linux-s390x-gnu": "4.14.1", - "@rollup/rollup-linux-x64-gnu": "4.14.1", - "@rollup/rollup-linux-x64-musl": "4.14.1", - "@rollup/rollup-win32-arm64-msvc": "4.14.1", - "@rollup/rollup-win32-ia32-msvc": "4.14.1", - "@rollup/rollup-win32-x64-msvc": "4.14.1", + "@rollup/rollup-android-arm-eabi": "4.18.1", + "@rollup/rollup-android-arm64": "4.18.1", + "@rollup/rollup-darwin-arm64": "4.18.1", + "@rollup/rollup-darwin-x64": "4.18.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.18.1", + "@rollup/rollup-linux-arm-musleabihf": "4.18.1", + "@rollup/rollup-linux-arm64-gnu": "4.18.1", + "@rollup/rollup-linux-arm64-musl": "4.18.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.18.1", + "@rollup/rollup-linux-riscv64-gnu": "4.18.1", + "@rollup/rollup-linux-s390x-gnu": "4.18.1", + "@rollup/rollup-linux-x64-gnu": "4.18.1", + "@rollup/rollup-linux-x64-musl": "4.18.1", + "@rollup/rollup-win32-arm64-msvc": "4.18.1", + "@rollup/rollup-win32-ia32-msvc": "4.18.1", + "@rollup/rollup-win32-x64-msvc": "4.18.1", "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true + }, "node_modules/run-applescript": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", @@ -12420,6 +12653,18 @@ "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", "integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", @@ -13575,6 +13820,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, "node_modules/sync-request": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/sync-request/-/sync-request-6.1.0.tgz", @@ -13687,62 +13938,6 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/test-exclude/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/testcontainers": { "version": "10.8.1", "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.8.1.tgz", @@ -13872,9 +14067,9 @@ "dev": true }, "node_modules/tinybench": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.6.0.tgz", - "integrity": "sha512-N8hW3PG/3aOoZAN5V/NSAEDz0ZixDSSt5b/a05iqtpgfLWMSVuCo7w0k2vVvEjdrIoeGqZzweX2WlyioNIHchA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.8.0.tgz", + "integrity": "sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==", "dev": true }, "node_modules/tinypool": { @@ -13941,6 +14136,30 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -14330,6 +14549,16 @@ "querystring": "0.2.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/url/node_modules/punycode": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", @@ -14372,20 +14601,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/v8-to-istanbul": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", - "integrity": "sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==", - "dev": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.12", - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^2.0.0" - }, - "engines": { - "node": ">=10.12.0" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -14404,13 +14619,13 @@ } }, "node_modules/vite": { - "version": "5.2.8", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.8.tgz", - "integrity": "sha512-OyZR+c1CE8yeHw5V5t59aXsUPPVTHMDjEZz8MgguLL/Q7NblxhZUlTu9xSPqlsUO/y+X7dlU05jdhvyycD55DA==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.3.tgz", + "integrity": "sha512-NPQdeCU0Dv2z5fu+ULotpuq5yfCS1BzKUIPhNbP3YBfAMGJXbt2nS+sbTFu+qchaqWTD+H3JK++nRwr6XIcp6A==", "dev": true, "dependencies": { - "esbuild": "^0.20.1", - "postcss": "^8.4.38", + "esbuild": "^0.21.3", + "postcss": "^8.4.39", "rollup": "^4.13.0" }, "bin": { @@ -14482,9 +14697,9 @@ } }, "node_modules/vite-node/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", "dev": true, "dependencies": { "ms": "2.1.2" @@ -14582,9 +14797,9 @@ } }, "node_modules/vitest/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", "dev": true, "dependencies": { "ms": "2.1.2" @@ -14614,6 +14829,18 @@ "pbf": "^3.2.1" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/watchpack": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz", @@ -15127,6 +15354,39 @@ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==" }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -15381,6 +15641,15 @@ "node": ">=8" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/xml2js": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", @@ -15401,6 +15670,12 @@ "node": ">=4.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, "node_modules/xmlhttprequest-ssl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", diff --git a/package.json b/package.json index 8ca9ceb5..3bf45e1b 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "data-validation-frontend", + "name": "provide-service", "version": "1.0.0", "description": "", "main": "index.js", @@ -39,13 +39,14 @@ "@playwright/test": "^1.39.0", "@testcontainers/localstack": "^10.7.2", "@types/node": "^20.8.9", - "@vitest/coverage-v8": "^0.34.6", "@wiremock/wiremock-testcontainers-node": "^0.0.1", "concurrently": "^8.2.2", - "husky": "^8.0.0", + "husky": "^9.0.11", + "jsdom": "^24.1.0", "nodemon": "^3.0.1", "standard": "^17.1.0", "supertest": "^7.0.0", + "vite": "^5.3.3", "vitest": "^0.34.6", "webpack": "^5.90.3", "webpack-cli": "^5.1.4", @@ -55,23 +56,26 @@ "@aws-sdk/client-s3": "^3.537.0", "@sentry/node": "^8.7.0", "@sentry/profiling-node": "^8.7.0", - "@x-govuk/govuk-prototype-components": "^2.0.3", - "@x-govuk/govuk-prototype-filters": "^1.2.0", + "@x-govuk/govuk-prototype-components": "^3.0.5", + "@x-govuk/govuk-prototype-filters": "^1.4.0", "aws-sdk": "^2.1581.0", "axios": "^1.6.2", + "body-parser": "^1.20.2", "connect-redis": "^7.1.1", + "cookie-parser": "^1.4.6", "dotenv": "^16.4.5", - "email-validator": "^2.0.4", - "express": "^4.18.2", + "express": "^4.19.2", "express-session": "^1.18.0", "fs": "^0.0.1-security", - "govuk-frontend": "^4.7.0", + "govuk-frontend": "^5.4.0", "hmpo-config": "^3.0.0", "hmpo-form-wizard": "^13.0.0", "hmpo-i18n": "^6.0.1", "js-yaml": "^4.1.0", + "lodash": "^4.17.21", "maplibre-gl": "^4.1.0", "multer": "^1.4.5-lts.1", + "notifications-node-client": "^8.2.0", "nunjucks": "^3.2.4", "redis": "^4.6.13", "sass": "^1.69.4", diff --git a/src/assets/scss/index.scss b/src/assets/scss/index.scss index 44131930..7f667914 100644 --- a/src/assets/scss/index.scss +++ b/src/assets/scss/index.scss @@ -1,6 +1,6 @@ $govuk-global-styles: true; -@import "node_modules/govuk-frontend/govuk/all"; +@import "node_modules/govuk-frontend/dist/govuk/all"; @import "node_modules/@x-govuk/govuk-prototype-components/x-govuk/all"; @import "src/assets/scss/_scrollable-container.scss"; diff --git a/src/controllers/CheckAnswersController.js b/src/controllers/CheckAnswersController.js new file mode 100644 index 00000000..22766cbe --- /dev/null +++ b/src/controllers/CheckAnswersController.js @@ -0,0 +1,91 @@ +import PageController from './pageController.js' +import notifyClient from '../utils/mailClient.js' +import config from '../../config/index.js' + +const dataManagementEmail = process.env.DATA_MANAGEMENT_EMAIL || config.email.dataManagementEmail + +class CheckAnswersController extends PageController { + /** + * Handles the HTTP POST request for choosing a dataset. + * during this, we will perform a few actions + * firstly, we will email Jira causing the creation of a ticket (this will be implemented at a later date) + * secondly, we will email the management team to inform them of the request + * finally, we will email the LPA/organisation to inform them that their request has been acknowledged + * @param {Object} req - The HTTP request object. + * @param {Object} res - The HTTP response object. + * @param {Function} next - The next middleware function. + */ + async post (req, res, next) { + const result = await this.sendEmails(req, res, next) + for (const err of (result.errors ?? [])) { + console.error(err) + } + + super.post(req, res, next) + } + + /** + * Attempts to send email notifications. + * + * @param {*} req + * @param {*} res + * @param {*} next + * @returns {Promise<{} | {errors: string[]}>} + */ + async sendEmails (req, res, next) { + const name = req.sessionModel.get('name') + const email = req.sessionModel.get('email') + const organisation = req.sessionModel.get('lpa') + const dataset = req.sessionModel.get('dataset') + const documentationUrl = req.sessionModel.get('documentation-url') + const endpoint = req.sessionModel.get('endpoint-url') + + const { RequestTemplateId, AcknowledgementTemplateId } = + config.email.templates + + const personalisation = { + name, + email, + organisation, + endpoint, + 'documentation-url': documentationUrl, + dataset + } + + const [reqResult, ackResult] = await Promise.allSettled([ + notifyClient.sendEmail(RequestTemplateId, dataManagementEmail, { + personalisation + }), + notifyClient.sendEmail(AcknowledgementTemplateId, email, { + personalisation + }) + ]) + + const errors = [] + if (reqResult.status === 'rejected') { + const msg = emailFailureMessage(RequestTemplateId, personalisation) + errors.push(msg) + } + if (ackResult.status === 'rejected') { + const msg = emailFailureMessage(AcknowledgementTemplateId, personalisation) + errors.push(msg) + } + + if (errors.length !== 0) { + return { errors } + } + return {} + } +} + +/** + * + * @param {string} templateId + * @param {{organisation: string, name: string, email: string}} metadata + * @returns + */ +function emailFailureMessage (templateId, { organisation, name, email }) { + return `Failed to send email template=${templateId} to (org: ${organisation}, name: ${name}, email: ${email}):` +} + +export default CheckAnswersController diff --git a/src/controllers/chooseDatasetController.js b/src/controllers/chooseDatasetController.js new file mode 100644 index 00000000..71480192 --- /dev/null +++ b/src/controllers/chooseDatasetController.js @@ -0,0 +1,18 @@ +import PageController from './pageController.js' + +import { dataSubjects } from '../utils/utils.js' + +class ChooseDatasetController extends PageController { + locals (req, res, next) { + const availableDataSubjects = Object.values(dataSubjects).filter(dataSubject => dataSubject.available) + const dataSets = Object.values(availableDataSubjects).map(dataSubject => dataSubject.dataSets).flat() + const availableDatasets = dataSets.filter(dataSet => dataSet.available) + availableDatasets.sort((a, b) => a.text.localeCompare(b.text)) + + req.form.options.datasetItems = availableDatasets + + super.locals(req, res, next) + } +} + +export default ChooseDatasetController diff --git a/src/controllers/lpaDetailsController.js b/src/controllers/lpaDetailsController.js new file mode 100644 index 00000000..6301564a --- /dev/null +++ b/src/controllers/lpaDetailsController.js @@ -0,0 +1,19 @@ +import PageController from './pageController.js' +import { fetchLocalAuthorities } from '../utils/fetchLocalAuthorities.js' + +class LpaDetailsController extends PageController { + async locals (req, res, next) { + const localAuthoritiesNames = await fetchLocalAuthorities() + + const listItems = localAuthoritiesNames.map(name => ({ + text: name, + value: name + })) + + req.form.options.localAuthorities = listItems + + super.locals(req, res, next) + } +} + +export default LpaDetailsController diff --git a/src/controllers/resultsController.js b/src/controllers/resultsController.js index 983f9d00..5c4cc7b9 100644 --- a/src/controllers/resultsController.js +++ b/src/controllers/resultsController.js @@ -9,39 +9,41 @@ const noErrorsTemplate = 'results/no-errors' class ResultsController extends PageController { async locals (req, res, next) { try { - const result = await getRequestData(req.params.id) - req.form.options.data = result + const requestData = await getRequestData(req.params.id) + req.form.options.data = requestData - if (!result.isComplete()) { + let responseDetails + + if (!requestData.isComplete()) { res.redirect(`/status/${req.params.id}`) return - } else if (result.isFailed()) { + } else if (req.form.options.data.isFailed()) { if (req.form.options.data.getType() === 'check_file') { req.form.options.template = failedFileRequestTemplate } else { req.form.options.template = failedUrlRequestTemplate } - } else if (result.hasErrors()) { + } else if (req.form.options.data.hasErrors()) { req.form.options.template = errorsTemplate - await result.fetchResponseDetails(req.params.pageNumber, 50, 'error') + responseDetails = await requestData.fetchResponseDetails(req.params.pageNumber, 50, 'error') } else { req.form.options.template = noErrorsTemplate - await result.fetchResponseDetails(req.params.pageNumber) + responseDetails = await requestData.fetchResponseDetails(req.params.pageNumber) } - req.form.options.requestParams = result.getParams() + req.form.options.requestParams = requestData.getParams() if (req.form.options.template !== failedFileRequestTemplate && req.form.options.template !== failedUrlRequestTemplate) { - req.form.options.errorSummary = result.getErrorSummary() - req.form.options.columns = result.getColumns() - req.form.options.fields = result.getFields() - req.form.options.mappings = result.getFieldMappings() - req.form.options.verboseRows = result.getRowsWithVerboseColumns(result.hasErrors()) - req.form.options.geometries = result.getGeometries() - req.form.options.pagination = result.getPagination(req.params.pageNumber) + req.form.options.errorSummary = requestData.getErrorSummary() + req.form.options.columns = responseDetails.getColumns() + req.form.options.fields = responseDetails.getFields() + req.form.options.mappings = responseDetails.getFieldMappings() + req.form.options.verboseRows = responseDetails.getRowsWithVerboseColumns(requestData.hasErrors()) + req.form.options.geometries = responseDetails.getGeometries() + req.form.options.pagination = responseDetails.getPagination(req.params.pageNumber) req.form.options.id = req.params.id } else { - req.form.options.error = result.getError() + req.form.options.error = requestData.getError() } super.locals(req, res, next) diff --git a/src/filters/debuggingFilters.js b/src/filters/debuggingFilters.js new file mode 100644 index 00000000..cfd2c443 --- /dev/null +++ b/src/filters/debuggingFilters.js @@ -0,0 +1,17 @@ +// some additional filters useful for debugging: + +export const getkeys = function (object) { + if (Array.isArray(object)) { + const keys = [] + for (let i = object.length - 1; i >= 0; i--) { + keys.push(Object.keys(object[i])) + } + return keys + } else { + return Object.keys(object) + } +} + +export const getContext = function () { + return this.ctx +} diff --git a/src/filters/filters.js b/src/filters/filters.js index 49eb9c58..e9e8e5bd 100644 --- a/src/filters/filters.js +++ b/src/filters/filters.js @@ -1,15 +1,44 @@ +import { getkeys, getContext } from './debuggingFilters.js' import xGovFilters from '@x-govuk/govuk-prototype-filters' import validationMessageLookup from './validationMessageLookup.js' import toErrorList from './toErrorList.js' import prettifyColumnName from './prettifyColumnName.js' +import getFullServiceName from './getFullServiceName.js' const { govukMarkdown } = xGovFilters -const addFilters = (nunjucksEnv) => { +/** + * + * @param {*} dataSubjects + * @returns {Map} + */ +function createDatasetMapping (dataSubjects) { + const mapping = new Map() + for (const data of Object.values(dataSubjects)) { + for (const dataset of data.dataSets) { + mapping.set(dataset.value, dataset.text) + } + } + return mapping +} + +const addFilters = (nunjucksEnv, { dataSubjects }) => { + const datasetNameMapping = createDatasetMapping(dataSubjects) + nunjucksEnv.addFilter('datasetSlugToReadableName', function (slug) { + const name = datasetNameMapping.get(slug) + if (!name) { + throw new Error(`Can't find a name for ${slug}`) + } + return name + }) + nunjucksEnv.addFilter('govukMarkdown', govukMarkdown) + nunjucksEnv.addFilter('getkeys', getkeys) + nunjucksEnv.addFilter('getContext', getContext) nunjucksEnv.addFilter('validationMessageLookup', validationMessageLookup) nunjucksEnv.addFilter('toErrorList', toErrorList) nunjucksEnv.addFilter('prettifyColumnName', prettifyColumnName) + nunjucksEnv.addFilter('getFullServiceName', getFullServiceName) } export default addFilters diff --git a/src/filters/getFullServiceName.js b/src/filters/getFullServiceName.js new file mode 100644 index 00000000..b012e0af --- /dev/null +++ b/src/filters/getFullServiceName.js @@ -0,0 +1,7 @@ +import config from '../../config/index.js' + +export default (service) => { + const serviceName = config.serviceName + + return serviceName.replace('Provide', service) +} diff --git a/src/filters/validationMessageLookup.js b/src/filters/validationMessageLookup.js index 9dbed277..dfee3710 100644 --- a/src/filters/validationMessageLookup.js +++ b/src/filters/validationMessageLookup.js @@ -34,6 +34,29 @@ const validationMessages = { }, validationResult: { required: 'Unable to contact the API' + }, + name: { + required: 'Enter your full name' + }, + email: { + required: 'Enter an email address', + email: 'Enter an email address in the correct format' + }, + dataset: { + required: 'Select a dataset' + }, + 'endpoint-url': { + required: 'Enter an endpoint URL', + format: 'Enter a valid endpoint URL', + maxlength: 'The URL must be less than 2048 characters' + }, + 'documentation-url': { + required: 'Enter a documentation URL', + format: 'Enter a valid documentation URL', + maxlength: 'The URL must be less than 2048 characters' + }, + hasLicence: { + required: 'You need to confirm this dataset is provided under the Open Government Licence' } } diff --git a/src/models/requestData.js b/src/models/requestData.js index a59aa077..dcbdd5ad 100644 --- a/src/models/requestData.js +++ b/src/models/requestData.js @@ -1,7 +1,7 @@ -import getVerboseColumns from '../utils/getVerboseColumns.js' import logger from '../utils/logger.js' import axios from 'axios' import config from '../../config/index.js' +import ResponseDetails from './responseDetails.js' export default class RequestData { constructor (response) { @@ -17,13 +17,22 @@ export default class RequestData { } const request = await axios.get(`${config.asyncRequestApi.url}/${config.asyncRequestApi.requestsEndpoint}/${this.id}/response-details?${urlParams.toString()}`, { timeout: 30000 }) - this.response.details = request.data - this.pagination = { + const pagination = { totalResults: request.headers['x-pagination-total-results'], offset: request.headers['x-pagination-offset'], limit: request.headers['x-pagination-limit'] } + + return new ResponseDetails(this.id, request.data, pagination, this.getColumnFieldLog()) + } + + getErrorSummary () { + if (!this.response || !this.response.data || !this.response.data['error-summary']) { + logger.error('trying to get error summary when there is none: request id: ' + this.id) + return [] + } + return this.response.data['error-summary'] } isFailed () { @@ -48,13 +57,8 @@ export default class RequestData { logger.error('trying to check for errors when there are none: request id: ' + this.id) return true } - if (this.response == null) { - return true - } - if (this.response.data == null) { - return true - } if (this.response.data['error-summary'] == null) { + logger.error('trying to check for errors but there is no error-summary: request id: ' + this.id) return true } return this.response.data['error-summary'].length > 0 @@ -65,14 +69,6 @@ export default class RequestData { return finishedProcessingStatuses.includes(this.status) } - getRows () { - if (!this.response || !this.response.details) { - logger.error('trying to get response details when there are none: request id: ' + this.id) - return [] - } - return this.response.details - } - getColumnFieldLog () { if (!this.response || !this.response.data || !this.response.data['column-field-log']) { logger.error('trying to get column field log when there is none: request id: ' + this.id) @@ -81,60 +77,6 @@ export default class RequestData { return this.response.data['column-field-log'] } - getGeometryKey () { - if (!this.params) { - logger.error('trying to get geometry key when there are no params: request id: ' + this.id) - return null - } - - const geometryType = this.params.geom_type - const columnFieldLog = this.getColumnFieldLog() - - if (!columnFieldLog) { - return null - } - - let geometryKey - - if (geometryType === 'point' && columnFieldLog.find(column => column.field === 'point')) { - geometryKey = columnFieldLog.find(column => column.field === 'point').column - } else if (columnFieldLog.find(column => column.field === 'geometry')) { - geometryKey = columnFieldLog.find(column => column.field === 'geometry').column - } - - return geometryKey - } - - getColumns (includeNonMapped = true) { - if (!this.getRows().length) { - return [] - } - return [...new Set(this.getRows().map(row => row.converted_row).flatMap(row => Object.keys(row)))] - } - - getFields (includeNonMapped = true) { - return [...new Set(this.getColumns().map(column => { - const columnFieldLog = this.getColumnFieldLog() - const fieldLog = columnFieldLog.find(fieldLog => fieldLog.column === column) - if (!fieldLog) { - return column - } else { - return fieldLog.field - } - }))] - } - - getFieldMappings () { - return Object.fromEntries(this.getFields().map(field => { - const columnFieldLog = this.getColumnFieldLog() - const columnLog = columnFieldLog.find(fieldLog => fieldLog.field === field) - return [ - field, - columnLog ? columnLog.column : null - ] - })) - } - getParams () { return this.params } @@ -142,110 +84,4 @@ export default class RequestData { getId () { return this.id } - - getErrorSummary () { - if (!this.response || !this.response.data || !this.response.data['error-summary']) { - logger.error('trying to get error summary when there is none: request id: ' + this.id) - return [] - } - return this.response.data['error-summary'] - } - - // This function returns an array of rows with verbose columns - getRowsWithVerboseColumns (filterNonErrors = false) { - if (!this.response || !this.response.details) { - logger.error('trying to get response details when there are none: request id: ' + this.id) - return [] - } - - let rows = this.response.details - - if (filterNonErrors) { - rows = rows.filter(row => row.issue_logs.filter(issue => issue.severity === 'error').length > 0) - } - - // Map over the details in the response and return an array of rows with verbose columns - return rows.map(row => ({ - entryNumber: row.entry_number, - hasErrors: row.issue_logs.filter(issue => issue.severity === 'error').length > 0, - columns: getVerboseColumns(row, this.getColumnFieldLog()) - })) - } - - getGeometries () { - if (!this.response || !this.response.details) { - logger.error('trying to get response details when there are none: request id: ' + this.id) - return undefined - } - - const geometryKey = this.getGeometryKey() - const geometries = this.response.details.map(row => row.converted_row[geometryKey]).filter(geometry => geometry !== '') - if (geometries.length === 0) { - return null - } - return geometries - } - - getPagination (pageNumber) { - pageNumber = parseInt(pageNumber) - const totalPages = Math.ceil(this.pagination.totalResults / this.pagination.limit) - - const items = pagination(totalPages, pageNumber + 1).map(item => { - if (item === '...') { - return { - ellipsis: true, - href: '#' - } - } else { - return { - number: item, - href: `/results/${this.id}/${parseInt(item) - 1}`, - current: pageNumber === parseInt(item) - 1 - } - } - }) - - return { - totalResults: parseInt(this.pagination.totalResults), - offset: parseInt(this.pagination.offset), - limit: parseInt(this.pagination.limit), - currentPage: pageNumber + 1, - nextPage: pageNumber < totalPages - 1 ? pageNumber + 1 : null, - previousPage: pageNumber > 0 ? pageNumber - 1 : null, - totalPages, - items - } - } -} - -const { min, max } = Math -const range = (lo, hi) => Array.from({ length: hi - lo }, (_, i) => i + lo) - -export const pagination = (count, current, ellipsis = '...') => { - if (count <= 5) { - return range(1, count + 1) - } - const adjacent = 1 - const left = current === count ? current - 2 * adjacent : max(1, current - adjacent) - const right = current === 1 ? 1 + adjacent * 2 : min(count, current + adjacent) - const middle = range(left, right + 1) - let leftEllipsis = left > 1 - let rightEllipsis = right < count - - if (leftEllipsis && middle[0] === 2) { - leftEllipsis = false - middle.unshift(1) - } - - if (rightEllipsis && middle[middle.length - 1] === count - 1) { - rightEllipsis = false - middle.push(count) - } - - const result = [ - ...(leftEllipsis ? [1, ellipsis] : middle), - ...(leftEllipsis && rightEllipsis ? middle : []), - ...(rightEllipsis ? [ellipsis, count] : middle) - ] - return result } diff --git a/src/models/responseDetails.js b/src/models/responseDetails.js new file mode 100644 index 00000000..d29b569f --- /dev/null +++ b/src/models/responseDetails.js @@ -0,0 +1,183 @@ +import { getVerboseColumns } from '../utils/getVerboseColumns.js' +import logger from '../utils/logger.js' + +export default class ResponseDetails { + constructor (id, response, pagination, columnFieldLog) { + this.id = id + this.response = response + this.pagination = pagination + this.columnFieldLog = columnFieldLog + } + + getRows () { + if (!this.response) { + logger.error('trying to get response details when there are none: request id: ' + this.id) + return [] + } + return this.response + } + + getColumnFieldLog () { + if (!this.columnFieldLog) { + logger.error('trying to get column field log when there is none: request id: ' + this.id) + return [] + } + return this.columnFieldLog + } + + getColumns () { + if (!this.getRows().length) { + return [] + } + + const fields = this.getFields() + + const ColumnsWithDuplicates = fields.map(field => { + const columnFieldLog = this.getColumnFieldLog() + const fieldLog = columnFieldLog.find(fieldLog => fieldLog.field === field) + return fieldLog ? fieldLog.column : field + }) + + return [...new Set(ColumnsWithDuplicates)] + } + + getFields () { + const columnKeys = [...new Set(this.getRows().map(row => row.converted_row).flatMap(row => Object.keys(row)))] + + return [...new Set(columnKeys.map(column => { + const columnFieldLog = this.getColumnFieldLog() + const fieldLog = columnFieldLog.find(fieldLog => fieldLog.column === column) + if (!fieldLog) { + return column + } else { + return fieldLog.field + } + }))] + } + + getFieldMappings () { + return Object.fromEntries(this.getFields().map(field => { + const columnFieldLog = this.getColumnFieldLog() + const columnLog = columnFieldLog.find(fieldLog => fieldLog.field === field) + return [ + field, + columnLog ? columnLog.column : null + ] + })) + } + + // This function returns an array of rows with verbose columns + getRowsWithVerboseColumns (filterNonErrors = false) { + if (!this.response) { + logger.error('trying to get response details when there are none: request id: ' + this.id) + return [] + } + + let rows = this.response + + if (filterNonErrors) { + rows = rows.filter(row => row.issue_logs.filter(issue => issue.severity === 'error').length > 0) + } + + // Map over the details in the response and return an array of rows with verbose columns + return rows.map(row => ({ + entryNumber: row.entry_number, + hasErrors: row.issue_logs.filter(issue => issue.severity === 'error').length > 0, + columns: getVerboseColumns(row, this.getColumnFieldLog()) + })) + } + + getGeometryKey () { + const columnFieldLog = this.getColumnFieldLog() + + if (!columnFieldLog) { + return null + } + + const columnFieldEntry = columnFieldLog.find(column => column.field === 'point') || columnFieldLog.find(column => column.field === 'geometry') + + if (!columnFieldEntry) { + return null + } + + return columnFieldEntry.column + } + + getGeometries () { + if (!this.response) { + logger.error('trying to get response details when there are none: request id: ' + this.id) + return undefined + } + + const geometryKey = this.getGeometryKey() + + const geometries = this.response.map(row => row.converted_row[geometryKey]).filter(geometry => geometry !== '') + if (geometries.length === 0) { + return null + } + return geometries + } + + getPagination (pageNumber) { + pageNumber = parseInt(pageNumber) + const totalPages = Math.ceil(this.pagination.totalResults / this.pagination.limit) + + const items = pagination(totalPages, pageNumber + 1).map(item => { + if (item === '...') { + return { + ellipsis: true, + href: '#' + } + } else { + return { + number: item, + href: `/results/${this.id}/${parseInt(item) - 1}`, + current: pageNumber === parseInt(item) - 1 + } + } + }) + + return { + totalResults: parseInt(this.pagination.totalResults), + offset: parseInt(this.pagination.offset), + limit: parseInt(this.pagination.limit), + currentPage: pageNumber + 1, + nextPage: pageNumber < totalPages - 1 ? pageNumber + 1 : null, + previousPage: pageNumber > 0 ? pageNumber - 1 : null, + totalPages, + items + } + } +} + +const { min, max } = Math +const range = (lo, hi) => Array.from({ length: hi - lo }, (_, i) => i + lo) + +export const pagination = (count, current, ellipsis = '...') => { + if (count <= 5) { + return range(1, count + 1) + } + const adjacent = 1 + const left = current === count ? current - 2 * adjacent : max(1, current - adjacent) + const right = current === 1 ? 1 + adjacent * 2 : min(count, current + adjacent) + const middle = range(left, right + 1) + let leftEllipsis = left > 1 + let rightEllipsis = right < count + + if (leftEllipsis && middle[0] === 2) { + leftEllipsis = false + middle.unshift(1) + } + + if (rightEllipsis && middle[middle.length - 1] === count - 1) { + rightEllipsis = false + middle.push(count) + } + + const result = [ + ...(leftEllipsis ? [1, ellipsis] : middle), + ...(leftEllipsis && rightEllipsis ? middle : []), + ...(rightEllipsis ? [ellipsis, count] : middle) + ] + return result +} diff --git a/src/routes/form-wizard/fields.js b/src/routes/form-wizard/check/fields.js similarity index 94% rename from src/routes/form-wizard/fields.js rename to src/routes/form-wizard/check/fields.js index a6d70255..9dfdf91c 100644 --- a/src/routes/form-wizard/fields.js +++ b/src/routes/form-wizard/check/fields.js @@ -1,3 +1,4 @@ +// ToDo: split this into two form wizards export default { 'data-subject': { validate: 'required', diff --git a/src/routes/form-wizard/index.js b/src/routes/form-wizard/check/index.js similarity index 71% rename from src/routes/form-wizard/index.js rename to src/routes/form-wizard/check/index.js index 032b56fa..fee852ca 100644 --- a/src/routes/form-wizard/index.js +++ b/src/routes/form-wizard/check/index.js @@ -5,6 +5,6 @@ import fields from './fields.js' const app = Router() -app.use(wizard(steps, fields, { name: 'my-wizard', csrf: false })) +app.use(wizard(steps, fields, { name: 'check-wizard', csrf: false })) export default app diff --git a/src/routes/form-wizard/steps.js b/src/routes/form-wizard/check/steps.js similarity index 74% rename from src/routes/form-wizard/steps.js rename to src/routes/form-wizard/check/steps.js index eedc7337..99a0ed5c 100644 --- a/src/routes/form-wizard/steps.js +++ b/src/routes/form-wizard/check/steps.js @@ -1,9 +1,10 @@ -import PageController from '../../controllers/pageController.js' -import datasetController from '../../controllers/datasetController.js' -import uploadFileController from '../../controllers/uploadFileController.js' -import submitUrlController from '../../controllers/submitUrlController.js' -import statusController from '../../controllers/statusController.js' -import resultsController from '../../controllers/resultsController.js' +// ToDo: Split this into two form wizards +import PageController from '../../../controllers/pageController.js' +import datasetController from '../../../controllers/datasetController.js' +import uploadFileController from '../../../controllers/uploadFileController.js' +import submitUrlController from '../../../controllers/submitUrlController.js' +import statusController from '../../../controllers/statusController.js' +import resultsController from '../../../controllers/resultsController.js' const baseSettings = { controller: PageController, @@ -16,7 +17,7 @@ export default { entryPoint: true, resetJourney: true, next: 'dataset', - template: '../views/start.html', + template: 'check/start.html', noPost: true }, // '/data-subject': { @@ -87,6 +88,7 @@ export default { '/confirmation': { ...baseSettings, noPost: true, - checkJourney: false // ToDo: it would be useful here if we make sure they have selected if their results are ok from the previous step + checkJourney: false, // ToDo: it would be useful here if we make sure they have selected if their results are ok from the previous step + template: 'check/confirmation.html' } } diff --git a/src/routes/form-wizard/endpoint-submission-form/fields.js b/src/routes/form-wizard/endpoint-submission-form/fields.js new file mode 100644 index 00000000..cd1504f5 --- /dev/null +++ b/src/routes/form-wizard/endpoint-submission-form/fields.js @@ -0,0 +1,37 @@ +// ToDo: split this into two form wizards +import { validUrl } from '../../../utils/validators.js' + +export default { + lpa: { + validate: ['required'] + }, + name: { + validate: ['required'] + }, + email: { + validate: [ + 'required', + 'email' + ] + }, + dataset: { + validate: ['required'] + }, + 'endpoint-url': { + validate: [ + 'required', + { type: 'format', fn: validUrl }, + { type: 'maxlength', arguments: [2048] } + ] + }, + 'documentation-url': { + validate: [ + 'required', + { type: 'format', fn: validUrl }, + { type: 'maxlength', arguments: [2048] } + ] + }, + hasLicence: { + validate: ['required'] + } +} diff --git a/src/routes/form-wizard/endpoint-submission-form/index.js b/src/routes/form-wizard/endpoint-submission-form/index.js new file mode 100644 index 00000000..2bfc0ac9 --- /dev/null +++ b/src/routes/form-wizard/endpoint-submission-form/index.js @@ -0,0 +1,10 @@ +import { Router } from 'express' +import wizard from 'hmpo-form-wizard' +import steps from './steps.js' +import fields from './fields.js' + +const app = Router() + +app.use(wizard(steps, fields, { name: 'endpoint-submission-form-wizard', csrf: false })) + +export default app diff --git a/src/routes/form-wizard/endpoint-submission-form/steps.js b/src/routes/form-wizard/endpoint-submission-form/steps.js new file mode 100644 index 00000000..1462cd97 --- /dev/null +++ b/src/routes/form-wizard/endpoint-submission-form/steps.js @@ -0,0 +1,49 @@ +import chooseDatasetController from '../../../controllers/chooseDatasetController.js' +import LpaDetailsController from '../../../controllers/lpaDetailsController.js' +import PageController from '../../../controllers/pageController.js' +import CheckAnswersController from '../../../controllers/CheckAnswersController.js' + +const defaultParams = { + entryPoint: false, + controller: PageController +} + +export default { + '/': { + ...defaultParams, + entryPoint: true, + resetJourney: true, + template: 'submit/start.html', + next: '/submit/lpa-details' + }, + '/lpa-details': { + ...defaultParams, + fields: ['lpa', 'name', 'email'], + next: 'choose-dataset', + controller: LpaDetailsController, + backLink: '/start' + }, + '/choose-dataset': { + ...defaultParams, + fields: ['dataset'], + next: 'dataset-details', + controller: chooseDatasetController, + backLink: '/lpa-details' + }, + '/dataset-details': { + ...defaultParams, + fields: ['endpoint-url', 'documentation-url', 'hasLicence'], + next: 'check-answers', + backLink: '/choose-dataset' + }, + '/check-answers': { + ...defaultParams, + controller: CheckAnswersController, + next: 'confirmation', + backLink: '/dataset-details' + }, + '/confirmation': { + ...defaultParams, + template: 'submit/confirmation.html' + } +} diff --git a/src/serverSetup/errorHandlers.js b/src/serverSetup/errorHandlers.js index 03afe1cc..f0e89430 100644 --- a/src/serverSetup/errorHandlers.js +++ b/src/serverSetup/errorHandlers.js @@ -19,7 +19,11 @@ export function setupErrorHandlers (app) { } err.status = err.status || 500 - res.status(err.status).render(err.template, { err }) + try { + res.status(err.status).render(err.template, { err }) + } catch (e) { + res.status(err.status).render('errorPages/500', { err }) + } }) app.use((req, res, next) => { diff --git a/src/serverSetup/middlewares.js b/src/serverSetup/middlewares.js index 70a74b1c..f2a617a1 100644 --- a/src/serverSetup/middlewares.js +++ b/src/serverSetup/middlewares.js @@ -17,7 +17,8 @@ export function setupMiddlewares (app) { next() }) - app.use('/assets', express.static('./node_modules/govuk-frontend/govuk/assets')) + app.use('/assets', express.static('./node_modules/govuk-frontend/dist/govuk/assets')) + app.use('/assets', express.static('./node_modules/@x-govuk/govuk-prototype-components/x-govuk')) app.use('/public', express.static('./public')) app.use(cookieParser()) diff --git a/src/serverSetup/nunjucks.js b/src/serverSetup/nunjucks.js index 6508a6ae..5a7a20e8 100644 --- a/src/serverSetup/nunjucks.js +++ b/src/serverSetup/nunjucks.js @@ -2,11 +2,16 @@ import nunjucks from 'nunjucks' import config from '../../config/index.js' import addFilters from '../filters/filters.js' -export function setupNunjucks (app) { - app.set('view engine', 'html') +export function setupNunjucks ({ app, dataSubjects }) { + if (app) { + app.set('view engine', 'html') + } + const nunjucksEnv = nunjucks.configure([ 'src/views', - 'node_modules/govuk-frontend/', + 'src/views/check', + 'src/views/submit', + 'node_modules/govuk-frontend/dist/', 'node_modules/@x-govuk/govuk-prototype-components/' ], { express: app, @@ -23,5 +28,7 @@ export function setupNunjucks (app) { Object.keys(globalValues).forEach((key) => { nunjucksEnv.addGlobal(key, globalValues[key]) }) - addFilters(nunjucksEnv) + addFilters(nunjucksEnv, { dataSubjects }) + + return nunjucks } diff --git a/src/serverSetup/routes.js b/src/serverSetup/routes.js index b91b97de..7d56e5d3 100644 --- a/src/serverSetup/routes.js +++ b/src/serverSetup/routes.js @@ -1,10 +1,12 @@ -import formWizard from '../routes/form-wizard/index.js' +import checkFormWizard from '../routes/form-wizard/check/index.js' +import endpointSubmissionFormFormWisard from '../routes/form-wizard/endpoint-submission-form/index.js' import accessibility from '../routes/accessibility.js' import polling from '../routes/api.js' import health from '../routes/health.js' export function setupRoutes (app) { - app.use('/', formWizard) + app.use('/', checkFormWizard) + app.use('/submit', endpointSubmissionFormFormWisard) app.use('/accessibility', accessibility) app.use('/api', polling) app.use('/health', health) diff --git a/src/utils/fetchLocalAuthorities.js b/src/utils/fetchLocalAuthorities.js new file mode 100644 index 00000000..5fb29790 --- /dev/null +++ b/src/utils/fetchLocalAuthorities.js @@ -0,0 +1,42 @@ +import axios from 'axios' + +/** + * Fetches a list of local authority names from a specified dataset. + * + * This function queries a dataset for local authorities, extracting a distinct list of names. + * It performs an HTTP GET request to retrieve the data, then processes the response to return + * only the names of the local authorities. + * + * @returns {Promise} A promise that resolves to an array of local authority names. + * @throws {Error} Throws an error if the HTTP request fails or data processing encounters an issue. + */ +export const fetchLocalAuthorities = async () => { + const sql = `select + distinct provision.organisation, + organisation.name, + organisation.dataset + from + provision, + organisation + where + provision.organisation = organisation.organisation + order by + provision.organisation` + + const url = `https://datasette.planning.data.gov.uk/digital-land.json?sql=${encodeURIComponent(sql)}` + try { + const response = await axios.get(url) + const names = response.data.rows.map(row => { + if (row[1] === null) { + console.log('Null value found in response:', row) + return null + } else { + return row[1] + } + }).filter(name => name !== null) // Filter out null values + return names // Return the fetched data + } catch (error) { + console.error('Error fetching local authorities data:', error) + throw error // Rethrow the error to be handled by the caller + } +} diff --git a/src/utils/getVerboseColumns.js b/src/utils/getVerboseColumns.js index bc30236f..220f5017 100644 --- a/src/utils/getVerboseColumns.js +++ b/src/utils/getVerboseColumns.js @@ -3,7 +3,7 @@ */ import logger from './logger.js' -export default (row, columnFieldLog) => { +const getVerboseColumns = (row, columnFieldLog) => { if (!columnFieldLog || !row.issue_logs) { // Log an error if the["column-field-log"] or issue_logs are missing, and return what we can logger.error('Invalid row data, missing["column-field-log"] or issue_logs') @@ -49,3 +49,5 @@ const reduceVerboseValues = (verboseValuesAsArray) => { return acc }, {}) } + +export { getVerboseColumns } diff --git a/src/utils/mailClient.js b/src/utils/mailClient.js new file mode 100644 index 00000000..c9739167 --- /dev/null +++ b/src/utils/mailClient.js @@ -0,0 +1,8 @@ +import { NotifyClient } from 'notifications-node-client' +import dotenv from 'dotenv' + +dotenv.config() + +const notifyClient = new NotifyClient(process.env.GOVUK_NOTIFY_API_KEY || 'test-key') + +export default notifyClient diff --git a/src/utils/utils.js b/src/utils/utils.js index a9d3877a..5e429735 100644 --- a/src/utils/utils.js +++ b/src/utils/utils.js @@ -89,7 +89,7 @@ export const allowedFileTypes = { xls: ['application/vnd.ms-excel', 'application/octet-stream', 'binary/octet-stream'], xlsx: ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/octet-stream', 'binary/octet-stream'], json: ['application/json', 'application/octet-stream', 'binary/octet-stream'], - geojson: ['application/vnd.geo+json', 'application/octet-stream', 'binary/octet-stream'], + geojson: ['application/vnd.geo+json', 'application/octet-stream', 'binary/octet-stream', 'application/geo+json'], gml: ['application/gml+xml', 'application/octet-stream', 'binary/octet-stream'], gpkg: ['application/gpkg', 'application/octet-stream', 'binary/octet-stream'], sqlite: ['application/geopackage+sqlite3', 'application/octet-stream', 'binary/octet-stream'], diff --git a/src/utils/validators.js b/src/utils/validators.js new file mode 100644 index 00000000..db48436f --- /dev/null +++ b/src/utils/validators.js @@ -0,0 +1,9 @@ +export const validUrl = (urlString) => { + let url + try { + url = new URL(urlString) + } catch (e) { + return false + } + return url.protocol === 'http:' || url.protocol === 'https:' +} diff --git a/src/views/check/check-answers.html b/src/views/check/check-answers.html new file mode 100644 index 00000000..0a9dc403 --- /dev/null +++ b/src/views/check/check-answers.html @@ -0,0 +1,168 @@ +{% extends "layouts/main.html" %} + +{% from "govuk/components/summary-list/macro.njk" import govukSummaryList %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/back-link/macro.njk" import govukBackLink %} + +{% set serviceType = 'Check' %} +{% set pageName = 'Check your answers' %} + +{% block beforeContent %} +{{ govukBackLink({ + text: "Back", + href: "javascript:window.history.back()" +}) }} +{% endblock %} + +{% block content %} +
+
+ +

+ Check your answers before submitting your dataset +

+ + {{ govukSummaryList({ + rows: [ + { + key: { + text: "Local planning authority" + }, + value: { + text: values['lpa'] + }, + actions: { + items: [ + { + href: "/submit-endpoint/lpa-details", + text: "Change", + visuallyHiddenText: "local planning authority" + } + ] + } + }, + { + key: { + text: "Full name" + }, + value: { + text: values['name'] + }, + actions: { + items: [ + { + href: "/submit-endpoint/lpa-details", + text: "Change", + visuallyHiddenText: "full name" + } + ] + } + }, + { + key: { + text: "Email address" + }, + value: { + text: values['email'] + }, + actions: { + items: [ + { + href: "/submit-endpoint/lpa-details", + text: "Change", + visuallyHiddenText: "email address" + } + ] + } + }, + { + key: { + text: "Dataset" + }, + value: { + text: values['dataset'] | datasetSlugToReadableName + }, + actions: { + items: [ + { + href: "/submit-endpoint/choose-dataset", + text: "Change", + visuallyHiddenText: "dataset" + } + ] + } + }, + { + key: { + text: "Dataset URL" + }, + value: { + text: values['endpoint-url'] + }, + actions: { + items: [ + { + href: "/submit-endpoint/dataset-details", + text: "Change", + visuallyHiddenText: "dataset URL" + } + ] + } + }, + { + key: { + text: "Documentation URL" + }, + value: { + text: values['documentation-url'] + }, + actions: { + items: [ + { + href: "/submit-endpoint/dataset-details", + text: "Change", + visuallyHiddenText: "documentation URL" + } + ] + } + }, + { + key: { + text: "Dataset provided under Open Government Licence?" + }, + value: { + text: "True" if values['hasLicence'] == "true" else "False" + }, + actions: { + items: [ + { + href: "/submit-endpoint/choose-dataset", + text: "Change", + visuallyHiddenText: "dataset" + } + ] + } + } + + ] + }) }} + +

+ If this looks correct you can submit your dataset +

+ +

+ Check everything above is correct and click below to submit your dataset. +

+ +
+ + {{ govukButton({ + text: "Submit dataset" + }) }} + +
+ +
+
+{% endblock %} \ No newline at end of file diff --git a/src/views/confirmation.html b/src/views/check/confirmation.html similarity index 98% rename from src/views/confirmation.html rename to src/views/check/confirmation.html index 538981b8..a378a2d5 100644 --- a/src/views/confirmation.html +++ b/src/views/check/confirmation.html @@ -1,9 +1,10 @@ + {% from 'govuk/components/panel/macro.njk' import govukPanel %} {% from "govuk/components/details/macro.njk" import govukDetails %} {% extends "layouts/main.html" %} - +{% set serviceType = 'Check' %} {% set pageName = "Send us your data" %} {% block content %} diff --git a/src/views/data-subject.html b/src/views/check/data-subject.html similarity index 98% rename from src/views/data-subject.html rename to src/views/check/data-subject.html index f7580126..c48ee947 100644 --- a/src/views/data-subject.html +++ b/src/views/check/data-subject.html @@ -7,7 +7,7 @@ {% from 'govuk/components/error-summary/macro.njk' import govukErrorSummary %} - +{% set serviceType = 'Check' %} {% set pageName = 'Data subject' %} {% set errorMessage = 'Select a data subject' %} diff --git a/src/views/dataset.html b/src/views/check/dataset.html similarity index 97% rename from src/views/dataset.html rename to src/views/check/dataset.html index 0f25d3b5..5cc25fdd 100644 --- a/src/views/dataset.html +++ b/src/views/check/dataset.html @@ -4,6 +4,7 @@ {% from 'govuk/components/error-message/macro.njk' import govukErrorMessage %} {% from 'govuk/components/error-summary/macro.njk' import govukErrorSummary %} +{% set serviceType = 'Check' %} {% set pageName = 'Dataset' %} {% set errorMessage = 'Select a dataset' %} diff --git a/src/views/geometry-type.html b/src/views/check/geometry-type.html similarity index 98% rename from src/views/geometry-type.html rename to src/views/check/geometry-type.html index 60b85b3f..24c8853b 100644 --- a/src/views/geometry-type.html +++ b/src/views/check/geometry-type.html @@ -5,6 +5,7 @@ {% extends "layouts/main.html" %} +{% set serviceType = 'Check' %} {% set pageName = 'Is your geometry data given as points or polygons?' %} {% set errorMessage = 'Select if your geometry data given as points or polygons' %} diff --git a/src/views/results/errors.html b/src/views/check/results/errors.html similarity index 95% rename from src/views/results/errors.html rename to src/views/check/results/errors.html index 485661da..a027829e 100644 --- a/src/views/results/errors.html +++ b/src/views/check/results/errors.html @@ -6,6 +6,7 @@ {% from "govuk/components/pagination/macro.njk" import govukPagination %} +{% set serviceType = 'Check' %} {% set pageName = 'Your data has errors' %} {% block pageTitle %} @@ -48,8 +49,8 @@

- {% for field in options.fields %} - + {% for column in options.columns %} + {% endfor %} diff --git a/src/views/results/failedFileRequest.html b/src/views/check/results/failedFileRequest.html similarity index 96% rename from src/views/results/failedFileRequest.html rename to src/views/check/results/failedFileRequest.html index 9cedf0fc..bd188817 100644 --- a/src/views/results/failedFileRequest.html +++ b/src/views/check/results/failedFileRequest.html @@ -1,5 +1,6 @@ {% extends "layouts/main.html" %} +{% set serviceType = 'Check' %} {% set pageName = 'Request Failed' %} {% set error = options.error %} diff --git a/src/views/results/failedUrlRequest.html b/src/views/check/results/failedUrlRequest.html similarity index 96% rename from src/views/results/failedUrlRequest.html rename to src/views/check/results/failedUrlRequest.html index 85f26584..ea93f1ec 100644 --- a/src/views/results/failedUrlRequest.html +++ b/src/views/check/results/failedUrlRequest.html @@ -3,6 +3,7 @@ {% from 'govuk/components/button/macro.njk' import govukButton %} {% from 'govuk/components/error-summary/macro.njk' import govukErrorSummary %} +{% set serviceType = 'Check' %} {% set pageName = 'Request Failed' %} {% set error = options.error %} diff --git a/src/views/results/no-errors.html b/src/views/check/results/no-errors.html similarity index 96% rename from src/views/results/no-errors.html rename to src/views/check/results/no-errors.html index 324460e2..079f8cb2 100644 --- a/src/views/results/no-errors.html +++ b/src/views/check/results/no-errors.html @@ -7,7 +7,7 @@ {% from "govuk/components/pagination/macro.njk" import govukPagination %} - +{% set serviceType = 'Check' %} {% set pageName = 'Check your data before you continue' %} {% set errorMessage = 'Select if your data looks ok' %} @@ -49,8 +49,8 @@

{{field}}{{column}}
- {% for field in options.fields %} - + {% for column in options.columns %} + {% endfor %} diff --git a/src/views/start.html b/src/views/check/start.html similarity index 95% rename from src/views/start.html rename to src/views/check/start.html index f5b80352..90e844c5 100644 --- a/src/views/start.html +++ b/src/views/check/start.html @@ -4,7 +4,8 @@ {% extends "layouts/main.html" %} -{% set pageName = serviceName %} +{% set serviceType = 'Check' %} +{% set pageName = 'Start' %} {% block content %} diff --git a/src/views/statusPage/checkingFileMacro.html b/src/views/check/statusPage/checkingFileMacro.html similarity index 100% rename from src/views/statusPage/checkingFileMacro.html rename to src/views/check/statusPage/checkingFileMacro.html diff --git a/src/views/statusPage/fileCheckedMacro.html b/src/views/check/statusPage/fileCheckedMacro.html similarity index 100% rename from src/views/statusPage/fileCheckedMacro.html rename to src/views/check/statusPage/fileCheckedMacro.html diff --git a/src/views/statusPage/status.html b/src/views/check/statusPage/status.html similarity index 97% rename from src/views/statusPage/status.html rename to src/views/check/statusPage/status.html index f28926ce..0ac2b69b 100644 --- a/src/views/statusPage/status.html +++ b/src/views/check/statusPage/status.html @@ -4,9 +4,9 @@ {% extends "layouts/main.html" %} +{% set serviceType = 'Check' %} {% set pageName = 'Status' %} - {% if options.processingComplete %} {% set pageContent = fileCheckedContent() %} {% set buttonText = "Continue" %} diff --git a/src/views/upload-method.html b/src/views/check/upload-method.html similarity index 98% rename from src/views/upload-method.html rename to src/views/check/upload-method.html index 333a252b..d0b08f27 100644 --- a/src/views/upload-method.html +++ b/src/views/check/upload-method.html @@ -5,6 +5,7 @@ {% extends "layouts/main.html" %} +{% set serviceType = 'Check' %} {% set pageName = 'How do you want to provide your data?' %} {% set errorMessage = 'Select how you want to provide your data' %} diff --git a/src/views/upload.html b/src/views/check/upload.html similarity index 98% rename from src/views/upload.html rename to src/views/check/upload.html index 27f158c0..67630bf4 100644 --- a/src/views/upload.html +++ b/src/views/check/upload.html @@ -5,7 +5,7 @@ {% from 'govuk/components/error-message/macro.njk' import govukErrorMessage %} {% from 'govuk/components/error-summary/macro.njk' import govukErrorSummary %} - +{% set serviceType = 'Check' %} {% set pageName = 'Upload data' %} {% if 'datafile' in errors %} diff --git a/src/views/url.html b/src/views/check/url.html similarity index 98% rename from src/views/url.html rename to src/views/check/url.html index adfc686a..6f703517 100644 --- a/src/views/url.html +++ b/src/views/check/url.html @@ -5,7 +5,7 @@ {% from 'govuk/components/error-message/macro.njk' import govukErrorMessage %} {% from 'govuk/components/error-summary/macro.njk' import govukErrorSummary %} - +{% set serviceType = 'Check' %} {% set pageName = 'URL' %} {% set errorMessage = 'There\'s something wrong with your URL' %} diff --git a/src/views/endpoint-submitted.html b/src/views/endpoint-submitted.html new file mode 100644 index 00000000..8bd8f3f4 --- /dev/null +++ b/src/views/endpoint-submitted.html @@ -0,0 +1,42 @@ +{% extends "layouts/main.html" %} + +{% block pageTitle %} + Confirmation page template – {{ serviceName }} – GOV.UK Prototype Kit +{% endblock %} + +{% block content %} + +
+
+ + {{ govukPanel({ + titleText: "Application complete", + html: "Your reference number
HDJ2123F" + }) }} + +

+ We have sent you a confirmation email. +

+ +

+ What happens next +

+ +

+ We've sent your application to Hackney Electoral Register Office. +

+ +

+ They will contact you either to confirm your registration, or to ask for more information. +

+

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

+ + +
+
+ +{% endblock %} diff --git a/src/views/layouts/main.html b/src/views/layouts/main.html index 17acfa65..291d8488 100644 --- a/src/views/layouts/main.html +++ b/src/views/layouts/main.html @@ -9,6 +9,10 @@ {% from 'govuk/components/phase-banner/macro.njk' import govukPhaseBanner%} {% from 'govuk/components/back-link/macro.njk' import govukBackLink %} +{% if serviceType %} + {% set serviceName = serviceType | getFullServiceName %} +{% endif %} + {% block pageTitle %} {{ pageName }} - {{ serviceName }} {% endblock %} @@ -69,6 +73,13 @@

Get help

{% block bodyEnd %} {{ super()}} {%block scripts %} + + ... + + + {{ super() }} {% endblock %} diff --git a/src/views/check.html b/src/views/submit/check.html similarity index 98% rename from src/views/check.html rename to src/views/submit/check.html index aeb843bd..f10ddbf9 100644 --- a/src/views/check.html +++ b/src/views/submit/check.html @@ -1,5 +1,6 @@ {% extends "layouts/main.html" %} +{% set serviceType = 'Submit' %} {% set pageName = 'Check details and send your data' %} {% from 'govuk/components/button/macro.njk' import govukButton %} diff --git a/src/views/submit/choose-dataset.html b/src/views/submit/choose-dataset.html new file mode 100644 index 00000000..b7acc4d2 --- /dev/null +++ b/src/views/submit/choose-dataset.html @@ -0,0 +1,65 @@ +{% extends "layouts/main.html" %} + +{% from "govuk/components/radios/macro.njk" import govukRadios %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from 'govuk/components/error-summary/macro.njk' import govukErrorSummary %} + +{% set serviceType = 'Submit' %} +{% set pageName = "Choose dataset" %} + +{% block pageTitle %} + {% if errors | length %} + Error: {{super()}} + {% else %} + {{super()}} + {% endif %} +{% endblock %} + +{% block beforeContent %} +{{ govukBackLink({ + text: "Back", + href: "javascript:window.history.back()" +}) }} +{% endblock %} + +{% block content %} + +
+
+ +
+ + {% if errors | length %} + {{ govukErrorSummary({ + titleText: "There’s a problem", + errorList: errors | toErrorList + }) }} + {% endif %} + + {{ govukRadios({ + idPrefix: "dataset", + name: "dataset", + fieldset: { + legend: { + text: pageName, + isPageHeading: true, + classes: "govuk-fieldset__legend--l" + } + }, + items: options.datasetItems, + value: data.dataset, + errorMessage: { + text: 'dataset' | validationMessageLookup(errors['dataset'].type) + } if 'dataset' in errors, + value: values.dataset + }) }} + + {{ govukButton({ + text: "Continue" + }) }} + + +
+
+ +{% endblock %} \ No newline at end of file diff --git a/src/views/submit/confirmation.html b/src/views/submit/confirmation.html new file mode 100644 index 00000000..89c2b19a --- /dev/null +++ b/src/views/submit/confirmation.html @@ -0,0 +1,40 @@ + +{% extends "layouts/main.html" %} + +{% from "govuk/components/panel/macro.njk" import govukPanel %} + +{% set serviceType = 'Submit' %} +{% set pageName = (values['dataset'] | datasetSlugToReadableName) + " submitted" %} + +{% block content %} + +{% set content %} + +## What happens next + +We will process your submission and add your dataset to the [planning data platform](https://www.planning.data.gov.uk/). + +Your dataset should appear on the platform within 5 working days. + +If there are any issues processing your data will we contact you on the email address you provided. + +[Add another dataset](/submit-endpoint/start) + +## Give feedback + +[Give feedback about this service](/feedback) (takes 30 seconds). + +{% endset %} + +
+
+ {{ govukPanel({ + titleHtml: pageName + }) }} + + {{content | govukMarkdown(headingsStartWith="l") | safe}} + +
+
+ +{% endblock %} diff --git a/src/views/submit/dataset-details.html b/src/views/submit/dataset-details.html new file mode 100644 index 00000000..087a3b4d --- /dev/null +++ b/src/views/submit/dataset-details.html @@ -0,0 +1,107 @@ +{% extends "layouts/main.html" %} + +{% from "govuk/components/input/macro.njk" import govukInput %} +{% from "govuk/components/checkboxes/macro.njk" import govukCheckboxes %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from 'govuk/components/error-summary/macro.njk' import govukErrorSummary %} + +{% set serviceType = 'Submit' %} +{% set pageName = "Enter " + (values['dataset'] | datasetSlugToReadableName | lower) + " details" %} + +{% block pageTitle %} + {% if errors | length %} + Error: {{super()}} + {% else %} + {{super()}} + {% endif %} +{% endblock %} + +{% block beforeContent %} +{{ govukBackLink({ + text: "Back", + href: "javascript:window.history.back()" +}) }} +{% endblock %} + +{% block content %} + +
+
+ +

+ {{ pageName }} +

+ +
+ + {% if errors | length %} + {{ govukErrorSummary({ + titleText: "There’s a problem", + errorList: errors | toErrorList + }) }} + {% endif %} + + {{ govukInput({ + label: { + text: "Endpoint URL", + classes: "govuk-label--m", + isPageHeading: false + }, + id: "endpoint-url", + name: "endpoint-url", + classes: "govuk-!-width-three-quarters", + errorMessage: { + text: 'endpoint-url' | validationMessageLookup(errors['endpoint-url'].type) + } if 'endpoint-url' in errors, + value: values['endpoint-url'] + }) }} + + {{ govukInput({ + label: { + text: "Documentation URL", + classes: "govuk-label--m", + isPageHeading: false + }, + hint: { + text: "Publicly accessible page on your website with information about the data" + }, + id: "documentation-url", + name: "documentation-url", + classes: "govuk-!-width-three-quarters", + errorMessage: { + text: 'documentation-url' | validationMessageLookup(errors['documentation-url'].type) + } if 'documentation-url' in errors, + value: values['documentation-url'] + }) }} + + {{ govukCheckboxes({ + name: "licence", + fieldset: { + legend: { + text: "Dataset licence", + isPageHeading: false, + classes: "govuk-fieldset__legend--m" + } + }, + items: [ + { + value: "true", + text: "I confirm this dataset is provided under the Open Government Licence", + name: "hasLicence" + } + ], + errorMessage: { + text: 'hasLicence' | validationMessageLookup(errors['hasLicence'].type) + } if 'hasLicence' in errors + }) }} + + {{ govukButton({ + text: "Continue" + }) }} + + + +
+
+ +{% endblock %} \ No newline at end of file diff --git a/src/views/submit/lpa-details.html b/src/views/submit/lpa-details.html new file mode 100644 index 00000000..82c61718 --- /dev/null +++ b/src/views/submit/lpa-details.html @@ -0,0 +1,99 @@ +{% extends "layouts/main.html" %} +{% from "govuk/components/input/macro.njk" import govukInput %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "x-govuk/components/autocomplete/macro.njk" import xGovukAutocomplete %} +{% from 'govuk/components/error-summary/macro.njk' import govukErrorSummary %} + +{% set serviceType = 'Submit' %} +{% set pageName = 'Enter LPA details' %} + +{% block pageTitle %} + {% if errors | length %} + Error: {{super()}} + {% else %} + {{super()}} + {% endif %} +{% endblock %} + +{% block beforeContent %} +{{ govukBackLink({ + text: "Back", + href: "javascript:window.history.back()" +}) }} +{% endblock %} + +{% block content %} + +
+
+ + {% if errors | length %} + {{ govukErrorSummary({ + titleText: "There’s a problem", + errorList: errors | toErrorList + }) }} + {% endif %} + +

+ {{pageName}} +

+ +
+ + {{ xGovukAutocomplete({ + id: "lpa", + name: "lpa", + allowEmpty: false, + label: { + classes: "govuk-label--m", + isPageHeading: false, + text: "Choose your local planning authority" + }, + items: options.localAuthorities, + errorMessage: { + text: 'lpa' | validationMessageLookup(errors['lpa'].type) + } if 'lpa' in errors, + value: values.lpa + }) }} + + {{ govukInput({ + label: { + text: "Full name", + classes: "govuk-label--m", + isPageHeading: false + }, + id: "name", + name: "name", + classes: "govuk-!-width-three-quarters", + errorMessage: { + text: 'name' | validationMessageLookup(errors['name'].type) + } if 'name' in errors, + value: values.name + }) }} + + + {{ govukInput({ + label: { + text: "Email address", + classes: "govuk-label--m", + isPageHeading: false + }, + id: "email", + name: "email", + classes: "govuk-!-width-three-quarters", + errorMessage: { + text: 'email' | validationMessageLookup(errors['email'].type) + } if 'email' in errors, + value: values.email + }) }} + + {{ govukButton({ + text: "Continue" + }) }} + + + +
+
+ +{% endblock %} diff --git a/src/views/submit/start.html b/src/views/submit/start.html new file mode 100644 index 00000000..01daeb65 --- /dev/null +++ b/src/views/submit/start.html @@ -0,0 +1,79 @@ +{% extends "layouts/main.html" %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "x-govuk/components/related-navigation/macro.njk" import xGovukRelatedNavigation %} + +{% set serviceType = 'Submit' %} +{% set pageName = 'Start' %} + +{% block content %} + +{% set content %} + +Use this service to submit your: + +- article 4 direction data +- conservation area data +{# - listed building data #} +{# - tree preservation order data #} + +## Before you start + +### Publish data on your website + +Your data must be on a URL the public can access. We collect the latest data from there every day. + +You must link to that URL from a webpage about the data. This needs to be on your official planning authority website, +usually ending in gov.uk. + +The webpage must include, for each dataset: + +- the link to the data URL +- a summary of what the data is about +- a statement that the data is provided under the Open Government Licence + + + {{ govukButton({ + text: "Start now", + isStartButton: true + }) }} + + +{% endset %} + +
+
+

+ {{ serviceName }} +

+
+
+ +
+
+ {{content | govukMarkdown(headingsStartWith="l") | safe}} +
+
+ {{ xGovukRelatedNavigation({ + sections: [{ + items: [ + { + text: "Prepare data to the specifications", + href: "https://www.planning.data.gov.uk/guidance/specifications/" + }, + { + text: "Publish data on your website", + href: "https://www.planning.data.gov.uk/guidance/publish-data-on-your-website" + } + ], + subsections: [{ + title: "Explore the topic", + items: [{ + text: "Housing and planning", + href: "/browse/performing-arts" + }] + }] + }] + }) }} +
+
+{% endblock %} diff --git a/test/PageObjectModels/BasePage.js b/test/PageObjectModels/BasePage.js index ff622d6b..8f6d9799 100644 --- a/test/PageObjectModels/BasePage.js +++ b/test/PageObjectModels/BasePage.js @@ -38,7 +38,11 @@ export default class BasePage { expect(await errorLink.isVisible(), 'Page should show an error summary that is a link to the problem field').toBeTruthy() expect(await fieldError.isVisible(), 'Page should show the error message next to the problem field').toBeTruthy() await errorLink.click() - const problemFieldIsFocused = await this.page.$eval(fieldName, (el) => el === document.activeElement) + let problemFieldIsFocused = await this.page.$eval(fieldName, (el) => el === document.activeElement) + if (problemFieldIsFocused === undefined) { // sometimes the page load is slow, so this returns nothing. if this happens. wait 0.5s and try again + await new Promise(resolve => setTimeout(resolve, 500)) + problemFieldIsFocused = await this.page.$eval(fieldName, (el) => el === document.activeElement) + } expect(problemFieldIsFocused, 'The focus should be on the problem field').toBeTruthy() } diff --git a/test/integration/test_recieving_results.playwright.test.js b/test/integration/test_recieving_results.playwright.test.js index 6d40e844..27756ec9 100644 --- a/test/integration/test_recieving_results.playwright.test.js +++ b/test/integration/test_recieving_results.playwright.test.js @@ -118,18 +118,20 @@ const getTableValuesFromResponse = (response, details) => { const columnFieldLog = response.response.data['column-field-log'] // Map over the details array and extract the necessary values - const columnHeaders = Object.keys(details[0].converted_row).map(column => { - const log = columnFieldLog.find(log => log.column === column) - if (log) { return log.field } else { return column } + const columnHeaders = Object.keys(details[0].converted_row) + + const notUniqueHeaders = columnHeaders.map(field => { + const fieldLog = columnFieldLog.find(fieldLog => fieldLog.field === field) + return fieldLog ? fieldLog.column : field }) - const uniqueColumnHeaders = [...new Set(columnHeaders)] + const uniqueHeaders = [...new Set(notUniqueHeaders)] - tableValues.unshift(uniqueColumnHeaders) + tableValues.unshift(uniqueHeaders) tableValues.push(...details.map(detail => { const convertedRow = detail.converted_row - return uniqueColumnHeaders.map(header => { + return uniqueHeaders.map(header => { const log = columnFieldLog.find(log => log.field === header) if (log) { header = log.column } diff --git a/test/unit/check-answers.test.js b/test/unit/check-answers.test.js new file mode 100644 index 00000000..69f505dc --- /dev/null +++ b/test/unit/check-answers.test.js @@ -0,0 +1,76 @@ +/* eslint-disable prefer-regex-literals */ + +import { describe, expect, it } from 'vitest' +import { setupNunjucks } from '../../src/serverSetup/nunjucks.js' +import { runGenericPageTests } from './generic-page.js' +import config from '../../config/index.js' +import { stripWhitespace } from '../utils/stripWhiteSpace.js' +import { mockDataSubjects } from './data.js' + +describe('check-answers View', async () => { + const params = { + values: { + lpa: 'mockLpa', + name: 'mockName', + email: 'mockEmail', + dataset: 'mockDataset', + 'endpoint-url': 'mockEndpointUrl', + 'documentation-url': 'mockDocumentationUrl', + hasLicence: 'true' + } + } + const nunjucks = setupNunjucks({ dataSubjects: mockDataSubjects }) + const html = stripWhitespace(nunjucks.render('check-answers.html', params)) + + runGenericPageTests(html, { + pageTitle: 'Check your answers - Check planning and housing data for England', + serviceName: config.serviceName + }) + + it('should render the lpa selected', () => { + const lpaRegex = new RegExp('
.*Local planning authority.*mockLpa.*Change.*
', 'g') + expect(html).toMatch(lpaRegex) + }) + + it('should render the name entered', () => { + const nameRegex = new RegExp('
.*Full name.*mockName.*Change.*
', 'g') + expect(html).toMatch(nameRegex) + }) + + it('should render the email entered', () => { + const emailRegex = new RegExp('
.*Email address.*mockEmail.*Change.*
', 'g') + expect(html).toMatch(emailRegex) + }) + + it('should render the dataset entered', () => { + const datasetRegex = new RegExp('
.*Dataset.*A Mock dataset.*Change.*
', 'g') + expect(html).toMatch(datasetRegex) + }) + + it('should render the endpoint url entered', () => { + const endpointUrlRegex = new RegExp('
.*Dataset URL.*mockEndpointUrl.*Change.*
', 'g') + expect(html).toMatch(endpointUrlRegex) + }) + + it('should render the documentation url entered', () => { + const documentationUrlRegex = new RegExp('
.*Documentation URL.*mockDocumentationUrl.*Change.*
', 'g') + expect(html).toMatch(documentationUrlRegex) + }) + + it('should render the licence selected as true if the licence has been confirmed', () => { + const hasLicenceRegex = new RegExp('
.*Licence.*True.*Change.*
', 'g') + expect(html).toMatch(hasLicenceRegex) + }) + + it('should render the licence selected as false if the licence has not been confirmed', () => { + const noLicenseParams = { + values: { + ...params.values, + hasLicence: 'false' + } + } + const html = stripWhitespace(nunjucks.render('check-answers.html', noLicenseParams)) + const hasLicenceRegex = new RegExp('
.*Licence.*False.*Change.*
', 'g') + expect(html).toMatch(hasLicenceRegex) + }) +}) diff --git a/test/unit/check/confirmationPage.test.js b/test/unit/check/confirmationPage.test.js new file mode 100644 index 00000000..8a668ffa --- /dev/null +++ b/test/unit/check/confirmationPage.test.js @@ -0,0 +1,29 @@ +/* eslint-disable prefer-regex-literals */ + +import { describe, expect, it } from 'vitest' +import { setupNunjucks } from '../../../src/serverSetup/nunjucks.js' +import { runGenericPageTests } from '../generic-page.js' +import config from '../../../config/index.js' +import { stripWhitespace } from '../../utils/stripWhiteSpace.js' +import { mockDataSubjects } from '../data.js' + +const nunjucks = setupNunjucks({ dataSubjects: mockDataSubjects }) + +describe('Check confirmation View', () => { + const params = { + values: { + dataset: 'mockDataset' + } + } + const html = stripWhitespace(nunjucks.render('submit/confirmation.html', params)) + + runGenericPageTests(html, { + pageTitle: 'A Mock dataset submitted - Submit planning and housing data for England', + serviceName: config.serviceName + }) + + it('should render the gov uk panel', () => { + const regex = new RegExp('

', 'g') + expect(html).toMatch(regex) + }) +}) diff --git a/test/unit/checkAnswersController.test.js b/test/unit/checkAnswersController.test.js new file mode 100644 index 00000000..f0988183 --- /dev/null +++ b/test/unit/checkAnswersController.test.js @@ -0,0 +1,89 @@ +/* eslint-disable new-cap */ + +import { describe, it, vi, expect, beforeEach } from 'vitest' +import notifyClient from '../../src/utils/mailClient.js' +import config from '../../config/index.js' + +vi.mock('../../src/utils/mailClient.js') + +function makeRequest () { + return { + sessionModel: { + get: vi.fn().mockImplementation((key) => { + const values = { + name: 'John Doe', + email: 'JohnDoe@mail.com', + lpa: 'LPA', + dataset: 'Dataset', + 'documentation-url': 'Documentation URL', + 'endpoint-url': 'Endpoint URL' + } + return values[key] + }) + } + } +} + +describe('Handle email notification handlers', async () => { + const CheckAnswersController = await vi.importActual('../../src/controllers/CheckAnswersController.js') + const sendEmailMock = vi.fn(() => Promise.reject(new Error('something went wrong'))) + notifyClient.sendEmail = sendEmailMock + + const controller = new CheckAnswersController.default({ route: '/dataset' }) + const req = makeRequest() + const res = {} + const next = vi.fn() + + it('should reuturn list of errors when failed to send email', async () => { + const result = await controller.sendEmails(req, res, next) + expect(result.errors.length).toBe(2) + }) +}) + +describe('Check answers controller', async () => { + let CheckAnswersController + let checkAnswersController + let sendEmailMock + + beforeEach(async () => { + // Setup a mock for sendEmail function + sendEmailMock = vi.fn() + notifyClient.sendEmail = sendEmailMock + CheckAnswersController = await vi.importActual('../../src/controllers/CheckAnswersController.js') + checkAnswersController = new CheckAnswersController.default({ + route: '/dataset' + }) + }) + + describe('send emails', () => { + it('should get the values from the session model and then send the request and acknowledgement emails', async () => { + // Mock req, res, next + const req = makeRequest() + const res = {} + const next = vi.fn() + + await checkAnswersController.sendEmails(req, res, next) + + const personalisation = { + name: 'John Doe', + email: 'JohnDoe@mail.com', + organisation: 'LPA', + dataset: 'Dataset', + 'documentation-url': 'Documentation URL', + endpoint: 'Endpoint URL' + } + + expect(sendEmailMock).toHaveBeenCalledWith( + config.email.templates.RequestTemplateId, + config.email.dataManagementEmail, + { personalisation } + ) + + expect(sendEmailMock).toHaveBeenCalledWith( + config.email.templates.AcknowledgementTemplateId, + 'JohnDoe@mail.com', + { personalisation } + ) + }) + }) +}) diff --git a/test/unit/choose-datasetPage.test.js b/test/unit/choose-datasetPage.test.js new file mode 100644 index 00000000..13e07b3c --- /dev/null +++ b/test/unit/choose-datasetPage.test.js @@ -0,0 +1,33 @@ +import { describe, it } from 'vitest' +import { setupNunjucks } from '../../src/serverSetup/nunjucks.js' +import { runGenericPageTests } from './generic-page.js' +import config from '../../config/index.js' +import { testValidationErrorMessage } from './validation-tests.js' + +const nunjucks = setupNunjucks({ dataSubjects: {} }) + +describe('choose dataset View', () => { + const params = { + errors: {} + } + const html = nunjucks.render('choose-dataset.html', params) + + runGenericPageTests(html, { + pageTitle: 'Choose dataset - Submit planning and housing data for England', + serviceName: config.serviceName + }) + + it('should display an error message when the dataset field is empty', () => { + const params = { + errors: { + dataset: { + type: 'required' + } + } + } + + const html = nunjucks.render('choose-dataset.html', params) + + testValidationErrorMessage(html, 'dataset', 'Select a dataset') + }) +}) diff --git a/test/unit/chooseDatasetController.test.js b/test/unit/chooseDatasetController.test.js new file mode 100644 index 00000000..1fd2c8d4 --- /dev/null +++ b/test/unit/chooseDatasetController.test.js @@ -0,0 +1,41 @@ +import ChooseDatasetController from '../../src/controllers/chooseDatasetController.js' + +import { describe, it, vi, expect, beforeEach } from 'vitest' + +describe('ChooseDatasetController', () => { + let chooseDatasetController + + beforeEach(() => { + chooseDatasetController = new ChooseDatasetController({ + route: '/dataset' + }) + + vi.mock('../../src/utils/utils.js', () => { + return { + dataSubjects: { + subject1: { available: true, dataSets: [{ available: true, text: 'B', value: 'B', requiresGeometryTypeSelection: true }, { available: false, text: 'A', value: 'A', requiresGeometryTypeSelection: false }] }, + subject2: { available: false, dataSets: [{ available: true, text: 'C', value: 'C', requiresGeometryTypeSelection: false }] }, + subject3: { available: true, dataSets: [{ available: true, text: 'A', value: 'A', requiresGeometryTypeSelection: true }] } + } + } + }) + }) + + it('locals correctly filters and sorts available datasets and assigns them to req.form.options.datasetItems', async () => { + // Mock dataSubjects + + // Mock req, res, next + const req = { form: { options: {} } } + const res = {} + const next = vi.fn() + + // Call locals function + chooseDatasetController.locals(req, res, next) + + // Check if the datasets are correctly filtered and sorted + expect(req.form.options.datasetItems).toEqual([{ available: true, text: 'A', value: 'A', requiresGeometryTypeSelection: true }, { available: true, text: 'B', value: 'B', requiresGeometryTypeSelection: true }]) + + // Check if next is called + expect(next).toHaveBeenCalled() + }) +}) diff --git a/test/unit/data.js b/test/unit/data.js new file mode 100644 index 00000000..ce76c3fd --- /dev/null +++ b/test/unit/data.js @@ -0,0 +1,15 @@ +/** + * This value should have the same shape as dataSubjects in 'src/utils/utils.js' + */ +export const mockDataSubjects = { + mockDataset: { + available: true, + dataSets: [ + { + value: 'mockDataset', + text: 'A Mock dataset', + available: true + } + ] + } +} diff --git a/test/unit/dataset-details.test.js b/test/unit/dataset-details.test.js new file mode 100644 index 00000000..ff5de20d --- /dev/null +++ b/test/unit/dataset-details.test.js @@ -0,0 +1,136 @@ +/* eslint-disable prefer-regex-literals */ + +import { describe, expect, it } from 'vitest' +import { setupNunjucks } from '../../src/serverSetup/nunjucks.js' +import { runGenericPageTests } from './generic-page.js' +import config from '../../config/index.js' +import { stripWhitespace } from '../utils/stripWhiteSpace.js' +import { testValidationErrorMessage } from './validation-tests.js' +import { mockDataSubjects } from './data.js' + +const nunjucks = setupNunjucks({ dataSubjects: mockDataSubjects }) + +function errorTestFn ({ + params, + fieldId, + fieldType, + template = 'dataset-details.html', + message: expectedMessage +}) { + return () => { + const errorParams = { + values: params.values, + errors: { + [fieldId]: { + type: fieldType + } + } + } + + const html = nunjucks.render(template, errorParams) + + testValidationErrorMessage(html, fieldId, expectedMessage) + } +} + +describe('dataset details View', () => { + const params = { + values: { + dataset: 'mockDataset' + }, + errors: {} + } + const html = stripWhitespace(nunjucks.render('dataset-details.html', params)) + const datasetName = mockDataSubjects.mockDataset.dataSets[0].text + runGenericPageTests(html, { + pageTitle: `Enter ${datasetName.toLowerCase()} details - Submit planning and housing data for England`, + serviceName: config.serviceName + }) + + it('should render the correct header', () => { + const regex = new RegExp( + `

`, + 'g' + ) + + expect(html).toMatch(regex) + }) + + describe('validation error messages', () => { + describe('endpoint-url', () => { + it( + 'should display an error message when the endpoint-url field is empty', + errorTestFn({ + params, + fieldId: 'endpoint-url', + fieldType: 'required', + message: 'Enter an endpoint URL' + }) + ) + + it( + 'should display an error message when the endpoint-url is not a valid URL', + errorTestFn({ + params, + fieldId: 'endpoint-url', + fieldType: 'format', + message: 'Enter a valid endpoint URL' + }) + ) + + it( + 'should display an error message when the endpoint-url is too long', + errorTestFn({ + params, + fieldId: 'endpoint-url', + fieldType: 'maxlength', + message: 'The URL must be less than 2048 characters' + }) + ) + }) + + describe('documentation-url', () => { + it( + 'should display an error message when the documentation-url field is empty', + errorTestFn({ + params, + fieldId: 'documentation-url', + fieldType: 'required', + message: 'Enter a documentation URL' + }) + ) + + it( + 'should display an error message when the documentation-url is not a valid URL', + errorTestFn({ + params, + fieldId: 'documentation-url', + fieldType: 'format', + message: 'Enter a valid documentation URL' + }) + ) + + it( + 'should display an error message when the documentation-url is too long', + errorTestFn({ + params, + fieldId: 'documentation-url', + fieldType: 'maxlength', + message: 'The URL must be less than 2048 characters' + }) + ) + }) + + describe('hasLicence', () => { + it( + 'should display an error message when the hasLicence field is empty', + errorTestFn({ + params, + fieldId: 'hasLicence', + fieldType: 'required', + message: 'You need to confirm this dataset is provided under the Open Government Licence' + }) + ) + }) + }) +}) diff --git a/test/unit/endpointSubmissionForm/confirmationPage.test.js b/test/unit/endpointSubmissionForm/confirmationPage.test.js new file mode 100644 index 00000000..cc624949 --- /dev/null +++ b/test/unit/endpointSubmissionForm/confirmationPage.test.js @@ -0,0 +1,29 @@ +/* eslint-disable prefer-regex-literals */ + +import { describe, expect, it } from 'vitest' +import { setupNunjucks } from '../../../src/serverSetup/nunjucks.js' +import { runGenericPageTests } from '../generic-page.js' +import config from '../../../config/index.js' +import { stripWhitespace } from '../../utils/stripWhiteSpace.js' +import { mockDataSubjects } from '../data.js' + +const nunjucks = setupNunjucks({ dataSubjects: mockDataSubjects }) + +describe('Submit confirmation View', () => { + const params = { + values: { + dataset: 'mockDataset' + } + } + const html = stripWhitespace(nunjucks.render('submit/confirmation.html', params)) + + runGenericPageTests(html, { + pageTitle: 'A Mock dataset submitted - Submit planning and housing data for England', + serviceName: config.serviceName + }) + + it('should render the gov uk panel', () => { + const regex = new RegExp('

', 'g') + expect(html).toMatch(regex) + }) +}) diff --git a/test/unit/errorsPage.test.js b/test/unit/errorsPage.test.js index 29d5e55b..722e13b4 100644 --- a/test/unit/errorsPage.test.js +++ b/test/unit/errorsPage.test.js @@ -6,11 +6,14 @@ import addFilters from '../../src/filters/filters' import errorResponse from '../../docker/request-api-stub/wiremock/__files/check_file/article-4/request-complete-errors.json' import errorResponseDetails from '../../docker/request-api-stub/wiremock/__files/check_file/article-4/request-complete-errors-details.json' +import ResponseDetails from '../../src/models/responseDetails.js' import paginationTemplateTests from './paginationTemplateTests.js' const nunjucksEnv = nunjucks.configure([ 'src/views', - 'node_modules/govuk-frontend/', + 'src/views/check', + 'src/views/submit', + 'node_modules/govuk-frontend/dist/', 'node_modules/@x-govuk/govuk-prototype-components/' ], { dev: true, @@ -18,13 +21,13 @@ const nunjucksEnv = nunjucks.configure([ watch: true }) -addFilters(nunjucksEnv) +addFilters(nunjucksEnv, { dataSubjects: {} }) describe('errors page', () => { it('renders the correct number of errors', () => { const requestData = new RequestData(errorResponse) - requestData.response.details = errorResponseDetails + const responseDetails = new ResponseDetails('id', errorResponseDetails, { totalResults: 3, offset: 0, limit: 50 }, requestData.getColumnFieldLog()) requestData.response.pagination = { totalResults: 100, @@ -36,11 +39,12 @@ describe('errors page', () => { options: { requestParams: requestData.getParams(), errorSummary: requestData.getErrorSummary(), - rows: requestData.getRows(), - geometryKey: requestData.getGeometryKey(), - columns: requestData.getColumns(), - fields: requestData.getFields(), - verboseRows: requestData.getRowsWithVerboseColumns() + rows: responseDetails.getRows(), + columns: responseDetails.getColumns(), + fields: responseDetails.getFields(), + mappings: responseDetails.getFieldMappings(), + verboseRows: responseDetails.getRowsWithVerboseColumns(), + pagination: responseDetails.getPagination(0) } } @@ -49,7 +53,7 @@ describe('errors page', () => { // error summary expect(html).toContain('
  • 1 geometry must be in Well-Known Text (WKT) format
  • 1 documentation URL must be a real URL
  • 1 entry date must be today or in the past
  • 1 start date must be a real date
  • 1 geometry missing
  • Reference column missing
') // table headers - expect(html).toContain('

') + expect(html).toContain('') // table rows expect(html).toContain('') diff --git a/test/unit/fetchLocalAuthorities.test.js b/test/unit/fetchLocalAuthorities.test.js new file mode 100644 index 00000000..9b3c73f2 --- /dev/null +++ b/test/unit/fetchLocalAuthorities.test.js @@ -0,0 +1,41 @@ +import axios from 'axios' +import { vi, it, describe, expect } from 'vitest' +import { fetchLocalAuthorities } from '../../src/utils/fetchLocalAuthorities' + +// Mock axios.get to return a fake response +vi.mock('axios') +axios.get.mockResolvedValue({ + data: { + rows: [ + [1, 'Local Authority 1'], + [2, 'Local Authority 2'], + [3, 'Local Authority 3'] + ] + } +}) + +describe('fetchLocalAuthorities', () => { + it('should fetch local authority names', async () => { + const result = await fetchLocalAuthorities() + expect(result).toEqual(['Local Authority 1', 'Local Authority 2', 'Local Authority 3']) + }) + + it('should throw an error if the HTTP request fails', async () => { + axios.get.mockRejectedValue(new Error('Failed to fetch data')) + await expect(fetchLocalAuthorities()).rejects.toThrow('Failed to fetch data') + }) + + it('should throw an error if data processing encounters an issue', async () => { + axios.get.mockResolvedValue({ + data: { + rows: [ + [1, 'Local Authority 1'], + [2, null], // Simulate null value in the response + [3, 'Local Authority 3'] + ] + } + }) + const result = await fetchLocalAuthorities() + expect(result).toEqual(['Local Authority 1', 'Local Authority 3']) + }) +}) diff --git a/test/unit/generic-page.js b/test/unit/generic-page.js new file mode 100644 index 00000000..28814025 --- /dev/null +++ b/test/unit/generic-page.js @@ -0,0 +1,34 @@ +// this file holds unit tests that apply to all pages + +import { it, expect } from 'vitest' +import jsdom from 'jsdom' + +/* + Params: + html: string + options: { + pageTitle: string, + serviceName: string + } +*/ +export const runGenericPageTests = (html, options) => { + const dom = new jsdom.JSDOM(html) + const document = dom.window.document + + it('should have the correct header', () => { + const govLogo = document.querySelector('.govuk-header__logo') + + expect(govLogo).not.toBeNull() + + const govLogoSvg = govLogo.querySelector('svg') + + expect(govLogoSvg).not.toBeNull() + expect(govLogoSvg.getAttribute('aria-label')).toBe('GOV.UK') + }) + + if (options.pageTitle) { + it('should have the correct title', () => { + expect(document.title).toBe(options.pageTitle) + }) + } +} diff --git a/test/unit/lpa-detailsPage.test.js b/test/unit/lpa-detailsPage.test.js new file mode 100644 index 00000000..5055add1 --- /dev/null +++ b/test/unit/lpa-detailsPage.test.js @@ -0,0 +1,78 @@ +import { describe, it } from 'vitest' +import { setupNunjucks } from '../../src/serverSetup/nunjucks.js' +import { runGenericPageTests } from './generic-page.js' +import config from '../../config/index.js' +import { testValidationErrorMessage } from './validation-tests.js' +import { mockDataSubjects } from './data.js' + +const nunjucks = setupNunjucks({ dataSubjects: mockDataSubjects }) + +describe('Lpa-details View', () => { + const params = { + errors: {} + } + const htmlNoErrors = nunjucks.render('lpa-details.html', params) + + runGenericPageTests(htmlNoErrors, { + pageTitle: 'Enter LPA details - Submit planning and housing data for England', + serviceName: config.serviceName + }) + + describe('validation errors', () => { + it('should display an error message when the lpa field is empty', () => { + const params = { + errors: { + lpa: { + type: 'required' + } + } + } + + const html = nunjucks.render('lpa-details.html', params) + + testValidationErrorMessage(html, 'lpa', 'Enter the name of your local planning authority') + }) + + it('should display an error message when the name field is empty', () => { + const params = { + errors: { + name: { + type: 'required' + } + } + } + + const html = nunjucks.render('lpa-details.html', params) + + testValidationErrorMessage(html, 'name', 'Enter your full name') + }) + + it('should display an error message when the email field is empty', () => { + const params = { + errors: { + email: { + type: 'required' + } + } + } + + const html = nunjucks.render('lpa-details.html', params) + + testValidationErrorMessage(html, 'email', 'Enter an email address') + }) + + it('should display an error message when the email field is not a valid email', () => { + const params = { + errors: { + email: { + type: 'email' + } + } + } + + const html = nunjucks.render('lpa-details.html', params) + + testValidationErrorMessage(html, 'email', 'Enter an email address in the correct format') + }) + }) +}) diff --git a/test/unit/lpaDetailsController.test.js b/test/unit/lpaDetailsController.test.js new file mode 100644 index 00000000..21355a83 --- /dev/null +++ b/test/unit/lpaDetailsController.test.js @@ -0,0 +1,66 @@ +/* eslint-disable no-import-assign */ +/* eslint-disable new-cap */ + +import PageController from '../../src/controllers/pageController.js' +import { vi, it, describe, expect, beforeEach, afterEach } from 'vitest' + +vi.mock('../../src/utils/fetchLocalAuthorities.js') + +describe('lpaDetailsController', async () => { + let fetchLocalAuthorities + let controller + + beforeEach(async () => { + fetchLocalAuthorities = await import('../../src/utils/fetchLocalAuthorities') + const LpaDetailsController = await import('../../src/controllers/lpaDetailsController.js') + controller = new LpaDetailsController.default({ + route: '/lpa-details' + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('locals', () => { + it('should set localAuthorities options in the form', async () => { + const req = { + form: { + options: {} + } + } + const res = {} + const next = vi.fn() + + const localAuthoritiesNames = ['Authority 1', 'Authority 2'] + + fetchLocalAuthorities.fetchLocalAuthorities = vi.fn().mockResolvedValue(localAuthoritiesNames) + + await controller.locals(req, res, next) + + expect(fetchLocalAuthorities.fetchLocalAuthorities).toHaveBeenCalled() + expect(req.form.options.localAuthorities).toEqual([ + { text: 'Authority 1', value: 'Authority 1' }, + { text: 'Authority 2', value: 'Authority 2' } + ]) + expect(next).toHaveBeenCalled() + }) + + it('should call super.locals', async () => { + const req = { + form: { + options: {} + } + } + const res = {} + const next = vi.fn() + + fetchLocalAuthorities.fetchLocalAuthorities = vi.fn().mockResolvedValue([]) + const superLocalsSpy = vi.spyOn(PageController.prototype, 'locals') + + await controller.locals(req, res, next) + + expect(superLocalsSpy).toHaveBeenCalledWith(req, res, next) + }) + }) +}) diff --git a/test/unit/noErrorsPage.test.js b/test/unit/noErrorsPage.test.js index 99dfd677..87783c43 100644 --- a/test/unit/noErrorsPage.test.js +++ b/test/unit/noErrorsPage.test.js @@ -6,11 +6,14 @@ import addFilters from '../../src/filters/filters' import errorResponse from '../../docker/request-api-stub/wiremock/__files/check_file/article-4/request-complete-errors.json' import errorResponseDetails from '../../docker/request-api-stub/wiremock/__files/check_file/article-4/request-complete-errors-details.json' +import ResponseDetails from '../../src/models/responseDetails.js' import paginationTemplateTests from './paginationTemplateTests.js' const nunjucksEnv = nunjucks.configure([ 'src/views', - 'node_modules/govuk-frontend/', + 'src/views/check', + 'src/views/submit', + 'node_modules/govuk-frontend/dist/', 'node_modules/@x-govuk/govuk-prototype-components/' ], { dev: true, @@ -18,13 +21,12 @@ const nunjucksEnv = nunjucks.configure([ watch: true }) -addFilters(nunjucksEnv) +addFilters(nunjucksEnv, { dataSubjects: {} }) describe('no Errors Page', () => { it('renders the correct number of errors', () => { const requestData = new RequestData(errorResponse) - - requestData.response.details = errorResponseDetails + const responseDetails = new ResponseDetails('id', errorResponseDetails, { totalResults: 3, offset: 0, limit: 50 }, requestData.getColumnFieldLog()) requestData.response.pagination = { totalResults: 100, @@ -36,12 +38,12 @@ describe('no Errors Page', () => { options: { requestParams: requestData.getParams(), errorSummary: requestData.getErrorSummary(), - rows: requestData.getRows(), - geometryKey: requestData.getGeometryKey(), - columns: requestData.getColumns(), - fields: requestData.getFields(), - mappings: requestData.getFieldMappings(), - verboseRows: requestData.getRowsWithVerboseColumns() + rows: responseDetails.getRows(), + columns: responseDetails.getColumns(), + fields: responseDetails.getFields(), + mappings: responseDetails.getFieldMappings(), + verboseRows: responseDetails.getRowsWithVerboseColumns(), + pagination: responseDetails.getPagination(0) }, errors: {} } @@ -49,7 +51,7 @@ describe('no Errors Page', () => { const html = nunjucks.render('results/no-errors.html', params).replace(/(\r\n|\n|\r)/gm, '').replace(/\t/gm, '').replace(/\s+/g, ' ') // expect the table column headers to be correct - expect(html).toContain('') + expect(html).toContain('') // expect the table rows to be correct expect(html).toContain(' ') expect(html).toContain(' ') diff --git a/test/unit/paginationTemplateTests.js b/test/unit/paginationTemplateTests.js index 67efea60..ee88082c 100644 --- a/test/unit/paginationTemplateTests.js +++ b/test/unit/paginationTemplateTests.js @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest' +import jsdom from 'jsdom' const paginationTests = (template, nunjucks) => { describe('pagination', () => { @@ -20,14 +21,13 @@ const paginationTests = (template, nunjucks) => { const params = { options: { pagination, - verboseRows: [{}] + verboseRows: [{}], + id: 'test' }, errors: {} } - const html = nunjucks.render(template, params).replace(/(\r\n|\n|\r)/gm, '').replace(/\t/gm, '').replace(/\s+/g, ' ') - - expect(html).toContain('') + testPagination({ template, params, nunjucks }) }) it('correctly renders the pagination when viewing the first page', () => { @@ -46,14 +46,13 @@ const paginationTests = (template, nunjucks) => { const params = { options: { pagination, - verboseRows: [{}] + verboseRows: [{}], + id: 'test' }, errors: {} } - const html = nunjucks.render(template, params).replace(/(\r\n|\n|\r)/gm, '').replace(/\t/gm, '').replace(/\s+/g, ' ') - - expect(html).toContain('') + testPagination({ template, nunjucks, params }) }) it('correctly renders the pagination when viewing the last page', () => { @@ -72,16 +71,62 @@ const paginationTests = (template, nunjucks) => { const params = { options: { pagination, - verboseRows: [{}] + verboseRows: [{}], + id: 'test' }, errors: {} } - const html = nunjucks.render(template, params).replace(/(\r\n|\n|\r)/gm, '').replace(/\t/gm, '').replace(/\s+/g, ' ') - - expect(html).toContain('') + testPagination({ template, nunjucks, params }) }) }) } +const testPagination = ({ template, nunjucks, params }) => { + const html = nunjucks.render(template, params).replace(/(\r\n|\n|\r)/gm, '').replace(/\t/gm, '').replace(/\s+/g, ' ') + + const { id, pagination } = params.options + + const dom = new jsdom.JSDOM(html) + const document = dom.window.document + + const paginationNode = document.querySelector('nav.govuk-pagination') + const paginationChildren = paginationNode.children + + let currentPaginationChild = 0 + + // Previous link + if (pagination.previousPage) { + expect(paginationChildren[currentPaginationChild].children[0].href).toEqual(`/results/${id}/${pagination.previousPage}`) + currentPaginationChild++ + } + + // page numbers + const pageNumberNodes = paginationChildren[currentPaginationChild].children + expect(pageNumberNodes.length).toEqual(pagination.items.length) + + pagination.items.forEach((item, index) => { + if (item.ellipsis) { + expect(pageNumberNodes[index].textContent).toEqual(' ⋯ ') + } else { + expect(pageNumberNodes[index].children[0].href).toEqual(item.href) + expect(pageNumberNodes[index].children[0].textContent).toEqual(` ${item.number} `) + if (item.current) { + expect(pageNumberNodes[index].className).toContain('current') + } else { + expect(pageNumberNodes[index].className).not.toContain('current') + } + } + }) + currentPaginationChild++ + + // next link + if (pagination.nextPage) { + expect(paginationChildren[currentPaginationChild].children[0].href).toEqual(`/results/${id}/${pagination.nextPage}`) + currentPaginationChild++ + } + + expect(paginationChildren.length).toEqual(currentPaginationChild) +} + export default paginationTests diff --git a/test/unit/requestData.test.js b/test/unit/requestData.test.js index 5a1eab26..3e2b714c 100644 --- a/test/unit/requestData.test.js +++ b/test/unit/requestData.test.js @@ -1,150 +1,294 @@ -import RequestData, { pagination } from '../../src/models/requestData' -import { describe, it, expect } from 'vitest' +import RequestData from '../../src/models/requestData' +import ResponseDetails from '../../src/models/responseDetails' +import { describe, it, expect, vi } from 'vitest' +import axios from 'axios' +import logger from '../../src/utils/logger' + +vi.mock('axios') + +vi.mock('../utils/logger.js', () => { + return { + default: { + error: vi.fn() + } + } +}) + +vi.spyOn(logger, 'error') // Tech Debt: we should write some more tests around the requestData.js file describe('RequestData', () => { - describe('getPagination', () => { - it('should return correct pagination data', () => { - const instance = { - pagination: { - totalResults: 100, - limit: 10, - offset: 0 + describe('fetchResponseDetails', () => { + it('should return a new ResponseDetails object', async () => { + axios.get.mockResolvedValue({ + headers: { + 'x-pagination-total-results': 1, + 'x-pagination-offset': 0, + 'x-pagination-limit': 50 }, - id: 'test' + data: { + 'error-summary': ['error1', 'error2'] + } + }) + + const response = { + id: 1, + getColumnFieldLog: () => [] } + const requestData = new RequestData(response) - const result = RequestData.prototype.getPagination.call(instance, 5) - - expect(result).toEqual({ - totalResults: 100, - offset: 0, - limit: 10, - currentPage: 6, - nextPage: 6, - previousPage: 4, - totalPages: 10, - items: [ - { number: 1, href: '/results/test/0', current: false }, - { ellipsis: true, href: '#' }, - { number: 5, href: '/results/test/4', current: false }, - { number: 6, href: '/results/test/5', current: true }, - { number: 7, href: '/results/test/6', current: false }, - { ellipsis: true, href: '#' }, - { number: 10, href: '/results/test/9', current: false } - ] + const responseDetails = await requestData.fetchResponseDetails() + + expect(responseDetails).toBeInstanceOf(ResponseDetails) + + expect(responseDetails.pagination.totalResults).toBe(1) + expect(responseDetails.pagination.offset).toBe(0) + expect(responseDetails.pagination.limit).toBe(50) + expect(responseDetails.response).toStrictEqual({ + 'error-summary': ['error1', 'error2'] }) + + expect(axios.get).toHaveBeenCalledWith('http://localhost:8001/requests/1/response-details?offset=0&limit=50', { timeout: 30000 }) }) - it('should return correct pagination data when on the last page', () => { - const instance = { - pagination: { - totalResults: 100, - limit: 10, - offset: 90 + it('correctly sets the jsonpath if severity is provided', async () => { + axios.get.mockResolvedValue({ + headers: { + 'x-pagination-total-results': 1, + 'x-pagination-offset': 0, + 'x-pagination-limit': 50 }, - id: 'test' - } - const result = RequestData.prototype.getPagination.call(instance, 9) - expect(result).toEqual({ - totalResults: 100, - offset: 90, - limit: 10, - currentPage: 10, - nextPage: null, - previousPage: 8, - totalPages: 10, - items: [ - { number: 1, href: '/results/test/0', current: false }, - { ellipsis: true, href: '#' }, - { number: 8, href: '/results/test/7', current: false }, - { number: 9, href: '/results/test/8', current: false }, - { number: 10, href: '/results/test/9', current: true } - ] + data: { + 'error-summary': ['error1', 'error2'] + } }) + + const response = { + id: 1, + getColumnFieldLog: () => [] + } + const requestData = new RequestData(response) + + await requestData.fetchResponseDetails(0, 50, 'error') + + expect(axios.get).toHaveBeenCalledWith(`http://localhost:8001/requests/1/response-details?offset=0&limit=50&jsonpath=${encodeURIComponent('$.issue_logs[*].severity=="error"')}`, { timeout: 30000 }) }) + }) - it('should return correct pagination data when on the first page', () => { - const instance = { - pagination: { - totalResults: 100, - limit: 10, - offset: 0 - }, - id: 'test' + describe('getErrorSummary', () => { + it('should return the error summary from the response', () => { + const response = { + data: { + 'error-summary': ['error1', 'error2'] + } } - const result = RequestData.prototype.getPagination.call(instance, 0) - expect(result).toEqual({ - totalResults: 100, - offset: 0, - limit: 10, - currentPage: 1, - nextPage: 1, - previousPage: null, - totalPages: 10, - items: [ - { number: 1, href: '/results/test/0', current: true }, - { number: 2, href: '/results/test/1', current: false }, - { number: 3, href: '/results/test/2', current: false }, - { ellipsis: true, href: '#' }, - { number: 10, href: '/results/test/9', current: false } - ] - }) + const requestData = new RequestData({ response }) + + const errorSummary = requestData.getErrorSummary() + + expect(errorSummary).toStrictEqual(['error1', 'error2']) + }) + + it('should return an empty array if there is no error summary and log an error', () => { + const response = {} + const requestData = new RequestData(response) + + const errorSummary = requestData.getErrorSummary() + + expect(errorSummary).toStrictEqual([]) + + expect(logger.error).toHaveBeenCalledWith('trying to get error summary when there is none: request id: undefined') }) }) -}) -describe('Pagination', () => { - const testCases = [ - { - input: { - count: 1, - current: 1 - }, - expected: [1] - }, - { - input: { - count: 5, - current: 3 - }, - expected: [1, 2, 3, 4, 5] - }, - { - input: { - count: 10, - current: 1 - }, - expected: [1, 2, 3, '...', 10] - }, - { - input: { - count: 6, - current: 2 - }, - expected: [1, 2, 3, '...', 6] - }, - { - input: { - count: 6, - current: 5 - }, - expected: [1, '...', 4, 5, 6] - }, - { - input: { - count: 10, - current: 5 - }, - expected: [1, '...', 4, 5, 6, '...', 10] - } - ] + describe('isFailed', () => { + it('should return true if the status is FAILED', () => { + const response = { + status: 'FAILED' + } + const requestData = new RequestData(response) + + const isFailed = requestData.isFailed() + + expect(isFailed).toBe(true) + }) + + it('should return false if the status is not FAILED', () => { + const response = { + status: 'SUCCESS' + } + const requestData = new RequestData(response) + + const isFailed = requestData.isFailed() + + expect(isFailed).toBe(false) + }) + }) + + describe('getType', () => { + it('should return the type', () => { + const response = { + type: 'type1' + } + const requestData = new RequestData(response) + + const type = requestData.getType() + + expect(type).toBe('type1') + }) + }) + + describe('getError', () => { + it('should return the error from the response', () => { + const response = { + error: { message: 'error message' } + } + const requestData = new RequestData({ response }) + + const error = requestData.getError() + + expect(error).toStrictEqual({ message: 'error message' }) + }) + + it('should return an unknown error if there is no error and log an error', () => { + const requestData = new RequestData({}) + + const error = requestData.getError() + + expect(error).toStrictEqual({ message: 'An unknown error occurred.' }) + + expect(logger.error).toHaveBeenCalledWith('trying to get error when there are none: request id: undefined') + }) + }) + + describe('hasErrors', () => { + it('should return true if there are errors', () => { + const response = { + data: { + 'error-summary': ['error1', 'error2'] + } + } + const requestData = new RequestData({ response }) + + const hasErrors = requestData.hasErrors() + + expect(hasErrors).toBe(true) + }) + + it('should return true if there are no errors and log an error', () => { + const requestData = new RequestData({}) + + const hasErrors = requestData.hasErrors() + + expect(hasErrors).toBe(true) + + expect(logger.error).toHaveBeenCalledWith('trying to check for errors when there are none: request id: undefined') + }) + + it('should return true if there is no error summary and log an error', () => { + const response = { + data: {} + } + const requestData = new RequestData({ response }) + + const hasErrors = requestData.hasErrors() + + expect(hasErrors).toBe(true) + + expect(logger.error).toHaveBeenCalledWith('trying to check for errors but there is no error-summary: request id: undefined') + }) + + it('should return false if the error summary is empty', () => { + const response = { + data: { + 'error-summary': [] + } + } + const requestData = new RequestData({ response }) + + const hasErrors = requestData.hasErrors() + + expect(hasErrors).toBe(false) + }) + }) + + describe('isComplete', () => { + it('should return true if the status is COMPLETE', () => { + const response = { + status: 'COMPLETE' + } + const requestData = new RequestData(response) + + const isComplete = requestData.isComplete() + + expect(isComplete).toBe(true) + }) + + it('should return true if the status is FAILED', () => { + const response = { + status: 'FAILED' + } + const requestData = new RequestData(response) + + const isComplete = requestData.isComplete() + + expect(isComplete).toBe(true) + }) + + it('should return false if the status is not COMPLETE or FAILED', () => { + const response = { + status: 'IN_PROGRESS' + } + const requestData = new RequestData(response) + + const isComplete = requestData.isComplete() + + expect(isComplete).toBe(false) + }) + }) + + describe('getColumnFieldLog', () => { + it('should return the column field log from the response', () => { + const response = { + data: { + 'column-field-log': ['column1', 'column2'] + } + } + const requestData = new RequestData({ response }) + + const columnFieldLog = requestData.getColumnFieldLog() + + expect(columnFieldLog).toStrictEqual(['column1', 'column2']) + }) + + it('should return an empty array if there is no column field log and log an error', () => { + const requestData = new RequestData({}) + + const columnFieldLog = requestData.getColumnFieldLog() + + expect(columnFieldLog).toStrictEqual([]) + + expect(logger.error).toHaveBeenCalledWith('trying to get column field log when there is none: request id: undefined') + }) + }) + + describe('getParams', () => { + it('should return the params', () => { + const requestData = new RequestData({ params: { param1: 'value1' } }) + + const params = requestData.getParams() + + expect(params).toStrictEqual({ param1: 'value1' }) + }) + }) + + describe('getId', () => { + it('should return the id', () => { + const requestData = new RequestData({ id: 1 }) + + const id = requestData.getId() - testCases.forEach((testCase, index) => { - it(`should return the expected pagination for test case ${index + 1}`, () => { - const { input, expected } = testCase - const result = pagination(input.count, input.current) - expect(result).toEqual(expected) + expect(id).toBe(1) }) }) }) diff --git a/test/unit/responseDetails.test.js b/test/unit/responseDetails.test.js new file mode 100644 index 00000000..791a6a47 --- /dev/null +++ b/test/unit/responseDetails.test.js @@ -0,0 +1,422 @@ +import ResponseDetails, { pagination } from '../../src/models/responseDetails' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import logger from '../../src/utils/logger.js' + +vi.mock('../../src/utils/getVerboseColumns.js', () => { + return { + getVerboseColumns: vi.fn((row, columnFieldLog) => { + return { row, columnFieldLog } + }) + } +}) + +describe('ResponseDetails', () => { + const mockResponse = [ + { + issue_logs: [], + entry_number: 1, + converted_row: { + id: '4', + wkt: 'POINT (423432.0000000000000000 564564.0000000000000000)', + name: 'South Jesmond', + Layer: 'Conservation Area', + 'area(ha)': '35.4', + geometry: 'POINT (423432.0000000000000000 564564.0000000000000000)', + 'entry-date': '04/04/2025', + 'start-date': '04/04/2024', + 'documentation-url': 'www.example.com' + } + }, + { + issue_logs: [], + entry_number: 2, + converted_row: { + id: '5', + wkt: 'POINT (423432.0000000000000000 564564.0000000000000000)', + name: 'North Jesmond', + Layer: 'Conservation Area', + 'area(ha)': '35.4', + geometry: 'POINT (423432.0000000000000000 564564.0000000000000000)', + 'entry-date': '04/04/2025', + 'start-date': '04/04/2024', + 'documentation-url': 'www.example.com' + } + } + ] + + const mockPagination = { + totalResults: 2, + offset: 0, + limit: 50 + } + + const mockColumnFieldLog = [ + { column: 'id', field: 'ID' }, + { column: 'wkt', field: 'WKT' }, + { column: 'name', field: 'Name' }, + { column: 'Layer', field: 'Layer' }, + { column: 'area(ha)', field: 'Area' }, + { column: 'geometry', field: 'Geometry' }, + { column: 'entry-date', field: 'Entry Date' }, + { column: 'start-date', field: 'Start Date' }, + { column: 'documentation-url', field: 'Documentation URL' } + ] + + vi.mock('../utils/logger.js', () => { + return { + default: { + error: vi.fn() + } + } + }) + + const loggerErrorSpy = vi.spyOn(logger, 'error') + + beforeEach(() => { + loggerErrorSpy.mockClear() + }) + + describe('getRows', () => { + it('returns the rows', () => { + const responseDetails = new ResponseDetails(undefined, mockResponse, mockPagination, mockColumnFieldLog) + const result = responseDetails.getRows() + expect(result).toBe(mockResponse) + }) + + it('returns an empty array if there are no rows and logs an error', () => { + const responseDetails = new ResponseDetails(undefined, undefined, undefined, undefined) + const result = responseDetails.getRows() + expect(result).toStrictEqual([]) + expect(loggerErrorSpy).toHaveBeenCalled() + }) + }) + + describe('getColumnFieldLog', () => { + it('returns the column field log', () => { + const responseDetails = new ResponseDetails(undefined, undefined, undefined, mockColumnFieldLog) + const result = responseDetails.getColumnFieldLog() + expect(result).toStrictEqual(mockColumnFieldLog) + }) + + it('returns an empty array if there is no column field log and logs an error', () => { + const responseDetails = new ResponseDetails(undefined, undefined, undefined, undefined) + const result = responseDetails.getColumnFieldLog() + expect(result).toStrictEqual([]) + expect(loggerErrorSpy).toHaveBeenCalled() + }) + }) + + describe('getFields', () => { + it('returns the unique fields from the column keys', () => { + const responseDetails = new ResponseDetails(undefined, mockResponse, mockPagination, mockColumnFieldLog) + const result = responseDetails.getFields() + const expected = ['ID', 'WKT', 'Name', 'Layer', 'Area', 'Geometry', 'Entry Date', 'Start Date', 'Documentation URL'] + expect(result).toEqual(expected) + }) + }) + + describe('getColumns', () => { + it('returns the columns', () => { + const responseDetails = new ResponseDetails(undefined, mockResponse, mockPagination, mockColumnFieldLog) + responseDetails.getFields = vi.fn(() => ['ID', 'WKT', 'Name', 'Layer', 'Area', 'Geometry', 'Entry Date', 'Start Date', 'Documentation URL']) + const result = responseDetails.getColumns() + expect(result).toEqual(['id', 'wkt', 'name', 'Layer', 'area(ha)', 'geometry', 'entry-date', 'start-date', 'documentation-url']) + }) + + it('returns an empty array if there are no rows', () => { + const responseDetails = new ResponseDetails(undefined, undefined, undefined, undefined) + const result = responseDetails.getColumns() + expect(result).toStrictEqual([]) + }) + }) + + it('getFieldMappings', () => { + const responseDetails = new ResponseDetails(undefined, mockResponse, mockPagination, mockColumnFieldLog) + const result = responseDetails.getFieldMappings() + const expected = { + ID: 'id', + WKT: 'wkt', + Name: 'name', + Layer: 'Layer', + Area: 'area(ha)', + Geometry: 'geometry', + 'Entry Date': 'entry-date', + 'Start Date': 'start-date', + 'Documentation URL': 'documentation-url' + } + expect(result).toEqual(expected) + }) + + describe('getRowsWithVerboseColumns', () => { + it('returns the rows with verbose columns', () => { + const responseDetails = new ResponseDetails(undefined, mockResponse, mockPagination, mockColumnFieldLog) + const result = responseDetails.getRowsWithVerboseColumns() + const expected = [ + { + entryNumber: 1, + hasErrors: false, + columns: { row: mockResponse[0], columnFieldLog: mockColumnFieldLog } + }, + { + entryNumber: 2, + hasErrors: false, + columns: { row: mockResponse[1], columnFieldLog: mockColumnFieldLog } + } + ] + expect(result).toStrictEqual(expected) + }) + + it('returns the rows with verbose columns and filters out non-errors', () => { + const errorRow = { + issue_logs: [ + { + message: 'a made up issue with this row', + severity: 'error' + } + ], + entry_number: 3, + converted_row: { + id: '4', + wkt: 'POINT (423432.0000000000000000 564564.0000000000000000)', + name: 'South Jesmond', + Layer: 'Conservation Area', + 'area(ha)': '35.4', + geometry: 'POINT (423432.0000000000000000 564564.0000000000000000)', + 'entry-date': '04/04/2025', + 'start-date': '04/04/2024', + 'documentation-url': 'www.example.com' + } + } + const _mockResponse = [ + ...mockResponse, + errorRow + ] + const responseDetails = new ResponseDetails(undefined, _mockResponse, mockPagination, mockColumnFieldLog) + const result = responseDetails.getRowsWithVerboseColumns(true) + const expected = [ + { + entryNumber: 3, + hasErrors: true, + columns: { row: errorRow, columnFieldLog: mockColumnFieldLog } + } + ] + expect(result).toStrictEqual(expected) + }) + + it('returns an empty array if there are no rows and logs an error', () => { + const responseDetails = new ResponseDetails(undefined, undefined, undefined, undefined) + const result = responseDetails.getRowsWithVerboseColumns() + expect(result).toStrictEqual([]) + expect(loggerErrorSpy).toHaveBeenCalled() + }) + }) + + describe('getGeometryKey', () => { + it('returns the column for "point" field', () => { + const responseDetails = new ResponseDetails(undefined, undefined, undefined, [ + { column: 'id', field: 'ID' }, + { column: 'wkt', field: 'WKT' }, + { column: 'point', field: 'point' }, + { column: 'name', field: 'Name' } + ]) + const result = responseDetails.getGeometryKey() + expect(result).toBe('point') + }) + + it('returns the column for "geometry" field', () => { + const responseDetails = new ResponseDetails(undefined, undefined, undefined, [ + { column: 'id', field: 'ID' }, + { column: 'wkt', field: 'WKT' }, + { column: 'geometry', field: 'geometry' }, + { column: 'name', field: 'Name' } + ]) + const result = responseDetails.getGeometryKey() + expect(result).toBe('geometry') + }) + + it('returns null if columnFieldLog is undefined', () => { + const responseDetails = new ResponseDetails(undefined, undefined, undefined, undefined) + const result = responseDetails.getGeometryKey() + expect(result).toBeNull() + }) + + it('returns null if no columnFieldEntry is found', () => { + const responseDetails = new ResponseDetails(undefined, undefined, undefined, [ + { column: 'id', field: 'ID' }, + { column: 'wkt', field: 'WKT' }, + { column: 'name', field: 'Name' } + ]) + const result = responseDetails.getGeometryKey() + expect(result).toBeNull() + }) + }) + + describe('getGeometries', () => { + it('returns undefined and logs an error if there is no response', () => { + const responseDetails = new ResponseDetails(undefined, undefined, undefined, undefined) + const result = responseDetails.getGeometries() + expect(result).toBeUndefined() + expect(loggerErrorSpy).toHaveBeenCalled() + }) + + it('returns null if there are no geometries', () => { + const responseDetails = new ResponseDetails(undefined, [], undefined, undefined) + const result = responseDetails.getGeometries() + expect(result).toBeNull() + }) + + it('returns an array of geometries', () => { + const mockColumnFieldLog = [ + { column: 'id', field: 'ID' }, + { column: 'wkt', field: 'WKT' }, + { column: 'geometry', field: 'geometry' }, + { column: 'name', field: 'Name' } + ] + const responseDetails = new ResponseDetails(undefined, mockResponse, undefined, mockColumnFieldLog) + const result = responseDetails.getGeometries() + const expected = [ + 'POINT (423432.0000000000000000 564564.0000000000000000)', + 'POINT (423432.0000000000000000 564564.0000000000000000)' + ] + expect(result).toEqual(expected) + }) + }) + + describe('getPagination', () => { + it('should return correct pagination data', () => { + const instance = { + pagination: { + totalResults: 100, + limit: 10, + offset: 0 + }, + id: 'test' + } + + const result = ResponseDetails.prototype.getPagination.call(instance, 5) + + expect(result).toEqual({ + totalResults: 100, + offset: 0, + limit: 10, + currentPage: 6, + nextPage: 6, + previousPage: 4, + totalPages: 10, + items: [ + { number: 1, href: '/results/test/0', current: false }, + { ellipsis: true, href: '#' }, + { number: 5, href: '/results/test/4', current: false }, + { number: 6, href: '/results/test/5', current: true }, + { number: 7, href: '/results/test/6', current: false }, + { ellipsis: true, href: '#' }, + { number: 10, href: '/results/test/9', current: false } + ] + }) + }) + + it('should return correct pagination data when on the last page', () => { + const instance = { + pagination: { + totalResults: 100, + limit: 10, + offset: 90 + }, + id: 'test' + } + const result = ResponseDetails.prototype.getPagination.call(instance, 9) + expect(result).toEqual({ + totalResults: 100, + offset: 90, + limit: 10, + currentPage: 10, + nextPage: null, + previousPage: 8, + totalPages: 10, + items: [ + { number: 1, href: '/results/test/0', current: false }, + { ellipsis: true, href: '#' }, + { number: 8, href: '/results/test/7', current: false }, + { number: 9, href: '/results/test/8', current: false }, + { number: 10, href: '/results/test/9', current: true } + ] + }) + }) + + it('should return correct pagination data when on the first page', () => { + const instance = { + pagination: { + totalResults: 100, + limit: 10, + offset: 0 + }, + id: 'test' + } + const result = ResponseDetails.prototype.getPagination.call(instance, 0) + expect(result).toEqual({ + totalResults: 100, + offset: 0, + limit: 10, + currentPage: 1, + nextPage: 1, + previousPage: null, + totalPages: 10, + items: [ + { number: 1, href: '/results/test/0', current: true }, + { number: 2, href: '/results/test/1', current: false }, + { number: 3, href: '/results/test/2', current: false }, + { ellipsis: true, href: '#' }, + { number: 10, href: '/results/test/9', current: false } + ] + }) + }) + }) +}) + +describe('Pagination', () => { + const testCases = [ + { + input: { + count: 1, + current: 0 + }, + expected: [1] + }, + { + input: { + count: 5, + current: 3 + }, + expected: [1, 2, 3, 4, 5] + }, + { + input: { + count: 6, + current: 2 + }, + expected: [1, 2, 3, '...', 6] + }, + { + input: { + count: 6, + current: 5 + }, + expected: [1, '...', 4, 5, 6] + }, + { + input: { + count: 10, + current: 5 + }, + expected: [1, '...', 4, 5, 6, '...', 10] + } + ] + + testCases.forEach((testCase, index) => { + it(`should return the expected pagination for test case ${index + 1}`, () => { + const { input, expected } = testCase + const result = pagination(input.count, input.current) + expect(result).toEqual(expected) + }) + }) +}) diff --git a/test/unit/resultsController.test.js b/test/unit/resultsController.test.js index d416cc69..7d95eb5e 100644 --- a/test/unit/resultsController.test.js +++ b/test/unit/resultsController.test.js @@ -9,7 +9,8 @@ describe('ResultsController', () => { const req = { params: { id: 'testId' }, - form: { options: {} } + form: { options: {} }, + session: { template: 'template' } } beforeEach(async () => { @@ -21,7 +22,62 @@ describe('ResultsController', () => { }) describe('locals', () => { - it('should set the result to the form options if the result is complete', async () => { + it('should set the template to the errors template if the result has errors', async () => { + const mockDetails = { + getErrorSummary: () => ['error summary'], + getColumns: () => ['columns'], + getFields: () => ['fields'], + getFieldMappings: () => 'fieldMappings', + getRowsWithVerboseColumns: () => ['verbose-columns'], + getGeometries: () => ['geometries'], + getPagination: () => 'pagination' + } + + const mockResult = { + isFailed: () => false, + getError: () => 'error', + hasErrors: () => true, + isComplete: () => true, + getParams: () => ('params'), + getId: () => 'fake_id', + fetchResponseDetails: () => mockDetails, + getErrorSummary: () => ['error summary'] + } + + asyncRequestApi.getRequestData = vi.fn().mockResolvedValue(mockResult) + + await resultsController.locals(req, {}, () => {}) + expect(req.form.options.template).toBe('results/errors') + }) + + it('should set the template to the no-errors template if the result has no errors', async () => { + const mockDetails = { + getErrorSummary: () => ['error summary'], + getColumns: () => ['columns'], + getFields: () => ['fields'], + getFieldMappings: () => 'fieldMappings', + getRowsWithVerboseColumns: () => ['verbose-columns'], + getGeometries: () => ['geometries'], + getPagination: () => 'pagination' + } + + const mockResult = { + isFailed: () => false, + getError: () => 'error', + hasErrors: () => false, + isComplete: () => true, + getParams: () => ('params'), + getId: () => 'fake_id', + fetchResponseDetails: () => mockDetails, + getErrorSummary: () => ['error summary'] + } + asyncRequestApi.getRequestData = vi.fn().mockResolvedValue(mockResult) + + await resultsController.locals(req, {}, () => {}) + expect(req.form.options.template).toBe('results/no-errors') + }) + + it('should redirect to the status page if the form is complete', async () => { const mockResult = { isComplete: () => true, isFailed: () => false, @@ -36,15 +92,9 @@ describe('ResultsController', () => { getPagination: () => 'pagination', fetchResponseDetails: () => {} } - const req = { - params: { id: 'test_id' }, - form: { - options: {} - } - } + const res = { redirect: vi.fn() } asyncRequestApi.getRequestData = vi.fn().mockResolvedValue(mockResult) - asyncRequestApi.g = vi.fn().mockResolvedValue(mockResult) await resultsController.locals(req, res, () => {}) @@ -56,6 +106,7 @@ describe('ResultsController', () => { expect(req.form.options.verboseRows).toStrictEqual(['verbose-columns']) expect(req.form.options.geometries).toStrictEqual(['geometries']) expect(req.form.options.pagination).toBe('pagination') + expect(req.form.options.errorSummary).toStrictEqual(['error summary']) }) }) diff --git a/test/unit/startPage.test.js b/test/unit/startPage.test.js new file mode 100644 index 00000000..be17d9f8 --- /dev/null +++ b/test/unit/startPage.test.js @@ -0,0 +1,19 @@ +// ToDo: need to duplicate this test for submit start page + +import { describe } from 'vitest' +import { setupNunjucks } from '../../src/serverSetup/nunjucks.js' +import { runGenericPageTests } from './generic-page.js' +import config from '../../config/index.js' +import { mockDataSubjects } from './data.js' + +const nunjucks = setupNunjucks({ dataSubjects: mockDataSubjects }) + +describe('Start View', () => { + const params = {} + const html = nunjucks.render('start.html', params) + + runGenericPageTests(html, { + pageTitle: 'Start - Check planning and housing data for England', + serviceName: config.serviceName + }) +}) diff --git a/test/unit/statusController.test.js b/test/unit/statusController.test.js index 724bf091..19280175 100644 --- a/test/unit/statusController.test.js +++ b/test/unit/statusController.test.js @@ -16,22 +16,28 @@ describe('StatusController', () => { }) describe('locals', () => { - it('configure should make a request and attach the result of that request to the req.form.options object', async () => { + it('should attach the result of the request to the req.form.options.data object', async () => { + const mockResult = { id: 'test_id', status: 'COMPLETE', response: { test: 'test' }, hasErrors: () => false } + asyncRequestApi.getRequestData = vi.fn().mockResolvedValue(mockResult) + const req = { - params: { id: 'test_id' }, form: { options: {} - } + }, + params: 'fake_id' } - const res = { render: vi.fn(), redirect: vi.fn() } - const next = vi.fn() - const mockResult = { response: { test: 'test' }, hasErrors: () => false } - asyncRequestApi.getRequestData = vi.fn().mockResolvedValue(mockResult) + const res = {} + const next = vi.fn() await statusController.locals(req, res, next) + expect(req.form.options.data).toBe(mockResult) + expect(req.form.options.processingComplete).toBe(true) + expect(req.form.options.pollingEndpoint).toBe(`/api/status/${mockResult.id}`) expect(asyncRequestApi.getRequestData).toHaveBeenCalledWith(req.params.id) + + expect(req.form.options.data).toBe(mockResult) }) }) }) diff --git a/test/unit/validation-tests.js b/test/unit/validation-tests.js new file mode 100644 index 00000000..c5b0875a --- /dev/null +++ b/test/unit/validation-tests.js @@ -0,0 +1,22 @@ +import { expect } from 'vitest' +import jsdom from 'jsdom' + +export const testValidationErrorMessage = (html, field, errorMessage) => { + const dom = new jsdom.JSDOM(html) + const document = dom.window.document + + const errorSummary = document.querySelector('.govuk-error-summary') + expect(errorSummary).not.toBeNull() + expect(errorSummary.textContent).toContain(errorMessage) + + const errorFields = document.querySelectorAll('.govuk-error-message') + expect(errorFields).not.toBeNull() + + let errorFieldExists = false + errorFields.forEach((errorField) => { + if (errorField.textContent.includes(errorMessage)) { + errorFieldExists = true + } + }) + expect(errorFieldExists).toBe(true) +} diff --git a/test/unit/validators.test.js b/test/unit/validators.test.js new file mode 100644 index 00000000..b72efab1 --- /dev/null +++ b/test/unit/validators.test.js @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest' +import { validUrl } from '../../src/utils/validators' + +describe('validUrl', () => { + it('should return true for a valid URL', () => { + const url = 'https://www.example.com' + const result = validUrl(url) + expect(result).toBe(true) + }) + + it('should return false for an invalid URL', () => { + const url = 'example.com' + const result = validUrl(url) + expect(result).toBe(false) + }) + + it('should return false for an empty string', () => { + const url = '' + const result = validUrl(url) + expect(result).toBe(false) + }) + + it('should return false for a null value', () => { + const url = null + const result = validUrl(url) + expect(result).toBe(false) + }) + + it('should return false for an undefined value', () => { + const url = undefined + const result = validUrl(url) + expect(result).toBe(false) + }) +}) diff --git a/test/utils/stripWhiteSpace.js b/test/utils/stripWhiteSpace.js new file mode 100644 index 00000000..2aed5c5f --- /dev/null +++ b/test/utils/stripWhiteSpace.js @@ -0,0 +1,4 @@ +// this function strips out unnecessary whitespace from our html to make sure comparisons are accurate +export const stripWhitespace = (str) => { + return str.replace(/(\r\n|\n|\r)/gm, '').replace(/\t/gm, '').replace(/\s+/g, ' ').replace(/>\s+<').replace(/>\s+/g, '>').replace(/\s+/g, '>').replace(/<\s+/g, '<') +}
{{field}}{{column}}
id geometry name Layer area(ha) entry-date start-date documentation-url
id wkt name Layer area(ha) entry-date start-date documentation-url
4

POLYGON ((-0.2 4153471441223381 51.64678234555299,-0.24153451533341586 51.64678375436429,-0.24153402837267088 51.646785131884954,-0.24153328311827696 51.64678646057366,-0.2415322658196911 51.64678772223974,-0.24153100606519867 51.6467888993419,-0.24152951934469136 51.64678996513527,-0.24152782080019092 51.64679090186209,-0.241525953770536 51.646791710171776,-0.24152394853978726 51.646792354548786,-0.241521819206364 51.64679284419671,-0.24151960980486648 51.64679316179073,-0.241517349227872 51.646793307763815,-0.24151508116211567 51.64679327377821,-0.24151283450017486 51.64679306026688,-0.24151065223303 51.64679267686634,-0.24150856325325645 51.64679212400957,-0.2415066102039489 51.646791420320184,-0.24150480787927503 51.64679055702751,-0.24150318378028418 51.64678957051299,-0.24150178124580876 51.64678846142601,-0.2415006140263633 51.64678724795729,-0.24149969552458145 51.64678595728458,-0.24149905393724236 51.646784607815064,-0.2414986741222941 51.64678321730649,-0.24149859872278576 51.64678180438247,-0.24149878335623448 51.64678039535491,-0.24149927031780533 51.64677901783456,-0.24150001557289963 51.646777689146184,-0.24150103287201724 51.64677642748049,-0.24150229262683906 51.64677525037871,-0.24150377934745013 51.64677418458572,-0.24150547789181703 51.64677324785923,-0.24150734457324669 51.64677244853694,-0.24150935015132094 51.64677179517301,-0.24151147948402624 51.64677130552519,-0.2415136888846846 51.64677098793116,-0.24151594911289265 51.646770850945124,-0.2415182171777299 51.64677088493055,-0.24152046418667236 51.64677108945449,-0.24152264645304639 51.64677147285469,-0.24152473543219838 51.6467720257111,-0.24152668813319955 51.64677273838721,-0.24152849080553268 51.64677359269237,-0.2415301149045335 51.64677457920655,-0.24153151743925044 51.64677568829322,-0.2415326846591531 51.646776901761726,-0.24153360316157546 51.64677819243428,-0.24153424474969987 51.64677954190374,-0.24153462456552752 51.64678093241235,-0.24153471441223381 51.64678234555299))

Geometry must be in Well Known Text (WKT) format

South Jesmond Conservation Area 35.4

04/04/2025

Entry date must be today or in the past

04/04/2024

www. example.com

Documentation URL must be a real URL

id geometry name Layer area(ha) entry-date start-date documentation-url
id wkt name Layer area(ha) entry-date start-date documentation-url
4 POLYGON ((-0.2 4153471441223381 51.64678234555299,-0.24153451533341586 51.64678375436429,-0.24153402837267088 51.646785131884954,-0.24153328311827696 51.64678646057366,-0.2415322658196911 51.64678772223974,-0.24153100606519867 51.6467888993419,-0.24152951934469136 51.64678996513527,-0.24152782080019092 51.64679090186209,-0.241525953770536 51.646791710171776,-0.24152394853978726 51.646792354548786,-0.241521819206364 51.64679284419671,-0.24151960980486648 51.64679316179073,-0.241517349227872 51.646793307763815,-0.24151508116211567 51.64679327377821,-0.24151283450017486 51.64679306026688,-0.24151065223303 51.64679267686634,-0.24150856325325645 51.64679212400957,-0.2415066102039489 51.646791420320184,-0.24150480787927503 51.64679055702751,-0.24150318378028418 51.64678957051299,-0.24150178124580876 51.64678846142601,-0.2415006140263633 51.64678724795729,-0.24149969552458145 51.64678595728458,-0.24149905393724236 51.646784607815064,-0.2414986741222941 51.64678321730649,-0.24149859872278576 51.64678180438247,-0.24149878335623448 51.64678039535491,-0.24149927031780533 51.64677901783456,-0.24150001557289963 51.646777689146184,-0.24150103287201724 51.64677642748049,-0.24150229262683906 51.64677525037871,-0.24150377934745013 51.64677418458572,-0.24150547789181703 51.64677324785923,-0.24150734457324669 51.64677244853694,-0.24150935015132094 51.64677179517301,-0.24151147948402624 51.64677130552519,-0.2415136888846846 51.64677098793116,-0.24151594911289265 51.646770850945124,-0.2415182171777299 51.64677088493055,-0.24152046418667236 51.64677108945449,-0.24152264645304639 51.64677147285469,-0.24152473543219838 51.6467720257111,-0.24152668813319955 51.64677273838721,-0.24152849080553268 51.64677359269237,-0.2415301149045335 51.64677457920655,-0.24153151743925044 51.64677568829322,-0.2415326846591531 51.646776901761726,-0.24153360316157546 51.64677819243428,-0.24153424474969987 51.64677954190374,-0.24153462456552752 51.64678093241235,-0.24153471441223381 51.64678234555299)) South Jesmond Conservation Area 35.4 04/04/2025 04/04/2024 www. example.com
6 POLYGON ((-0.24153471441223381 51.64678234555299,-0.24153451533341586 51.64678375436429,-0.24153402837267088 51.646785131884954,-0.24153328311827696 51.64678646057366,-0.2415322658196911 51.64678772223974,-0.24153100606519867 51.6467888993419,-0.24152951934469136 51.64678996513527,-0.24152782080019092 51.64679090186209,-0.241525953770536 51.646791710171776,-0.24152394853978726 51.646792354548786,-0.241521819206364 51.64679284419671,-0.24151960980486648 51.64679316179073,-0.241517349227872 51.646793307763815,-0.24151508116211567 51.64679327377821,-0.24151283450017486 51.64679306026688,-0.24151065223303 51.64679267686634,-0.24150856325325645 51.64679212400957,-0.2415066102039489 51.646791420320184,-0.24150480787927503 51.64679055702751,-0.24150318378028418 51.64678957051299,-0.24150178124580876 51.64678846142601,-0.2415006140263633 51.64678724795729,-0.24149969552458145 51.64678595728458,-0.24149905393724236 51.646784607815064,-0.2414986741222941 51.64678321730649,-0.24149859872278576 51.64678180438247,-0.24149878335623448 51.64678039535491,-0.24149927031780533 51.64677901783456,-0.24150001557289963 51.646777689146184,-0.24150103287201724 51.64677642748049,-0.24150229262683906 51.64677525037871,-0.24150377934745013 51.64677418458572,-0.24150547789181703 51.64677324785923,-0.24150734457324669 51.64677244853694,-0.24150935015132094 51.64677179517301,-0.24151147948402624 51.64677130552519,-0.2415136888846846 51.64677098793116,-0.24151594911289265 51.646770850945124,-0.2415182171777299 51.64677088493055,-0.24152046418667236 51.64677108945449,-0.24152264645304639 51.64677147285469,-0.24152473543219838 51.6467720257111,-0.24152668813319955 51.64677273838721,-0.24152849080553268 51.64677359269237,-0.2415301149045335 51.64677457920655,-0.24153151743925044 51.64677568829322,-0.2415326846591531 51.646776901761726,-0.24153360316157546 51.64677819243428,-0.24153424474969987 51.64677954190374,-0.24153462456552752 51.64678093241235,-0.24153471441223381 51.64678234555299)) Northumberland Gardens Conservation Area 6.2 04/04/2024 https://www.newcastle.gov.uk/services/planning-building-and-development/historic-enviornment-and-urban-design/conservation-areas