diff --git a/api.planx.uk/package.json b/api.planx.uk/package.json index 60c545b6cd..51aed359df 100644 --- a/api.planx.uk/package.json +++ b/api.planx.uk/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@airbrake/node": "^2.1.8", - "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#6a83682", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#e73d702", "@types/isomorphic-fetch": "^0.0.36", "adm-zip": "^0.5.10", "aws-sdk": "^2.1467.0", diff --git a/api.planx.uk/pnpm-lock.yaml b/api.planx.uk/pnpm-lock.yaml index 5260532974..7803c5c01f 100644 --- a/api.planx.uk/pnpm-lock.yaml +++ b/api.planx.uk/pnpm-lock.yaml @@ -14,8 +14,8 @@ dependencies: specifier: ^2.1.8 version: 2.1.8 '@opensystemslab/planx-core': - specifier: git+https://github.com/theopensystemslab/planx-core#6a83682 - version: github.com/theopensystemslab/planx-core/6a83682 + specifier: git+https://github.com/theopensystemslab/planx-core#e73d702 + version: github.com/theopensystemslab/planx-core/e73d702 '@types/isomorphic-fetch': specifier: ^0.0.36 version: 0.0.36 @@ -1064,23 +1064,30 @@ packages: resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==} dev: false - /@formatjs/ecma402-abstract@2.0.0: - resolution: {integrity: sha512-rRqXOqdFmk7RYvj4khklyqzcfQl9vEL/usogncBHRZfZBDOwMGuSRNFl02fu5KGHXdbinju+YXyuR+Nk8xlr/g==} + /@formatjs/ecma402-abstract@2.2.0: + resolution: {integrity: sha512-IpM+ev1E4QLtstniOE29W1rqH9eTdx5hQdNL8pzrflMj/gogfaoONZqL83LUeQScHAvyMbpqP5C9MzNf+fFwhQ==} dependencies: - '@formatjs/intl-localematcher': 0.5.4 + '@formatjs/fast-memoize': 2.2.1 + '@formatjs/intl-localematcher': 0.5.5 tslib: 2.7.0 dev: false - /@formatjs/intl-listformat@7.5.7: - resolution: {integrity: sha512-MG2TSChQJQT9f7Rlv+eXwUFiG24mKSzmF144PLb8m8OixyXqn4+YWU+5wZracZGCgVTVmx8viCf7IH3QXoiB2g==} + /@formatjs/fast-memoize@2.2.1: + resolution: {integrity: sha512-XS2RcOSyWxmUB7BUjj3mlPH0exsUzlf6QfhhijgI941WaJhVxXQ6mEWkdUFIdnKi3TuTYxRdelsgv3mjieIGIA==} dependencies: - '@formatjs/ecma402-abstract': 2.0.0 - '@formatjs/intl-localematcher': 0.5.4 tslib: 2.7.0 dev: false - /@formatjs/intl-localematcher@0.5.4: - resolution: {integrity: sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g==} + /@formatjs/intl-listformat@7.5.9: + resolution: {integrity: sha512-HqtGkxUh2Uz0oGVTxHAvPZ3EGxc8+ol5+Bx7S9xB97d4PEJJd9oOgHrerIghHA0gtIjsNKBFUae3P0My+F6YUA==} + dependencies: + '@formatjs/ecma402-abstract': 2.2.0 + '@formatjs/intl-localematcher': 0.5.5 + tslib: 2.7.0 + dev: false + + /@formatjs/intl-localematcher@0.5.5: + resolution: {integrity: sha512-t5tOGMgZ/i5+ALl2/offNqAQq/lfUnKLEw0mXQI4N4bqpedhrSE+fyKLpwnd22sK0dif6AV+ufQcTsKShB9J1g==} dependencies: tslib: 2.7.0 dev: false @@ -1642,8 +1649,8 @@ packages: dependencies: undici-types: 5.26.5 - /@types/node@20.16.11: - resolution: {integrity: sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw==} + /@types/node@22.7.5: + resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==} dependencies: undici-types: 6.19.8 dev: false @@ -2814,11 +2821,12 @@ packages: dependencies: esutils: 2.0.3 - /docx@8.5.0: - resolution: {integrity: sha512-4SbcbedPXTciySXiSnNNLuJXpvxFe5nqivbiEHXyL8P/w0wx2uW7YXNjnYgjW0e2e6vy+L/tMISU/oAiXCl57Q==} + /docx@9.0.2: + resolution: {integrity: sha512-Uyq4JUqF4mR55t6laO6mst9W9loV9l6tyAopqKvuxqFgmgZ8PHiXQV8RwpcrHE6SHVPQDHWK4lFjRzxXylI3vg==} engines: {node: '>=10'} dependencies: - '@types/node': 20.16.11 + '@types/node': 22.7.5 + hash.js: 1.1.7 jszip: 3.10.1 nanoid: 5.0.7 xml: 1.0.1 @@ -3670,6 +3678,13 @@ packages: dependencies: has-symbols: 1.0.3 + /hash.js@1.1.7: + resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + dev: false + /hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -4445,6 +4460,10 @@ packages: engines: {node: '>=12'} dev: true + /minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + dev: false + /minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: @@ -6246,8 +6265,8 @@ packages: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} dev: false - github.com/theopensystemslab/planx-core/6a83682: - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/6a83682} + github.com/theopensystemslab/planx-core/e73d702: + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/e73d702} name: '@opensystemslab/planx-core' version: 1.0.0 prepare: true @@ -6255,14 +6274,14 @@ packages: dependencies: '@emotion/react': 11.13.3(react@18.3.1) '@emotion/styled': 11.13.0(@emotion/react@11.13.3)(react@18.3.1) - '@formatjs/intl-listformat': 7.5.7 + '@formatjs/intl-listformat': 7.5.9 '@mui/base': 5.0.0-beta.40(react-dom@18.3.1)(react@18.3.1) '@mui/material': 5.16.7(@emotion/react@11.13.3)(@emotion/styled@11.13.0)(react-dom@18.3.1)(react@18.3.1) ajv: 8.17.1 ajv-formats: 2.1.1(ajv@8.17.1) cheerio: 1.0.0 copyfiles: 2.4.1 - docx: 8.5.0 + docx: 9.0.2 eslint: 8.57.1 fast-xml-parser: 4.5.0 graphql: 16.9.0 diff --git a/doc/how-to/how-to-add-a-secret.md b/doc/how-to/how-to-add-a-secret.md index 58b8f28bef..72332e6f66 100644 --- a/doc/how-to/how-to-add-a-secret.md +++ b/doc/how-to/how-to-add-a-secret.md @@ -9,13 +9,13 @@ This guide will demonstrate how to - ## Process -**Setup** +### Setup 1. Generate a secret [using the existing process](how-to-generate-a-secret.md), or obtain one from a third-party integration 2. Add to your local `.env` file for local development - Note: This file is never checked into our public repository and is listed in our `.gitignore` config 3. Document the secret in `.env.example` -**Docker Environments (Local development + Pizza environments)** +### Docker Environments (Local development + Pizza environments) To pass a secret into our Docker Compose setup you will need to map it into the relevant container in `docker-compose.yml`. For example - ```yml @@ -35,7 +35,7 @@ When building Pizza environments for testing, GitHub actions access secrets via > Please be aware that if you are rotating secrets this may affect existing Pizzas which will need to be rebuilt. This can be done manually in GitHub by re-running the latest action associated with affected PRs. -**AWS / Pulumi Environments (Staging + Production environments)** +### AWS / Pulumi Environments (Staging + Production environments) Secrets for Staging and Production environment are not handled in `.env` files, and are set directly in Pulumi, our Infrastruture as Code (IaC) platform. These values are set using the [Pulumi CLI](https://www.pulumi.com/docs/reference/cli/) diff --git a/doc/how-to/how-to-add-a-team-secret.md b/doc/how-to/how-to-add-a-team-secret.md new file mode 100644 index 0000000000..a1cc1bfaf1 --- /dev/null +++ b/doc/how-to/how-to-add-a-team-secret.md @@ -0,0 +1,30 @@ +# How to add a team secret +This document describes our processes for adding a new team secret to the PlanX application, e.g. Gov Pay API keys. + +This guide will demonstrate how to - + - Encrypt a secret + - Store the encrypted secret in our database + - Verify that the encrypted secret matches the decrypted/raw version + +## Process + +### Obtain the secret +1. Get the raw secret, e.g. you might have been sent it in an email from a council officer. + +### Get the encryption key +1. In `/infrastructure/application`, run `pulumi config get encryption-key --stack production`. +2. This should output the encryption key in the terminal. + +### Encrypt the secret +1. In `/scripts/encrypt`, run the encryption script using the encryption key and raw secret that you obtained in the previous steps: `pnpm encrypt `. +2. This should output the encrypted secret in the terminal. +3. It is useful to double check that the encryption was successful by running the decryption script and checking you get the same secret back: `pnpm decrypt `. + +### Update the database with the encrypted secret +1. Go to our [production Hasura instance](hasura.editor.planx.uk). +2. In the `team_integrations` table, find the row for the relevant team and paste the encrypted secret into the correct field (e.g. `production_govpay_secret`). +3. Press save! + +### Test + +You should now prompt the team representative (e.g. council officer) to test that the secret has been successfully updated, e.g. test a flow with GovPay. diff --git a/e2e/tests/api-driven/package.json b/e2e/tests/api-driven/package.json index 096080b4d9..fc0011a301 100644 --- a/e2e/tests/api-driven/package.json +++ b/e2e/tests/api-driven/package.json @@ -7,7 +7,7 @@ "packageManager": "pnpm@8.6.6", "dependencies": { "@cucumber/cucumber": "^9.3.0", - "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#6a83682", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#e73d702", "axios": "^1.7.4", "dotenv": "^16.3.1", "dotenv-expand": "^10.0.0", diff --git a/e2e/tests/api-driven/pnpm-lock.yaml b/e2e/tests/api-driven/pnpm-lock.yaml index 3c40c6b215..bd2bc19a5c 100644 --- a/e2e/tests/api-driven/pnpm-lock.yaml +++ b/e2e/tests/api-driven/pnpm-lock.yaml @@ -9,8 +9,8 @@ dependencies: specifier: ^9.3.0 version: 9.3.0 '@opensystemslab/planx-core': - specifier: git+https://github.com/theopensystemslab/planx-core#6a83682 - version: github.com/theopensystemslab/planx-core/6a83682 + specifier: git+https://github.com/theopensystemslab/planx-core#e73d702 + version: github.com/theopensystemslab/planx-core/e73d702 axios: specifier: ^1.7.4 version: 1.7.4 @@ -486,25 +486,32 @@ packages: resolution: {integrity: sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==} dev: false - /@formatjs/ecma402-abstract@2.0.0: - resolution: {integrity: sha512-rRqXOqdFmk7RYvj4khklyqzcfQl9vEL/usogncBHRZfZBDOwMGuSRNFl02fu5KGHXdbinju+YXyuR+Nk8xlr/g==} + /@formatjs/ecma402-abstract@2.2.0: + resolution: {integrity: sha512-IpM+ev1E4QLtstniOE29W1rqH9eTdx5hQdNL8pzrflMj/gogfaoONZqL83LUeQScHAvyMbpqP5C9MzNf+fFwhQ==} dependencies: - '@formatjs/intl-localematcher': 0.5.4 - tslib: 2.6.3 + '@formatjs/fast-memoize': 2.2.1 + '@formatjs/intl-localematcher': 0.5.5 + tslib: 2.7.0 dev: false - /@formatjs/intl-listformat@7.5.7: - resolution: {integrity: sha512-MG2TSChQJQT9f7Rlv+eXwUFiG24mKSzmF144PLb8m8OixyXqn4+YWU+5wZracZGCgVTVmx8viCf7IH3QXoiB2g==} + /@formatjs/fast-memoize@2.2.1: + resolution: {integrity: sha512-XS2RcOSyWxmUB7BUjj3mlPH0exsUzlf6QfhhijgI941WaJhVxXQ6mEWkdUFIdnKi3TuTYxRdelsgv3mjieIGIA==} dependencies: - '@formatjs/ecma402-abstract': 2.0.0 - '@formatjs/intl-localematcher': 0.5.4 - tslib: 2.6.3 + tslib: 2.7.0 dev: false - /@formatjs/intl-localematcher@0.5.4: - resolution: {integrity: sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g==} + /@formatjs/intl-listformat@7.5.9: + resolution: {integrity: sha512-HqtGkxUh2Uz0oGVTxHAvPZ3EGxc8+ol5+Bx7S9xB97d4PEJJd9oOgHrerIghHA0gtIjsNKBFUae3P0My+F6YUA==} dependencies: - tslib: 2.6.3 + '@formatjs/ecma402-abstract': 2.2.0 + '@formatjs/intl-localematcher': 0.5.5 + tslib: 2.7.0 + dev: false + + /@formatjs/intl-localematcher@0.5.5: + resolution: {integrity: sha512-t5tOGMgZ/i5+ALl2/offNqAQq/lfUnKLEw0mXQI4N4bqpedhrSE+fyKLpwnd22sK0dif6AV+ufQcTsKShB9J1g==} + dependencies: + tslib: 2.7.0 dev: false /@graphql-typed-document-node/core@3.2.0(graphql@16.9.0): @@ -814,10 +821,10 @@ packages: resolution: {integrity: sha512-DZxSZWXxFfOlx7k7Rv4LAyiMroaxa3Ly/7OOzZO8cBNho0YzAi4qlbrx8W27JGqG57IgR/6J7r+nOJWw6kcvZA==} dev: true - /@types/node@20.14.15: - resolution: {integrity: sha512-Fz1xDMCF/B00/tYSVMlmK7hVeLh7jE5f3B7X1/hmV0MJBwE27KlS7EvD/Yp+z1lm8mVhwV5w+n8jOZG8AfTlKw==} + /@types/node@22.7.5: + resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==} dependencies: - undici-types: 5.26.5 + undici-types: 6.19.8 dev: false /@types/parse-json@4.0.2: @@ -1253,11 +1260,12 @@ packages: esutils: 2.0.3 dev: false - /docx@8.5.0: - resolution: {integrity: sha512-4SbcbedPXTciySXiSnNNLuJXpvxFe5nqivbiEHXyL8P/w0wx2uW7YXNjnYgjW0e2e6vy+L/tMISU/oAiXCl57Q==} + /docx@9.0.2: + resolution: {integrity: sha512-Uyq4JUqF4mR55t6laO6mst9W9loV9l6tyAopqKvuxqFgmgZ8PHiXQV8RwpcrHE6SHVPQDHWK4lFjRzxXylI3vg==} engines: {node: '>=10'} dependencies: - '@types/node': 20.14.15 + '@types/node': 22.7.5 + hash.js: 1.1.7 jszip: 3.10.1 nanoid: 5.0.7 xml: 1.0.1 @@ -1675,6 +1683,13 @@ packages: engines: {node: '>=8'} dev: false + /hash.js@1.1.7: + resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + dev: false + /hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -2043,6 +2058,10 @@ packages: mime-db: 1.52.0 dev: false + /minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + dev: false + /minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: @@ -2709,6 +2728,10 @@ packages: resolution: {integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==} dev: false + /tslib@2.7.0: + resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} + dev: false + /type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -2732,8 +2755,8 @@ packages: hasBin: true dev: true - /undici-types@5.26.5: - resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + /undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} dev: false /undici@6.19.8: @@ -2933,8 +2956,8 @@ packages: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} dev: false - github.com/theopensystemslab/planx-core/6a83682: - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/6a83682} + github.com/theopensystemslab/planx-core/e73d702: + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/e73d702} name: '@opensystemslab/planx-core' version: 1.0.0 prepare: true @@ -2942,14 +2965,14 @@ packages: dependencies: '@emotion/react': 11.13.3(react@18.3.1) '@emotion/styled': 11.13.0(@emotion/react@11.13.3)(react@18.3.1) - '@formatjs/intl-listformat': 7.5.7 + '@formatjs/intl-listformat': 7.5.9 '@mui/base': 5.0.0-beta.40(react-dom@18.3.1)(react@18.3.1) '@mui/material': 5.16.7(@emotion/react@11.13.3)(@emotion/styled@11.13.0)(react-dom@18.3.1)(react@18.3.1) ajv: 8.17.1 ajv-formats: 2.1.1(ajv@8.17.1) cheerio: 1.0.0 copyfiles: 2.4.1 - docx: 8.5.0 + docx: 9.0.2 eslint: 8.57.1 fast-xml-parser: 4.5.0 graphql: 16.9.0 diff --git a/e2e/tests/ui-driven/package.json b/e2e/tests/ui-driven/package.json index 0d92f6357b..a677f5b43e 100644 --- a/e2e/tests/ui-driven/package.json +++ b/e2e/tests/ui-driven/package.json @@ -8,7 +8,7 @@ "postinstall": "./install-dependencies.sh" }, "dependencies": { - "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#6a83682", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#e73d702", "axios": "^1.7.4", "dotenv": "^16.3.1", "eslint": "^8.56.0", diff --git a/e2e/tests/ui-driven/pnpm-lock.yaml b/e2e/tests/ui-driven/pnpm-lock.yaml index 3c108ed612..70621a1f3b 100644 --- a/e2e/tests/ui-driven/pnpm-lock.yaml +++ b/e2e/tests/ui-driven/pnpm-lock.yaml @@ -6,8 +6,8 @@ settings: dependencies: '@opensystemslab/planx-core': - specifier: git+https://github.com/theopensystemslab/planx-core#6a83682 - version: github.com/theopensystemslab/planx-core/6a83682 + specifier: git+https://github.com/theopensystemslab/planx-core#e73d702 + version: github.com/theopensystemslab/planx-core/e73d702 axios: specifier: ^1.7.4 version: 1.7.4 @@ -350,25 +350,32 @@ packages: resolution: {integrity: sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==} dev: false - /@formatjs/ecma402-abstract@2.0.0: - resolution: {integrity: sha512-rRqXOqdFmk7RYvj4khklyqzcfQl9vEL/usogncBHRZfZBDOwMGuSRNFl02fu5KGHXdbinju+YXyuR+Nk8xlr/g==} + /@formatjs/ecma402-abstract@2.2.0: + resolution: {integrity: sha512-IpM+ev1E4QLtstniOE29W1rqH9eTdx5hQdNL8pzrflMj/gogfaoONZqL83LUeQScHAvyMbpqP5C9MzNf+fFwhQ==} dependencies: - '@formatjs/intl-localematcher': 0.5.4 - tslib: 2.6.3 + '@formatjs/fast-memoize': 2.2.1 + '@formatjs/intl-localematcher': 0.5.5 + tslib: 2.7.0 dev: false - /@formatjs/intl-listformat@7.5.7: - resolution: {integrity: sha512-MG2TSChQJQT9f7Rlv+eXwUFiG24mKSzmF144PLb8m8OixyXqn4+YWU+5wZracZGCgVTVmx8viCf7IH3QXoiB2g==} + /@formatjs/fast-memoize@2.2.1: + resolution: {integrity: sha512-XS2RcOSyWxmUB7BUjj3mlPH0exsUzlf6QfhhijgI941WaJhVxXQ6mEWkdUFIdnKi3TuTYxRdelsgv3mjieIGIA==} dependencies: - '@formatjs/ecma402-abstract': 2.0.0 - '@formatjs/intl-localematcher': 0.5.4 - tslib: 2.6.3 + tslib: 2.7.0 dev: false - /@formatjs/intl-localematcher@0.5.4: - resolution: {integrity: sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g==} + /@formatjs/intl-listformat@7.5.9: + resolution: {integrity: sha512-HqtGkxUh2Uz0oGVTxHAvPZ3EGxc8+ol5+Bx7S9xB97d4PEJJd9oOgHrerIghHA0gtIjsNKBFUae3P0My+F6YUA==} dependencies: - tslib: 2.6.3 + '@formatjs/ecma402-abstract': 2.2.0 + '@formatjs/intl-localematcher': 0.5.5 + tslib: 2.7.0 + dev: false + + /@formatjs/intl-localematcher@0.5.5: + resolution: {integrity: sha512-t5tOGMgZ/i5+ALl2/offNqAQq/lfUnKLEw0mXQI4N4bqpedhrSE+fyKLpwnd22sK0dif6AV+ufQcTsKShB9J1g==} + dependencies: + tslib: 2.7.0 dev: false /@graphql-typed-document-node/core@3.2.0(graphql@16.9.0): @@ -661,10 +668,10 @@ packages: resolution: {integrity: sha512-DZxSZWXxFfOlx7k7Rv4LAyiMroaxa3Ly/7OOzZO8cBNho0YzAi4qlbrx8W27JGqG57IgR/6J7r+nOJWw6kcvZA==} dev: true - /@types/node@20.14.15: - resolution: {integrity: sha512-Fz1xDMCF/B00/tYSVMlmK7hVeLh7jE5f3B7X1/hmV0MJBwE27KlS7EvD/Yp+z1lm8mVhwV5w+n8jOZG8AfTlKw==} + /@types/node@22.7.5: + resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==} dependencies: - undici-types: 5.26.5 + undici-types: 6.19.8 dev: false /@types/parse-json@4.0.2: @@ -1117,11 +1124,12 @@ packages: dependencies: esutils: 2.0.3 - /docx@8.5.0: - resolution: {integrity: sha512-4SbcbedPXTciySXiSnNNLuJXpvxFe5nqivbiEHXyL8P/w0wx2uW7YXNjnYgjW0e2e6vy+L/tMISU/oAiXCl57Q==} + /docx@9.0.2: + resolution: {integrity: sha512-Uyq4JUqF4mR55t6laO6mst9W9loV9l6tyAopqKvuxqFgmgZ8PHiXQV8RwpcrHE6SHVPQDHWK4lFjRzxXylI3vg==} engines: {node: '>=10'} dependencies: - '@types/node': 20.14.15 + '@types/node': 22.7.5 + hash.js: 1.1.7 jszip: 3.10.1 nanoid: 5.0.7 xml: 1.0.1 @@ -1558,6 +1566,13 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + /hash.js@1.1.7: + resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + dev: false + /hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -1913,6 +1928,10 @@ packages: engines: {node: '>=6'} dev: false + /minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + dev: false + /minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: @@ -2500,8 +2519,8 @@ packages: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} dev: false - /tslib@2.6.3: - resolution: {integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==} + /tslib@2.7.0: + resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} dev: false /type-check@0.4.0: @@ -2524,8 +2543,8 @@ packages: engines: {node: '>=16'} dev: false - /undici-types@5.26.5: - resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + /undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} dev: false /undici@6.19.8: @@ -2687,8 +2706,8 @@ packages: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} dev: false - github.com/theopensystemslab/planx-core/6a83682: - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/6a83682} + github.com/theopensystemslab/planx-core/e73d702: + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/e73d702} name: '@opensystemslab/planx-core' version: 1.0.0 prepare: true @@ -2696,14 +2715,14 @@ packages: dependencies: '@emotion/react': 11.13.3(react@18.3.1) '@emotion/styled': 11.13.0(@emotion/react@11.13.3)(react@18.3.1) - '@formatjs/intl-listformat': 7.5.7 + '@formatjs/intl-listformat': 7.5.9 '@mui/base': 5.0.0-beta.40(react-dom@18.3.1)(react@18.3.1) '@mui/material': 5.16.7(@emotion/react@11.13.3)(@emotion/styled@11.13.0)(react-dom@18.3.1)(react@18.3.1) ajv: 8.17.1 ajv-formats: 2.1.1(ajv@8.17.1) cheerio: 1.0.0 copyfiles: 2.4.1 - docx: 8.5.0 + docx: 9.0.2 eslint: 8.57.1 fast-xml-parser: 4.5.0 graphql: 16.9.0 diff --git a/editor.planx.uk/package.json b/editor.planx.uk/package.json index 36e9e0c3c3..d27ebfcc2e 100644 --- a/editor.planx.uk/package.json +++ b/editor.planx.uk/package.json @@ -15,7 +15,7 @@ "@mui/material": "^5.15.10", "@mui/utils": "^5.15.11", "@opensystemslab/map": "1.0.0-alpha.3", - "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#6a83682", + "@opensystemslab/planx-core": "git+https://github.com/theopensystemslab/planx-core#e73d702", "@tiptap/core": "^2.4.0", "@tiptap/extension-bold": "^2.0.3", "@tiptap/extension-bubble-menu": "^2.1.13", diff --git a/editor.planx.uk/pnpm-lock.yaml b/editor.planx.uk/pnpm-lock.yaml index 3fa1349447..8aefbd28fd 100644 --- a/editor.planx.uk/pnpm-lock.yaml +++ b/editor.planx.uk/pnpm-lock.yaml @@ -47,8 +47,8 @@ dependencies: specifier: 1.0.0-alpha.3 version: 1.0.0-alpha.3 '@opensystemslab/planx-core': - specifier: git+https://github.com/theopensystemslab/planx-core#6a83682 - version: github.com/theopensystemslab/planx-core/6a83682(@types/react@18.2.45) + specifier: git+https://github.com/theopensystemslab/planx-core#e73d702 + version: github.com/theopensystemslab/planx-core/e73d702(@types/react@18.2.45) '@tiptap/core': specifier: ^2.4.0 version: 2.4.0(@tiptap/pm@2.0.3) @@ -2789,23 +2789,30 @@ packages: react: 18.2.0 dev: true - /@formatjs/ecma402-abstract@2.0.0: - resolution: {integrity: sha512-rRqXOqdFmk7RYvj4khklyqzcfQl9vEL/usogncBHRZfZBDOwMGuSRNFl02fu5KGHXdbinju+YXyuR+Nk8xlr/g==} + /@formatjs/ecma402-abstract@2.2.0: + resolution: {integrity: sha512-IpM+ev1E4QLtstniOE29W1rqH9eTdx5hQdNL8pzrflMj/gogfaoONZqL83LUeQScHAvyMbpqP5C9MzNf+fFwhQ==} dependencies: - '@formatjs/intl-localematcher': 0.5.4 + '@formatjs/fast-memoize': 2.2.1 + '@formatjs/intl-localematcher': 0.5.5 tslib: 2.7.0 dev: false - /@formatjs/intl-listformat@7.5.7: - resolution: {integrity: sha512-MG2TSChQJQT9f7Rlv+eXwUFiG24mKSzmF144PLb8m8OixyXqn4+YWU+5wZracZGCgVTVmx8viCf7IH3QXoiB2g==} + /@formatjs/fast-memoize@2.2.1: + resolution: {integrity: sha512-XS2RcOSyWxmUB7BUjj3mlPH0exsUzlf6QfhhijgI941WaJhVxXQ6mEWkdUFIdnKi3TuTYxRdelsgv3mjieIGIA==} dependencies: - '@formatjs/ecma402-abstract': 2.0.0 - '@formatjs/intl-localematcher': 0.5.4 tslib: 2.7.0 dev: false - /@formatjs/intl-localematcher@0.5.4: - resolution: {integrity: sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g==} + /@formatjs/intl-listformat@7.5.9: + resolution: {integrity: sha512-HqtGkxUh2Uz0oGVTxHAvPZ3EGxc8+ol5+Bx7S9xB97d4PEJJd9oOgHrerIghHA0gtIjsNKBFUae3P0My+F6YUA==} + dependencies: + '@formatjs/ecma402-abstract': 2.2.0 + '@formatjs/intl-localematcher': 0.5.5 + tslib: 2.7.0 + dev: false + + /@formatjs/intl-localematcher@0.5.5: + resolution: {integrity: sha512-t5tOGMgZ/i5+ALl2/offNqAQq/lfUnKLEw0mXQI4N4bqpedhrSE+fyKLpwnd22sK0dif6AV+ufQcTsKShB9J1g==} dependencies: tslib: 2.7.0 dev: false @@ -5574,17 +5581,10 @@ packages: /@types/node@17.0.45: resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==} - /@types/node@20.16.11: - resolution: {integrity: sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw==} - dependencies: - undici-types: 6.19.8 - dev: false - /@types/node@22.7.5: resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==} dependencies: undici-types: 6.19.8 - dev: true /@types/parse-json@4.0.2: resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -7882,11 +7882,12 @@ packages: dependencies: esutils: 2.0.3 - /docx@8.5.0: - resolution: {integrity: sha512-4SbcbedPXTciySXiSnNNLuJXpvxFe5nqivbiEHXyL8P/w0wx2uW7YXNjnYgjW0e2e6vy+L/tMISU/oAiXCl57Q==} + /docx@9.0.2: + resolution: {integrity: sha512-Uyq4JUqF4mR55t6laO6mst9W9loV9l6tyAopqKvuxqFgmgZ8PHiXQV8RwpcrHE6SHVPQDHWK4lFjRzxXylI3vg==} engines: {node: '>=10'} dependencies: - '@types/node': 20.16.11 + '@types/node': 22.7.5 + hash.js: 1.1.7 jszip: 3.10.1 nanoid: 5.0.7 xml: 1.0.1 @@ -9262,6 +9263,13 @@ packages: engines: {node: '>= 0.4.0'} dev: true + /hash.js@1.1.7: + resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + dev: false + /hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -11396,6 +11404,10 @@ packages: engines: {node: '>=4'} dev: true + /minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + dev: false + /minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: @@ -15347,9 +15359,9 @@ packages: use-sync-external-store: 1.2.0(react@18.2.0) dev: false - github.com/theopensystemslab/planx-core/6a83682(@types/react@18.2.45): - resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/6a83682} - id: github.com/theopensystemslab/planx-core/6a83682 + github.com/theopensystemslab/planx-core/e73d702(@types/react@18.2.45): + resolution: {tarball: https://codeload.github.com/theopensystemslab/planx-core/tar.gz/e73d702} + id: github.com/theopensystemslab/planx-core/e73d702 name: '@opensystemslab/planx-core' version: 1.0.0 prepare: true @@ -15357,14 +15369,14 @@ packages: dependencies: '@emotion/react': 11.13.3(@types/react@18.2.45)(react@18.3.1) '@emotion/styled': 11.13.0(@emotion/react@11.13.3)(@types/react@18.2.45)(react@18.3.1) - '@formatjs/intl-listformat': 7.5.7 + '@formatjs/intl-listformat': 7.5.9 '@mui/base': 5.0.0-beta.40(@types/react@18.2.45)(react-dom@18.3.1)(react@18.3.1) '@mui/material': 5.15.10(@emotion/react@11.13.3)(@emotion/styled@11.13.0)(@types/react@18.2.45)(react-dom@18.3.1)(react@18.3.1) ajv: 8.17.1 ajv-formats: 2.1.1(ajv@8.17.1) cheerio: 1.0.0 copyfiles: 2.4.1 - docx: 8.5.0 + docx: 9.0.2 eslint: 8.57.1 fast-xml-parser: 4.5.0 graphql: 16.9.0 diff --git a/editor.planx.uk/src/@planx/components/NumberInput/Editor.tsx b/editor.planx.uk/src/@planx/components/NumberInput/Editor.tsx index 7155859abf..446462fcbc 100644 --- a/editor.planx.uk/src/@planx/components/NumberInput/Editor.tsx +++ b/editor.planx.uk/src/@planx/components/NumberInput/Editor.tsx @@ -3,10 +3,7 @@ import Switch from "@mui/material/Switch"; import { ComponentType as TYPES } from "@opensystemslab/planx-core/types"; import type { NumberInput } from "@planx/components/NumberInput/model"; import { parseNumberInput } from "@planx/components/NumberInput/model"; -import { - EditorProps, - ICONS, -} from "@planx/components/ui"; +import { EditorProps, ICONS } from "@planx/components/ui"; import { useFormik } from "formik"; import React from "react"; import { ModalFooter } from "ui/editor/ModalFooter"; @@ -91,6 +88,19 @@ export default function NumberInputComponent(props: Props): FCReturn { label="Allow negative numbers to be input" /> + + + formik.setFieldValue("isInteger", !formik.values.isInteger) + } + /> + } + label="Only allow whole numbers" + /> + diff --git a/editor.planx.uk/src/@planx/components/NumberInput/Public.test.tsx b/editor.planx.uk/src/@planx/components/NumberInput/Public.test.tsx index 9fbc619d99..838a96ea03 100644 --- a/editor.planx.uk/src/@planx/components/NumberInput/Public.test.tsx +++ b/editor.planx.uk/src/@planx/components/NumberInput/Public.test.tsx @@ -84,6 +84,54 @@ test("allows negative numbers to be input when toggled on by editor", async () = expect(handleSubmit).toHaveBeenCalledWith({ data: { fahrenheit: -10 } }); }); +test("a clear error is shown if decimal value added when onlyWholeNumbers is toggled on", async () => { + const handleSubmit = vi.fn(); + + const { user } = setup( + , + ); + + expect(screen.getByRole("heading")).toHaveTextContent( + "What's the temperature?", + ); + + const textArea = screen.getByLabelText("What's the temperature?"); + + await user.type(textArea, "10.06"); + await user.click(screen.getByTestId("continue-button")); + + expect(screen.getByText("Enter a whole number")).toBeInTheDocument(); + expect(handleSubmit).not.toHaveBeenCalled(); +}); + +test("allows only whole numbers to be submitted when toggled on by editor", async () => { + const handleSubmit = vi.fn(); + + const { user } = setup( + , + ); + + expect(screen.getByRole("heading")).toHaveTextContent( + "What's the temperature?", + ); + + const textArea = screen.getByLabelText("What's the temperature?"); + + await user.type(textArea, "10"); + await user.click(screen.getByTestId("continue-button")); + expect(handleSubmit).toHaveBeenCalledWith({ data: { fahrenheit: 10 } }); +}); + test("requires a value before being able to continue", async () => { const handleSubmit = vi.fn(); diff --git a/editor.planx.uk/src/@planx/components/NumberInput/model.ts b/editor.planx.uk/src/@planx/components/NumberInput/model.ts index 0c98f276d8..686d483d4b 100644 --- a/editor.planx.uk/src/@planx/components/NumberInput/model.ts +++ b/editor.planx.uk/src/@planx/components/NumberInput/model.ts @@ -8,6 +8,7 @@ export interface NumberInput extends BaseNodeData { fn?: string; units?: string; allowNegatives?: boolean; + isInteger?: boolean; } export type UserData = number; @@ -28,6 +29,7 @@ export const parseNumberInput = ( fn: data?.fn || "", units: data?.units, allowNegatives: data?.allowNegatives || false, + isInteger: data?.isInteger || false, ...parseBaseNodeData(data), }); @@ -52,6 +54,19 @@ export const numberInputValidationSchema = (input: NumberInput) => } return value === "0" ? true : Boolean(parseNumber(value)); }, + }) + .test({ + name: "check for a whole number", + message: "Enter a whole number", + test: (value: string | undefined) => { + if (!value) { + return false; + } + if (input.isInteger && !Number.isInteger(Number(value))) { + return false; + } + return true; + }, }); export const validationSchema = (input: NumberInput) => diff --git a/editor.planx.uk/src/@planx/components/TextInput/Public.test.tsx b/editor.planx.uk/src/@planx/components/TextInput/Public.test.tsx index 1d5f5e6f3f..18d5c21bbe 100644 --- a/editor.planx.uk/src/@planx/components/TextInput/Public.test.tsx +++ b/editor.planx.uk/src/@planx/components/TextInput/Public.test.tsx @@ -8,6 +8,9 @@ import { axe } from "vitest-axe"; import { ERROR_MESSAGE } from "../shared/constants"; import { TextInputType } from "./model"; import TextInput from "./Public"; + +const twentyFiveCharacterTest = "25 characters for me....."; + test("requires a value before being able to continue", async () => { const handleSubmit = vi.fn(); @@ -164,3 +167,70 @@ it("should change the role of the ErrorWrapper when an invalid input is given", expect(errorWrapper).not.toBeEmptyDOMElement(); expect(errorWrapper).toHaveAttribute("role", "alert"); }); + +test("character limit counter should appear for long text inputs", async () => { + setup(); + + const characterCounter = await screen.findByTestId("screen-reader-count"); + expect(characterCounter).toBeInTheDocument(); +}); + +test("character limit counter should not appear for short text inputs", async () => { + setup(); + const characterCounter = screen.queryByTestId("screen-reader-count"); + + expect(characterCounter).not.toBeInTheDocument(); +}); + +test("character limit counter should change when typed", async () => { + const { user } = setup(); + + const textArea = screen.getByRole("textbox", { + name: /hello/i, + }); + + await user.type(textArea, twentyFiveCharacterTest); + + const newCharacterCounter = await screen.findByText( + "You have 225 characters remaining", + ); + + expect(newCharacterCounter).toBeInTheDocument(); +}); + +test("character limit counter shows error state when over limit", async () => { + const { user } = setup(); + const textArea = screen.getByRole("textbox", { + name: /hello/i, + }); + + await user.type(textArea, `${twentyFiveCharacterTest.repeat(10)}`); + await user.type(textArea, `extra`); + + const errorCharacterCounter = await screen.findByText( + "You have 5 characters too many", + ); + + expect(errorCharacterCounter).toHaveStyle({ color: "#D4351C" }); +}); + +test("character limit counter should meet accessibility requirements", async () => { + const { user, container } = setup( + , + ); + const textArea = screen.getByRole("textbox", { + name: /hello/i, + }); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + + await user.type(textArea, `${twentyFiveCharacterTest.repeat(10)}`); + + const resultsAfterTyping = await axe(container); + expect(resultsAfterTyping).toHaveNoViolations(); + + await user.type(textArea, `extra`); + const resultsWithError = await axe(container); + expect(resultsWithError).toHaveNoViolations(); +}); diff --git a/editor.planx.uk/src/@planx/components/TextInput/Public.tsx b/editor.planx.uk/src/@planx/components/TextInput/Public.tsx index c23de13a14..37b67305c5 100644 --- a/editor.planx.uk/src/@planx/components/TextInput/Public.tsx +++ b/editor.planx.uk/src/@planx/components/TextInput/Public.tsx @@ -4,6 +4,7 @@ import { PublicProps } from "@planx/components/ui"; import { useFormik } from "formik"; import React from "react"; import InputLabel from "ui/public/InputLabel"; +import { CharacterCounter, isLongTextType } from "ui/shared/CharacterCounter"; import Input from "ui/shared/Input"; import InputRow from "ui/shared/InputRow"; import { object } from "yup"; @@ -11,7 +12,7 @@ import { object } from "yup"; import { DESCRIPTION_TEXT, ERROR_MESSAGE } from "../shared/constants"; import { getPreviouslySubmittedData, makeData } from "../shared/utils"; import type { TextInput } from "./model"; -import { userDataSchema } from "./model"; +import { TextInputType, userDataSchema } from "./model"; export type Props = PublicProps; @@ -31,6 +32,8 @@ const TextInputComponent: React.FC = (props) => { }), }); + const characterCountLimit = props.type && isLongTextType(props.type); + return ( = (props) => { id={props.id} inputProps={{ "aria-describedby": [ - props.description ? DESCRIPTION_TEXT : "", + props.description + ? `${DESCRIPTION_TEXT} character-hint` + : "character-hint", formik.errors.text ? `${ERROR_MESSAGE}-${props.id}` : "", ] .filter(Boolean) .join(" "), }} /> + {characterCountLimit && ( + + )} diff --git a/editor.planx.uk/src/@planx/components/TextInput/model.ts b/editor.planx.uk/src/@planx/components/TextInput/model.ts index 3a96c2f2e0..0f26dfdf16 100644 --- a/editor.planx.uk/src/@planx/components/TextInput/model.ts +++ b/editor.planx.uk/src/@planx/components/TextInput/model.ts @@ -12,6 +12,12 @@ export enum TextInputType { Phone = "phone", } +export const TEXT_LIMITS = { + [TextInputType.Short]: 120, + [TextInputType.Long]: 250, + [TextInputType.ExtraLong]: 750, +} as const; + export const emailRegex = // eslint-disable-next-line /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; @@ -25,42 +31,25 @@ export const userDataSchema = ({ type }: TextInput): SchemaOf => if (!type) { return "Enter your answer before continuing"; } - if (type === TextInputType.Short) { - return "Your answer must be 120 characters or fewer."; - } - if (type === TextInputType.Long) { - return "Your answer must be 250 characters or fewer."; - } - if (type === TextInputType.ExtraLong) { - return "Your answer must be 750 characters or fewer."; + if (type === TextInputType.Phone) { + return "Enter a valid phone number."; } if (type === TextInputType.Email) { return "Enter an email address in the correct format, like name@example.com"; } - if (type === TextInputType.Phone) { - return "Enter a valid phone number."; - } + return `Your answer must be ${TEXT_LIMITS[type]} characters or fewer.`; })(), test: (value: string | undefined) => { if (!type) { return true; } - if (type === TextInputType.Short) { - return Boolean(value && value.length <= 120); - } - if (type === TextInputType.Long) { - return Boolean(value && value.length <= 250); - } - if (type === TextInputType.ExtraLong) { - return Boolean(value && value.length <= 750); - } if (type === TextInputType.Email) { return Boolean(value && emailRegex.test(value)); } if (type === TextInputType.Phone) { return Boolean(value); } - return false; + return Boolean(value && value.length <= TEXT_LIMITS[type]); }, }); diff --git a/editor.planx.uk/src/@planx/components/shared/Schema/InputFields/TextFieldInput.tsx b/editor.planx.uk/src/@planx/components/shared/Schema/InputFields/TextFieldInput.tsx index bba487d5d6..a19ed460b6 100644 --- a/editor.planx.uk/src/@planx/components/shared/Schema/InputFields/TextFieldInput.tsx +++ b/editor.planx.uk/src/@planx/components/shared/Schema/InputFields/TextFieldInput.tsx @@ -1,6 +1,8 @@ import type { TextField } from "@planx/components/shared/Schema/model"; +import { TextInputType } from "@planx/components/TextInput/model"; import React from "react"; import InputLabel from "ui/public/InputLabel"; +import { CharacterCounter, isLongTextType } from "ui/shared/CharacterCounter"; import Input from "ui/shared/Input"; import { DESCRIPTION_TEXT, ERROR_MESSAGE } from "../../constants"; @@ -12,6 +14,7 @@ export const TextFieldInput: React.FC> = (props) => { const { data, formik } = props; const { id, errorMessage } = fieldProps; + const characterCountLimit = data.type && isLongTextType(data.type); return ( {data.description && ( @@ -33,13 +36,22 @@ export const TextFieldInput: React.FC> = (props) => { required inputProps={{ "aria-describedby": [ - data.description ? DESCRIPTION_TEXT : "", + data.description + ? `${DESCRIPTION_TEXT} character-hint` + : "character-hint", errorMessage ? `${ERROR_MESSAGE}-${id}` : "", ] .filter(Boolean) .join(" "), }} /> + {characterCountLimit && ( + + )} ); }; diff --git a/editor.planx.uk/src/pages/Login.tsx b/editor.planx.uk/src/pages/Login.tsx index dce5378a3c..fe7cf774d4 100644 --- a/editor.planx.uk/src/pages/Login.tsx +++ b/editor.planx.uk/src/pages/Login.tsx @@ -83,7 +83,6 @@ const Login: React.FC = () => { Continue with Microsoft - (in development) diff --git a/editor.planx.uk/src/ui/shared/CharacterCounter.tsx b/editor.planx.uk/src/ui/shared/CharacterCounter.tsx new file mode 100644 index 0000000000..22b63312aa --- /dev/null +++ b/editor.planx.uk/src/ui/shared/CharacterCounter.tsx @@ -0,0 +1,92 @@ +import Typography from "@mui/material/Typography"; +import { visuallyHidden } from "@mui/utils"; +import { TEXT_LIMITS, TextInputType } from "@planx/components/TextInput/model"; +import { debounce } from "lodash"; +import React, { useCallback, useEffect, useState } from "react"; +import { FONT_WEIGHT_SEMI_BOLD } from "theme"; + +export type Props = { + textInputType: TextInputType; + count: number; + error: boolean; +}; + +export function isLongTextType( + type: TextInputType, +): type is TextInputType.Long | TextInputType.ExtraLong { + return type === TextInputType.Long || type === TextInputType.ExtraLong; +} + +export const CharacterCounter: React.FC = ({ + textInputType, + count, + error, +}) => { + const [screenReaderCount, setScreenReaderCount] = useState(0); + const [showReaderCount, setShowReaderCount] = useState(false); + + const updateCharacterCount = useCallback( + debounce((count: number) => { + setScreenReaderCount(count); + setShowReaderCount(true); + }, 500), + [], + ); + + function getLongTextLimit(type: TextInputType): number { + if (isLongTextType(type)) { + return TEXT_LIMITS[type]; + } else { + return 0; + } + } + + const currentCharacterCount = getLongTextLimit(textInputType) - count; + + const showCharacterLimitError = currentCharacterCount < 0; + + useEffect(() => { + if (count !== screenReaderCount) { + setShowReaderCount(false); + } + if (count > 0) { + updateCharacterCount(currentCharacterCount); + } + }, [currentCharacterCount, updateCharacterCount, count, screenReaderCount]); + + const characterLimitText = showCharacterLimitError + ? `You have ${Math.abs(currentCharacterCount)} characters too many` + : `You have ${currentCharacterCount} characters remaining`; + + const screenReaderCountText = showCharacterLimitError + ? `You have ${Math.abs(screenReaderCount)} characters too many` + : `You have ${screenReaderCount} characters remaining`; + + return ( + <> + + {`You can enter up to ${getLongTextLimit(textInputType)} characters`} + + + {characterLimitText} + + + {showReaderCount && screenReaderCountText} + + + ); +}; diff --git a/editor.planx.uk/src/ui/shared/Input.tsx b/editor.planx.uk/src/ui/shared/Input.tsx index 903fab985d..45b57e3a3e 100644 --- a/editor.planx.uk/src/ui/shared/Input.tsx +++ b/editor.planx.uk/src/ui/shared/Input.tsx @@ -115,7 +115,7 @@ export default forwardRef((props: Props, ref): FCReturn => { container.current?.querySelector("input")?.select(); }, }), - [], + [] ); return (