diff --git a/.prettierrc.json b/.prettierrc.json index 99c247ae5..f3c0e3054 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -15,7 +15,7 @@ "tabWidth": 2, "trailingComma": "es5", "useTabs": false, - "importOrder": ["^((api-mocks|components|data|formio|hooks|map|story-utils|types)/(.*)|(api|api-mocks|cache|Context|errors|headers|i18n|sdk|sentry|types|utils))$", "^[./]"], + "importOrder": ["^((api-mocks|components|data|formio|hooks|map|routes|story-utils|types)/(.*)|(api|api-mocks|cache|Context|errors|headers|i18n|routes|sdk|sentry|types|utils))$", "^[./]"], "importOrderSeparation": true, "importOrderSortSpecifiers": true } diff --git a/.storybook/main.mts b/.storybook/main.mts index 9917db5d9..c6604d812 100644 --- a/.storybook/main.mts +++ b/.storybook/main.mts @@ -17,7 +17,6 @@ const config: StorybookConfig = { 'storybook-react-intl', 'storybook-addon-remix-react-router', '@storybook/addon-coverage', - '@storybook/addon-webpack5-compiler-babel', ], framework: { name: '@storybook/react-vite', diff --git a/codecov.yml b/codecov.yml index 35f677c24..984721139 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,3 +1,8 @@ --- flag_management: {} + +ignore: + - "src/api-mocks/*" + - "src/story-utils/*" + - "src/**/mocks.{js,jsx}" diff --git a/package-lock.json b/package-lock.json index 3297a6fc6..3ef62e3f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,10 +70,10 @@ "@storybook/react-vite": "^8.4.7", "@storybook/test-runner": "^0.20.0", "@storybook/types": "^8.4.7", - "@testing-library/dom": ">=8.20.0", - "@testing-library/jest-dom": "^5.14.1", - "@testing-library/react": "^14.0.0", - "@testing-library/user-event": "^14.4.3", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.2.0", + "@testing-library/user-event": "^14.6.0", "@trivago/prettier-plugin-sort-imports": "^4.3.0", "@utrecht/component-library-css": "1.0.0-alpha.604", "@utrecht/component-library-react": "1.0.0-alpha.353", @@ -6255,24 +6255,6 @@ "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" } }, - "node_modules/@storybook/test/node_modules/@testing-library/dom": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", - "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@storybook/test/node_modules/@testing-library/jest-dom": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.5.0.tgz", @@ -6309,6 +6291,18 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==" }, + "node_modules/@storybook/test/node_modules/@testing-library/user-event": { + "version": "14.5.2", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", + "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@storybook/test/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -6323,29 +6317,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@storybook/test/node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/@storybook/test/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@storybook/test/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -6622,29 +6593,27 @@ } }, "node_modules/@testing-library/dom": { - "version": "9.3.4", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", - "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", - "dev": true, + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", - "aria-query": "5.1.3", + "aria-query": "5.3.0", "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "pretty-format": "^27.0.2" }, "engines": { - "node": ">=14" + "node": ">=18" } }, "node_modules/@testing-library/dom/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -6659,7 +6628,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -6675,7 +6643,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "engines": { "node": ">=8" } @@ -6684,7 +6651,6 @@ "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" }, @@ -6693,23 +6659,21 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz", - "integrity": "sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg==", + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz", + "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==", "dev": true, "dependencies": { - "@adobe/css-tools": "^4.0.1", - "@babel/runtime": "^7.9.2", - "@types/testing-library__jest-dom": "^5.9.1", + "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "chalk": "^3.0.0", "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.5.6", - "lodash": "^4.17.15", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.21", "redent": "^3.0.0" }, "engines": { - "node": ">=8", + "node": ">=14", "npm": ">=6", "yarn": ">=1" } @@ -6742,6 +6706,12 @@ "node": ">=8" } }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true + }, "node_modules/@testing-library/jest-dom/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -6764,27 +6734,37 @@ } }, "node_modules/@testing-library/react": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.2.1.tgz", - "integrity": "sha512-sGdjws32ai5TLerhvzThYFbpnF9XtL65Cjf+gB0Dhr29BGqK+mAeN7SURSdu+eqgET4ANcWoC7FQpkaiGvBr+A==", + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.2.0.tgz", + "integrity": "sha512-2cSskAvA1QNtKc8Y9VJQRv0tm3hLVgxRGDB+KYhIaPQJ1I+RHbhIXcM+zClKXzMes/wshsMVzf4B9vS4IZpqDQ==", "dev": true, "dependencies": { - "@babel/runtime": "^7.12.5", - "@testing-library/dom": "^9.0.0", - "@types/react-dom": "^18.0.0" + "@babel/runtime": "^7.12.5" }, "engines": { - "node": ">=14" + "node": ">=18" }, "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, "node_modules/@testing-library/user-event": { - "version": "14.5.2", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", - "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", + "version": "14.6.0", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.0.tgz", + "integrity": "sha512-+jsfK7kVJbqnCYtLTln8Ja/NmVrZRwBJHmHR9IxIVccMWSOZ6Oy0FkDJNeyVu4QSpMNmRfy10Xb76ObRDlWWBQ==", + "dev": true, "engines": { "node": ">=12", "npm": ">=6" @@ -7035,67 +7015,6 @@ "@types/istanbul-lib-report": "*" } }, - "node_modules/@types/jest": { - "version": "28.1.3", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-28.1.3.tgz", - "integrity": "sha512-Tsbjk8Y2hkBaY/gJsataeb4q9Mubw9EOz7+4RjPkzD5KjTvHHs7cpws22InaoXxAVAhF5HfFbzJjo6oKWqSZLw==", - "dev": true, - "dependencies": { - "jest-matcher-utils": "^28.0.0", - "pretty-format": "^28.0.0" - } - }, - "node_modules/@types/jest/node_modules/@jest/schemas": { - "version": "28.1.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-28.1.3.tgz", - "integrity": "sha512-/l/VWsdt/aBXgjshLWOFyFt3IVdYypu5y2Wn2rOO1un6nkqIn8SLXzgIMYXFyYsRWDyF5EthmKJMIdJvk08grg==", - "dev": true, - "dependencies": { - "@sinclair/typebox": "^0.24.1" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" - } - }, - "node_modules/@types/jest/node_modules/@sinclair/typebox": { - "version": "0.24.51", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", - "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==", - "dev": true - }, - "node_modules/@types/jest/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@types/jest/node_modules/pretty-format": { - "version": "28.1.3", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.3.tgz", - "integrity": "sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q==", - "dev": true, - "dependencies": { - "@jest/schemas": "^28.1.3", - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" - } - }, - "node_modules/@types/jest/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==", - "dev": true - }, "node_modules/@types/js-cookie": { "version": "2.2.7", "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.7.tgz", @@ -7160,15 +7079,6 @@ "csstype": "^3.0.2" } }, - "node_modules/@types/react-dom": { - "version": "18.2.22", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.22.tgz", - "integrity": "sha512-fHkBXPeNtfvri6gdsMYyW+dW7RXFo6Ad09nLFK0VQWR7yGLai/Cyvyj696gbwYvBnhGtevUG9cET0pmUbMtoPQ==", - "dev": true, - "dependencies": { - "@types/react": "*" - } - }, "node_modules/@types/react-transition-group": { "version": "4.4.10", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", @@ -7194,15 +7104,6 @@ "integrity": "sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==", "dev": true }, - "node_modules/@types/testing-library__jest-dom": { - "version": "5.14.9", - "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.9.tgz", - "integrity": "sha512-FSYhIjFlfOpGSRyVoMBMuS3ws5ehFQODymf3vlI7U1K8c7PHwWwFY7VREfmsuzHSOnoKs/9/Y983ayOs7eRzqw==", - "dev": true, - "dependencies": { - "@types/jest": "*" - } - }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -7920,12 +7821,11 @@ } }, "node_modules/aria-query": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", - "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", - "dev": true, + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dependencies": { - "deep-equal": "^2.0.5" + "dequal": "^2.0.3" } }, "node_modules/array-buffer-byte-length": { @@ -9728,38 +9628,6 @@ "node": ">=6" } }, - "node_modules/deep-equal": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", - "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", - "dev": true, - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.5", - "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.2", - "is-arguments": "^1.1.1", - "is-array-buffer": "^3.0.2", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "isarray": "^2.0.5", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "side-channel": "^1.0.4", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -9883,15 +9751,6 @@ "resolved": "https://registry.npmjs.org/dialog-polyfill/-/dialog-polyfill-0.5.6.tgz", "integrity": "sha512-ZbVDJI9uvxPAKze6z146rmfUZjBqNEwcnFTVamQzXH+svluiV7swmVIGr7miwADgfgt1G2JQIytypM9fbyhX4w==" }, - "node_modules/diff-sequences": { - "version": "28.1.1", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-28.1.1.tgz", - "integrity": "sha512-FU0iFaH/E23a+a718l8Qa/19bF9p06kgE0KipMOMadwa3SjnaElKzPaUC0vnibs6/B/9ni97s61mcejk8W1fQw==", - "dev": true, - "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" - } - }, "node_modules/diffable-html": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/diffable-html/-/diffable-html-4.1.0.tgz", @@ -10189,26 +10048,6 @@ "node": ">= 0.4" } }, - "node_modules/es-get-iterator": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", - "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "is-arguments": "^1.1.1", - "is-map": "^2.0.2", - "is-set": "^2.0.2", - "is-string": "^1.0.7", - "isarray": "^2.0.5", - "stop-iteration-iterator": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/es-iterator-helpers": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", @@ -13852,133 +13691,6 @@ "node": ">=8" } }, - "node_modules/jest-diff": { - "version": "28.1.3", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-28.1.3.tgz", - "integrity": "sha512-8RqP1B/OXzjjTWkqMX67iqgwBVJRgCyKD3L9nq+6ZqJMdvjE8RgHktqZ6jNrkdMT+dJuYNI3rhQpxaz7drJHfw==", - "dev": true, - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^28.1.1", - "jest-get-type": "^28.0.2", - "pretty-format": "^28.1.3" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" - } - }, - "node_modules/jest-diff/node_modules/@jest/schemas": { - "version": "28.1.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-28.1.3.tgz", - "integrity": "sha512-/l/VWsdt/aBXgjshLWOFyFt3IVdYypu5y2Wn2rOO1un6nkqIn8SLXzgIMYXFyYsRWDyF5EthmKJMIdJvk08grg==", - "dev": true, - "dependencies": { - "@sinclair/typebox": "^0.24.1" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" - } - }, - "node_modules/jest-diff/node_modules/@sinclair/typebox": { - "version": "0.24.51", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", - "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==", - "dev": true - }, - "node_modules/jest-diff/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-diff/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-diff/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-diff/node_modules/jest-get-type": { - "version": "28.0.2", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-28.0.2.tgz", - "integrity": "sha512-ioj2w9/DxSYHfOm5lJKCdcAmPJzQXmbM/Url3rhlghrPvT3tt+7a/+oXc9azkKmLvoiXjtV83bEWqi+vs5nlPA==", - "dev": true, - "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" - } - }, - "node_modules/jest-diff/node_modules/pretty-format": { - "version": "28.1.3", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.3.tgz", - "integrity": "sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q==", - "dev": true, - "dependencies": { - "@jest/schemas": "^28.1.3", - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" - } - }, - "node_modules/jest-diff/node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-diff/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==", - "dev": true - }, - "node_modules/jest-diff/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/jest-docblock": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", @@ -14250,133 +13962,6 @@ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", "dev": true }, - "node_modules/jest-matcher-utils": { - "version": "28.1.3", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-28.1.3.tgz", - "integrity": "sha512-kQeJ7qHemKfbzKoGjHHrRKH6atgxMk8Enkk2iPQ3XwO6oE/KYD8lMYOziCkeSB9G4adPM4nR1DE8Tf5JeWH6Bw==", - "dev": true, - "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^28.1.3", - "jest-get-type": "^28.0.2", - "pretty-format": "^28.1.3" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" - } - }, - "node_modules/jest-matcher-utils/node_modules/@jest/schemas": { - "version": "28.1.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-28.1.3.tgz", - "integrity": "sha512-/l/VWsdt/aBXgjshLWOFyFt3IVdYypu5y2Wn2rOO1un6nkqIn8SLXzgIMYXFyYsRWDyF5EthmKJMIdJvk08grg==", - "dev": true, - "dependencies": { - "@sinclair/typebox": "^0.24.1" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" - } - }, - "node_modules/jest-matcher-utils/node_modules/@sinclair/typebox": { - "version": "0.24.51", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", - "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==", - "dev": true - }, - "node_modules/jest-matcher-utils/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-matcher-utils/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-matcher-utils/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-matcher-utils/node_modules/jest-get-type": { - "version": "28.0.2", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-28.0.2.tgz", - "integrity": "sha512-ioj2w9/DxSYHfOm5lJKCdcAmPJzQXmbM/Url3rhlghrPvT3tt+7a/+oXc9azkKmLvoiXjtV83bEWqi+vs5nlPA==", - "dev": true, - "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" - } - }, - "node_modules/jest-matcher-utils/node_modules/pretty-format": { - "version": "28.1.3", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.3.tgz", - "integrity": "sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q==", - "dev": true, - "dependencies": { - "@jest/schemas": "^28.1.3", - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" - } - }, - "node_modules/jest-matcher-utils/node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-matcher-utils/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==", - "dev": true - }, - "node_modules/jest-matcher-utils/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/jest-message-util": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", @@ -16622,22 +16207,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object-is": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", - "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -18798,18 +18367,6 @@ "integrity": "sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==", "dev": true }, - "node_modules/stop-iteration-iterator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", - "integrity": "sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==", - "dev": true, - "dependencies": { - "internal-slot": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/storybook": { "version": "8.4.7", "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.4.7.tgz", diff --git a/package.json b/package.json index bba692856..f4e9403b3 100644 --- a/package.json +++ b/package.json @@ -115,10 +115,10 @@ "@storybook/react-vite": "^8.4.7", "@storybook/test-runner": "^0.20.0", "@storybook/types": "^8.4.7", - "@testing-library/dom": ">=8.20.0", - "@testing-library/jest-dom": "^5.14.1", - "@testing-library/react": "^14.0.0", - "@testing-library/user-event": "^14.4.3", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.2.0", + "@testing-library/user-event": "^14.6.0", "@trivago/prettier-plugin-sort-imports": "^4.3.0", "@utrecht/component-library-css": "1.0.0-alpha.604", "@utrecht/component-library-react": "1.0.0-alpha.353", diff --git a/src/Context.js b/src/Context.js index 331fd04d9..a6f148d35 100644 --- a/src/Context.js +++ b/src/Context.js @@ -27,14 +27,6 @@ const FormContext = React.createContext({ }); FormContext.displayName = 'FormContext'; -const AnalyticsToolsConfigContext = React.createContext({ - govmetricSourceIdFormFinished: '', - govmetricSourceIdFormAborted: '', - govmetricSecureGuidFormFinished: '', - govmetricSecureGuidFormAborted: '', - enableGovmetricAnalytics: false, -}); - const ConfigContext = React.createContext({ baseUrl: '', clientBaseUrl: window.location.href, @@ -51,10 +43,4 @@ FormioTranslations.displayName = 'FormioTranslations'; const SubmissionContext = React.createContext({submission: null}); SubmissionContext.displayName = 'SubmissionContext'; -export { - FormContext, - ConfigContext, - FormioTranslations, - SubmissionContext, - AnalyticsToolsConfigContext, -}; +export {FormContext, ConfigContext, FormioTranslations, SubmissionContext}; diff --git a/src/api-mocks/submissions.js b/src/api-mocks/submissions.js index bc2842d40..f40eb5764 100644 --- a/src/api-mocks/submissions.js +++ b/src/api-mocks/submissions.js @@ -73,9 +73,9 @@ export const mockSubmissionPost = (submission = buildSubmission()) => return HttpResponse.json(submission, {status: 201}); }); -export const mockSubmissionGet = () => +export const mockSubmissionGet = (submission = buildSubmission()) => http.get(`${BASE_URL}submissions/:uuid`, () => { - return HttpResponse.json(SUBMISSION_DETAILS, {status: 200}); + return HttpResponse.json(submission, {status: 200}); }); export const mockSubmissionStepGet = () => @@ -112,6 +112,13 @@ export const mockSubmissionSummaryGet = () => ) ); +export const mockSubmissionCompletePost = () => + http.post(`${BASE_URL}submissions/:uuid/_complete`, () => + HttpResponse.json({ + statusUrl: `${BASE_URL}submissions/${SUBMISSION_DETAILS.id}/super-random-token/status`, + }) + ); + /** * Simulate a successful backend processing status without payment. */ @@ -160,6 +167,6 @@ export const mockSubmissionProcessingStatusErrorGet = http.get( }) ); -export const mockSubmissionPaymentStartGet = http.post(`${BASE_URL}payment/:uuid/demo/start`, () => - HttpResponse.json({data: {method: 'get', action: 'https://example.com'}}) -); +export const mockSubmissionPaymentStartPost = ( + data = {type: 'get', url: 'https://example.com', data: {}} +) => http.post(`${BASE_URL}payment/:uuid/demo/start`, () => HttpResponse.json(data)); diff --git a/src/components/AbortButton/AbortButton.jsx b/src/components/AbortButton/AbortButton.jsx index 8fc4958ee..7078b469a 100644 --- a/src/components/AbortButton/AbortButton.jsx +++ b/src/components/AbortButton/AbortButton.jsx @@ -1,15 +1,14 @@ import PropTypes from 'prop-types'; -import {useContext} from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; -import {AnalyticsToolsConfigContext} from 'Context'; import {OFButton} from 'components/Button'; +import {useAnalyticsToolsConfig} from 'components/analytics/AnalyticsToolConfigProvider'; import {buildGovMetricUrl} from 'components/analytics/utils'; import useFormContext from 'hooks/useFormContext'; const AbortButton = ({isAuthenticated, onDestroySession}) => { const intl = useIntl(); - const analyticsToolsConfig = useContext(AnalyticsToolsConfigContext); + const analyticsToolsConfig = useAnalyticsToolsConfig(); const form = useFormContext(); const confirmationMessage = isAuthenticated diff --git a/src/components/App.jsx b/src/components/App.jsx index 238688802..c8406e627 100644 --- a/src/components/App.jsx +++ b/src/components/App.jsx @@ -1,60 +1,14 @@ -import {Navigate, Outlet, useMatch} from 'react-router-dom'; +import {Navigate, Outlet, useMatch, useSearchParams} from 'react-router-dom'; -import {Cosign, cosignRoutes} from 'components/CoSign'; -import ErrorBoundary from 'components/Errors/ErrorBoundary'; -import Form from 'components/Form'; -import SessionExpired from 'components/Sessions/SessionExpired'; -import { - CreateAppointment, - appointmentRoutes, - manageAppointmentRoutes, -} from 'components/appointments'; -import formRoutes from 'components/formRoutes'; import useFormContext from 'hooks/useFormContext'; -import useQuery from 'hooks/useQuery'; import useZodErrorMap from 'hooks/useZodErrorMap'; -export const routes = [ - { - path: 'afspraak-annuleren/*', - children: manageAppointmentRoutes, - }, - { - path: 'afspraak-maken/*', - element: <CreateAppointment />, - children: appointmentRoutes, - }, - { - path: 'cosign/*', - element: <Cosign />, - children: cosignRoutes, - }, - { - path: 'sessie-verlopen', - element: ( - <ErrorBoundary useCard> - <SessionExpired /> - </ErrorBoundary> - ), - }, - // All the rest goes to the formio-based form flow - { - path: '*', - element: ( - <ErrorBoundary useCard> - <Form /> - </ErrorBoundary> - ), - children: formRoutes, - }, -]; - /* Top level router - routing between an actual form or supporting screens. */ const App = () => { const form = useFormContext(); - const query = useQuery(); + const [params] = useSearchParams(); const appointmentMatch = useMatch('afspraak-maken/*'); const appointmentCancelMatch = useMatch('afspraak-annuleren/*'); const isSessionExpiryMatch = useMatch('sessie-verlopen'); @@ -69,7 +23,7 @@ const App = () => { replace to={{ pathname: '../afspraak-maken', - search: `?${query}`, + search: `?${params}`, }} /> ); diff --git a/src/components/App.stories.jsx b/src/components/App.stories.jsx index 1dd589691..41a253747 100644 --- a/src/components/App.stories.jsx +++ b/src/components/App.stories.jsx @@ -10,9 +10,10 @@ import { mockSubmissionStepGet, } from 'api-mocks/submissions'; import {mockLanguageChoicePut, mockLanguageInfoGet} from 'components/LanguageSelection/mocks'; +import routes from 'routes'; import {ConfigDecorator, LayoutDecorator} from 'story-utils/decorators'; -import App, {routes as nestedRoutes} from './App'; +import App from './App'; import {SUBMISSION_ALLOWED} from './constants'; export default { @@ -84,13 +85,6 @@ export default { }; const Wrapper = ({form, showExternalHeader}) => { - const routes = [ - { - path: '*', - element: <App />, - children: nestedRoutes, - }, - ]; const router = createMemoryRouter(routes, { initialEntries: ['/'], initialIndex: 0, diff --git a/src/components/CoSign/Cosign.spec.jsx b/src/components/CoSign/Cosign.spec.jsx index 146c579d7..9a6fa5f28 100644 --- a/src/components/CoSign/Cosign.spec.jsx +++ b/src/components/CoSign/Cosign.spec.jsx @@ -7,9 +7,9 @@ import {ConfigContext, FormContext} from 'Context'; import {BASE_URL, buildForm} from 'api-mocks'; import mswServer from 'api-mocks/msw-server'; import {mockSubmissionGet, mockSubmissionSummaryGet} from 'api-mocks/submissions'; +import cosignRoutes from 'routes/cosign'; import Cosign from './Cosign'; -import {default as nestedRoutes} from './routes'; beforeEach(() => { localStorage.clear(); @@ -55,7 +55,7 @@ const routes = [ { path: '/cosign/*', element: <Cosign />, - children: nestedRoutes, + children: cosignRoutes, }, ]; diff --git a/src/components/CoSign/index.jsx b/src/components/CoSign/index.jsx index 71db38b45..2a224845a 100644 --- a/src/components/CoSign/index.jsx +++ b/src/components/CoSign/index.jsx @@ -1,7 +1,6 @@ import CoSignOld from './CoSignOld'; import Cosign from './Cosign'; import CosignDone from './CosignDone'; -import cosignRoutes from './routes'; export default CoSignOld; -export {Cosign, CosignDone, cosignRoutes}; +export {Cosign, CosignDone}; diff --git a/src/components/Form.jsx b/src/components/Form.jsx index 3ead291d9..bf48cdc6e 100644 --- a/src/components/Form.jsx +++ b/src/components/Form.jsx @@ -1,102 +1,22 @@ -import PropTypes from 'prop-types'; -import React, {useContext, useEffect} from 'react'; +import {useContext, useEffect, useState} from 'react'; import {useIntl} from 'react-intl'; -import { - Navigate, - Outlet, - Route, - Routes, - useLocation, - useMatch, - useNavigate, -} from 'react-router-dom'; -import {useAsync, usePrevious} from 'react-use'; -import {useImmerReducer} from 'use-immer'; +import {Navigate, Outlet, useLocation, useNavigate, useSearchParams} from 'react-router-dom'; +import {usePrevious} from 'react-use'; -import {AnalyticsToolsConfigContext, ConfigContext} from 'Context'; -import {destroy, get} from 'api'; -import ErrorBoundary from 'components/Errors/ErrorBoundary'; +import {ConfigContext} from 'Context'; +import {destroy} from 'api'; +import FormProgressIndicator from 'components/FormProgressIndicator'; import Loader from 'components/Loader'; -import {ConfirmationView, StartPaymentView} from 'components/PostCompletionViews'; -import ProgressIndicator from 'components/ProgressIndicator'; -import RequireSubmission from 'components/RequireSubmission'; -import {SessionTrackerModal} from 'components/Sessions'; -import {SubmissionSummary} from 'components/Summary'; -import { - PI_TITLE, - START_FORM_QUERY_PARAM, - STEP_LABELS, - SUBMISSION_ALLOWED, -} from 'components/constants'; +import SubmissionProvider from 'components/SubmissionProvider'; +import AnalyticsToolsConfigProvider from 'components/analytics/AnalyticsToolConfigProvider'; +import {START_FORM_QUERY_PARAM} from 'components/constants'; import {flagActiveSubmission, flagNoActiveSubmission} from 'data/submissions'; import useAutomaticRedirect from 'hooks/useAutomaticRedirect'; import useFormContext from 'hooks/useFormContext'; import usePageViews from 'hooks/usePageViews'; -import useQuery from 'hooks/useQuery'; import useRecycleSubmission from 'hooks/useRecycleSubmission'; -import Types from 'types'; import FormDisplay from './FormDisplay'; -import {addFixedSteps, getStepsInfo} from './ProgressIndicator/utils'; - -const initialState = { - submission: null, - submittedSubmission: null, - processingStatusUrl: '', - processingError: '', - completed: false, - startingError: '', -}; - -const reducer = (draft, action) => { - switch (action.type) { - case 'SUBMISSION_LOADED': { - // keep the submission instance in the state and set the current step to the - // first step of the form. - draft.submission = action.payload; - break; - } - case 'SUBMITTED': { - return { - ...initialState, - submittedSubmission: action.payload.submission, - processingStatusUrl: action.payload.processingStatusUrl, - }; - } - case 'PROCESSING_FAILED': { - // set the error message in the state - draft.processingError = action.payload; - // put the submission back in the state as well, so we can re-submit - draft.submission = draft.submittedSubmission; - break; - } - case 'PROCESSING_SUCCEEDED': { - draft.processingError = null; - draft.completed = true; - break; - } - case 'CLEAR_PROCESSING_ERROR': { - draft.processingError = ''; - break; - } - case 'DESTROY_SUBMISSION': { - return { - ...initialState, - }; - } - case 'RESET': { - const initialState = action.payload; - return initialState; - } - case 'STARTING_ERROR': { - draft.startingError = action.payload; - break; - } - default: { - throw new Error(`Unknown action ${action.type}`); - } - } -}; /** * An OpenForms form. @@ -110,301 +30,99 @@ const Form = () => { const form = useFormContext(); const navigate = useNavigate(); const shouldAutomaticallyRedirect = useAutomaticRedirect(form); - const queryParams = useQuery(); + const [params] = useSearchParams(); usePageViews(); const intl = useIntl(); const prevLocale = usePrevious(intl.locale); - const {pathname: currentPathname} = useLocation(); - - // TODO replace absolute path check with relative - const introductionMatch = useMatch('/introductie'); - const stepMatch = useMatch('/stap/:step'); - const summaryMatch = useMatch('/overzicht'); - const paymentMatch = useMatch('/betalen'); - const confirmationMatch = useMatch('/bevestiging'); + const {state: routerState} = useLocation(); // extract the declared properties and configuration - const {steps} = form; const config = useContext(ConfigContext); - // load the state management/reducer - const initialStateFromProps = {...initialState, step: steps[0]}; - const [state, dispatch] = useImmerReducer(reducer, initialStateFromProps); + // figure out the submission in the state. If it's stored in the router state, extract + // it and set it in the React state to 'persist' it. + const submissionFromRouterState = routerState?.submission; + const [submission, setSubmission] = useState(null); + if (submission == null && submissionFromRouterState != null) { + setSubmission(submissionFromRouterState); + } - const onSubmissionLoaded = (submission, next = '') => { - dispatch({ - type: 'SUBMISSION_LOADED', - payload: submission, - }); + const onSubmissionLoaded = submission => { + setSubmission(submission); flagActiveSubmission(); - // navigate to the first step - const firstStepRoute = `/stap/${form.steps[0].slug}`; - navigate(next ? next : firstStepRoute); }; // if there is an active submission still, re-load that (relevant for hard-refreshes) + // TODO: should probably move to the router loader const [loading, setSubmissionId, removeSubmissionId] = useRecycleSubmission( form, - state.submission, + submission, onSubmissionLoaded ); - const {value: analyticsToolsConfigInfo, loading: loadingAnalyticsConfig} = useAsync(async () => { - return await get(`${config.baseUrl}analytics/analytics-tools-config-info`); - }, [intl.locale]); - useEffect( () => { if (prevLocale === undefined) return; - if (intl.locale !== prevLocale && state.submission) { + if (intl.locale !== prevLocale && submission) { removeSubmissionId(); - dispatch({type: 'DESTROY_SUBMISSION'}); + setSubmission(null); flagNoActiveSubmission(); navigate(`/?${START_FORM_QUERY_PARAM}=1`); } }, - [intl.locale, prevLocale, removeSubmissionId, state.submission] // eslint-disable-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps + [intl.locale, prevLocale, removeSubmissionId, submission] ); - const onSubmissionObtained = submission => { - dispatch({ - type: 'SUBMISSION_LOADED', - payload: submission, - }); - flagActiveSubmission(); - setSubmissionId(submission.id); - }; - - const onSubmitForm = processingStatusUrl => { - removeSubmissionId(); - dispatch({ - type: 'SUBMITTED', - payload: { - submission: state.submission, - processingStatusUrl, - }, - }); - - if (submission?.payment.isRequired && !state.submission.payment.hasPaid) { - navigate('/betalen'); - } else { - navigate('/bevestiging'); - } - }; - const onDestroySession = async () => { - await destroy(`${config.baseUrl}authentication/${state.submission.id}/session`); - + await destroy(`${config.baseUrl}authentication/${submission.id}/session`); removeSubmissionId(); - dispatch({ - type: 'RESET', - payload: initialStateFromProps, - }); + setSubmission(null); navigate('/'); }; - const onProcessingFailure = errorMessage => { - // TODO: provide generic fallback message in case no explicit - // message is shown - dispatch({type: 'PROCESSING_FAILED', payload: errorMessage}); - navigate('/overzicht'); - }; - // handle redirect from payment provider to render appropriate page and include the // params as state for the next component. - if (queryParams.get('of_payment_status')) { + if (params.get('of_payment_status')) { + // TODO: store details in sessionStorage instead, to survive hard refreshes return ( <Navigate replace to="/bevestiging" state={{ - status: queryParams.get('of_payment_status'), - userAction: queryParams.get('of_payment_action'), - statusUrl: queryParams.get('of_submission_status'), + status: params.get('of_payment_status'), + userAction: params.get('of_payment_action'), + statusUrl: params.get('of_submission_status'), }} /> ); } - if (loading || loadingAnalyticsConfig || shouldAutomaticallyRedirect) { + if (loading || shouldAutomaticallyRedirect) { return <Loader modifiers={['centered']} />; } - // Progress Indicator - - const isIntroductionPage = !!introductionMatch; - const isStartPage = !isIntroductionPage && !summaryMatch && stepMatch == null && !paymentMatch; - const submissionAllowedSpec = state.submission?.submissionAllowed ?? form.submissionAllowed; - const showOverview = submissionAllowedSpec !== SUBMISSION_ALLOWED.noWithoutOverview; - const submission = state.submission || state.submittedSubmission; - const isCompleted = state.completed; - const formName = form.name; - const needsPayment = submission ? submission.payment.isRequired : form.paymentRequired; - - // Figure out the slug from the currently active step IF we're looking at a step - const stepSlug = stepMatch ? stepMatch.params.step : ''; - - // figure out the title for the mobile menu based on the state - let activeStepTitle; - if (isIntroductionPage) { - activeStepTitle = intl.formatMessage(STEP_LABELS.introduction); - } else if (isStartPage) { - activeStepTitle = intl.formatMessage(STEP_LABELS.login); - } else if (summaryMatch) { - activeStepTitle = intl.formatMessage(STEP_LABELS.overview); - } else if (paymentMatch) { - activeStepTitle = intl.formatMessage(STEP_LABELS.payment); - } else { - const step = steps.find(step => step.slug === stepSlug); - activeStepTitle = step.formDefinition; - } - - const ariaMobileIconLabel = intl.formatMessage({ - description: 'Progress step indicator toggle icon (mobile)', - defaultMessage: 'Toggle the progress status display', - }); - - const accessibleToggleStepsLabel = intl.formatMessage( - { - description: 'Active step accessible label in mobile progress indicator', - defaultMessage: 'Current step in form {formName}: {activeStepTitle}', - }, - {formName, activeStepTitle} - ); - - // process the form/submission steps information into step data that can be passed - // to the progress indicator. - // If the form is marked to not display non-applicable steps at all, filter them out. - const showNonApplicableSteps = !form.hideNonApplicableSteps; - const updatedSteps = - // first, process all the form steps in a format suitable for the PI - getStepsInfo(steps, submission, currentPathname) - // then, filter out the non-applicable steps if they should not be displayed - .filter(step => showNonApplicableSteps || step.isApplicable); - - const stepsToRender = addFixedSteps( - intl, - updatedSteps, - submission, - currentPathname, - showOverview, - needsPayment, - isCompleted, - !!form.introductionPageContent - ); - - // Show the progress indicator if enabled on the form AND we're not in the payment - // confirmation screen. - const progressIndicator = - form.showProgressIndicator && !confirmationMatch ? ( - <ProgressIndicator - title={PI_TITLE} - formTitle={formName} - steps={stepsToRender} - ariaMobileIconLabel={ariaMobileIconLabel} - accessibleToggleStepsLabel={accessibleToggleStepsLabel} - /> - ) : null; - - if (state.startingError) throw state.startingError; - - // Route the correct page based on URL - const router = ( - <Routes> - <Route - path="overzicht" - element={ - <ErrorBoundary useCard> - <SessionTrackerModal> - <RequireSubmission - retrieveSubmissionFromContext - processingError={state.processingError} - onConfirm={onSubmitForm} - component={SubmissionSummary} - onClearProcessingErrors={() => dispatch({type: 'CLEAR_PROCESSING_ERROR'})} - onDestroySession={onDestroySession} - form={form} - /> - </SessionTrackerModal> - </ErrorBoundary> - } - /> - - <Route - path="betalen" - element={ - <ErrorBoundary useCard> - <RequireSubmission - submission={state.submittedSubmission} - statusUrl={state.processingStatusUrl} - onFailure={onProcessingFailure} - onConfirmed={() => dispatch({type: 'PROCESSING_SUCCEEDED'})} - component={StartPaymentView} - donwloadPDFText={form.submissionReportDownloadLinkTitle} - /> - </ErrorBoundary> - } - /> - - <Route - path="bevestiging" - element={ - <ErrorBoundary useCard> - <ConfirmationView - statusUrl={state.processingStatusUrl} - onFailure={onProcessingFailure} - onConfirmed={() => dispatch({type: 'PROCESSING_SUCCEEDED'})} - downloadPDFText={form.submissionReportDownloadLinkTitle} - /> - </ErrorBoundary> - } - /> - </Routes> - ); - - // render the form step if there's an active submission (and no summary) + // render the container for the router and necessary context providers for deeply + // nested child components return ( - <FormDisplay progressIndicator={progressIndicator}> - <AnalyticsToolsConfigContext.Provider value={analyticsToolsConfigInfo}> + <FormDisplay progressIndicator={<FormProgressIndicator submission={submission} />}> + <AnalyticsToolsConfigProvider> <SubmissionProvider - submission={state.submission} - onSubmissionObtained={onSubmissionObtained} + submission={submission} + onSubmissionObtained={submission => { + onSubmissionLoaded(submission); + setSubmissionId(submission.id); + }} onDestroySession={onDestroySession} + removeSubmissionId={removeSubmissionId} > <Outlet /> - {router} </SubmissionProvider> - </AnalyticsToolsConfigContext.Provider> + </AnalyticsToolsConfigProvider> </FormDisplay> ); }; Form.propTypes = {}; -const SubmissionContext = React.createContext({ - submission: null, - onSubmissionObtained: () => {}, - onDestroySession: () => {}, -}); - -const SubmissionProvider = ({ - submission = null, - onSubmissionObtained, - onDestroySession, - children, -}) => ( - <SubmissionContext.Provider value={{submission, onSubmissionObtained, onDestroySession}}> - {children} - </SubmissionContext.Provider> -); - -SubmissionProvider.propTypes = { - submission: Types.Submission, - onSubmissionObtained: PropTypes.func.isRequired, - onDestroySession: PropTypes.func.isRequired, -}; - -const useSubmissionContext = () => useContext(SubmissionContext); - export default Form; -export {useSubmissionContext, SubmissionProvider}; diff --git a/src/components/Form.spec.jsx b/src/components/Form.spec.jsx index 86b850df7..b416e860b 100644 --- a/src/components/Form.spec.jsx +++ b/src/components/Form.spec.jsx @@ -1,26 +1,47 @@ -import {render, screen} from '@testing-library/react'; +import {render, screen, waitForElementToBeRemoved} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import messagesEN from 'i18n/compiled/en.json'; import {IntlProvider} from 'react-intl'; import {RouterProvider, createMemoryRouter} from 'react-router-dom'; import {ConfigContext, FormContext} from 'Context'; -import {BASE_URL, buildForm, mockAnalyticsToolConfigGet} from 'api-mocks'; +import {BASE_URL, buildForm, buildSubmission, mockAnalyticsToolConfigGet} from 'api-mocks'; import mswServer from 'api-mocks/msw-server'; -import {mockSubmissionPost, mockSubmissionStepGet} from 'api-mocks/submissions'; -import {routes} from 'components/App'; +import { + mockSubmissionCompletePost, + mockSubmissionGet, + mockSubmissionPaymentStartPost, + mockSubmissionPost, + mockSubmissionProcessingStatusErrorGet, + mockSubmissionProcessingStatusGet, + mockSubmissionStepGet, + mockSubmissionSummaryGet, +} from 'api-mocks/submissions'; +import {SUBMISSION_ALLOWED} from 'components/constants'; +import routes from 'routes'; window.scrollTo = vi.fn(); +beforeAll(() => { + vi.stubGlobal('jest', { + advanceTimersByTime: vi.advanceTimersByTime.bind(vi), + }); +}); + beforeEach(() => { localStorage.clear(); }); afterEach(() => { + if (vi.isFakeTimers()) { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + } localStorage.clear(); }); afterAll(() => { + vi.unstubAllGlobals(); vi.clearAllMocks(); }); @@ -129,3 +150,129 @@ test('Navigation through form without introduction page', async () => { const formInput = await screen.findByLabelText('Component 1'); expect(formInput).toBeVisible(); }); + +test('Submitting the form with failing background processing', async () => { + const user = userEvent.setup({ + advanceTimers: vi.advanceTimersByTime, + }); + // The summary page submits the form and needs to trigger the appropriate redirects. + // When the status check reports failure, we need to be redirected back to the summary + // page for a retry. + const form = buildForm({loginRequired: false, submissionStatementsConfiguration: []}); + const submission = buildSubmission({ + submissionAllowed: SUBMISSION_ALLOWED.yes, + payment: { + isRequired: false, + amount: undefined, + hasPaid: false, + }, + MARKER: true, + }); + mswServer.use( + mockAnalyticsToolConfigGet(), + mockSubmissionGet(submission), + mockSubmissionSummaryGet(), + mockSubmissionCompletePost(), + mockSubmissionProcessingStatusErrorGet + ); + + render(<Wrapper form={form} initialEntry={`/overzicht?submission_uuid=${submission.id}`} />); + + expect(await screen.findByRole('heading', {name: 'Check and confirm'})).toBeVisible(); + + // confirm the submission and complete it + vi.useFakeTimers(); + await user.click(screen.getByRole('button', {name: 'Confirm'})); + expect(await screen.findByRole('heading', {name: 'Processing...'})).toBeVisible(); + const loader = await screen.findByRole('status'); + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + await waitForElementToBeRemoved(loader); + + // due to the error we get redirected back to the summary page. + expect(await screen.findByRole('heading', {name: 'Check and confirm'})).toBeVisible(); + expect(screen.getByText('Computer says no.')).toBeVisible(); +}); + +test('Submitting the form with successful background processing', async () => { + const user = userEvent.setup({ + advanceTimers: vi.advanceTimersByTime, + }); + // The summary page submits the form and needs to trigger the appropriate redirects. + // When the status check reports failure, we need to be redirected back to the summary + // page for a retry. + const form = buildForm({loginRequired: false, submissionStatementsConfiguration: []}); + const submission = buildSubmission({ + submissionAllowed: SUBMISSION_ALLOWED.yes, + payment: { + isRequired: false, + amount: undefined, + hasPaid: false, + }, + MARKER: true, + }); + mswServer.use( + mockAnalyticsToolConfigGet(), + mockSubmissionGet(submission), + mockSubmissionSummaryGet(), + mockSubmissionCompletePost(), + mockSubmissionProcessingStatusGet + ); + + render(<Wrapper form={form} initialEntry={`/overzicht?submission_uuid=${submission.id}`} />); + + expect(await screen.findByRole('heading', {name: 'Check and confirm'})).toBeVisible(); + + // confirm the submission and complete it + vi.useFakeTimers(); + await user.click(screen.getByRole('button', {name: 'Confirm'})); + expect(await screen.findByRole('heading', {name: 'Processing...'})).toBeVisible(); + const loader = await screen.findByRole('status'); + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + await waitForElementToBeRemoved(loader); + + // on success, the summary page must display the reference obtained from the backend + expect(await screen.findByRole('heading', {name: 'Confirmation: OF-L337'})).toBeVisible(); +}); + +test('Submitting form with payment requirement', async () => { + const user = userEvent.setup({ + advanceTimers: vi.advanceTimersByTime, + }); + // The summary page submits the form and needs to trigger the appropriate redirects. + // When the status check reports failure, we need to be redirected back to the summary + // page for a retry. + const form = buildForm({loginRequired: false, submissionStatementsConfiguration: []}); + const submission = buildSubmission({ + submissionAllowed: SUBMISSION_ALLOWED.yes, + payment: { + isRequired: true, + amount: '42.69', + hasPaid: false, + }, + MARKER: true, + }); + mswServer.use( + mockAnalyticsToolConfigGet(), + mockSubmissionGet(submission), + mockSubmissionSummaryGet(), + mockSubmissionCompletePost(), + mockSubmissionProcessingStatusGet, + mockSubmissionPaymentStartPost(null) + ); + + render(<Wrapper form={form} initialEntry={`/overzicht?submission_uuid=${submission.id}`} />); + expect(await screen.findByRole('heading', {name: 'Check and confirm'})).toBeVisible(); + + // confirm the submission and complete it + vi.useFakeTimers(); + await user.click(screen.getByRole('button', {name: 'Confirm'})); + expect(await screen.findByRole('heading', {name: 'Processing...'})).toBeVisible(); + const loader = await screen.findByRole('status'); + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + await waitForElementToBeRemoved(loader); + + expect(await screen.findByText('A payment is required for this product.')).toBeVisible(); +}); diff --git a/src/components/FormProgressIndicator.jsx b/src/components/FormProgressIndicator.jsx new file mode 100644 index 000000000..c6685228c --- /dev/null +++ b/src/components/FormProgressIndicator.jsx @@ -0,0 +1,121 @@ +import {useIntl} from 'react-intl'; +import {matchPath, useLocation, useMatch} from 'react-router-dom'; + +import ProgressIndicator from 'components/ProgressIndicator'; +import {addFixedSteps, getStepsInfo} from 'components/ProgressIndicator/utils'; +import {PI_TITLE, STEP_LABELS, SUBMISSION_ALLOWED} from 'components/constants'; +import useFormContext from 'hooks/useFormContext'; +import Types from 'types'; + +const getProgressIndicatorSteps = ({intl, form, submission, currentPathname, isCompleted}) => { + const submissionAllowedSpec = submission?.submissionAllowed ?? form.submissionAllowed; + const showOverview = submissionAllowedSpec !== SUBMISSION_ALLOWED.noWithoutOverview; + const needsPayment = submission?.payment.isRequired ?? form.paymentRequired; + + const showNonApplicableSteps = !form.hideNonApplicableSteps; + const filteredSteps = + // first, process all the form steps in a format suitable for the PI + getStepsInfo(form.steps, submission, currentPathname) + // then, filter out the non-applicable steps if they should not be displayed + .filter(step => showNonApplicableSteps || step.isApplicable); + + return addFixedSteps( + intl, + filteredSteps, + submission, + currentPathname, + showOverview, + needsPayment, + isCompleted, + !!form.introductionPageContent + ); +}; + +/** + * Determine the 'step' title to render for the accessible mobile menu label. + * @param {IntlShape} intl The `useIntl` return value. + * @param {String} pathname The pathname ('url') of the current location. + * @param {Object} form The Open Forms form instance being rendered. + * @return {String} The (formatted) string for the step title/name. + */ +const getMobileStepTitle = (intl, pathname, form) => { + // TODO replace absolute path check with relative + if (matchPath('/introductie', pathname)) { + return intl.formatMessage(STEP_LABELS.introduction); + } + if (matchPath('/startpagina', pathname)) { + return intl.formatMessage(STEP_LABELS.login); + } + + const stepMatch = matchPath('/stap/:step', pathname); + if (stepMatch) { + const slug = stepMatch.params.step; + const step = form.steps.find(step => step.slug === slug); + return step.formDefinition; + } + + if (matchPath('/overzicht', pathname)) { + return intl.formatMessage(STEP_LABELS.overview); + } + if (matchPath('/betalen', pathname)) { + return intl.formatMessage(STEP_LABELS.payment); + } + + // we *may* end up here in tests that haven't set up all routes and so path matches + // fail. + /* istanbul ignore next */ + return ''; +}; + +/** + * Component to configure the progress indicator for a specific form. + * + * This component encapsulates the render/no render behaviour of the progress indicator + * by looking at the form configuration settings. + */ +const FormProgressIndicator = ({submission}) => { + const form = useFormContext(); + const {pathname: currentPathname, state: routerState} = useLocation(); + const confirmationMatch = useMatch('/bevestiging'); + const intl = useIntl(); + + // don't render anything if the form is configured to never display the progress + // indicator, or we're on the final confirmation page + if (!form.showProgressIndicator || confirmationMatch) { + return null; + } + + // otherwise collect the necessary information to render the PI. + const isCompleted = !!routerState?.statusUrl; + const steps = getProgressIndicatorSteps({intl, form, submission, currentPathname, isCompleted}); + + const ariaMobileIconLabel = intl.formatMessage({ + description: 'Progress step indicator toggle icon (mobile)', + defaultMessage: 'Toggle the progress status display', + }); + + const activeStepTitle = getMobileStepTitle(intl, currentPathname, form); + const accessibleToggleStepsLabel = intl.formatMessage( + { + description: 'Active step accessible label in mobile progress indicator', + defaultMessage: 'Current step in form {formName}: {activeStepTitle}', + }, + {formName: form.name, activeStepTitle} + ); + + return ( + <ProgressIndicator + title={PI_TITLE} + formTitle={form.name} + steps={steps} + ariaMobileIconLabel={ariaMobileIconLabel} + accessibleToggleStepsLabel={accessibleToggleStepsLabel} + /> + ); +}; + +FormProgressIndicator.propTypes = { + submission: Types.Submission, +}; + +export default FormProgressIndicator; diff --git a/src/components/FormStart/index.jsx b/src/components/FormStart/index.jsx index 160a62146..e7e70d7b8 100644 --- a/src/components/FormStart/index.jsx +++ b/src/components/FormStart/index.jsx @@ -7,12 +7,12 @@ import {ConfigContext} from 'Context'; import Body from 'components/Body'; import Card from 'components/Card'; import ExistingSubmissionOptions from 'components/ExistingSubmissionOptions'; -import {useSubmissionContext} from 'components/Form'; import FormMaximumSubmissions from 'components/FormMaximumSubmissions'; import {LiteralsProvider} from 'components/Literal'; import Loader from 'components/Loader'; import LoginOptions from 'components/LoginOptions'; import MaintenanceMode from 'components/MaintenanceMode'; +import {useSubmissionContext} from 'components/SubmissionProvider'; import { AuthenticationErrors, useDetectAuthErrorMessages, diff --git a/src/components/FormStart/tests.spec.jsx b/src/components/FormStart/tests.spec.jsx index d9b218610..255cfe145 100644 --- a/src/components/FormStart/tests.spec.jsx +++ b/src/components/FormStart/tests.spec.jsx @@ -9,7 +9,7 @@ import {ConfigContext, FormContext} from 'Context'; import {BASE_URL, buildForm, buildSubmission} from 'api-mocks'; import mswServer from 'api-mocks/msw-server'; import {mockSubmissionPost} from 'api-mocks/submissions'; -import {SubmissionProvider} from 'components/Form'; +import SubmissionProvider from 'components/SubmissionProvider'; import FormStart from './index'; @@ -52,6 +52,7 @@ const Wrap = ({ onSubmissionObtained?.(); }} onDestroySession={() => {}} + removeSubmissionId={vi.fn()} > <RouterProvider router={router} /> </SubmissionProvider> diff --git a/src/components/FormStep/FormStep.stories.jsx b/src/components/FormStep/FormStep.stories.jsx index b7bb322a4..58a0cb0bb 100644 --- a/src/components/FormStep/FormStep.stories.jsx +++ b/src/components/FormStep/FormStep.stories.jsx @@ -10,7 +10,7 @@ import { mockEmailVerificationPost, mockEmailVerificationVerifyCodePost, } from 'components/EmailVerification/mocks'; -import {SubmissionProvider} from 'components/Form'; +import SubmissionProvider from 'components/SubmissionProvider'; import {AnalyticsToolsDecorator, ConfigDecorator} from 'story-utils/decorators'; import {sleep} from 'utils'; @@ -29,6 +29,7 @@ export default { args: { onSubmissionObtained: fn(), onDestroySession: fn(), + removeSubmissionId: fn(), }, argTypes: { submission: {control: false}, @@ -56,6 +57,7 @@ const render = ({ submission, onSubmissionObtained, onDestroySession, + removeSubmissionId, // story args formioConfiguration, validationErrors = undefined, @@ -83,6 +85,7 @@ const render = ({ submission={submission} onSubmissionObtained={onSubmissionObtained} onDestroySession={onDestroySession} + removeSubmissionId={removeSubmissionId} > <FormStep /> </SubmissionProvider> diff --git a/src/components/FormStep/index.jsx b/src/components/FormStep/index.jsx index 87de28f1b..425fb64ba 100644 --- a/src/components/FormStep/index.jsx +++ b/src/components/FormStep/index.jsx @@ -36,11 +36,11 @@ import {get} from 'api'; import ButtonsToolbar from 'components/ButtonsToolbar'; import Card, {CardTitle} from 'components/Card'; import {EmailVerificationModal} from 'components/EmailVerification'; -import {useSubmissionContext} from 'components/Form'; import FormStepDebug from 'components/FormStepDebug'; import {LiteralsProvider} from 'components/Literal'; import Loader from 'components/Loader'; import PreviousLink from 'components/PreviousLink'; +import {useSubmissionContext} from 'components/SubmissionProvider'; import {SummaryProgress} from 'components/SummaryProgress'; import FormStepSaveModal from 'components/modals/FormStepSaveModal'; import { diff --git a/src/components/LoginOptions/index.jsx b/src/components/LoginOptions/index.jsx index ddcb88b43..0462037b0 100644 --- a/src/components/LoginOptions/index.jsx +++ b/src/components/LoginOptions/index.jsx @@ -1,16 +1,16 @@ import PropTypes from 'prop-types'; import React from 'react'; import {FormattedMessage} from 'react-intl'; +import {useSearchParams} from 'react-router-dom'; import {Literal} from 'components/Literal'; import {getCosignLoginUrl, getLoginUrl} from 'components/utils'; -import useQuery from 'hooks/useQuery'; import Types from 'types'; import LoginOptionsDisplay from './LoginOptionsDisplay'; const LoginOptions = ({form, onFormStart, extraNextParams = {}, isolateCosignOptions = true}) => { - const queryParams = useQuery(); + const [params] = useSearchParams(); const loginAsYourselfOptions = []; const loginAsGemachtigdeOptions = []; @@ -35,7 +35,7 @@ const LoginOptions = ({form, onFormStart, extraNextParams = {}, isolateCosignOpt }); if (form.cosignLoginOptions) { - const cosignCode = queryParams.get('code'); + const cosignCode = params.get('code'); form.cosignLoginOptions.forEach(option => { const loginUrl = getCosignLoginUrl(option, cosignCode ? {code: cosignCode} : undefined); cosignLoginOptions.push({ diff --git a/src/components/PostCompletionViews/ConfirmationView.jsx b/src/components/PostCompletionViews/ConfirmationView.jsx index fcf20653c..2b06b77d4 100644 --- a/src/components/PostCompletionViews/ConfirmationView.jsx +++ b/src/components/PostCompletionViews/ConfirmationView.jsx @@ -1,11 +1,13 @@ import PropTypes from 'prop-types'; import React, {useContext} from 'react'; import {FormattedMessage, defineMessage, useIntl} from 'react-intl'; -import {useLocation} from 'react-router-dom'; +import {useLocation, useSearchParams} from 'react-router-dom'; import Body from 'components/Body'; import ErrorMessage from 'components/Errors/ErrorMessage'; import {GovMetricSnippet} from 'components/analytics'; +import useFormContext from 'hooks/useFormContext'; +import {DEBUG} from 'utils'; import PostCompletionView from './PostCompletionView'; import StatusUrlPoller, {SubmissionStatusContext} from './StatusUrlPoller'; @@ -105,19 +107,42 @@ ConfirmationViewDisplay.propTypes = { downloadPDFText: PropTypes.node, }; -const ConfirmationView = ({statusUrl, onFailure, onConfirmed, downloadPDFText}) => { +const ConfirmationView = ({onFailureNavigateTo, onConfirmed}) => { + const form = useFormContext(); + // TODO: take statusUrl from session storage instead of router state / query params, + // which is the best tradeoff between security and convenience (state doesn't survive + // hard refreshes, query params is prone to accidental information leaking) + const location = useLocation(); + const [params] = useSearchParams(); + const statusUrl = params.get('statusUrl') ?? location.state?.statusUrl; + + if (DEBUG && !statusUrl) { + throw new Error( + 'You must pass the status URL via the router state (preferably) or query params.' + ); + } + return ( - <StatusUrlPoller statusUrl={statusUrl} onFailure={onFailure} onConfirmed={onConfirmed}> - <ConfirmationViewDisplay downloadPDFText={downloadPDFText} /> + <StatusUrlPoller + statusUrl={statusUrl} + onFailureNavigateTo={onFailureNavigateTo} + onConfirmed={onConfirmed} + > + <ConfirmationViewDisplay downloadPDFText={form.submissionReportDownloadLinkTitle} /> </StatusUrlPoller> ); }; ConfirmationView.propTypes = { - statusUrl: PropTypes.string, - onFailure: PropTypes.func, + /** + * Location to navigate to on failure. + */ + onFailureNavigateTo: PropTypes.string, + /** + * Optional callback to invoke when processing was successful. + * @deprecated + */ onConfirmed: PropTypes.func, - downloadPDFText: PropTypes.node, }; export {ConfirmationViewDisplay}; diff --git a/src/components/PostCompletionViews/ConfirmationView.stories.jsx b/src/components/PostCompletionViews/ConfirmationView.stories.jsx index 1a3702184..2cc28d91a 100644 --- a/src/components/PostCompletionViews/ConfirmationView.stories.jsx +++ b/src/components/PostCompletionViews/ConfirmationView.stories.jsx @@ -6,7 +6,7 @@ import {AnalyticsToolsDecorator, withForm, withSubmissionPollInfo} from 'story-u import {ConfirmationViewDisplay} from './ConfirmationView'; export default { - title: 'Private API / Post completion views / Confirmation view', + title: 'Views / Post completion views / Confirmation view', component: ConfirmationViewDisplay, decorators: [withForm, AnalyticsToolsDecorator, withSubmissionPollInfo, withRouter], argTypes: { diff --git a/src/components/PostCompletionViews/PostCompletionView.stories.jsx b/src/components/PostCompletionViews/PostCompletionView.stories.jsx index 53cefb592..a8549b792 100644 --- a/src/components/PostCompletionViews/PostCompletionView.stories.jsx +++ b/src/components/PostCompletionViews/PostCompletionView.stories.jsx @@ -3,7 +3,7 @@ import Body from 'components/Body'; import PostCompletionView from './PostCompletionView'; export default { - title: 'Private API / Post completion views ', + title: 'Views / Post completion views ', component: PostCompletionView, render: ({body, ...args}) => <PostCompletionView {...args} body={<Body>{body}</Body>} />, }; diff --git a/src/components/PostCompletionViews/StartPaymentView.jsx b/src/components/PostCompletionViews/StartPaymentView.jsx index fe97c6de3..c812bc354 100644 --- a/src/components/PostCompletionViews/StartPaymentView.jsx +++ b/src/components/PostCompletionViews/StartPaymentView.jsx @@ -1,9 +1,12 @@ import PropTypes from 'prop-types'; import {useContext} from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; +import {useLocation} from 'react-router-dom'; import Body from 'components/Body'; import ErrorBoundary from 'components/Errors/ErrorBoundary'; +import useFormContext from 'hooks/useFormContext'; +import {DEBUG} from 'utils'; import PostCompletionView from './PostCompletionView'; import {StartPayment} from './StartPayment'; @@ -58,19 +61,22 @@ StartPaymentViewDisplay.propTypes = { downloadPDFText: PropTypes.node, }; -const StartPaymentView = ({statusUrl, onFailure, onConfirmed, downloadPDFText}) => { +const StartPaymentView = ({onFailureNavigateTo}) => { + const form = useFormContext(); + const {statusUrl} = useLocation().state || {}; + if (DEBUG && !statusUrl) throw new Error('You must pass the status URL via the route state.'); return ( - <StatusUrlPoller statusUrl={statusUrl} onFailure={onFailure} onConfirmed={onConfirmed}> - <StartPaymentViewDisplay downloadPDFText={downloadPDFText} /> + <StatusUrlPoller statusUrl={statusUrl} onFailureNavigateTo={onFailureNavigateTo}> + <StartPaymentViewDisplay downloadPDFText={form.submissionReportDownloadLinkTitle} /> </StatusUrlPoller> ); }; StartPaymentView.propTypes = { - statusUrl: PropTypes.string, - onFailure: PropTypes.func, - onConfirmed: PropTypes.func, - downloadPDFText: PropTypes.node, + /** + * Location to navigate to on failure. + */ + onFailureNavigateTo: PropTypes.string, }; export default StartPaymentView; diff --git a/src/components/PostCompletionViews/StartPaymentView.stories.jsx b/src/components/PostCompletionViews/StartPaymentView.stories.jsx index fe6d18edf..fe3afc461 100644 --- a/src/components/PostCompletionViews/StartPaymentView.stories.jsx +++ b/src/components/PostCompletionViews/StartPaymentView.stories.jsx @@ -2,7 +2,7 @@ import {expect, waitFor, within} from '@storybook/test'; import {BASE_URL} from 'api-mocks'; import { - mockSubmissionPaymentStartGet, + mockSubmissionPaymentStartPost, mockSubmissionProcessingStatusGet, } from 'api-mocks/submissions'; import {withSubmissionPollInfo} from 'story-utils/decorators'; @@ -10,7 +10,7 @@ import {withSubmissionPollInfo} from 'story-utils/decorators'; import {StartPaymentViewDisplay} from './StartPaymentView'; export default { - title: 'Private API / Post completion views / Start payment', + title: 'Views / Post completion views / Start payment', component: StartPaymentViewDisplay, decorators: [withSubmissionPollInfo], args: { @@ -21,7 +21,7 @@ export default { }, parameters: { msw: { - handlers: [mockSubmissionProcessingStatusGet, mockSubmissionPaymentStartGet], + handlers: [mockSubmissionProcessingStatusGet, mockSubmissionPaymentStartPost()], }, }, }; diff --git a/src/components/PostCompletionViews/StatusUrlPoller.jsx b/src/components/PostCompletionViews/StatusUrlPoller.jsx index f2b1d5ed7..685cea9e0 100644 --- a/src/components/PostCompletionViews/StatusUrlPoller.jsx +++ b/src/components/PostCompletionViews/StatusUrlPoller.jsx @@ -1,7 +1,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; -import {useLocation} from 'react-router-dom'; +import {useLocation, useNavigate} from 'react-router-dom'; import Body from 'components/Body'; import Card from 'components/Card'; @@ -21,9 +21,10 @@ const SubmissionStatusContext = React.createContext({ }); SubmissionStatusContext.displayName = 'SubmissionStatusContext'; -const StatusUrlPoller = ({statusUrl, onFailure, onConfirmed, children}) => { +const StatusUrlPoller = ({statusUrl, onFailureNavigateTo, onConfirmed, children}) => { const intl = useIntl(); const location = useLocation(); + const navigate = useNavigate(); const genericErrorMessage = intl.formatMessage({ description: 'Generic submission error', @@ -35,15 +36,16 @@ const StatusUrlPoller = ({statusUrl, onFailure, onConfirmed, children}) => { error, response: statusResponse, } = usePoll( - statusUrl || location?.state?.statusUrl, + statusUrl, 1000, response => response.status === 'done', response => { if (response.result === RESULT_FAILED) { const errorMessage = response.errorMessage || genericErrorMessage; - if (onFailure) onFailure(errorMessage); + const newState = {...(location.state || {}), errorMessage}; + navigate(onFailureNavigateTo, {state: newState}); } else if (response.result === RESULT_SUCCESS) { - if (onConfirmed) onConfirmed(); + onConfirmed?.(); } } ); @@ -71,13 +73,11 @@ const StatusUrlPoller = ({statusUrl, onFailure, onConfirmed, children}) => { // FIXME: https://github.com/open-formulieren/open-forms/issues/3255 // errors (bad gateway 502, for example) appear to result in infinite loading - // spinners - if (error) { - console.error(error); - } + // spinners. Throwing during rendering will at least make it bubble up to the nearest + // error boundary. + if (error) throw error; const { - result, paymentUrl, publicReference, reportDownloadUrl, @@ -86,10 +86,6 @@ const StatusUrlPoller = ({statusUrl, onFailure, onConfirmed, children}) => { mainWebsiteUrl, } = statusResponse; - if (result === RESULT_FAILED) { - throw new Error('Failure should have been handled in the onFailure prop.'); - } - return ( <SubmissionStatusContext.Provider value={{ @@ -107,8 +103,17 @@ const StatusUrlPoller = ({statusUrl, onFailure, onConfirmed, children}) => { }; StatusUrlPoller.propTypes = { - statusUrl: PropTypes.string, - onFailure: PropTypes.func, + /** + * Backend status URL to poll for status checks. + */ + statusUrl: PropTypes.string.isRequired, + /** + * Route to navigate to if the status check reports failure. + * + * The route state will be extended with `errorMessage` property retrieved from the + * backend processing. + */ + onFailureNavigateTo: PropTypes.string.isRequired, onConfirmed: PropTypes.func, children: PropTypes.node, }; diff --git a/src/components/PostCompletionViews/viewsWithPolling.stories.jsx b/src/components/PostCompletionViews/viewsWithPolling.stories.jsx index fe811466d..c5407301a 100644 --- a/src/components/PostCompletionViews/viewsWithPolling.stories.jsx +++ b/src/components/PostCompletionViews/viewsWithPolling.stories.jsx @@ -1,7 +1,7 @@ -import {expect, fn, waitFor, within} from '@storybook/test'; +import {expect, fn, waitForElementToBeRemoved, within} from '@storybook/test'; import {withRouter} from 'storybook-addon-remix-react-router'; -import {BASE_URL} from 'api-mocks'; +import {BASE_URL, buildSubmission} from 'api-mocks'; import { mockSubmissionProcessingStatusGet, mockSubmissionProcessingStatusPendingGet, @@ -11,15 +11,13 @@ import {withSubmissionPollInfo} from 'story-utils/decorators'; import ConfirmationView from './ConfirmationView'; export default { - title: 'Private API / Post completion views / With Polling', + title: 'Views / Post completion views / With Polling', component: ConfirmationView, decorators: [withSubmissionPollInfo, withRouter], argTypes: { statusUrl: {control: false}, }, args: { - statusUrl: `${BASE_URL}submissions/4b0e86a8-dc5f-41cc-b812-c89857b9355b/-token-/status`, - onFailure: fn(), onConfirmed: fn(), }, parameters: { @@ -27,7 +25,12 @@ export default { handlers: [mockSubmissionProcessingStatusGet], }, reactRouter: { - location: {state: {}}, + location: { + state: { + statusUrl: `${BASE_URL}submissions/4b0e86a8-dc5f-41cc-b812-c89857b9355b/-token-/status`, + submission: buildSubmission(), + }, + }, }, }, }; @@ -36,15 +39,10 @@ export const WithoutPayment = { play: async ({canvasElement, args}) => { const canvas = within(canvasElement); - await waitFor( - async () => { - expect(canvas.getByRole('button', {name: 'Terug naar de website'})).toBeVisible(); - }, - { - timeout: 2000, - interval: 100, - } - ); + const loader = await canvas.findByRole('status'); + await waitForElementToBeRemoved(loader, {timeout: 2000, interval: 100}); + + expect(canvas.getByRole('button', {name: 'Terug naar de website'})).toBeVisible(); expect(canvas.getByText(/OF-L337/)).toBeVisible(); expect(args.onConfirmed).toBeCalledTimes(1); }, diff --git a/src/components/ProgressIndicator/ProgressIndicatorItem.jsx b/src/components/ProgressIndicator/ProgressIndicatorItem.jsx index 9b7a08c56..f149cf7fa 100644 --- a/src/components/ProgressIndicator/ProgressIndicatorItem.jsx +++ b/src/components/ProgressIndicator/ProgressIndicatorItem.jsx @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import {FormattedMessage} from 'react-intl'; +import {useLocation} from 'react-router-dom'; import Link from 'components/Link'; import {getBEMClassName} from 'utils'; @@ -27,6 +28,7 @@ const getLinkModifiers = isActive => { * Once a step is completed, it is displayed with a completion checkmark in front of it. */ const ProgressIndicatorItem = ({label, to, isActive, isCompleted, canNavigateTo, isApplicable}) => { + const location = useLocation(); return ( <div className={getBEMClassName('progress-indicator-item')}> <div className={getBEMClassName('progress-indicator-item__marker')}> @@ -35,6 +37,7 @@ const ProgressIndicatorItem = ({label, to, isActive, isCompleted, canNavigateTo, <div className={getBEMClassName('progress-indicator-item__label')}> <Link to={to} + state={location.state} placeholder={!canNavigateTo} modifiers={canNavigateTo ? getLinkModifiers(isActive) : []} aria-label={label} diff --git a/src/components/ProgressIndicator/progressIndicator.spec.jsx b/src/components/ProgressIndicator/progressIndicator.spec.jsx index a61129f16..2a66b5446 100644 --- a/src/components/ProgressIndicator/progressIndicator.spec.jsx +++ b/src/components/ProgressIndicator/progressIndicator.spec.jsx @@ -8,15 +8,7 @@ import {ConfigContext, FormContext} from 'Context'; import {BASE_URL, buildForm, mockAnalyticsToolConfigGet} from 'api-mocks'; import mswServer from 'api-mocks/msw-server'; import {buildSubmission, mockSubmissionPost} from 'api-mocks/submissions'; -import App, {routes as nestedRoutes} from 'components/App'; - -const routes = [ - { - path: '*', - element: <App />, - children: nestedRoutes, - }, -]; +import routes from 'routes'; const renderApp = (form, initialRoute = '/') => { const router = createMemoryRouter(routes, { diff --git a/src/components/RequireSubmission.jsx b/src/components/RequireSubmission.jsx index cc6eaa25d..8170c26ae 100644 --- a/src/components/RequireSubmission.jsx +++ b/src/components/RequireSubmission.jsx @@ -1,31 +1,30 @@ import PropTypes from 'prop-types'; import {Navigate} from 'react-router-dom'; -import {useSubmissionContext} from 'components/Form'; import MaintenanceMode from 'components/MaintenanceMode'; +import {useSubmissionContext} from 'components/SubmissionProvider'; import {ServiceUnavailable} from 'errors'; import {IsFormDesigner} from 'headers'; import useFormContext from 'hooks/useFormContext'; /** - * Higher order component to enforce there is an active submission in the state. + * Wrapper component to enforce there is an active submission in the state. * - * If there is no submission, the user is forcibly redirected to the start of the form. + * If there is no submission, the user is forcibly redirected to the start of the form, + * or an error is thrown if the form is temporarily unavailable. Ensure you wrap the + * component in an error boundary that can handle these. * - * Provide either the component or children prop to render the actual content. The - * `component` prop is deprecated in favour of specifying explicit elements. + * The submission is taken from the context set via the `SubmissionProvider` component. + * Pass the content to render if there's a submission/session via the `children` prop, + * e.g.: + * + * <RequireSubmission> + * <MyProtectedView /> + * </RequireSubmission> */ -const RequireSubmission = ({ - retrieveSubmissionFromContext = false, - submission: submissionFromProps, - children, - component: Component, - ...props -}) => { +const RequireSubmission = ({children}) => { const {maintenanceMode} = useFormContext(); - const {submission: submissionFromContext} = useSubmissionContext(); - - const submission = retrieveSubmissionFromContext ? submissionFromContext : submissionFromProps; + const {submission} = useSubmissionContext(); const userIsFormDesigner = IsFormDesigner.getValue(); if (!userIsFormDesigner && maintenanceMode) { @@ -44,25 +43,13 @@ const RequireSubmission = ({ return ( <> {userIsFormDesigner && maintenanceMode && <MaintenanceMode />} - {children ?? <Component submission={submission} {...props} />} + {children} </> ); }; RequireSubmission.propTypes = { - retrieveSubmissionFromContext: PropTypes.bool, - /** - * Submission (or null-ish) to test if there's an active submission. - * @deprecated - grab it from the context via `retrieveSubmissionFromContext` instead. - */ - submission: PropTypes.object, - children: PropTypes.node, - /** - * Component to render with the provided props. If children are provided, those get - * priority. - * @deprecated - */ - component: PropTypes.elementType, + children: PropTypes.node.isRequired, }; export default RequireSubmission; diff --git a/src/components/SubmissionProvider.jsx b/src/components/SubmissionProvider.jsx new file mode 100644 index 000000000..281aabc04 --- /dev/null +++ b/src/components/SubmissionProvider.jsx @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; +import React, {useContext} from 'react'; + +import Types from 'types'; + +const SubmissionContext = React.createContext({ + submission: null, + onSubmissionObtained: () => {}, + onDestroySession: () => {}, + removeSubmissionId: () => {}, +}); + +const SubmissionProvider = ({ + submission = null, + onSubmissionObtained, + onDestroySession, + removeSubmissionId, + children, +}) => ( + <SubmissionContext.Provider + value={{submission, onSubmissionObtained, onDestroySession, removeSubmissionId}} + > + {children} + </SubmissionContext.Provider> +); + +SubmissionProvider.propTypes = { + /** + * The submission currently being filled out / submitted / viewed. It must exist in + * the backend session. + */ + submission: Types.Submission, + /** + * Callback for when a submission was (re-)loaded to store it in the state. + */ + onSubmissionObtained: PropTypes.func.isRequired, + /** + * Callback for when an abort/logout/stop button is clicked which terminates the + * form submission / session. + */ + onDestroySession: PropTypes.func.isRequired, + /** + * Callback to remove the submission reference (it's ID) from the local storage. + */ + removeSubmissionId: PropTypes.func.isRequired, +}; + +export const useSubmissionContext = () => useContext(SubmissionContext); + +export default SubmissionProvider; diff --git a/src/components/Summary/SubmissionSummary.jsx b/src/components/Summary/SubmissionSummary.jsx index 253a068cd..5eee3a438 100644 --- a/src/components/Summary/SubmissionSummary.jsx +++ b/src/components/Summary/SubmissionSummary.jsx @@ -1,50 +1,42 @@ -import PropTypes from 'prop-types'; +import {useState} from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; -import {useNavigate} from 'react-router-dom'; +import {useLocation, useNavigate} from 'react-router-dom'; import {useAsync} from 'react-use'; -import {useImmerReducer} from 'use-immer'; import {post} from 'api'; import {LiteralsProvider} from 'components/Literal'; +import {useSubmissionContext} from 'components/SubmissionProvider'; import {SUBMISSION_ALLOWED} from 'components/constants'; import {findPreviousApplicableStep} from 'components/utils'; +import useFormContext from 'hooks/useFormContext'; import useRefreshSubmission from 'hooks/useRefreshSubmission'; import useTitle from 'hooks/useTitle'; -import Types from 'types'; import GenericSummary from './GenericSummary'; import {loadSummaryData} from './utils'; -const initialState = { - error: '', +const completeSubmission = async (submission, statementValues) => { + const response = await post(`${submission.url}/_complete`, statementValues); + const {statusUrl} = response.data; + return statusUrl; }; -const reducer = (draft, action) => { - switch (action.type) { - case 'ERROR': { - draft.error = action.payload; - break; - } - default: { - throw new Error(`Unknown action ${action.type}`); - } - } -}; - -const SubmissionSummary = ({ - form, - submission, - processingError = '', - onConfirm, - onClearProcessingErrors, - onDestroySession, -}) => { - const [state, dispatch] = useImmerReducer(reducer, initialState); +const SubmissionSummary = () => { + const location = useLocation(); const navigate = useNavigate(); const intl = useIntl(); - + const form = useFormContext(); + const {submission, onDestroySession, removeSubmissionId} = useSubmissionContext(); const refreshedSubmission = useRefreshSubmission(submission); + const [submitError, setSubmitError] = useState(''); + + const pageTitle = intl.formatMessage({ + description: 'Summary page title', + defaultMessage: 'Check and confirm', + }); + useTitle(pageTitle, form.name); + const paymentInfo = refreshedSubmission.payment; const { @@ -55,19 +47,31 @@ const SubmissionSummary = ({ const submissionUrl = new URL(refreshedSubmission.url); return await loadSummaryData(submissionUrl); }, [refreshedSubmission.url]); - - if (error) { - console.error(error); - } + // throw to nearest error boundary + if (error) throw error; const onSubmit = async statementValues => { if (refreshedSubmission.submissionAllowed !== SUBMISSION_ALLOWED.yes) return; + let statusUrl; try { - const {statusUrl} = await completeSubmission(refreshedSubmission, statementValues); - onConfirm(statusUrl); + statusUrl = await completeSubmission(refreshedSubmission, statementValues); } catch (e) { - dispatch({type: 'ERROR', payload: e.message}); + setSubmitError(e.message); + return; } + + // the completion went through, proceed to redirect to the next page and set up + // the necessary state. + const needsPayment = + refreshedSubmission.payment.isRequired && !refreshedSubmission.payment.hasPaid; + const nextUrl = needsPayment ? '/betalen' : '/bevestiging'; + removeSubmissionId(); + navigate(nextUrl, { + state: { + submission: refreshedSubmission, + statusUrl, + }, + }); }; const getPreviousPage = () => { @@ -79,34 +83,10 @@ const SubmissionSummary = ({ const onPrevPage = event => { event.preventDefault(); - onClearProcessingErrors(); - navigate(getPreviousPage()); }; - const completeSubmission = async (submission, statementValues) => { - const response = await post(`${submission.url}/_complete`, statementValues); - if (!response.ok) { - console.error(response.data); - // TODO Specific error for each type of invalid data? - throw new Error('InvalidSubmissionData'); - } else { - return response.data; - } - }; - - const pageTitle = intl.formatMessage({ - description: 'Summary page title', - defaultMessage: 'Check and confirm', - }); - useTitle(pageTitle, form.name); - - const getErrors = () => { - let errors = []; - if (processingError) errors.push(processingError); - if (state.error) errors.push(state.error); - return errors; - }; + const errorMessages = [location.state?.errorMessage, submitError].filter(Boolean); return ( <LiteralsProvider literals={form.literals}> @@ -124,7 +104,7 @@ const SubmissionSummary = ({ editStepText={form.literals.changeText.resolved} isLoading={loading} isAuthenticated={refreshedSubmission.isAuthenticated} - errors={getErrors()} + errors={errorMessages} prevPage={getPreviousPage()} onSubmit={onSubmit} onPrevPage={onPrevPage} @@ -134,13 +114,6 @@ const SubmissionSummary = ({ ); }; -SubmissionSummary.propTypes = { - form: Types.Form.isRequired, - submission: Types.Submission.isRequired, - processingError: PropTypes.string, - onConfirm: PropTypes.func.isRequired, - onClearProcessingErrors: PropTypes.func.isRequired, - onDestroySession: PropTypes.func.isRequired, -}; +SubmissionSummary.propTypes = {}; export default SubmissionSummary; diff --git a/src/components/Summary/SubmissionSummary.spec.jsx b/src/components/Summary/SubmissionSummary.spec.jsx new file mode 100644 index 000000000..f319ec69e --- /dev/null +++ b/src/components/Summary/SubmissionSummary.spec.jsx @@ -0,0 +1,84 @@ +import {render, screen} from '@testing-library/react'; +import messagesNL from 'i18n/compiled/nl.json'; +import {IntlProvider} from 'react-intl'; +import {RouterProvider, createMemoryRouter} from 'react-router-dom'; + +import {ConfigContext, FormContext} from 'Context'; +import {BASE_URL, buildForm, buildSubmission} from 'api-mocks'; +import mswServer from 'api-mocks/msw-server'; +import {mockSubmissionGet, mockSubmissionSummaryGet} from 'api-mocks/submissions'; +import SubmissionProvider from 'components/SubmissionProvider'; +import {SubmissionSummary} from 'components/Summary'; +import {SUBMISSION_ALLOWED} from 'components/constants'; + +const Wrapper = ({form, submission}) => { + const routes = [ + { + path: '/overzicht', + element: ( + <SubmissionProvider + submission={submission} + onSubmissionObtained={vi.fn()} + onDestroySession={vi.fn()} + removeSubmissionId={vi.fn()} + > + <SubmissionSummary /> + </SubmissionProvider> + ), + }, + ]; + const router = createMemoryRouter(routes, { + initialEntries: ['/overzicht'], + }); + return ( + <ConfigContext.Provider + value={{ + baseUrl: BASE_URL, + clientBaseUrl: 'http://localhost/', + basePath: '', + baseTitle: '', + requiredFieldsWithAsterisk: true, + }} + > + <IntlProvider locale="nl" messages={messagesNL}> + <FormContext.Provider value={form}> + <RouterProvider router={router} /> + </FormContext.Provider> + </IntlProvider> + </ConfigContext.Provider> + ); +}; + +test.each([true, false])( + 'Summary displays logout button if isAuthenticated is true (loginRequired: %s)', + async loginRequired => { + const form = buildForm({loginRequired}); + const submissionIsAuthenticated = buildSubmission({ + submissionAllowed: SUBMISSION_ALLOWED.yes, + isAuthenticated: true, + }); + mswServer.use(mockSubmissionGet(submissionIsAuthenticated), mockSubmissionSummaryGet()); + + render(<Wrapper form={form} submission={submissionIsAuthenticated} />); + + const logoutButton = await screen.findByRole('button', {name: 'Uitloggen'}); + expect(logoutButton).toBeVisible(); + } +); + +test('Summary when isAuthenticated and loginRequired are false', async () => { + const form = buildForm({loginRequired: false}); + const submissionNotAuthenticated = buildSubmission({ + submissionAllowed: SUBMISSION_ALLOWED.yes, + isAuthenticated: false, + }); + mswServer.use(mockSubmissionGet(submissionNotAuthenticated), mockSubmissionSummaryGet()); + + render(<Wrapper form={form} submission={submissionNotAuthenticated} />); + + // we expect an abort button instead of log out + const cancelButton = await screen.findByRole('button', {name: 'Annuleren'}); + expect(cancelButton).toBeVisible(); + const logoutButton = screen.queryByRole('button', {name: 'Uitloggen'}); + expect(logoutButton).toBeNull(); +}); diff --git a/src/components/Summary/test.spec.jsx b/src/components/Summary/test.spec.jsx deleted file mode 100644 index c76034170..000000000 --- a/src/components/Summary/test.spec.jsx +++ /dev/null @@ -1,109 +0,0 @@ -import {render, screen} from '@testing-library/react'; -import messagesNL from 'i18n/compiled/nl.json'; -import {IntlProvider} from 'react-intl'; -import {MemoryRouter} from 'react-router-dom'; -import {useAsync} from 'react-use'; - -import {buildForm} from 'api-mocks'; -import {SubmissionSummary} from 'components/Summary'; -import {SUBMISSION_ALLOWED} from 'components/constants'; -import useRefreshSubmission from 'hooks/useRefreshSubmission'; - -const SUBMISSION = { - id: 'random-id', - url: 'https://example.com', - form: 'https://example.com', - steps: [ - { - formStep: - 'http://testserver/api/v1/forms/33af5a1c-552e-4e8f-8b19-287cf35b9edd/steps/0c2a1816-a7d7-4193-b431-918956744038', - }, - ], - submissionAllowed: SUBMISSION_ALLOWED.yes, - payment: { - isRequired: false, - amount: '', - hasPaid: false, - }, - isAuthenticated: false, -}; - -vi.mock('react-use'); -vi.mock('hooks/useRefreshSubmission'); - -const Wrap = ({children}) => ( - <IntlProvider locale="nl" messages={messagesNL}> - <MemoryRouter>{children}</MemoryRouter> - </IntlProvider> -); - -it('Summary displays logout button if isAuthenticated is true', () => { - const submissionIsAuthenticated = { - ...SUBMISSION, - isAuthenticated: true, - }; - const onDestroySession = vi.fn(); - const onConfirm = vi.fn(); - - useAsync.mockReturnValue({loading: false, value: []}); - useRefreshSubmission.mockReturnValue(submissionIsAuthenticated); - - render( - <Wrap> - <SubmissionSummary - form={buildForm()} - submission={SUBMISSION} - onConfirm={onConfirm} - onDestroySession={onDestroySession} - onClearProcessingErrors={() => {}} - /> - </Wrap> - ); - - expect(screen.getByRole('button', {name: 'Uitloggen'})).toBeVisible(); -}); - -it('Summary does not display logout button if loginRequired is false', () => { - const formLoginRequired = buildForm({loginRequired: false}); - const onDestroySession = vi.fn(); - const onConfirm = vi.fn(); - - useAsync.mockReturnValue({loading: false, value: []}); - useRefreshSubmission.mockReturnValue({...SUBMISSION, isAuthenticated: false}); - - render( - <Wrap> - <SubmissionSummary - form={formLoginRequired} - submission={SUBMISSION} - onConfirm={onConfirm} - onDestroySession={onDestroySession} - onClearProcessingErrors={() => {}} - /> - </Wrap> - ); - - expect(screen.queryByRole('button', {name: 'Uitloggen'})).toBeNull(); -}); - -it('Summary displays abort button if isAuthenticated is false', () => { - const onDestroySession = vi.fn(); - const onConfirm = vi.fn(); - - useAsync.mockReturnValue({loading: false, value: []}); - useRefreshSubmission.mockReturnValue({...SUBMISSION, isAuthenticated: false}); - - render( - <Wrap> - <SubmissionSummary - form={buildForm()} - submission={SUBMISSION} - onConfirm={onConfirm} - onDestroySession={onDestroySession} - onClearProcessingErrors={() => {}} - /> - </Wrap> - ); - - expect(screen.queryByRole('button', {name: 'Annuleren'})).toBeInTheDocument(); -}); diff --git a/src/components/analytics/AnalyticsToolConfigProvider.jsx b/src/components/analytics/AnalyticsToolConfigProvider.jsx new file mode 100644 index 000000000..17fb41f25 --- /dev/null +++ b/src/components/analytics/AnalyticsToolConfigProvider.jsx @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; +import React, {useContext} from 'react'; +import {useIntl} from 'react-intl'; +import {useAsync} from 'react-use'; + +import {ConfigContext} from 'Context'; +import {get} from 'api'; + +export const AnalyticsToolsConfigContext = React.createContext({ + govmetricSourceIdFormFinished: '', + govmetricSourceIdFormAborted: '', + govmetricSecureGuidFormFinished: '', + govmetricSecureGuidFormAborted: '', + enableGovmetricAnalytics: false, +}); + +AnalyticsToolsConfigContext.displayName = 'AnalyticsToolsConfigContext'; + +const AnalyticsToolsConfigProvider = ({children}) => { + const {locale} = useIntl(); + const {baseUrl} = useContext(ConfigContext); + + const {value} = useAsync(async () => { + return await get(`${baseUrl}analytics/analytics-tools-config-info`); + }, [baseUrl, locale]); + + return ( + <AnalyticsToolsConfigContext.Provider value={value}> + {children} + </AnalyticsToolsConfigContext.Provider> + ); +}; + +AnalyticsToolsConfigProvider.propTypes = { + children: PropTypes.node, +}; + +export const useAnalyticsToolsConfig = () => useContext(AnalyticsToolsConfigContext); + +export default AnalyticsToolsConfigProvider; diff --git a/src/components/analytics/GovMetricSnippet.jsx b/src/components/analytics/GovMetricSnippet.jsx index 6c7ea276d..ca4e2eeea 100644 --- a/src/components/analytics/GovMetricSnippet.jsx +++ b/src/components/analytics/GovMetricSnippet.jsx @@ -1,17 +1,16 @@ import govmetricAverageImg from 'img/govmetric/average.png'; import govmetricGoodImg from 'img/govmetric/good.png'; import govmetricPoorImg from 'img/govmetric/poor.png'; -import {useContext} from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; -import {AnalyticsToolsConfigContext} from 'Context'; import useFormContext from 'hooks/useFormContext'; +import {useAnalyticsToolsConfig} from './AnalyticsToolConfigProvider'; import {buildGovMetricUrl, govMetricURLWithRating} from './utils'; const GovMetricSnippet = () => { const {enableGovmetricAnalytics, govmetricSourceIdFormFinished, govmetricSecureGuidFormFinished} = - useContext(AnalyticsToolsConfigContext); + useAnalyticsToolsConfig(); const form = useFormContext(); const intl = useIntl(); diff --git a/src/components/appointments/CreateAppointment/AppointmentProgress.jsx b/src/components/appointments/CreateAppointment/AppointmentProgress.jsx index 60bf47709..9fdd9c9fd 100644 --- a/src/components/appointments/CreateAppointment/AppointmentProgress.jsx +++ b/src/components/appointments/CreateAppointment/AppointmentProgress.jsx @@ -7,7 +7,7 @@ import {PI_TITLE, STEP_LABELS} from 'components/constants'; import {checkMatchesPath} from 'components/utils/routers'; import {useCreateAppointmentContext} from './CreateAppointmentState'; -import {APPOINTMENT_STEPS, APPOINTMENT_STEP_PATHS} from './routes'; +import {APPOINTMENT_STEPS, APPOINTMENT_STEP_PATHS} from './steps'; const AppointmentProgress = ({title, currentStep}) => { const {submission, submittedSteps} = useCreateAppointmentContext(); diff --git a/src/components/appointments/CreateAppointment/Confirmation.jsx b/src/components/appointments/CreateAppointment/Confirmation.jsx index a0960b0c6..251a03e5f 100644 --- a/src/components/appointments/CreateAppointment/Confirmation.jsx +++ b/src/components/appointments/CreateAppointment/Confirmation.jsx @@ -1,31 +1,15 @@ -import {useNavigate, useSearchParams} from 'react-router-dom'; +import {useSearchParams} from 'react-router-dom'; import {ConfirmationView} from 'components/PostCompletionViews'; -import useFormContext from 'hooks/useFormContext'; import {useCreateAppointmentContext} from './CreateAppointmentState'; const Confirmation = () => { - const form = useFormContext(); const [params] = useSearchParams(); - const navigate = useNavigate(); - const {reset, setProcessingError} = useCreateAppointmentContext(); + const {reset} = useCreateAppointmentContext(); const statusUrl = params.get('statusUrl'); if (!statusUrl) throw new Error('Missing statusUrl param'); - - const onProcessingFailure = errorMessage => { - setProcessingError(errorMessage); - navigate('../overzicht'); - }; - - return ( - <ConfirmationView - statusUrl={statusUrl} - onFailure={onProcessingFailure} - onConfirmed={reset} - downloadPDFText={form.submissionReportDownloadLinkTitle} - /> - ); + return <ConfirmationView onFailureNavigateTo="../overzicht" onConfirmed={reset} />; }; Confirmation.propTypes = {}; diff --git a/src/components/appointments/CreateAppointment/CreateAppointment.jsx b/src/components/appointments/CreateAppointment/CreateAppointment.jsx index 0ee8b11bc..924347870 100644 --- a/src/components/appointments/CreateAppointment/CreateAppointment.jsx +++ b/src/components/appointments/CreateAppointment/CreateAppointment.jsx @@ -14,7 +14,7 @@ import useSessionTimeout from 'hooks/useSessionTimeout'; import {AppointmentConfigContext} from '../Context'; import AppointmentProgress from './AppointmentProgress'; import {CreateAppointmentState} from './CreateAppointmentState'; -import {APPOINTMENT_STEP_PATHS} from './routes'; +import {APPOINTMENT_STEP_PATHS} from './steps'; const useIsConfirmation = () => { // useMatch requires absolute paths... and react-router are NOT receptive to changing that. diff --git a/src/components/appointments/CreateAppointment/CreateAppointment.spec.jsx b/src/components/appointments/CreateAppointment/CreateAppointment.spec.jsx index 50c4e569a..76b5f793c 100644 --- a/src/components/appointments/CreateAppointment/CreateAppointment.spec.jsx +++ b/src/components/appointments/CreateAppointment/CreateAppointment.spec.jsx @@ -14,8 +14,8 @@ import { mockSubmissionPost, mockSubmissionProcessingStatusErrorGet, } from 'api-mocks/submissions'; -import App, {routes as nestedRoutes} from 'components/App'; import {SESSION_STORAGE_KEY as SUBMISSION_SESSION_STORAGE_KEY} from 'hooks/useGetOrCreateSubmission'; +import routes from 'routes'; import { mockAppointmentCustomerFieldsGet, @@ -31,14 +31,6 @@ import {SESSION_STORAGE_KEY as APPOINTMENT_SESSION_STORAGE_KEY} from './CreateAp let scrollIntoViewMock = vi.fn(); window.HTMLElement.prototype.scrollIntoView = scrollIntoViewMock; -const routes = [ - { - path: '*', - element: <App />, - children: nestedRoutes, - }, -]; - const renderApp = (initialRoute = '/') => { const form = buildForm({ appointmentOptions: { @@ -177,6 +169,14 @@ describe('Create appointment status checking', () => { // wait for summary page to be rendered again await screen.findByText('Check and confirm', undefined, {timeout: 2000}); expect(screen.getByText('Computer says no.')).toBeVisible(); + + // submitting again causes error message to vanish + for (const checkbox of screen.getAllByRole('checkbox')) { + await user.click(checkbox); + } + const submitButton2 = screen.getByRole('button', {name: 'Confirm'}); + await user.click(submitButton2); + expect(screen.queryByText('Computer says no.')).toBeNull(); }); }); diff --git a/src/components/appointments/CreateAppointment/CreateAppointment.stories.jsx b/src/components/appointments/CreateAppointment/CreateAppointment.stories.jsx index 56839486d..01d4f61de 100644 --- a/src/components/appointments/CreateAppointment/CreateAppointment.stories.jsx +++ b/src/components/appointments/CreateAppointment/CreateAppointment.stories.jsx @@ -6,6 +6,7 @@ import {FormContext} from 'Context'; import {buildForm} from 'api-mocks'; import {mockSubmissionPost, mockSubmissionProcessingStatusGet} from 'api-mocks/submissions'; import {loadCalendarLocale} from 'components/forms/DateField/DatePickerCalendar'; +import {createAppointmentRoutes} from 'routes/appointments'; import {ConfigDecorator, LayoutDecorator} from 'story-utils/decorators'; import { @@ -16,7 +17,7 @@ import { mockAppointmentProductsGet, mockAppointmentTimesGet, } from '../mocks'; -import CreateAppointment, {routes as childRoutes} from './'; +import CreateAppointment from './'; export default { title: 'Private API / Appointments / CreateForm', @@ -49,7 +50,7 @@ const Wrapper = ({form}) => { { path: '/appointments/*', element: <CreateAppointment />, - children: childRoutes, + children: createAppointmentRoutes, }, ]; const router = createMemoryRouter(routes, { diff --git a/src/components/appointments/CreateAppointment/CreateAppointmentState.jsx b/src/components/appointments/CreateAppointment/CreateAppointmentState.jsx index 1c9ab1b80..659bc7a7d 100644 --- a/src/components/appointments/CreateAppointment/CreateAppointmentState.jsx +++ b/src/components/appointments/CreateAppointment/CreateAppointmentState.jsx @@ -24,8 +24,6 @@ export const buildContextValue = ({ appointmentErrors = {initialTouched: {}, initialErrors: {}}, setAppointmentErrors = () => {}, resetSession = () => {}, - processingError = '', - setProcessingError = () => {}, }) => { const submittedSteps = Object.keys(appointmentData).filter( subObject => Object.keys(subObject).length @@ -55,8 +53,6 @@ export const buildContextValue = ({ submitStep: values => setAppointmentData({...appointmentData, [currentStep]: values}), setErrors: setAppointmentErrors, stepErrors: {initialTouched: stepInitialTouched, initialErrors: stepInitialErrors}, - processingError, - setProcessingError, clearStepErrors: () => { const newInitialErrors = produce(initialErrors, draft => { errorKeys.forEach(key => delete draft[key]); @@ -64,7 +60,6 @@ export const buildContextValue = ({ setAppointmentErrors({initialTouched, initialErrors: newInitialErrors}); }, reset: () => { - setProcessingError(''); setAppointmentData({}); resetSession(); }, @@ -77,7 +72,6 @@ export const CreateAppointmentState = ({currentStep, submission, resetSession, c initialTouched: {}, initialErrors: {}, }); - const [processingError, setProcessingError] = useState(''); // check if the session is expired useSessionTimeout(); @@ -90,8 +84,6 @@ export const CreateAppointmentState = ({currentStep, submission, resetSession, c appointmentErrors, setAppointmentErrors, resetSession, - processingError, - setProcessingError, }); return ( diff --git a/src/components/appointments/CreateAppointment/Summary.jsx b/src/components/appointments/CreateAppointment/Summary.jsx index af8f924a1..106fc67ff 100644 --- a/src/components/appointments/CreateAppointment/Summary.jsx +++ b/src/components/appointments/CreateAppointment/Summary.jsx @@ -1,7 +1,7 @@ import {Form, Formik} from 'formik'; import {useContext, useState} from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; -import {createSearchParams, useNavigate} from 'react-router-dom'; +import {createSearchParams, useLocation, useNavigate} from 'react-router-dom'; import {useAsync} from 'react-use'; import {ConfigContext} from 'Context'; @@ -60,9 +60,10 @@ const getErrorsNavigateTo = errors => { const Summary = () => { const intl = useIntl(); const {baseUrl} = useContext(ConfigContext); + const {state: routerState} = useLocation(); const navigate = useNavigate(); - const {appointmentData, submission, setErrors, processingError, setProcessingError} = - useCreateAppointmentContext(); + const {appointmentData, submission, setErrors} = useCreateAppointmentContext(); + const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); useTitle( intl.formatMessage({ @@ -162,7 +163,7 @@ const Summary = () => { * Submit the appointment data to the backend. */ const onSubmit = async statementValues => { - setProcessingError(''); + setSubmitting(true); let appointment; try { appointment = await createAppointment(baseUrl, submission, appointmentData, statementValues); @@ -176,15 +177,24 @@ const Summary = () => { } setSubmitError(e); return; + } finally { + setSubmitting(false); } - navigate({ - pathname: '../bevestiging', - search: createSearchParams({ - statusUrl: appointment.statusUrl, - }).toString(), - }); + // TODO: store details in sessionStorage instead, to survive hard refreshes + navigate( + { + pathname: '../bevestiging', + search: createSearchParams({ + statusUrl: appointment.statusUrl, + }).toString(), + }, + { + state: {submission}, + } + ); }; + const processingError = submitting ? '' : routerState?.errorMessage; return ( <> <CardTitle diff --git a/src/components/appointments/CreateAppointment/index.jsx b/src/components/appointments/CreateAppointment/index.jsx index 699df6433..111aece52 100644 --- a/src/components/appointments/CreateAppointment/index.jsx +++ b/src/components/appointments/CreateAppointment/index.jsx @@ -1,4 +1,3 @@ import CreateAppointment from './CreateAppointment'; export default CreateAppointment; -export {routes} from './routes'; diff --git a/src/components/appointments/CreateAppointment/routes.jsx b/src/components/appointments/CreateAppointment/steps.jsx similarity index 66% rename from src/components/appointments/CreateAppointment/routes.jsx rename to src/components/appointments/CreateAppointment/steps.jsx index c142d5c35..473aadb57 100644 --- a/src/components/appointments/CreateAppointment/routes.jsx +++ b/src/components/appointments/CreateAppointment/steps.jsx @@ -1,11 +1,7 @@ import {defineMessage} from 'react-intl'; -import {Navigate} from 'react-router-dom'; - -import useQuery from 'hooks/useQuery'; +import {Navigate, useSearchParams} from 'react-router-dom'; import {ChooseProductStep, ContactDetailsStep, LocationAndTimeStep} from '../steps'; -import Confirmation from './Confirmation'; -import Summary from './Summary'; export const APPOINTMENT_STEPS = [ { @@ -36,34 +32,16 @@ export const APPOINTMENT_STEPS = [ export const APPOINTMENT_STEP_PATHS = APPOINTMENT_STEPS.map(s => s.path); -const LandingPage = () => { - const query = useQuery(); +// TODO: replace with loader that redirects at the route level +export const LandingPage = () => { + const [params] = useSearchParams(); return ( <Navigate replace to={{ pathname: APPOINTMENT_STEP_PATHS[0], - search: `?${query}`, + search: `?${params}`, }} /> ); }; - -/** - * Route subtree for appointment forms. - */ -export const routes = [ - { - path: '', - element: <LandingPage />, - }, - ...APPOINTMENT_STEPS.map(({path, element}) => ({path, element})), - { - path: 'overzicht', - element: <Summary />, - }, - { - path: 'bevestiging', - element: <Confirmation />, - }, -]; diff --git a/src/components/appointments/ManageAppointment/index.jsx b/src/components/appointments/ManageAppointment/index.jsx deleted file mode 100644 index 77bd44d6f..000000000 --- a/src/components/appointments/ManageAppointment/index.jsx +++ /dev/null @@ -1 +0,0 @@ -export {routes} from './routes'; diff --git a/src/components/appointments/ManageAppointment/routes.jsx b/src/components/appointments/ManageAppointment/routes.jsx deleted file mode 100644 index a02da59f6..000000000 --- a/src/components/appointments/ManageAppointment/routes.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import ErrorBoundary from 'components/Errors/ErrorBoundary'; - -import {CancelAppointment, CancelAppointmentSuccess} from '../cancel'; - -export const routes = [ - { - path: '', - element: ( - <ErrorBoundary> - <CancelAppointment /> - </ErrorBoundary> - ), - }, - { - path: 'succes', - element: <CancelAppointmentSuccess />, - }, -]; diff --git a/src/components/appointments/cancel/CancelAppointment.integration.spec.jsx b/src/components/appointments/cancel/CancelAppointment.integration.spec.jsx index 34e06ad2c..8194d66de 100644 --- a/src/components/appointments/cancel/CancelAppointment.integration.spec.jsx +++ b/src/components/appointments/cancel/CancelAppointment.integration.spec.jsx @@ -5,7 +5,7 @@ import {RouterProvider, createMemoryRouter} from 'react-router-dom'; import {ConfigContext, FormContext} from 'Context'; import {BASE_URL, buildForm} from 'api-mocks'; -import {routes} from 'components/App'; +import routes from 'routes'; const Wrapper = () => { const form = buildForm({ diff --git a/src/components/appointments/cancel/CancelAppointment.jsx b/src/components/appointments/cancel/CancelAppointment.jsx index 504141b90..33487eb1b 100644 --- a/src/components/appointments/cancel/CancelAppointment.jsx +++ b/src/components/appointments/cancel/CancelAppointment.jsx @@ -1,7 +1,7 @@ import {Formik} from 'formik'; import {useContext, useState} from 'react'; import {FormattedDate, FormattedMessage} from 'react-intl'; -import {useNavigate} from 'react-router-dom'; +import {useNavigate, useSearchParams} from 'react-router-dom'; import {ConfigContext} from 'Context'; import {post} from 'api'; @@ -12,18 +12,17 @@ import ErrorMessage from 'components/Errors/ErrorMessage'; import {Toolbar, ToolbarList} from 'components/Toolbar'; import {EmailField} from 'components/forms'; import {ValidationError} from 'errors'; -import useQuery from 'hooks/useQuery'; const CancelAppointment = () => { const {baseUrl} = useContext(ConfigContext); const navigate = useNavigate(); - const queryParams = useQuery(); + const [params] = useSearchParams(); const [failed, setFailed] = useState(false); // validate the necessary information to know which submission we are dealing with - const timeParam = queryParams.get('time'); - const submissionId = queryParams.get('submission_uuid'); + const timeParam = params.get('time'); + const submissionId = params.get('submission_uuid'); // input validation - show error message if people are messing with URLs rather // than crashing hard. diff --git a/src/components/appointments/index.jsx b/src/components/appointments/index.jsx index 6eee0217f..6981aaa6d 100644 --- a/src/components/appointments/index.jsx +++ b/src/components/appointments/index.jsx @@ -1,2 +1 @@ -export {default as CreateAppointment, routes as appointmentRoutes} from './CreateAppointment'; -export {routes as manageAppointmentRoutes} from './ManageAppointment'; +export {default as CreateAppointment} from './CreateAppointment'; diff --git a/src/components/appointments/steps/ChooseProductStep.jsx b/src/components/appointments/steps/ChooseProductStep.jsx index 6aa81632d..a33f30d68 100644 --- a/src/components/appointments/steps/ChooseProductStep.jsx +++ b/src/components/appointments/steps/ChooseProductStep.jsx @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import {useContext} from 'react'; import {flushSync} from 'react-dom'; import {FormattedMessage, useIntl} from 'react-intl'; -import {useNavigate} from 'react-router-dom'; +import {useNavigate, useSearchParams} from 'react-router-dom'; import {z} from 'zod'; import {toFormikValidationSchema} from 'zod-formik-adapter'; @@ -12,7 +12,6 @@ import {OFButton} from 'components/Button'; import {CardTitle} from 'components/Card'; import {EditGrid, EditGridButtonGroup, EditGridItem} from 'components/EditGrid'; import FAIcon from 'components/FAIcon'; -import useQuery from 'hooks/useQuery'; import useTitle from 'hooks/useTitle'; import {AppointmentConfigContext} from '../Context'; @@ -172,8 +171,8 @@ const ChooseProductStep = ({navigateTo = null}) => { defaultMessage: 'Product', }) ); - const query = useQuery(); - const initialProductId = query.get('product'); + const [params] = useSearchParams(); + const initialProductId = params.get('product'); const initialValues = produce(INITIAL_VALUES, draft => { if (initialProductId) { diff --git a/src/components/auth/AuthenticationErrors/index.jsx b/src/components/auth/AuthenticationErrors/index.jsx index 3e9e90e08..9cfdba0b2 100644 --- a/src/components/auth/AuthenticationErrors/index.jsx +++ b/src/components/auth/AuthenticationErrors/index.jsx @@ -1,8 +1,8 @@ import PropTypes from 'prop-types'; import {useIntl} from 'react-intl'; +import {useSearchParams} from 'react-router-dom'; import ErrorMessage from 'components/Errors/ErrorMessage'; -import useQuery from 'hooks/useQuery'; const MAPPING_PARAMS_SERVICE = { '_digid-message': 'DigiD', @@ -13,11 +13,11 @@ const MAPPING_PARAMS_SERVICE = { const CANCEL_LOGIN_PARAM = 'login-cancelled'; const useDetectAuthErrorMessages = () => { - const query = useQuery(); + const [params] = useSearchParams(); let parameters = {}; - for (const [key, value] of query.entries()) { + for (const [key, value] of params.entries()) { if (key in MAPPING_PARAMS_SERVICE) { parameters[key] = value; return parameters; diff --git a/src/components/auth/AuthenticationOutage.jsx b/src/components/auth/AuthenticationOutage.jsx index 574e58cb2..4e2794e43 100644 --- a/src/components/auth/AuthenticationOutage.jsx +++ b/src/components/auth/AuthenticationOutage.jsx @@ -1,14 +1,14 @@ import PropTypes from 'prop-types'; import {FormattedMessage} from 'react-intl'; +import {useSearchParams} from 'react-router-dom'; import ErrorMessage from 'components/Errors/ErrorMessage'; -import useQuery from 'hooks/useQuery'; const AUTHENTICATION_OUTAGE_QUERY_PARAM = 'of-auth-problem'; export const useDetectAuthenticationOutage = () => { - const query = useQuery(); - return query.get(AUTHENTICATION_OUTAGE_QUERY_PARAM); + const [params] = useSearchParams(); + return params.get(AUTHENTICATION_OUTAGE_QUERY_PARAM); }; const AuthenticationOutage = ({loginOption}) => ( diff --git a/src/hooks/useQuery.js b/src/hooks/useQuery.js deleted file mode 100644 index 75e5de02c..000000000 --- a/src/hooks/useQuery.js +++ /dev/null @@ -1,8 +0,0 @@ -import {useSearchParams} from 'react-router-dom'; - -const useQuery = () => { - const [searchParams] = useSearchParams(); - return searchParams; -}; - -export default useQuery; diff --git a/src/hooks/useRecycleSubmission.js b/src/hooks/useRecycleSubmission.js index 9cf5f4cd9..7a1dca3db 100644 --- a/src/hooks/useRecycleSubmission.js +++ b/src/hooks/useRecycleSubmission.js @@ -1,22 +1,21 @@ import {useContext} from 'react'; -import {useLocation} from 'react-router-dom'; +import {useLocation, useSearchParams} from 'react-router-dom'; import {useAsync, useLocalStorage} from 'react-use'; import {ConfigContext} from 'Context'; import {apiCall} from 'api'; -import useQuery from 'hooks/useQuery'; const useRecycleSubmission = (form, currentSubmission, onSubmissionLoaded, onError = () => {}) => { const location = useLocation(); const config = useContext(ConfigContext); - const queryParams = useQuery(); + const [params] = useSearchParams(); // XXX: use sessionStorage instead of localStorage for this, so that it's scoped to // a single tab/window? let [submissionId, setSubmissionId, removeSubmissionId] = useLocalStorage(form.uuid, ''); // If no submissionID is in the localStorage see if one can be retrieved from the query param if (!submissionId) { - submissionId = queryParams.get('submission_uuid'); + submissionId = params.get('submission_uuid'); } const url = submissionId ? `${config.baseUrl}submissions/${submissionId}` : null; diff --git a/src/hooks/useScrollIntoView.js b/src/hooks/useScrollIntoView.js index 5c83b4fc1..81b8d0941 100644 --- a/src/hooks/useScrollIntoView.js +++ b/src/hooks/useScrollIntoView.js @@ -4,7 +4,11 @@ const useScrollIntoView = (options = {behavior: 'smooth'}) => { const ref = useRef(); useEffect(() => { if (!ref.current) return; - ref.current.scrollIntoView(options); + // scrollIntoView is not available in jest-dom, and this can cause to crashing/infinitely + // loading (integration) tests because ErrorMessage uses this hook, which is used + // in the usual ErrorBoundary component... So, be very conservative here with the + // scrollIntoView behaviour/expectations! + ref.current.scrollIntoView?.(options); }, [ref, options]); return ref; }; diff --git a/src/hooks/useScrollIntoView.spec.jsx b/src/hooks/useScrollIntoView.spec.jsx new file mode 100644 index 000000000..de0810d98 --- /dev/null +++ b/src/hooks/useScrollIntoView.spec.jsx @@ -0,0 +1,16 @@ +import {render, screen} from '@testing-library/react'; + +import useScrollIntoView from './useScrollIntoView'; + +const TestComponent = () => { + const ref = useScrollIntoView(); + return <div ref={ref}>Scroll me!</div>; +}; + +test('useScrollIntoView in jest-dom', () => { + expect(window.HTMLElement.prototype.scrollIntoView).toBeUndefined(); + + render(<TestComponent />); + + expect(screen.getByText('Scroll me!')).toBeVisible(); +}); diff --git a/src/hooks/useStartSubmission.js b/src/hooks/useStartSubmission.js index 5ebfaea5c..640da79a2 100644 --- a/src/hooks/useStartSubmission.js +++ b/src/hooks/useStartSubmission.js @@ -1,10 +1,10 @@ -import {START_FORM_QUERY_PARAM} from 'components/constants'; +import {useSearchParams} from 'react-router-dom'; -import useQuery from './useQuery'; +import {START_FORM_QUERY_PARAM} from 'components/constants'; const useStartSubmission = () => { - const query = useQuery(); - return !!query.get(START_FORM_QUERY_PARAM); + const [params] = useSearchParams(); + return !!params.get(START_FORM_QUERY_PARAM); }; export default useStartSubmission; diff --git a/src/routes/app.jsx b/src/routes/app.jsx new file mode 100644 index 000000000..5e49ed178 --- /dev/null +++ b/src/routes/app.jsx @@ -0,0 +1,62 @@ +import App from 'components/App'; +import {Cosign} from 'components/CoSign'; +import ErrorBoundary from 'components/Errors/ErrorBoundary'; +import Form from 'components/Form'; +import SessionExpired from 'components/Sessions/SessionExpired'; +import {CreateAppointment} from 'components/appointments'; + +import {createAppointmentRoutes, manageAppointmentRoutes} from './appointments'; +import cosignRoutes from './cosign'; +import formRoutes from './form'; + +/** + * Main app entrypoint routes. + * + * These routes are the top-level routes, dividing the SDK into distinct features/ + * chunks. + * + * @todo - soon-ish we can use dynamic loading to split up the bundle for lazy loading + * and reduce the initial load time. + */ +const routes = [ + { + path: '*', + element: <App />, + children: [ + { + path: 'afspraak-annuleren/*', + children: manageAppointmentRoutes, + }, + { + path: 'afspraak-maken/*', + element: <CreateAppointment />, + children: createAppointmentRoutes, + }, + { + path: 'cosign/*', + element: <Cosign />, + children: cosignRoutes, + }, + { + path: 'sessie-verlopen', + element: ( + <ErrorBoundary useCard> + <SessionExpired /> + </ErrorBoundary> + ), + }, + // All the rest goes to the formio-based form flow + { + path: '*', + element: ( + <ErrorBoundary useCard> + <Form /> + </ErrorBoundary> + ), + children: formRoutes, + }, + ], + }, +]; + +export default routes; diff --git a/src/routes/appointments.jsx b/src/routes/appointments.jsx new file mode 100644 index 000000000..38db46de3 --- /dev/null +++ b/src/routes/appointments.jsx @@ -0,0 +1,41 @@ +import ErrorBoundary from 'components/Errors/ErrorBoundary'; +import Confirmation from 'components/appointments/CreateAppointment/Confirmation'; +import Summary from 'components/appointments/CreateAppointment/Summary'; +import {APPOINTMENT_STEPS, LandingPage} from 'components/appointments/CreateAppointment/steps'; +import {CancelAppointment, CancelAppointmentSuccess} from 'components/appointments/cancel'; + +/** + * Route subtree for appointment forms. + */ +const createAppointmentRoutes = [ + { + path: '', + element: <LandingPage />, + }, + ...APPOINTMENT_STEPS.map(({path, element}) => ({path, element})), + { + path: 'overzicht', + element: <Summary />, + }, + { + path: 'bevestiging', + element: <Confirmation />, + }, +]; + +const manageAppointmentRoutes = [ + { + path: '', + element: ( + <ErrorBoundary> + <CancelAppointment /> + </ErrorBoundary> + ), + }, + { + path: 'succes', + element: <CancelAppointmentSuccess />, + }, +]; + +export {createAppointmentRoutes, manageAppointmentRoutes}; diff --git a/src/components/CoSign/routes.jsx b/src/routes/cosign.jsx similarity index 56% rename from src/components/CoSign/routes.jsx rename to src/routes/cosign.jsx index 31d8267df..6f9b6caac 100644 --- a/src/components/CoSign/routes.jsx +++ b/src/routes/cosign.jsx @@ -1,6 +1,6 @@ -import CosignCheck from './CosignCheck'; -import CosignDone from './CosignDone'; -import CosignStart from './CosignStart'; +import CosignCheck from 'components/CoSign/CosignCheck'; +import CosignDone from 'components/CoSign/CosignDone'; +import CosignStart from 'components/CoSign/CosignStart'; const routes = [ { diff --git a/src/components/formRoutes.jsx b/src/routes/form.jsx similarity index 50% rename from src/components/formRoutes.jsx rename to src/routes/form.jsx index 579b02d8f..c367b6e57 100644 --- a/src/components/formRoutes.jsx +++ b/src/routes/form.jsx @@ -3,10 +3,12 @@ import FormLandingPage from 'components/FormLandingPage'; import FormStart from 'components/FormStart'; import FormStep from 'components/FormStep'; import IntroductionPage from 'components/IntroductionPage'; +import {ConfirmationView, StartPaymentView} from 'components/PostCompletionViews'; import RequireSubmission from 'components/RequireSubmission'; import {SessionTrackerModal} from 'components/Sessions'; +import {SubmissionSummary} from 'components/Summary'; -const formRoutes = [ +const routes = [ { path: '', element: <FormLandingPage />, @@ -28,13 +30,43 @@ const formRoutes = [ element: ( <ErrorBoundary useCard> <SessionTrackerModal> - <RequireSubmission retrieveSubmissionFromContext> + <RequireSubmission> <FormStep /> </RequireSubmission> </SessionTrackerModal> </ErrorBoundary> ), }, + { + path: 'overzicht', + element: ( + <ErrorBoundary useCard> + <SessionTrackerModal> + <RequireSubmission> + <SubmissionSummary /> + </RequireSubmission> + </SessionTrackerModal> + </ErrorBoundary> + ), + }, + { + path: 'betalen', + element: ( + <ErrorBoundary useCard> + <RequireSubmission> + <StartPaymentView onFailureNavigateTo="/overzicht" /> + </RequireSubmission> + </ErrorBoundary> + ), + }, + { + path: 'bevestiging', + element: ( + <ErrorBoundary useCard> + <ConfirmationView onFailureNavigateTo="/overzicht" /> + </ErrorBoundary> + ), + }, ]; -export default formRoutes; +export default routes; diff --git a/src/routes/index.jsx b/src/routes/index.jsx new file mode 100644 index 000000000..eae5ea402 --- /dev/null +++ b/src/routes/index.jsx @@ -0,0 +1 @@ +export {default} from './app'; diff --git a/src/sdk.jsx b/src/sdk.jsx index 7433a3bde..f786731bf 100644 --- a/src/sdk.jsx +++ b/src/sdk.jsx @@ -11,11 +11,11 @@ import {NonceProvider} from 'react-select'; import {ConfigContext, FormContext} from 'Context'; import {get} from 'api'; -import App, {routes as nestedRoutes} from 'components/App'; import {getRedirectParams} from 'components/routingActions'; import {AddFetchAuth} from 'formio/plugins'; import {CSPNonce} from 'headers'; import {I18NErrorBoundary, I18NManager} from 'i18n'; +import routes from 'routes'; import initialiseSentry from 'sentry'; import {DEBUG, getVersion} from 'utils'; @@ -50,14 +50,6 @@ fixLeafletIconUrls(); const VERSION = getVersion(); -const routes = [ - { - path: '*', - element: <App />, - children: nestedRoutes, - }, -]; - class OpenForm { constructor(targetNode, opts) { const { diff --git a/src/story-utils/decorators.jsx b/src/story-utils/decorators.jsx index 3cc2ff516..26cdb8890 100644 --- a/src/story-utils/decorators.jsx +++ b/src/story-utils/decorators.jsx @@ -2,11 +2,12 @@ import {Document} from '@utrecht/component-library-react'; import {Formik} from 'formik'; import merge from 'lodash/merge'; -import {AnalyticsToolsConfigContext, ConfigContext, FormContext} from 'Context'; +import {ConfigContext, FormContext} from 'Context'; import {BASE_URL, buildForm} from 'api-mocks'; import Card from 'components/Card'; import {LiteralsProvider} from 'components/Literal'; import {SubmissionStatusContext} from 'components/PostCompletionViews'; +import {AnalyticsToolsConfigContext} from 'components/analytics/AnalyticsToolConfigProvider'; import {ModalContext} from 'components/modals/Modal'; export const ConfigDecorator = (Story, {parameters}) => { @@ -26,8 +27,10 @@ export const ConfigDecorator = (Story, {parameters}) => { export const AnalyticsToolsDecorator = (Story, {parameters}) => { const defaults = { - govmetricSourceId: '', - govmetricSecureGuid: '', + govmetricSourceIdFormFinished: '', + govmetricSourceIdFormAborted: '', + govmetricSecureGuidFormFinished: '', + govmetricSecureGuidFormAborted: '', enableGovmetricAnalytics: false, }; diff --git a/vite.config.mts b/vite.config.mts index 59ef9e935..bda0ece7b 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -145,6 +145,7 @@ export default defineConfig(({mode}) => ({ 'src/**/*.d.ts', 'src/**/*.stories.{js,jsx,ts,tsx}', 'src/api-mocks/*', + 'src/**/mocks.{js,jsx}', 'src/story-utils/*', ...coverageConfigDefaults.exclude, ],