From 535beee627a954ef886f061f6c80af8303e666c1 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Thu, 16 Jan 2025 12:56:55 +0100 Subject: [PATCH 01/35] :wrench: Remove obsolete addon It became obsolete by switching to the react-vite builder. --- .storybook/main.mts | 1 - 1 file changed, 1 deletion(-) 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', From 8f9adffac11c798751552ab413d02d89c6650b76 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Thu, 16 Jan 2025 13:39:51 +0100 Subject: [PATCH 02/35] :recycle: [open-formulieren/open-forms#4929] Refactor test - mock at the network layer Instead of mocking the hooks, mock the API layer for more accurate behaviour and less tight coupling with implementation details. --- src/api-mocks/submissions.js | 4 +- .../Summary/SubmissionSummary.spec.jsx | 98 ++++++++++++++++ src/components/Summary/test.spec.jsx | 109 ------------------ 3 files changed, 100 insertions(+), 111 deletions(-) create mode 100644 src/components/Summary/SubmissionSummary.spec.jsx delete mode 100644 src/components/Summary/test.spec.jsx diff --git a/src/api-mocks/submissions.js b/src/api-mocks/submissions.js index bc2842d40..6fd2f0101 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 = () => diff --git a/src/components/Summary/SubmissionSummary.spec.jsx b/src/components/Summary/SubmissionSummary.spec.jsx new file mode 100644 index 000000000..7404c342a --- /dev/null +++ b/src/components/Summary/SubmissionSummary.spec.jsx @@ -0,0 +1,98 @@ +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 {SubmissionSummary} from 'components/Summary'; +import {SUBMISSION_ALLOWED} from 'components/constants'; + +const Wrap = ({form, children}) => { + const routes = [ + { + path: '/overzicht', + element: children, + }, + ]; + const router = createMemoryRouter(routes, { + initialEntries: ['/overzicht'], + }); + return ( + + + + + + + + ); +}; + +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()); + const onDestroySession = vi.fn(); + const onConfirm = vi.fn(); + + render( + + {}} + /> + + ); + + 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 submissionIsAuthenticated = buildSubmission({ + submissionAllowed: SUBMISSION_ALLOWED.yes, + isAuthenticated: false, + }); + mswServer.use(mockSubmissionGet(submissionIsAuthenticated), mockSubmissionSummaryGet()); + const onDestroySession = vi.fn(); + const onConfirm = vi.fn(); + + render( + + {}} + /> + + ); + + // 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}) => ( - - {children} - -); - -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( - - {}} - /> - - ); - - 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( - - {}} - /> - - ); - - 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( - - {}} - /> - - ); - - expect(screen.queryByRole('button', {name: 'Annuleren'})).toBeInTheDocument(); -}); From 88017d63451305c6afa13a546fd49338c797c6da Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Thu, 16 Jan 2025 15:37:12 +0100 Subject: [PATCH 03/35] :recycle: [open-formulieren/open-forms#4929] Take props from context in SubmissionSummary component Rather than dynamically providing the props from the parent component, ensure that the parent(s) set up the context properly and take the necessary information from there so that we can statically declare the route for the submission summary. --- src/components/Summary/SubmissionSummary.jsx | 17 +++---- .../Summary/SubmissionSummary.spec.jsx | 45 +++++++------------ 2 files changed, 21 insertions(+), 41 deletions(-) diff --git a/src/components/Summary/SubmissionSummary.jsx b/src/components/Summary/SubmissionSummary.jsx index 253a068cd..2826da161 100644 --- a/src/components/Summary/SubmissionSummary.jsx +++ b/src/components/Summary/SubmissionSummary.jsx @@ -5,9 +5,11 @@ import {useAsync} from 'react-use'; import {useImmerReducer} from 'use-immer'; import {post} from 'api'; +import {useSubmissionContext} from 'components/Form'; import {LiteralsProvider} from 'components/Literal'; 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'; @@ -31,14 +33,10 @@ const reducer = (draft, action) => { } }; -const SubmissionSummary = ({ - form, - submission, - processingError = '', - onConfirm, - onClearProcessingErrors, - onDestroySession, -}) => { +const SubmissionSummary = ({processingError = '', onConfirm, onClearProcessingErrors}) => { + const form = useFormContext(); + const {submission, onDestroySession} = useSubmissionContext(); + const [state, dispatch] = useImmerReducer(reducer, initialState); const navigate = useNavigate(); const intl = useIntl(); @@ -135,12 +133,9 @@ 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, }; export default SubmissionSummary; diff --git a/src/components/Summary/SubmissionSummary.spec.jsx b/src/components/Summary/SubmissionSummary.spec.jsx index 7404c342a..d85bad393 100644 --- a/src/components/Summary/SubmissionSummary.spec.jsx +++ b/src/components/Summary/SubmissionSummary.spec.jsx @@ -7,14 +7,23 @@ 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/Form'; import {SubmissionSummary} from 'components/Summary'; import {SUBMISSION_ALLOWED} from 'components/constants'; -const Wrap = ({form, children}) => { +const Wrapper = ({form, submission}) => { const routes = [ { path: '/overzicht', - element: children, + element: ( + + + + ), }, ]; const router = createMemoryRouter(routes, { @@ -48,20 +57,8 @@ test.each([true, false])( isAuthenticated: true, }); mswServer.use(mockSubmissionGet(submissionIsAuthenticated), mockSubmissionSummaryGet()); - const onDestroySession = vi.fn(); - const onConfirm = vi.fn(); - render( - - {}} - /> - - ); + render(); const logoutButton = await screen.findByRole('button', {name: 'Uitloggen'}); expect(logoutButton).toBeVisible(); @@ -70,25 +67,13 @@ test.each([true, false])( test('Summary when isAuthenticated and loginRequired are false', async () => { const form = buildForm({loginRequired: false}); - const submissionIsAuthenticated = buildSubmission({ + const submissionNotAuthenticated = buildSubmission({ submissionAllowed: SUBMISSION_ALLOWED.yes, isAuthenticated: false, }); - mswServer.use(mockSubmissionGet(submissionIsAuthenticated), mockSubmissionSummaryGet()); - const onDestroySession = vi.fn(); - const onConfirm = vi.fn(); + mswServer.use(mockSubmissionGet(submissionNotAuthenticated), mockSubmissionSummaryGet()); - render( - - {}} - /> - - ); + render(); // we expect an abort button instead of log out const cancelButton = await screen.findByRole('button', {name: 'Annuleren'}); From a4683da5f28a784dc7c90605ef28d17462a3ef56 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Thu, 16 Jan 2025 21:23:13 +0100 Subject: [PATCH 04/35] :arrow_up: [open-formulieren/open-forms#4929] Bump testing-library packages Upgraded the packages in an attempt to debug why a new test would keep running forever without crashing/erroring out, seemingly being stuck in an infinite loop. I suspected something odd with timers/setTimeout going on and there are some open issues in testing-library/vitest related to that, but this was not the problem. I've decided to keep the upgrades since things *appear* to work with the new versions, but of course only CI will decide that for real. If we can keep this 'free' upgrade, that's a nice accident, if it's problematic, I revert it. --- package-lock.json | 563 +++++----------------------------------------- package.json | 8 +- 2 files changed, 64 insertions(+), 507 deletions(-) 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", From 5114bdb6915d550b53ad06e6613bc4cbb86d86e2 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Thu, 16 Jan 2025 21:28:15 +0100 Subject: [PATCH 05/35] :bug: [open-formulieren/open-forms#4929] Fixed a test-specific bug in useScrollIntoView jest-dom doesn't provide the scrollIntoView method on HTML elements for it's JS API, which explains why this code/flow appears to work properly when using a real browser and simulating testing flows manually, while being problematic in tests. The problem here was ultimately that no mock was set up for this method, causing the useEffect hook to crash because undefined is not a function that can be called. This problem is then compounded because React will render the nearest ErrorBoundary, which we have define in most of our routes (that are currently under testing), and these error boundaries use the ErrorMessage component too, which also calls useScrollIntoView, which also crashes and seemingly gets stuck in an infinite render, causing Vitest to keep running forever without erroring or even timing out (presumably because the JS thread is just stuck in an infinite loop). The fool-proof way to mitigate this is to nullish check the function before calling it, so that future oversights (forgetting to mock) will not cause us to spend hours of debugging again. For the tests, the scrolling behaviour is not relevant anyway. A test is added to establish the assumption that this callback doesn't exist, which protects against accidentally setting up a mock in this test file. --- src/hooks/useScrollIntoView.js | 6 +++++- src/hooks/useScrollIntoView.spec.jsx | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useScrollIntoView.spec.jsx 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
Scroll me!
; +}; + +test('useScrollIntoView in jest-dom', () => { + expect(window.HTMLElement.prototype.scrollIntoView).toBeUndefined(); + + render(); + + expect(screen.getByText('Scroll me!')).toBeVisible(); +}); From 0079fa0948ee4051730156c428e6e054bbc93639 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Thu, 16 Jan 2025 21:29:23 +0100 Subject: [PATCH 06/35] :clown_face: [open-formulieren/open-forms#4929] Add API endpoint mock for submission completion This endpoint was not mocked yet, apparently. We can now properly write tests that simulate the submission/completion of a form and the behaviour that happens afterwards. --- src/api-mocks/submissions.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/api-mocks/submissions.js b/src/api-mocks/submissions.js index 6fd2f0101..87333b017 100644 --- a/src/api-mocks/submissions.js +++ b/src/api-mocks/submissions.js @@ -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. */ From 37aeaf9b9c0587c9a9744fa339f80f6301386041 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Thu, 16 Jan 2025 21:31:08 +0100 Subject: [PATCH 07/35] :art: [open-formulieren/open-forms#4929] Minor improvements to the StatusUrlPoller component * Use nullish-check for callback invocation which is a bit more idiomatic Javascript * Throw error instead of just logging it so that it bubbles to the nearest error boundary. This makes the flow of rendering easier to follow/debug/understand. --- .../PostCompletionViews/StatusUrlPoller.jsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/PostCompletionViews/StatusUrlPoller.jsx b/src/components/PostCompletionViews/StatusUrlPoller.jsx index f2b1d5ed7..befc0ac5c 100644 --- a/src/components/PostCompletionViews/StatusUrlPoller.jsx +++ b/src/components/PostCompletionViews/StatusUrlPoller.jsx @@ -41,12 +41,13 @@ const StatusUrlPoller = ({statusUrl, onFailure, onConfirmed, children}) => { response => { if (response.result === RESULT_FAILED) { const errorMessage = response.errorMessage || genericErrorMessage; - if (onFailure) onFailure(errorMessage); + onFailure?.(errorMessage); } else if (response.result === RESULT_SUCCESS) { - if (onConfirmed) onConfirmed(); + onConfirmed?.(); } } ); + if (error) throw error; if (loading) { return ( @@ -71,10 +72,9 @@ 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, From 55c165cf79fc10d41190f2f7edb9e5b11ffdcde3 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Thu, 16 Jan 2025 21:33:09 +0100 Subject: [PATCH 08/35] :art: [open-formulieren/open-forms#4929] Small code improvements * Take out the component-invariant function and move it to the module level. This gives it a stable reference as it doesn't depend on any components state or props. * Throw errors instead of just logging them for a more obvious error handling flow. --- src/components/Summary/SubmissionSummary.jsx | 29 +++++++++----------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/src/components/Summary/SubmissionSummary.jsx b/src/components/Summary/SubmissionSummary.jsx index 2826da161..1ad127787 100644 --- a/src/components/Summary/SubmissionSummary.jsx +++ b/src/components/Summary/SubmissionSummary.jsx @@ -12,7 +12,6 @@ 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'; @@ -21,6 +20,17 @@ const initialState = { error: '', }; +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 reducer = (draft, action) => { switch (action.type) { case 'ERROR': { @@ -53,10 +63,8 @@ const SubmissionSummary = ({processingError = '', onConfirm, onClearProcessingEr 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; @@ -82,17 +90,6 @@ const SubmissionSummary = ({processingError = '', onConfirm, onClearProcessingEr 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', From 5f96507a72dcaa1f392b4525b380298113f5f534 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Thu, 16 Jan 2025 21:54:28 +0100 Subject: [PATCH 09/35] :white_check_mark: [open-formulieren/open-forms#4929] Add test for summary -> status -> error submission flow There's now a test to lock in the behaviour after confirming the submission, getting the status page with spinner and redirect back to summary page if there's a background processing error. Now we can refactor the error message state handling to get these props out of the Summary component. The fake timer situation is... complicated. See https://github.com/testing-library/react-testing-library/issues/1197 --- src/components/Form.jsx | 4 +-- src/components/Form.spec.jsx | 68 ++++++++++++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/src/components/Form.jsx b/src/components/Form.jsx index 3ead291d9..75eac821e 100644 --- a/src/components/Form.jsx +++ b/src/components/Form.jsx @@ -318,12 +318,10 @@ const Form = () => { dispatch({type: 'CLEAR_PROCESSING_ERROR'})} - onDestroySession={onDestroySession} - form={form} /> diff --git a/src/components/Form.spec.jsx b/src/components/Form.spec.jsx index 86b850df7..52829ac1e 100644 --- a/src/components/Form.spec.jsx +++ b/src/components/Form.spec.jsx @@ -1,26 +1,45 @@ -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 { + mockSubmissionCompletePost, + mockSubmissionGet, + mockSubmissionPost, + mockSubmissionProcessingStatusErrorGet, + mockSubmissionStepGet, + mockSubmissionSummaryGet, +} from 'api-mocks/submissions'; import {routes} from 'components/App'; +import {SUBMISSION_ALLOWED} from 'components/constants'; 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 +148,46 @@ 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(); + + 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(); +}); From 71f42502c3de4aeaef9f3d09ec80ecd1840b2c08 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Fri, 17 Jan 2025 09:14:38 +0100 Subject: [PATCH 10/35] :recycle: [open-formulieren/open-forms#4929] Pass processing error via router state Instead of tracking this state at the component level, we can pass it while redirecting back to the overview page, which removes the need for some props. Navigating to another location clears the error state again and it is updated with new submit attempts. --- src/components/Form.jsx | 16 ++-------------- src/components/Summary/SubmissionSummary.jsx | 10 ++++------ 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/src/components/Form.jsx b/src/components/Form.jsx index 75eac821e..fc4e5d152 100644 --- a/src/components/Form.jsx +++ b/src/components/Form.jsx @@ -43,7 +43,6 @@ const initialState = { submission: null, submittedSubmission: null, processingStatusUrl: '', - processingError: '', completed: false, startingError: '', }; @@ -64,21 +63,14 @@ const reducer = (draft, action) => { }; } 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 + // put the submission back in the state 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, @@ -204,10 +196,8 @@ const Form = () => { }; const onProcessingFailure = errorMessage => { - // TODO: provide generic fallback message in case no explicit - // message is shown dispatch({type: 'PROCESSING_FAILED', payload: errorMessage}); - navigate('/overzicht'); + navigate('/overzicht', {state: {errorMessage}}); }; // handle redirect from payment provider to render appropriate page and include the @@ -319,9 +309,7 @@ const Form = () => { dispatch({type: 'CLEAR_PROCESSING_ERROR'})} /> diff --git a/src/components/Summary/SubmissionSummary.jsx b/src/components/Summary/SubmissionSummary.jsx index 1ad127787..c4829e20e 100644 --- a/src/components/Summary/SubmissionSummary.jsx +++ b/src/components/Summary/SubmissionSummary.jsx @@ -1,6 +1,6 @@ import PropTypes from 'prop-types'; 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'; @@ -43,11 +43,12 @@ const reducer = (draft, action) => { } }; -const SubmissionSummary = ({processingError = '', onConfirm, onClearProcessingErrors}) => { +const SubmissionSummary = ({onConfirm}) => { const form = useFormContext(); const {submission, onDestroySession} = useSubmissionContext(); const [state, dispatch] = useImmerReducer(reducer, initialState); + const location = useLocation(); const navigate = useNavigate(); const intl = useIntl(); @@ -85,8 +86,6 @@ const SubmissionSummary = ({processingError = '', onConfirm, onClearProcessingEr const onPrevPage = event => { event.preventDefault(); - onClearProcessingErrors(); - navigate(getPreviousPage()); }; @@ -98,6 +97,7 @@ const SubmissionSummary = ({processingError = '', onConfirm, onClearProcessingEr const getErrors = () => { let errors = []; + const processingError = location.state?.errorMessage; if (processingError) errors.push(processingError); if (state.error) errors.push(state.error); return errors; @@ -130,9 +130,7 @@ const SubmissionSummary = ({processingError = '', onConfirm, onClearProcessingEr }; SubmissionSummary.propTypes = { - processingError: PropTypes.string, onConfirm: PropTypes.func.isRequired, - onClearProcessingErrors: PropTypes.func.isRequired, }; export default SubmissionSummary; From e0484cfb9a3026ecdd86acb4c209ce17b61f8441 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Fri, 17 Jan 2025 09:19:47 +0100 Subject: [PATCH 11/35] :art: [open-formulieren/open-forms#4929] Simplify SubmissionSummary state management Removed reducer that only supported one action type, in favour of a plain useState hook. --- src/components/Summary/SubmissionSummary.jsx | 51 ++++++-------------- 1 file changed, 14 insertions(+), 37 deletions(-) diff --git a/src/components/Summary/SubmissionSummary.jsx b/src/components/Summary/SubmissionSummary.jsx index c4829e20e..9cfe2df14 100644 --- a/src/components/Summary/SubmissionSummary.jsx +++ b/src/components/Summary/SubmissionSummary.jsx @@ -1,8 +1,8 @@ import PropTypes from 'prop-types'; +import {useState} from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; import {useLocation, useNavigate} from 'react-router-dom'; import {useAsync} from 'react-use'; -import {useImmerReducer} from 'use-immer'; import {post} from 'api'; import {useSubmissionContext} from 'components/Form'; @@ -16,10 +16,6 @@ import useTitle from 'hooks/useTitle'; import GenericSummary from './GenericSummary'; import {loadSummaryData} from './utils'; -const initialState = { - error: '', -}; - const completeSubmission = async (submission, statementValues) => { const response = await post(`${submission.url}/_complete`, statementValues); if (!response.ok) { @@ -31,29 +27,22 @@ const completeSubmission = async (submission, statementValues) => { } }; -const reducer = (draft, action) => { - switch (action.type) { - case 'ERROR': { - draft.error = action.payload; - break; - } - default: { - throw new Error(`Unknown action ${action.type}`); - } - } -}; - const SubmissionSummary = ({onConfirm}) => { - const form = useFormContext(); - const {submission, onDestroySession} = useSubmissionContext(); - - const [state, dispatch] = useImmerReducer(reducer, initialState); const location = useLocation(); const navigate = useNavigate(); const intl = useIntl(); - + const form = useFormContext(); + const {submission, onDestroySession} = 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 { @@ -73,7 +62,7 @@ const SubmissionSummary = ({onConfirm}) => { const {statusUrl} = await completeSubmission(refreshedSubmission, statementValues); onConfirm(statusUrl); } catch (e) { - dispatch({type: 'ERROR', payload: e.message}); + setSubmitError(e.message); } }; @@ -89,19 +78,7 @@ const SubmissionSummary = ({onConfirm}) => { navigate(getPreviousPage()); }; - const pageTitle = intl.formatMessage({ - description: 'Summary page title', - defaultMessage: 'Check and confirm', - }); - useTitle(pageTitle, form.name); - - const getErrors = () => { - let errors = []; - const processingError = location.state?.errorMessage; - if (processingError) errors.push(processingError); - if (state.error) errors.push(state.error); - return errors; - }; + const errorMessages = [location.state?.errorMessage, submitError].filter(Boolean); return ( @@ -119,7 +96,7 @@ const SubmissionSummary = ({onConfirm}) => { editStepText={form.literals.changeText.resolved} isLoading={loading} isAuthenticated={refreshedSubmission.isAuthenticated} - errors={getErrors()} + errors={errorMessages} prevPage={getPreviousPage()} onSubmit={onSubmit} onPrevPage={onPrevPage} From c750dccd9c934639d5582c58967a5026216a56cd Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Fri, 17 Jan 2025 11:18:01 +0100 Subject: [PATCH 12/35] :recycle: [open-formulieren/open-forms#4929] Remove need for processingStatusUrl prop The Form/SubmissionSummary components no longer need to manage the processing status URL (backend API URL) state to be passed down to other components, instead they're now passed via the router location state. The payment start and confirmation page now grab them from the location state. Appointments uses the query string parameter for the status URL, but this should at some point all be moved to session storage so that we can survive hard refreshes (which may happen by accident or by impatient users), as it is more secure since sharing the status URL is enough to get access to the submission data via the PDF. --- src/components/Form.jsx | 49 +++++++++++-------- src/components/FormStart/tests.spec.jsx | 1 + src/components/FormStep/FormStep.stories.jsx | 3 ++ .../PostCompletionViews/ConfirmationView.jsx | 16 ++++-- .../PostCompletionViews/StartPaymentView.jsx | 8 ++- .../viewsWithPolling.stories.jsx | 22 ++++----- src/components/Summary/SubmissionSummary.jsx | 23 +++++++-- .../Summary/SubmissionSummary.spec.jsx | 1 + .../CreateAppointment/Confirmation.jsx | 1 - .../CreateAppointment/Summary.jsx | 1 + 10 files changed, 84 insertions(+), 41 deletions(-) diff --git a/src/components/Form.jsx b/src/components/Form.jsx index fc4e5d152..1c811b1e0 100644 --- a/src/components/Form.jsx +++ b/src/components/Form.jsx @@ -9,6 +9,7 @@ import { useLocation, useMatch, useNavigate, + useSearchParams, } from 'react-router-dom'; import {useAsync, usePrevious} from 'react-use'; import {useImmerReducer} from 'use-immer'; @@ -32,7 +33,6 @@ 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'; @@ -42,7 +42,6 @@ import {addFixedSteps, getStepsInfo} from './ProgressIndicator/utils'; const initialState = { submission: null, submittedSubmission: null, - processingStatusUrl: '', completed: false, startingError: '', }; @@ -59,7 +58,6 @@ const reducer = (draft, action) => { return { ...initialState, submittedSubmission: action.payload.submission, - processingStatusUrl: action.payload.processingStatusUrl, }; } case 'PROCESSING_FAILED': { @@ -102,7 +100,7 @@ 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); @@ -167,21 +165,13 @@ const Form = () => { setSubmissionId(submission.id); }; - const onSubmitForm = processingStatusUrl => { - removeSubmissionId(); + const onSubmitForm = () => { dispatch({ type: 'SUBMITTED', payload: { submission: state.submission, - processingStatusUrl, }, }); - - if (submission?.payment.isRequired && !state.submission.payment.hasPaid) { - navigate('/betalen'); - } else { - navigate('/bevestiging'); - } }; const onDestroySession = async () => { @@ -202,15 +192,16 @@ const Form = () => { // 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 ( ); @@ -322,7 +313,6 @@ const Form = () => { dispatch({type: 'PROCESSING_SUCCEEDED'})} component={StartPaymentView} @@ -337,7 +327,6 @@ const Form = () => { element={ dispatch({type: 'PROCESSING_SUCCEEDED'})} downloadPDFText={form.submissionReportDownloadLinkTitle} @@ -356,6 +345,7 @@ const Form = () => { submission={state.submission} onSubmissionObtained={onSubmissionObtained} onDestroySession={onDestroySession} + removeSubmissionId={removeSubmissionId} > {router} @@ -371,23 +361,42 @@ const SubmissionContext = React.createContext({ submission: null, onSubmissionObtained: () => {}, onDestroySession: () => {}, + removeSubmissionId: () => {}, }); const SubmissionProvider = ({ submission = null, onSubmissionObtained, onDestroySession, + removeSubmissionId, children, }) => ( - + {children} ); 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, }; const useSubmissionContext = () => useContext(SubmissionContext); diff --git a/src/components/FormStart/tests.spec.jsx b/src/components/FormStart/tests.spec.jsx index d9b218610..5fa2853a5 100644 --- a/src/components/FormStart/tests.spec.jsx +++ b/src/components/FormStart/tests.spec.jsx @@ -52,6 +52,7 @@ const Wrap = ({ onSubmissionObtained?.(); }} onDestroySession={() => {}} + removeSubmissionId={vi.fn()} > diff --git a/src/components/FormStep/FormStep.stories.jsx b/src/components/FormStep/FormStep.stories.jsx index b7bb322a4..7d8bd7361 100644 --- a/src/components/FormStep/FormStep.stories.jsx +++ b/src/components/FormStep/FormStep.stories.jsx @@ -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} > diff --git a/src/components/PostCompletionViews/ConfirmationView.jsx b/src/components/PostCompletionViews/ConfirmationView.jsx index fcf20653c..b045805a8 100644 --- a/src/components/PostCompletionViews/ConfirmationView.jsx +++ b/src/components/PostCompletionViews/ConfirmationView.jsx @@ -1,11 +1,12 @@ 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 {DEBUG} from 'utils'; import PostCompletionView from './PostCompletionView'; import StatusUrlPoller, {SubmissionStatusContext} from './StatusUrlPoller'; @@ -105,7 +106,17 @@ ConfirmationViewDisplay.propTypes = { downloadPDFText: PropTypes.node, }; -const ConfirmationView = ({statusUrl, onFailure, onConfirmed, downloadPDFText}) => { +const ConfirmationView = ({onFailure, onConfirmed, downloadPDFText}) => { + // 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 ( @@ -114,7 +125,6 @@ const ConfirmationView = ({statusUrl, onFailure, onConfirmed, downloadPDFText}) }; ConfirmationView.propTypes = { - statusUrl: PropTypes.string, onFailure: PropTypes.func, onConfirmed: PropTypes.func, downloadPDFText: PropTypes.node, diff --git a/src/components/PostCompletionViews/StartPaymentView.jsx b/src/components/PostCompletionViews/StartPaymentView.jsx index fe97c6de3..5f0ea0594 100644 --- a/src/components/PostCompletionViews/StartPaymentView.jsx +++ b/src/components/PostCompletionViews/StartPaymentView.jsx @@ -1,9 +1,11 @@ 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 {DEBUG} from 'utils'; import PostCompletionView from './PostCompletionView'; import {StartPayment} from './StartPayment'; @@ -58,7 +60,10 @@ StartPaymentViewDisplay.propTypes = { downloadPDFText: PropTypes.node, }; -const StartPaymentView = ({statusUrl, onFailure, onConfirmed, downloadPDFText}) => { +const StartPaymentView = ({onFailure, onConfirmed, downloadPDFText}) => { + const location = useLocation(); + const statusUrl = location.state?.statusUrl; + if (DEBUG && !statusUrl) throw new Error('You must pass the status URL via the route state.'); return ( @@ -67,7 +72,6 @@ const StartPaymentView = ({statusUrl, onFailure, onConfirmed, downloadPDFText}) }; StartPaymentView.propTypes = { - statusUrl: PropTypes.string, onFailure: PropTypes.func, onConfirmed: PropTypes.func, downloadPDFText: PropTypes.node, diff --git a/src/components/PostCompletionViews/viewsWithPolling.stories.jsx b/src/components/PostCompletionViews/viewsWithPolling.stories.jsx index fe811466d..4ab28addc 100644 --- a/src/components/PostCompletionViews/viewsWithPolling.stories.jsx +++ b/src/components/PostCompletionViews/viewsWithPolling.stories.jsx @@ -1,4 +1,4 @@ -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'; @@ -18,7 +18,6 @@ export default { statusUrl: {control: false}, }, args: { - statusUrl: `${BASE_URL}submissions/4b0e86a8-dc5f-41cc-b812-c89857b9355b/-token-/status`, onFailure: fn(), onConfirmed: fn(), }, @@ -27,7 +26,11 @@ export default { handlers: [mockSubmissionProcessingStatusGet], }, reactRouter: { - location: {state: {}}, + location: { + state: { + statusUrl: `${BASE_URL}submissions/4b0e86a8-dc5f-41cc-b812-c89857b9355b/-token-/status`, + }, + }, }, }, }; @@ -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/Summary/SubmissionSummary.jsx b/src/components/Summary/SubmissionSummary.jsx index 9cfe2df14..20a56cd77 100644 --- a/src/components/Summary/SubmissionSummary.jsx +++ b/src/components/Summary/SubmissionSummary.jsx @@ -32,7 +32,7 @@ const SubmissionSummary = ({onConfirm}) => { const navigate = useNavigate(); const intl = useIntl(); const form = useFormContext(); - const {submission, onDestroySession} = useSubmissionContext(); + const {submission, onDestroySession, removeSubmissionId} = useSubmissionContext(); const refreshedSubmission = useRefreshSubmission(submission); const [submitError, setSubmitError] = useState(''); @@ -58,12 +58,29 @@ const SubmissionSummary = ({onConfirm}) => { const onSubmit = async statementValues => { if (refreshedSubmission.submissionAllowed !== SUBMISSION_ALLOWED.yes) return; + let statusUrl; try { - const {statusUrl} = await completeSubmission(refreshedSubmission, statementValues); - onConfirm(statusUrl); + const responseData = await completeSubmission(refreshedSubmission, statementValues); + statusUrl = responseData.statusUrl; } catch (e) { setSubmitError(e.message); + return; } + + onConfirm(); + + // 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 = () => { diff --git a/src/components/Summary/SubmissionSummary.spec.jsx b/src/components/Summary/SubmissionSummary.spec.jsx index d85bad393..d0d31546d 100644 --- a/src/components/Summary/SubmissionSummary.spec.jsx +++ b/src/components/Summary/SubmissionSummary.spec.jsx @@ -20,6 +20,7 @@ const Wrapper = ({form, submission}) => { submission={submission} onSubmissionObtained={vi.fn()} onDestroySession={vi.fn()} + removeSubmissionId={vi.fn()} > diff --git a/src/components/appointments/CreateAppointment/Confirmation.jsx b/src/components/appointments/CreateAppointment/Confirmation.jsx index a0960b0c6..e8ba995be 100644 --- a/src/components/appointments/CreateAppointment/Confirmation.jsx +++ b/src/components/appointments/CreateAppointment/Confirmation.jsx @@ -20,7 +20,6 @@ const Confirmation = () => { return ( { setSubmitError(e); return; } + // TODO: store details in sessionStorage instead, to survive hard refreshes navigate({ pathname: '../bevestiging', search: createSearchParams({ From 3e35c074a900ffbe58a6badf3e3f27bd5ca03f87 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Fri, 17 Jan 2025 12:18:17 +0100 Subject: [PATCH 13/35] :truck: Move post completion views to 'views' folder in storybook. --- src/components/PostCompletionViews/ConfirmationView.stories.jsx | 2 +- .../PostCompletionViews/PostCompletionView.stories.jsx | 2 +- src/components/PostCompletionViews/StartPaymentView.stories.jsx | 2 +- src/components/PostCompletionViews/viewsWithPolling.stories.jsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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}) => {body}} />, }; diff --git a/src/components/PostCompletionViews/StartPaymentView.stories.jsx b/src/components/PostCompletionViews/StartPaymentView.stories.jsx index fe6d18edf..6a074390e 100644 --- a/src/components/PostCompletionViews/StartPaymentView.stories.jsx +++ b/src/components/PostCompletionViews/StartPaymentView.stories.jsx @@ -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: { diff --git a/src/components/PostCompletionViews/viewsWithPolling.stories.jsx b/src/components/PostCompletionViews/viewsWithPolling.stories.jsx index 4ab28addc..d64eea0d5 100644 --- a/src/components/PostCompletionViews/viewsWithPolling.stories.jsx +++ b/src/components/PostCompletionViews/viewsWithPolling.stories.jsx @@ -11,7 +11,7 @@ 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: { From f6e25379a6468ba062cd88e040077f02e2dcf594 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Fri, 17 Jan 2025 12:38:17 +0100 Subject: [PATCH 14/35] :recycle: [open-formulieren/open-forms#4929] Remove the need for an onConfirm prop By passing the submitted submission in the router state, we don't need to track it in the parent component any longer to be able to restore it when there are failures. --- src/components/Form.jsx | 28 ++++--------------- .../PostCompletionViews/ConfirmationView.jsx | 23 +++++++++++---- .../PostCompletionViews/StartPaymentView.jsx | 16 ++++++++--- .../viewsWithPolling.stories.jsx | 3 +- src/components/Summary/SubmissionSummary.jsx | 9 ++---- .../Summary/SubmissionSummary.spec.jsx | 2 +- .../CreateAppointment/Confirmation.jsx | 2 +- .../CreateAppointment/Summary.jsx | 17 +++++++---- 8 files changed, 52 insertions(+), 48 deletions(-) diff --git a/src/components/Form.jsx b/src/components/Form.jsx index 1c811b1e0..dbb55bec0 100644 --- a/src/components/Form.jsx +++ b/src/components/Form.jsx @@ -54,15 +54,10 @@ const reducer = (draft, action) => { draft.submission = action.payload; break; } - case 'SUBMITTED': { - return { - ...initialState, - submittedSubmission: action.payload.submission, - }; - } case 'PROCESSING_FAILED': { // put the submission back in the state so we can re-submit - draft.submission = draft.submittedSubmission; + const {submission} = action.payload; + draft.submission = submission; break; } case 'PROCESSING_SUCCEEDED': { @@ -165,15 +160,6 @@ const Form = () => { setSubmissionId(submission.id); }; - const onSubmitForm = () => { - dispatch({ - type: 'SUBMITTED', - payload: { - submission: state.submission, - }, - }); - }; - const onDestroySession = async () => { await destroy(`${config.baseUrl}authentication/${state.submission.id}/session`); @@ -185,8 +171,8 @@ const Form = () => { navigate('/'); }; - const onProcessingFailure = errorMessage => { - dispatch({type: 'PROCESSING_FAILED', payload: errorMessage}); + const onProcessingFailure = (submission, errorMessage) => { + dispatch({type: 'PROCESSING_FAILED', payload: {submission}}); navigate('/overzicht', {state: {errorMessage}}); }; @@ -297,11 +283,7 @@ const Form = () => { element={ - + } diff --git a/src/components/PostCompletionViews/ConfirmationView.jsx b/src/components/PostCompletionViews/ConfirmationView.jsx index b045805a8..1fde05863 100644 --- a/src/components/PostCompletionViews/ConfirmationView.jsx +++ b/src/components/PostCompletionViews/ConfirmationView.jsx @@ -113,12 +113,25 @@ const ConfirmationView = ({onFailure, onConfirmed, downloadPDFText}) => { 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.' - ); + const submittedSubmission = location.state?.submission; + + if (DEBUG) { + if (!statusUrl) { + throw new Error( + 'You must pass the status URL via the router state (preferably) or query params.' + ); + } + if (!submittedSubmission) { + throw new Error('You must pass the submitted submission via the router state.'); + } + } + return ( - + onFailure(submittedSubmission, error)} + onConfirmed={onConfirmed} + > ); diff --git a/src/components/PostCompletionViews/StartPaymentView.jsx b/src/components/PostCompletionViews/StartPaymentView.jsx index 5f0ea0594..fb0b3257a 100644 --- a/src/components/PostCompletionViews/StartPaymentView.jsx +++ b/src/components/PostCompletionViews/StartPaymentView.jsx @@ -61,11 +61,19 @@ StartPaymentViewDisplay.propTypes = { }; const StartPaymentView = ({onFailure, onConfirmed, downloadPDFText}) => { - const location = useLocation(); - const statusUrl = location.state?.statusUrl; - if (DEBUG && !statusUrl) throw new Error('You must pass the status URL via the route state.'); + const {statusUrl, submission} = useLocation().state || {}; + if (DEBUG) { + if (!statusUrl) throw new Error('You must pass the status URL via the route state.'); + if (!submission) { + throw new Error('You must pass the submitted submission via the router state.'); + } + } return ( - + onFailure(submission, error)} + onConfirmed={onConfirmed} + > ); diff --git a/src/components/PostCompletionViews/viewsWithPolling.stories.jsx b/src/components/PostCompletionViews/viewsWithPolling.stories.jsx index d64eea0d5..7485acefa 100644 --- a/src/components/PostCompletionViews/viewsWithPolling.stories.jsx +++ b/src/components/PostCompletionViews/viewsWithPolling.stories.jsx @@ -1,7 +1,7 @@ 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, @@ -29,6 +29,7 @@ export default { location: { state: { statusUrl: `${BASE_URL}submissions/4b0e86a8-dc5f-41cc-b812-c89857b9355b/-token-/status`, + submission: buildSubmission(), }, }, }, diff --git a/src/components/Summary/SubmissionSummary.jsx b/src/components/Summary/SubmissionSummary.jsx index 20a56cd77..5f22c1dc5 100644 --- a/src/components/Summary/SubmissionSummary.jsx +++ b/src/components/Summary/SubmissionSummary.jsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import {useState} from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; import {useLocation, useNavigate} from 'react-router-dom'; @@ -27,7 +26,7 @@ const completeSubmission = async (submission, statementValues) => { } }; -const SubmissionSummary = ({onConfirm}) => { +const SubmissionSummary = () => { const location = useLocation(); const navigate = useNavigate(); const intl = useIntl(); @@ -67,8 +66,6 @@ const SubmissionSummary = ({onConfirm}) => { return; } - onConfirm(); - // the completion went through, proceed to redirect to the next page and set up // the necessary state. const needsPayment = @@ -123,8 +120,6 @@ const SubmissionSummary = ({onConfirm}) => { ); }; -SubmissionSummary.propTypes = { - onConfirm: PropTypes.func.isRequired, -}; +SubmissionSummary.propTypes = {}; export default SubmissionSummary; diff --git a/src/components/Summary/SubmissionSummary.spec.jsx b/src/components/Summary/SubmissionSummary.spec.jsx index d0d31546d..69ccb6f07 100644 --- a/src/components/Summary/SubmissionSummary.spec.jsx +++ b/src/components/Summary/SubmissionSummary.spec.jsx @@ -22,7 +22,7 @@ const Wrapper = ({form, submission}) => { onDestroySession={vi.fn()} removeSubmissionId={vi.fn()} > - + ), }, diff --git a/src/components/appointments/CreateAppointment/Confirmation.jsx b/src/components/appointments/CreateAppointment/Confirmation.jsx index e8ba995be..c734f574b 100644 --- a/src/components/appointments/CreateAppointment/Confirmation.jsx +++ b/src/components/appointments/CreateAppointment/Confirmation.jsx @@ -13,7 +13,7 @@ const Confirmation = () => { const statusUrl = params.get('statusUrl'); if (!statusUrl) throw new Error('Missing statusUrl param'); - const onProcessingFailure = errorMessage => { + const onProcessingFailure = (submission, errorMessage) => { setProcessingError(errorMessage); navigate('../overzicht'); }; diff --git a/src/components/appointments/CreateAppointment/Summary.jsx b/src/components/appointments/CreateAppointment/Summary.jsx index 03a9c54e8..400946523 100644 --- a/src/components/appointments/CreateAppointment/Summary.jsx +++ b/src/components/appointments/CreateAppointment/Summary.jsx @@ -178,12 +178,17 @@ const Summary = () => { return; } // TODO: store details in sessionStorage instead, to survive hard refreshes - navigate({ - pathname: '../bevestiging', - search: createSearchParams({ - statusUrl: appointment.statusUrl, - }).toString(), - }); + navigate( + { + pathname: '../bevestiging', + search: createSearchParams({ + statusUrl: appointment.statusUrl, + }).toString(), + }, + { + state: {submission}, + } + ); }; return ( From 7b96f503db15a33410593be5ed9d16562baef6d5 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Fri, 17 Jan 2025 12:59:02 +0100 Subject: [PATCH 15/35] :recycle: [open-formulieren/open-forms#4929] Move SubmissionSummary route to static route declarations --- src/components/Form.jsx | 13 ------------- src/components/formRoutes.jsx | 13 +++++++++++++ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/components/Form.jsx b/src/components/Form.jsx index dbb55bec0..044a926c7 100644 --- a/src/components/Form.jsx +++ b/src/components/Form.jsx @@ -21,8 +21,6 @@ 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, @@ -278,17 +276,6 @@ const Form = () => { // Route the correct page based on URL const router = ( - - - - - - } - /> - ), }, + { + path: 'overzicht', + element: ( + + + + + + + + ), + }, ]; export default formRoutes; From 9697156fc45da9b0cc8f871658e13a2823cb9847 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Fri, 17 Jan 2025 16:27:23 +0100 Subject: [PATCH 16/35] :recycle: [open-formulieren/open-forms#4929] Ensure summary -> payment flow isn't broken because of previous refactor The Form component sometimes needs to grab the submission from the router state too, in particular to render the payment start page. --- src/components/Form.jsx | 6 +++--- src/components/Form.spec.jsx | 41 ++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/components/Form.jsx b/src/components/Form.jsx index 044a926c7..1ac1e6e11 100644 --- a/src/components/Form.jsx +++ b/src/components/Form.jsx @@ -97,7 +97,7 @@ const Form = () => { usePageViews(); const intl = useIntl(); const prevLocale = usePrevious(intl.locale); - const {pathname: currentPathname} = useLocation(); + const {pathname: currentPathname, state: routerState} = useLocation(); // TODO replace absolute path check with relative const introductionMatch = useMatch('/introductie'); @@ -201,7 +201,7 @@ const Form = () => { 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 submission = state.submission || (!!paymentMatch && routerState.submission) || null; const isCompleted = state.completed; const formName = form.name; const needsPayment = submission ? submission.payment.isRequired : form.paymentRequired; @@ -281,7 +281,7 @@ const Form = () => { element={ dispatch({type: 'PROCESSING_SUCCEEDED'})} component={StartPaymentView} diff --git a/src/components/Form.spec.jsx b/src/components/Form.spec.jsx index 52829ac1e..d4e425b7f 100644 --- a/src/components/Form.spec.jsx +++ b/src/components/Form.spec.jsx @@ -12,6 +12,7 @@ import { mockSubmissionGet, mockSubmissionPost, mockSubmissionProcessingStatusErrorGet, + mockSubmissionProcessingStatusGet, mockSubmissionStepGet, mockSubmissionSummaryGet, } from 'api-mocks/submissions'; @@ -191,3 +192,43 @@ test('Submitting the form with failing background processing', async () => { expect(await screen.findByRole('heading', {name: 'Check and confirm'})).toBeVisible(); expect(screen.getByText('Computer says no.')).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 + ); + + render(); + 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(); +}); From 51d8674b4513802f0ca42da2a03e306f2f5751f2 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Fri, 17 Jan 2025 16:36:48 +0100 Subject: [PATCH 17/35] :coffin: [open-formulieren/open-forms#4929] Delete dead code Dispatcher actions were obsoleted by earlier refactor work. --- src/components/Form.jsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/components/Form.jsx b/src/components/Form.jsx index 1ac1e6e11..8a924cdc1 100644 --- a/src/components/Form.jsx +++ b/src/components/Form.jsx @@ -39,9 +39,7 @@ import {addFixedSteps, getStepsInfo} from './ProgressIndicator/utils'; const initialState = { submission: null, - submittedSubmission: null, completed: false, - startingError: '', }; const reducer = (draft, action) => { @@ -71,10 +69,6 @@ const reducer = (draft, action) => { const initialState = action.payload; return initialState; } - case 'STARTING_ERROR': { - draft.startingError = action.payload; - break; - } default: { throw new Error(`Unknown action ${action.type}`); } @@ -271,8 +265,6 @@ const Form = () => { /> ) : null; - if (state.startingError) throw state.startingError; - // Route the correct page based on URL const router = ( From 3d278135069ff68db4cde079228d19c71f56c295 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Fri, 17 Jan 2025 16:43:22 +0100 Subject: [PATCH 18/35] :white_check_mark: [open-formulieren/open-forms#4929] Add tests for flow to success view --- src/components/Form.spec.jsx | 42 ++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/components/Form.spec.jsx b/src/components/Form.spec.jsx index d4e425b7f..b7ca723d9 100644 --- a/src/components/Form.spec.jsx +++ b/src/components/Form.spec.jsx @@ -193,6 +193,48 @@ test('Submitting the form with failing background processing', async () => { 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(); + + 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: 'Confirmation: OF-L337'})).toBeVisible(); +}); + test('Submitting form with payment requirement', async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime, From 5f43008e50bd9a688ab854f7887ca4811660f1e6 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Fri, 17 Jan 2025 16:52:32 +0100 Subject: [PATCH 19/35] :clown_face: [open-formulieren/open-forms#4929] Fix payment plugin start mocks --- src/api-mocks/submissions.js | 6 +++--- src/components/Form.spec.jsx | 6 ++++-- .../PostCompletionViews/StartPaymentView.stories.jsx | 4 ++-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/api-mocks/submissions.js b/src/api-mocks/submissions.js index 87333b017..f40eb5764 100644 --- a/src/api-mocks/submissions.js +++ b/src/api-mocks/submissions.js @@ -167,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/Form.spec.jsx b/src/components/Form.spec.jsx index b7ca723d9..9464236fb 100644 --- a/src/components/Form.spec.jsx +++ b/src/components/Form.spec.jsx @@ -10,6 +10,7 @@ import mswServer from 'api-mocks/msw-server'; import { mockSubmissionCompletePost, mockSubmissionGet, + mockSubmissionPaymentStartPost, mockSubmissionPost, mockSubmissionProcessingStatusErrorGet, mockSubmissionProcessingStatusGet, @@ -235,7 +236,7 @@ test('Submitting the form with successful background processing', async () => { expect(await screen.findByRole('heading', {name: 'Confirmation: OF-L337'})).toBeVisible(); }); -test('Submitting form with payment requirement', async () => { +test.only('Submitting form with payment requirement', async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime, }); @@ -257,7 +258,8 @@ test('Submitting form with payment requirement', async () => { mockSubmissionGet(submission), mockSubmissionSummaryGet(), mockSubmissionCompletePost(), - mockSubmissionProcessingStatusGet + mockSubmissionProcessingStatusGet, + mockSubmissionPaymentStartPost(null) ); render(); diff --git a/src/components/PostCompletionViews/StartPaymentView.stories.jsx b/src/components/PostCompletionViews/StartPaymentView.stories.jsx index 6a074390e..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'; @@ -21,7 +21,7 @@ export default { }, parameters: { msw: { - handlers: [mockSubmissionProcessingStatusGet, mockSubmissionPaymentStartGet], + handlers: [mockSubmissionProcessingStatusGet, mockSubmissionPaymentStartPost()], }, }, }; From 703519a031dae44ad62b032a40eab95a464ce555 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Fri, 17 Jan 2025 18:26:08 +0100 Subject: [PATCH 20/35] :recycle: [open-formulieren/open-forms#4929] Removed the need to track completion state We can infer the submission _complete call/result state from the presence of the status URL in the router state, so we no longer need to pass an onConfirmed callback down to set this in the parent state. --- src/components/Form.jsx | 11 +++-------- src/components/Form.spec.jsx | 2 +- .../PostCompletionViews/StartPaymentView.jsx | 9 ++------- .../ProgressIndicator/ProgressIndicatorItem.jsx | 3 +++ 4 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/components/Form.jsx b/src/components/Form.jsx index 8a924cdc1..71032e63a 100644 --- a/src/components/Form.jsx +++ b/src/components/Form.jsx @@ -39,7 +39,6 @@ import {addFixedSteps, getStepsInfo} from './ProgressIndicator/utils'; const initialState = { submission: null, - completed: false, }; const reducer = (draft, action) => { @@ -56,10 +55,6 @@ const reducer = (draft, action) => { draft.submission = submission; break; } - case 'PROCESSING_SUCCEEDED': { - draft.completed = true; - break; - } case 'DESTROY_SUBMISSION': { return { ...initialState, @@ -196,7 +191,6 @@ const Form = () => { const submissionAllowedSpec = state.submission?.submissionAllowed ?? form.submissionAllowed; const showOverview = submissionAllowedSpec !== SUBMISSION_ALLOWED.noWithoutOverview; const submission = state.submission || (!!paymentMatch && routerState.submission) || null; - const isCompleted = state.completed; const formName = form.name; const needsPayment = submission ? submission.payment.isRequired : form.paymentRequired; @@ -241,6 +235,9 @@ const Form = () => { // then, filter out the non-applicable steps if they should not be displayed .filter(step => showNonApplicableSteps || step.isApplicable); + // the statusUrl is put in the router state once the summary page is confirmed and the + // submission is completed. + const isCompleted = !!routerState?.statusUrl; const stepsToRender = addFixedSteps( intl, updatedSteps, @@ -275,7 +272,6 @@ const Form = () => { dispatch({type: 'PROCESSING_SUCCEEDED'})} component={StartPaymentView} donwloadPDFText={form.submissionReportDownloadLinkTitle} /> @@ -289,7 +285,6 @@ const Form = () => { dispatch({type: 'PROCESSING_SUCCEEDED'})} downloadPDFText={form.submissionReportDownloadLinkTitle} /> diff --git a/src/components/Form.spec.jsx b/src/components/Form.spec.jsx index 9464236fb..5af746a1f 100644 --- a/src/components/Form.spec.jsx +++ b/src/components/Form.spec.jsx @@ -236,7 +236,7 @@ test('Submitting the form with successful background processing', async () => { expect(await screen.findByRole('heading', {name: 'Confirmation: OF-L337'})).toBeVisible(); }); -test.only('Submitting form with payment requirement', async () => { +test('Submitting form with payment requirement', async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime, }); diff --git a/src/components/PostCompletionViews/StartPaymentView.jsx b/src/components/PostCompletionViews/StartPaymentView.jsx index fb0b3257a..150ccf342 100644 --- a/src/components/PostCompletionViews/StartPaymentView.jsx +++ b/src/components/PostCompletionViews/StartPaymentView.jsx @@ -60,7 +60,7 @@ StartPaymentViewDisplay.propTypes = { downloadPDFText: PropTypes.node, }; -const StartPaymentView = ({onFailure, onConfirmed, downloadPDFText}) => { +const StartPaymentView = ({onFailure, downloadPDFText}) => { const {statusUrl, submission} = useLocation().state || {}; if (DEBUG) { if (!statusUrl) throw new Error('You must pass the status URL via the route state.'); @@ -69,11 +69,7 @@ const StartPaymentView = ({onFailure, onConfirmed, downloadPDFText}) => { } } return ( - onFailure(submission, error)} - onConfirmed={onConfirmed} - > + onFailure(submission, error)}> ); @@ -81,7 +77,6 @@ const StartPaymentView = ({onFailure, onConfirmed, downloadPDFText}) => { StartPaymentView.propTypes = { onFailure: PropTypes.func, - onConfirmed: PropTypes.func, downloadPDFText: PropTypes.node, }; 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 (
@@ -35,6 +37,7 @@ const ProgressIndicatorItem = ({label, to, isActive, isCompleted, canNavigateTo,
Date: Fri, 17 Jan 2025 19:05:03 +0100 Subject: [PATCH 21/35] :recycle: [open-formulieren/open-forms#4929] Simplify props for payment/confirmation status view page --- src/components/Form.jsx | 52 ++----------------- .../PostCompletionViews/ConfirmationView.jsx | 46 ++++++++++------ .../PostCompletionViews/StartPaymentView.jsx | 38 +++++++++----- .../CreateAppointment/Confirmation.jsx | 17 +----- src/components/formRoutes.jsx | 19 +++++++ 5 files changed, 80 insertions(+), 92 deletions(-) diff --git a/src/components/Form.jsx b/src/components/Form.jsx index 71032e63a..b90874300 100644 --- a/src/components/Form.jsx +++ b/src/components/Form.jsx @@ -4,8 +4,6 @@ import {useIntl} from 'react-intl'; import { Navigate, Outlet, - Route, - Routes, useLocation, useMatch, useNavigate, @@ -16,11 +14,8 @@ import {useImmerReducer} from 'use-immer'; import {AnalyticsToolsConfigContext, ConfigContext} from 'Context'; import {destroy, get} from 'api'; -import ErrorBoundary from 'components/Errors/ErrorBoundary'; import Loader from 'components/Loader'; -import {ConfirmationView, StartPaymentView} from 'components/PostCompletionViews'; import ProgressIndicator from 'components/ProgressIndicator'; -import RequireSubmission from 'components/RequireSubmission'; import { PI_TITLE, START_FORM_QUERY_PARAM, @@ -49,12 +44,6 @@ const reducer = (draft, action) => { draft.submission = action.payload; break; } - case 'PROCESSING_FAILED': { - // put the submission back in the state so we can re-submit - const {submission} = action.payload; - draft.submission = submission; - break; - } case 'DESTROY_SUBMISSION': { return { ...initialState, @@ -158,11 +147,6 @@ const Form = () => { navigate('/'); }; - const onProcessingFailure = (submission, errorMessage) => { - dispatch({type: 'PROCESSING_FAILED', payload: {submission}}); - navigate('/overzicht', {state: {errorMessage}}); - }; - // handle redirect from payment provider to render appropriate page and include the // params as state for the next component. if (params.get('of_payment_status')) { @@ -184,13 +168,15 @@ const Form = () => { return ; } + const submissionFromRouterState = routerState?.submission ?? null; + const submission = state.submission || submissionFromRouterState; + // 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 || (!!paymentMatch && routerState.submission) || null; const formName = form.name; const needsPayment = submission ? submission.payment.isRequired : form.paymentRequired; @@ -262,37 +248,6 @@ const Form = () => { /> ) : null; - // Route the correct page based on URL - const router = ( - - - - - } - /> - - - - - } - /> - - ); - // render the form step if there's an active submission (and no summary) return ( @@ -304,7 +259,6 @@ const Form = () => { removeSubmissionId={removeSubmissionId} > - {router} diff --git a/src/components/PostCompletionViews/ConfirmationView.jsx b/src/components/PostCompletionViews/ConfirmationView.jsx index 1fde05863..76fdbd1b5 100644 --- a/src/components/PostCompletionViews/ConfirmationView.jsx +++ b/src/components/PostCompletionViews/ConfirmationView.jsx @@ -1,11 +1,12 @@ import PropTypes from 'prop-types'; import React, {useContext} from 'react'; import {FormattedMessage, defineMessage, useIntl} from 'react-intl'; -import {useLocation, useSearchParams} from 'react-router-dom'; +import {useLocation, useNavigate, 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'; @@ -106,41 +107,54 @@ ConfirmationViewDisplay.propTypes = { downloadPDFText: PropTypes.node, }; -const ConfirmationView = ({onFailure, onConfirmed, downloadPDFText}) => { +const ConfirmationView = ({returnTo, onFailure, 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 navigate = useNavigate(); const [params] = useSearchParams(); const statusUrl = params.get('statusUrl') ?? location.state?.statusUrl; - const submittedSubmission = location.state?.submission; - - if (DEBUG) { - if (!statusUrl) { - throw new Error( - 'You must pass the status URL via the router state (preferably) or query params.' - ); - } - if (!submittedSubmission) { - throw new Error('You must pass the submitted submission via the router state.'); - } + + if (DEBUG && !statusUrl) { + throw new Error( + 'You must pass the status URL via the router state (preferably) or query params.' + ); } return ( onFailure(submittedSubmission, error)} + onFailure={error => { + onFailure?.(error); + if (returnTo) { + const newState = {...(location.state || {}), errorMessage: error}; + navigate(returnTo, {state: newState}); + } + }} onConfirmed={onConfirmed} > - + ); }; ConfirmationView.propTypes = { + /** + * Location to navigate to on failure. + */ + returnTo: PropTypes.string, + /** + * Optional callback to invoke when processing failed. + * @deprecated + */ onFailure: PropTypes.func, + /** + * Optional callback to invoke when processing was successful. + * @deprecated + */ onConfirmed: PropTypes.func, - downloadPDFText: PropTypes.node, }; export {ConfirmationViewDisplay}; diff --git a/src/components/PostCompletionViews/StartPaymentView.jsx b/src/components/PostCompletionViews/StartPaymentView.jsx index 150ccf342..a680bce77 100644 --- a/src/components/PostCompletionViews/StartPaymentView.jsx +++ b/src/components/PostCompletionViews/StartPaymentView.jsx @@ -1,10 +1,11 @@ import PropTypes from 'prop-types'; import {useContext} 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 ErrorBoundary from 'components/Errors/ErrorBoundary'; +import useFormContext from 'hooks/useFormContext'; import {DEBUG} from 'utils'; import PostCompletionView from './PostCompletionView'; @@ -60,24 +61,37 @@ StartPaymentViewDisplay.propTypes = { downloadPDFText: PropTypes.node, }; -const StartPaymentView = ({onFailure, downloadPDFText}) => { - const {statusUrl, submission} = useLocation().state || {}; - if (DEBUG) { - if (!statusUrl) throw new Error('You must pass the status URL via the route state.'); - if (!submission) { - throw new Error('You must pass the submitted submission via the router state.'); - } - } +const StartPaymentView = ({returnTo, onFailure}) => { + const form = useFormContext(); + const navigate = useNavigate(); + const {statusUrl} = useLocation().state || {}; + if (DEBUG && !statusUrl) throw new Error('You must pass the status URL via the route state.'); return ( - onFailure(submission, error)}> - + { + onFailure(error); + if (returnTo) { + const newState = {...(location.state || {}), errorMessage: error}; + navigate(returnTo, {state: newState}); + } + }} + > + ); }; StartPaymentView.propTypes = { + /** + * Location to navigate to on failure. + */ + returnTo: PropTypes.string, + /** + * Optional callback to invoke when processing failed. + * @deprecated + */ onFailure: PropTypes.func, - downloadPDFText: PropTypes.node, }; export default StartPaymentView; diff --git a/src/components/appointments/CreateAppointment/Confirmation.jsx b/src/components/appointments/CreateAppointment/Confirmation.jsx index c734f574b..816134aa1 100644 --- a/src/components/appointments/CreateAppointment/Confirmation.jsx +++ b/src/components/appointments/CreateAppointment/Confirmation.jsx @@ -1,29 +1,16 @@ -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 statusUrl = params.get('statusUrl'); if (!statusUrl) throw new Error('Missing statusUrl param'); - - const onProcessingFailure = (submission, errorMessage) => { - setProcessingError(errorMessage); - navigate('../overzicht'); - }; - return ( - + ); }; diff --git a/src/components/formRoutes.jsx b/src/components/formRoutes.jsx index d5b21867f..c115cc7b1 100644 --- a/src/components/formRoutes.jsx +++ b/src/components/formRoutes.jsx @@ -3,6 +3,7 @@ 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'; @@ -48,6 +49,24 @@ const formRoutes = [ ), }, + { + path: 'betalen', + element: ( + + + + + + ), + }, + { + path: 'bevestiging', + element: ( + + + + ), + }, ]; export default formRoutes; From 537d74369e057caee32adb8a0264f7358b7b9c16 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Fri, 17 Jan 2025 20:17:17 +0100 Subject: [PATCH 22/35] :recycle: [open-formulieren/open-forms#4929] Remove need to track appointment processing error We can grab the error from the state now and make it disappear when a new submit attempt is made with local component state rather than needing to use or update the context. --- .../PostCompletionViews/viewsWithPolling.stories.jsx | 1 - .../appointments/CreateAppointment/Confirmation.jsx | 6 ++---- .../CreateAppointment/CreateAppointment.spec.jsx | 8 ++++++++ .../CreateAppointment/CreateAppointmentState.jsx | 8 -------- .../appointments/CreateAppointment/Summary.jsx | 12 ++++++++---- 5 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/components/PostCompletionViews/viewsWithPolling.stories.jsx b/src/components/PostCompletionViews/viewsWithPolling.stories.jsx index 7485acefa..c5407301a 100644 --- a/src/components/PostCompletionViews/viewsWithPolling.stories.jsx +++ b/src/components/PostCompletionViews/viewsWithPolling.stories.jsx @@ -18,7 +18,6 @@ export default { statusUrl: {control: false}, }, args: { - onFailure: fn(), onConfirmed: fn(), }, parameters: { diff --git a/src/components/appointments/CreateAppointment/Confirmation.jsx b/src/components/appointments/CreateAppointment/Confirmation.jsx index 816134aa1..fa0c5539f 100644 --- a/src/components/appointments/CreateAppointment/Confirmation.jsx +++ b/src/components/appointments/CreateAppointment/Confirmation.jsx @@ -6,12 +6,10 @@ import {useCreateAppointmentContext} from './CreateAppointmentState'; const Confirmation = () => { const [params] = useSearchParams(); - const {reset, setProcessingError} = useCreateAppointmentContext(); + const {reset} = useCreateAppointmentContext(); const statusUrl = params.get('statusUrl'); if (!statusUrl) throw new Error('Missing statusUrl param'); - return ( - - ); + return ; }; Confirmation.propTypes = {}; diff --git a/src/components/appointments/CreateAppointment/CreateAppointment.spec.jsx b/src/components/appointments/CreateAppointment/CreateAppointment.spec.jsx index 50c4e569a..cd9fdd443 100644 --- a/src/components/appointments/CreateAppointment/CreateAppointment.spec.jsx +++ b/src/components/appointments/CreateAppointment/CreateAppointment.spec.jsx @@ -177,6 +177,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/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 400946523..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,6 +177,8 @@ const Summary = () => { } setSubmitError(e); return; + } finally { + setSubmitting(false); } // TODO: store details in sessionStorage instead, to survive hard refreshes navigate( @@ -191,6 +194,7 @@ const Summary = () => { ); }; + const processingError = submitting ? '' : routerState?.errorMessage; return ( <> Date: Fri, 17 Jan 2025 20:19:45 +0100 Subject: [PATCH 23/35] :coffin: [open-formulieren/open-forms#4929] Remove onFailure prop It's no longer necessary. --- src/components/PostCompletionViews/ConfirmationView.jsx | 8 +------- src/components/PostCompletionViews/StartPaymentView.jsx | 8 +------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/src/components/PostCompletionViews/ConfirmationView.jsx b/src/components/PostCompletionViews/ConfirmationView.jsx index 76fdbd1b5..7ffa90164 100644 --- a/src/components/PostCompletionViews/ConfirmationView.jsx +++ b/src/components/PostCompletionViews/ConfirmationView.jsx @@ -107,7 +107,7 @@ ConfirmationViewDisplay.propTypes = { downloadPDFText: PropTypes.node, }; -const ConfirmationView = ({returnTo, onFailure, onConfirmed}) => { +const ConfirmationView = ({returnTo, 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 @@ -127,7 +127,6 @@ const ConfirmationView = ({returnTo, onFailure, onConfirmed}) => { { - onFailure?.(error); if (returnTo) { const newState = {...(location.state || {}), errorMessage: error}; navigate(returnTo, {state: newState}); @@ -145,11 +144,6 @@ ConfirmationView.propTypes = { * Location to navigate to on failure. */ returnTo: PropTypes.string, - /** - * Optional callback to invoke when processing failed. - * @deprecated - */ - onFailure: PropTypes.func, /** * Optional callback to invoke when processing was successful. * @deprecated diff --git a/src/components/PostCompletionViews/StartPaymentView.jsx b/src/components/PostCompletionViews/StartPaymentView.jsx index a680bce77..2111b786c 100644 --- a/src/components/PostCompletionViews/StartPaymentView.jsx +++ b/src/components/PostCompletionViews/StartPaymentView.jsx @@ -61,7 +61,7 @@ StartPaymentViewDisplay.propTypes = { downloadPDFText: PropTypes.node, }; -const StartPaymentView = ({returnTo, onFailure}) => { +const StartPaymentView = ({returnTo}) => { const form = useFormContext(); const navigate = useNavigate(); const {statusUrl} = useLocation().state || {}; @@ -70,7 +70,6 @@ const StartPaymentView = ({returnTo, onFailure}) => { { - onFailure(error); if (returnTo) { const newState = {...(location.state || {}), errorMessage: error}; navigate(returnTo, {state: newState}); @@ -87,11 +86,6 @@ StartPaymentView.propTypes = { * Location to navigate to on failure. */ returnTo: PropTypes.string, - /** - * Optional callback to invoke when processing failed. - * @deprecated - */ - onFailure: PropTypes.func, }; export default StartPaymentView; From 4644a93d2d841c80c2142f96467838b74978ad1e Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Sat, 18 Jan 2025 17:27:54 +0100 Subject: [PATCH 24/35] :coffin: [open-formulieren/open-forms#4929] Delete dead code The error handling of the post method is obsoleted because the apiCall helper itself already calls throwForStatus which results in an exception if/when response.ok is false, so this block of code will never execute. This also further simplifies the helper to directly return the status URL, which is the only bit of relevant information returned by the API endpoint. --- src/components/Summary/SubmissionSummary.jsx | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/components/Summary/SubmissionSummary.jsx b/src/components/Summary/SubmissionSummary.jsx index 5f22c1dc5..8d77082dd 100644 --- a/src/components/Summary/SubmissionSummary.jsx +++ b/src/components/Summary/SubmissionSummary.jsx @@ -17,13 +17,8 @@ import {loadSummaryData} from './utils'; 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 {statusUrl} = response.data; + return statusUrl; }; const SubmissionSummary = () => { @@ -59,8 +54,7 @@ const SubmissionSummary = () => { if (refreshedSubmission.submissionAllowed !== SUBMISSION_ALLOWED.yes) return; let statusUrl; try { - const responseData = await completeSubmission(refreshedSubmission, statementValues); - statusUrl = responseData.statusUrl; + statusUrl = await completeSubmission(refreshedSubmission, statementValues); } catch (e) { setSubmitError(e.message); return; From dd1f2e9f3e8487b66ee02adec05a52b056834122 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Sat, 18 Jan 2025 17:47:41 +0100 Subject: [PATCH 25/35] :recycle: [open-formulieren/open-forms#4929] Move failure handling entirely into StatusUrlPoller The StatusUrlPoller component is tightly coupled with the particular submission status API endpoint anyway - it already checks specific props to determine whether it's failing or succeeding. Since the failure handlers are the same in both places, we can localize this behaviour/the details in the component itself and only pass the particular route it should redirect/navigate the user to which is responsible for display the error message. This simplifies quite a bit of code. Maybe at some point we can also get rid of the success callback used by the appointments flow. --- .../PostCompletionViews/ConfirmationView.jsx | 14 ++++------- .../PostCompletionViews/StartPaymentView.jsx | 17 ++++---------- .../PostCompletionViews/StatusUrlPoller.jsx | 23 ++++++++++++++----- .../CreateAppointment/Confirmation.jsx | 2 +- src/components/formRoutes.jsx | 4 ++-- 5 files changed, 28 insertions(+), 32 deletions(-) diff --git a/src/components/PostCompletionViews/ConfirmationView.jsx b/src/components/PostCompletionViews/ConfirmationView.jsx index 7ffa90164..2b06b77d4 100644 --- a/src/components/PostCompletionViews/ConfirmationView.jsx +++ b/src/components/PostCompletionViews/ConfirmationView.jsx @@ -1,7 +1,7 @@ import PropTypes from 'prop-types'; import React, {useContext} from 'react'; import {FormattedMessage, defineMessage, useIntl} from 'react-intl'; -import {useLocation, useNavigate, useSearchParams} from 'react-router-dom'; +import {useLocation, useSearchParams} from 'react-router-dom'; import Body from 'components/Body'; import ErrorMessage from 'components/Errors/ErrorMessage'; @@ -107,13 +107,12 @@ ConfirmationViewDisplay.propTypes = { downloadPDFText: PropTypes.node, }; -const ConfirmationView = ({returnTo, onConfirmed}) => { +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 navigate = useNavigate(); const [params] = useSearchParams(); const statusUrl = params.get('statusUrl') ?? location.state?.statusUrl; @@ -126,12 +125,7 @@ const ConfirmationView = ({returnTo, onConfirmed}) => { return ( { - if (returnTo) { - const newState = {...(location.state || {}), errorMessage: error}; - navigate(returnTo, {state: newState}); - } - }} + onFailureNavigateTo={onFailureNavigateTo} onConfirmed={onConfirmed} > @@ -143,7 +137,7 @@ ConfirmationView.propTypes = { /** * Location to navigate to on failure. */ - returnTo: PropTypes.string, + onFailureNavigateTo: PropTypes.string, /** * Optional callback to invoke when processing was successful. * @deprecated diff --git a/src/components/PostCompletionViews/StartPaymentView.jsx b/src/components/PostCompletionViews/StartPaymentView.jsx index 2111b786c..c812bc354 100644 --- a/src/components/PostCompletionViews/StartPaymentView.jsx +++ b/src/components/PostCompletionViews/StartPaymentView.jsx @@ -1,7 +1,7 @@ import PropTypes from 'prop-types'; import {useContext} from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; -import {useLocation, useNavigate} from 'react-router-dom'; +import {useLocation} from 'react-router-dom'; import Body from 'components/Body'; import ErrorBoundary from 'components/Errors/ErrorBoundary'; @@ -61,21 +61,12 @@ StartPaymentViewDisplay.propTypes = { downloadPDFText: PropTypes.node, }; -const StartPaymentView = ({returnTo}) => { +const StartPaymentView = ({onFailureNavigateTo}) => { const form = useFormContext(); - const navigate = useNavigate(); const {statusUrl} = useLocation().state || {}; if (DEBUG && !statusUrl) throw new Error('You must pass the status URL via the route state.'); return ( - { - if (returnTo) { - const newState = {...(location.state || {}), errorMessage: error}; - navigate(returnTo, {state: newState}); - } - }} - > + ); @@ -85,7 +76,7 @@ StartPaymentView.propTypes = { /** * Location to navigate to on failure. */ - returnTo: PropTypes.string, + onFailureNavigateTo: PropTypes.string, }; export default StartPaymentView; diff --git a/src/components/PostCompletionViews/StatusUrlPoller.jsx b/src/components/PostCompletionViews/StatusUrlPoller.jsx index befc0ac5c..41a86513c 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,13 +36,14 @@ 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; - onFailure?.(errorMessage); + const newState = {...(location.state || {}), errorMessage}; + navigate(onFailureNavigateTo, {state: newState}); } else if (response.result === RESULT_SUCCESS) { onConfirmed?.(); } @@ -107,8 +109,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/appointments/CreateAppointment/Confirmation.jsx b/src/components/appointments/CreateAppointment/Confirmation.jsx index fa0c5539f..251a03e5f 100644 --- a/src/components/appointments/CreateAppointment/Confirmation.jsx +++ b/src/components/appointments/CreateAppointment/Confirmation.jsx @@ -9,7 +9,7 @@ const Confirmation = () => { const {reset} = useCreateAppointmentContext(); const statusUrl = params.get('statusUrl'); if (!statusUrl) throw new Error('Missing statusUrl param'); - return ; + return ; }; Confirmation.propTypes = {}; diff --git a/src/components/formRoutes.jsx b/src/components/formRoutes.jsx index c115cc7b1..ef1ba4444 100644 --- a/src/components/formRoutes.jsx +++ b/src/components/formRoutes.jsx @@ -54,7 +54,7 @@ const formRoutes = [ element: ( - + ), @@ -63,7 +63,7 @@ const formRoutes = [ path: 'bevestiging', element: ( - + ), }, From f53040022cc4bb15cd28f8c644284e6c53b99b88 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Sat, 18 Jan 2025 18:07:00 +0100 Subject: [PATCH 26/35] :recycle: [open-formulieren/open-forms#4929] Simplify Form state management Replace the reducer with a simple useState - the complex state management was obsoleted by the previous refactors. --- src/components/Form.jsx | 90 +++++++++++------------------------------ 1 file changed, 23 insertions(+), 67 deletions(-) diff --git a/src/components/Form.jsx b/src/components/Form.jsx index b90874300..c5e7bee14 100644 --- a/src/components/Form.jsx +++ b/src/components/Form.jsx @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import React, {useContext, useEffect} from 'react'; +import React, {useContext, useEffect, useState} from 'react'; import {useIntl} from 'react-intl'; import { Navigate, @@ -10,7 +10,6 @@ import { useSearchParams, } from 'react-router-dom'; import {useAsync, usePrevious} from 'react-use'; -import {useImmerReducer} from 'use-immer'; import {AnalyticsToolsConfigContext, ConfigContext} from 'Context'; import {destroy, get} from 'api'; @@ -32,33 +31,6 @@ import Types from 'types'; import FormDisplay from './FormDisplay'; import {addFixedSteps, getStepsInfo} from './ProgressIndicator/utils'; -const initialState = { - submission: null, -}; - -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 'DESTROY_SUBMISSION': { - return { - ...initialState, - }; - } - case 'RESET': { - const initialState = action.payload; - return initialState; - } - default: { - throw new Error(`Unknown action ${action.type}`); - } - } -}; - /** * An OpenForms form. * @@ -85,28 +57,24 @@ const Form = () => { const confirmationMatch = useMatch('/bevestiging'); // 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); + 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) const [loading, setSubmissionId, removeSubmissionId] = useRecycleSubmission( form, - state.submission, + submission, onSubmissionLoaded ); @@ -117,33 +85,21 @@ const Form = () => { 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 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('/'); }; @@ -168,14 +124,11 @@ const Form = () => { return ; } - const submissionFromRouterState = routerState?.submission ?? null; - const submission = state.submission || submissionFromRouterState; - // Progress Indicator const isIntroductionPage = !!introductionMatch; const isStartPage = !isIntroductionPage && !summaryMatch && stepMatch == null && !paymentMatch; - const submissionAllowedSpec = state.submission?.submissionAllowed ?? form.submissionAllowed; + const submissionAllowedSpec = submission?.submissionAllowed ?? form.submissionAllowed; const showOverview = submissionAllowedSpec !== SUBMISSION_ALLOWED.noWithoutOverview; const formName = form.name; const needsPayment = submission ? submission.payment.isRequired : form.paymentRequired; @@ -194,7 +147,7 @@ const Form = () => { } else if (paymentMatch) { activeStepTitle = intl.formatMessage(STEP_LABELS.payment); } else { - const step = steps.find(step => step.slug === stepSlug); + const step = form.steps.find(step => step.slug === stepSlug); activeStepTitle = step.formDefinition; } @@ -217,7 +170,7 @@ const Form = () => { const showNonApplicableSteps = !form.hideNonApplicableSteps; const updatedSteps = // first, process all the form steps in a format suitable for the PI - getStepsInfo(steps, submission, currentPathname) + getStepsInfo(form.steps, submission, currentPathname) // then, filter out the non-applicable steps if they should not be displayed .filter(step => showNonApplicableSteps || step.isApplicable); @@ -253,8 +206,11 @@ const Form = () => { { + onSubmissionLoaded(submission); + setSubmissionId(submission.id); + }} onDestroySession={onDestroySession} removeSubmissionId={removeSubmissionId} > From cff4b051ed4ebd8450d996e5fb78acbb07bb7a8a Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Sat, 18 Jan 2025 18:26:02 +0100 Subject: [PATCH 27/35] :recycle: [open-formulieren/open-forms#4929] Move analytics tool config loading into own component Instead of hooking this up to the Form component, we can instead wrap the context provider and let it fetch the config by itself. This way, we slim down the Form component, and get a small performance boost since loading the analytics tool config is not immediately relevant. Since context is used, only when the config loading is resolved will the consumers be re-rendered, which at this point is mostly the AbortButton component. We deliberately don't show a loader when the config is being retrieved as this would block the entire page without good reason and we have a faster time-to-interact this way. --- src/Context.js | 16 +------- src/components/AbortButton/AbortButton.jsx | 5 +-- src/components/Form.jsx | 18 ++++----- .../analytics/AnalyticsToolConfigProvider.jsx | 40 +++++++++++++++++++ src/components/analytics/GovMetricSnippet.jsx | 5 +-- src/story-utils/decorators.jsx | 9 +++-- 6 files changed, 59 insertions(+), 34 deletions(-) create mode 100644 src/components/analytics/AnalyticsToolConfigProvider.jsx 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/components/AbortButton/AbortButton.jsx b/src/components/AbortButton/AbortButton.jsx index 8fc4958ee..8bb982d5f 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/Form.jsx b/src/components/Form.jsx index c5e7bee14..cf587468b 100644 --- a/src/components/Form.jsx +++ b/src/components/Form.jsx @@ -9,12 +9,13 @@ import { useNavigate, useSearchParams, } from 'react-router-dom'; -import {useAsync, usePrevious} from 'react-use'; +import {usePrevious} from 'react-use'; -import {AnalyticsToolsConfigContext, ConfigContext} from 'Context'; -import {destroy, get} from 'api'; +import {ConfigContext} from 'Context'; +import {destroy} from 'api'; import Loader from 'components/Loader'; import ProgressIndicator from 'components/ProgressIndicator'; +import AnalyticsToolsConfigProvider from 'components/analytics/AnalyticsToolConfigProvider'; import { PI_TITLE, START_FORM_QUERY_PARAM, @@ -72,16 +73,13 @@ const Form = () => { }; // 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, 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; @@ -120,7 +118,7 @@ const Form = () => { ); } - if (loading || loadingAnalyticsConfig || shouldAutomaticallyRedirect) { + if (loading || shouldAutomaticallyRedirect) { return ; } @@ -204,7 +202,7 @@ const Form = () => { // render the form step if there's an active submission (and no summary) return ( - + { @@ -216,7 +214,7 @@ const Form = () => { > - + ); }; diff --git a/src/components/analytics/AnalyticsToolConfigProvider.jsx b/src/components/analytics/AnalyticsToolConfigProvider.jsx new file mode 100644 index 000000000..044d89962 --- /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`); + }, [locale]); + + return ( + + {children} + + ); +}; + +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/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, }; From f4df338f373f18cbd68561823b65a12b290e1b59 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Sat, 18 Jan 2025 18:34:18 +0100 Subject: [PATCH 28/35] :truck: [open-formulieren/open-forms#4929] Move SubmissionProvider into its own component The component was moved from Form.jsx into its own file and imports are updated. --- src/components/Form.jsx | 50 +------------------ src/components/FormStart/index.jsx | 2 +- src/components/FormStart/tests.spec.jsx | 2 +- src/components/FormStep/FormStep.stories.jsx | 2 +- src/components/FormStep/index.jsx | 2 +- src/components/RequireSubmission.jsx | 2 +- src/components/SubmissionProvider.jsx | 50 +++++++++++++++++++ src/components/Summary/SubmissionSummary.jsx | 2 +- .../Summary/SubmissionSummary.spec.jsx | 2 +- 9 files changed, 59 insertions(+), 55 deletions(-) create mode 100644 src/components/SubmissionProvider.jsx diff --git a/src/components/Form.jsx b/src/components/Form.jsx index cf587468b..18f93dc41 100644 --- a/src/components/Form.jsx +++ b/src/components/Form.jsx @@ -1,5 +1,4 @@ -import PropTypes from 'prop-types'; -import React, {useContext, useEffect, useState} from 'react'; +import {useContext, useEffect, useState} from 'react'; import {useIntl} from 'react-intl'; import { Navigate, @@ -15,6 +14,7 @@ import {ConfigContext} from 'Context'; import {destroy} from 'api'; import Loader from 'components/Loader'; import ProgressIndicator from 'components/ProgressIndicator'; +import SubmissionProvider from 'components/SubmissionProvider'; import AnalyticsToolsConfigProvider from 'components/analytics/AnalyticsToolConfigProvider'; import { PI_TITLE, @@ -27,7 +27,6 @@ import useAutomaticRedirect from 'hooks/useAutomaticRedirect'; import useFormContext from 'hooks/useFormContext'; import usePageViews from 'hooks/usePageViews'; import useRecycleSubmission from 'hooks/useRecycleSubmission'; -import Types from 'types'; import FormDisplay from './FormDisplay'; import {addFixedSteps, getStepsInfo} from './ProgressIndicator/utils'; @@ -221,49 +220,4 @@ const Form = () => { Form.propTypes = {}; -const SubmissionContext = React.createContext({ - submission: null, - onSubmissionObtained: () => {}, - onDestroySession: () => {}, - removeSubmissionId: () => {}, -}); - -const SubmissionProvider = ({ - submission = null, - onSubmissionObtained, - onDestroySession, - removeSubmissionId, - children, -}) => ( - - {children} - -); - -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, -}; - -const useSubmissionContext = () => useContext(SubmissionContext); - export default Form; -export {useSubmissionContext, SubmissionProvider}; 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 5fa2853a5..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'; diff --git a/src/components/FormStep/FormStep.stories.jsx b/src/components/FormStep/FormStep.stories.jsx index 7d8bd7361..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'; 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/RequireSubmission.jsx b/src/components/RequireSubmission.jsx index cc6eaa25d..fb3ddc228 100644 --- a/src/components/RequireSubmission.jsx +++ b/src/components/RequireSubmission.jsx @@ -1,8 +1,8 @@ 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'; 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, +}) => ( + + {children} + +); + +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 8d77082dd..5eee3a438 100644 --- a/src/components/Summary/SubmissionSummary.jsx +++ b/src/components/Summary/SubmissionSummary.jsx @@ -4,8 +4,8 @@ import {useLocation, useNavigate} from 'react-router-dom'; import {useAsync} from 'react-use'; import {post} from 'api'; -import {useSubmissionContext} from 'components/Form'; 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'; diff --git a/src/components/Summary/SubmissionSummary.spec.jsx b/src/components/Summary/SubmissionSummary.spec.jsx index 69ccb6f07..f319ec69e 100644 --- a/src/components/Summary/SubmissionSummary.spec.jsx +++ b/src/components/Summary/SubmissionSummary.spec.jsx @@ -7,7 +7,7 @@ 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/Form'; +import SubmissionProvider from 'components/SubmissionProvider'; import {SubmissionSummary} from 'components/Summary'; import {SUBMISSION_ALLOWED} from 'components/constants'; From f20a1f30ff56eada60424fba0cc38f727a6d327c Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Sat, 18 Jan 2025 18:40:10 +0100 Subject: [PATCH 29/35] :fire: [open-formulieren/open-forms#4929] Remove deprecated code/constructs RequireSubmission now always takes the submission from the context and requires children to be specified instead of a render prop/component. --- src/components/RequireSubmission.jsx | 43 ++++++++++------------------ src/components/formRoutes.jsx | 6 ++-- 2 files changed, 18 insertions(+), 31 deletions(-) diff --git a/src/components/RequireSubmission.jsx b/src/components/RequireSubmission.jsx index fb3ddc228..8170c26ae 100644 --- a/src/components/RequireSubmission.jsx +++ b/src/components/RequireSubmission.jsx @@ -8,24 +8,23 @@ 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.: + * + * + * + * */ -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 && } - {children ?? } + {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/formRoutes.jsx b/src/components/formRoutes.jsx index ef1ba4444..b20123d11 100644 --- a/src/components/formRoutes.jsx +++ b/src/components/formRoutes.jsx @@ -30,7 +30,7 @@ const formRoutes = [ element: ( - + @@ -42,7 +42,7 @@ const formRoutes = [ element: ( - + @@ -53,7 +53,7 @@ const formRoutes = [ path: 'betalen', element: ( - + From 963ead2ca652cfc0ba555b7ec4d244b36a300eba Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Sat, 18 Jan 2025 21:47:02 +0100 Subject: [PATCH 30/35] :recycle: [open-formulieren/open-forms#4929] Extract progress indicator calculations from Form component Now that we have all the necessary information available via context, we can encapsulate all the progress indicator render logic in a single component instead of polluting the 'container' Form component with it. --- src/components/Form.jsx | 113 ++------------------- src/components/FormProgressIndicator.jsx | 121 +++++++++++++++++++++++ 2 files changed, 130 insertions(+), 104 deletions(-) create mode 100644 src/components/FormProgressIndicator.jsx diff --git a/src/components/Form.jsx b/src/components/Form.jsx index 18f93dc41..bf48cdc6e 100644 --- a/src/components/Form.jsx +++ b/src/components/Form.jsx @@ -1,27 +1,15 @@ import {useContext, useEffect, useState} from 'react'; import {useIntl} from 'react-intl'; -import { - Navigate, - Outlet, - useLocation, - useMatch, - useNavigate, - useSearchParams, -} from 'react-router-dom'; +import {Navigate, Outlet, useLocation, useNavigate, useSearchParams} from 'react-router-dom'; import {usePrevious} from 'react-use'; import {ConfigContext} from 'Context'; import {destroy} from 'api'; +import FormProgressIndicator from 'components/FormProgressIndicator'; import Loader from 'components/Loader'; -import ProgressIndicator from 'components/ProgressIndicator'; import SubmissionProvider from 'components/SubmissionProvider'; import AnalyticsToolsConfigProvider from 'components/analytics/AnalyticsToolConfigProvider'; -import { - PI_TITLE, - START_FORM_QUERY_PARAM, - STEP_LABELS, - SUBMISSION_ALLOWED, -} from 'components/constants'; +import {START_FORM_QUERY_PARAM} from 'components/constants'; import {flagActiveSubmission, flagNoActiveSubmission} from 'data/submissions'; import useAutomaticRedirect from 'hooks/useAutomaticRedirect'; import useFormContext from 'hooks/useFormContext'; @@ -29,7 +17,6 @@ import usePageViews from 'hooks/usePageViews'; import useRecycleSubmission from 'hooks/useRecycleSubmission'; import FormDisplay from './FormDisplay'; -import {addFixedSteps, getStepsInfo} from './ProgressIndicator/utils'; /** * An OpenForms form. @@ -47,19 +34,13 @@ const Form = () => { usePageViews(); const intl = useIntl(); const prevLocale = usePrevious(intl.locale); - const {pathname: currentPathname, state: routerState} = 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 config = useContext(ConfigContext); - // load the state management/reducer + // 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) { @@ -121,86 +102,10 @@ const Form = () => { return ; } - // Progress Indicator - - const isIntroductionPage = !!introductionMatch; - const isStartPage = !isIntroductionPage && !summaryMatch && stepMatch == null && !paymentMatch; - const submissionAllowedSpec = submission?.submissionAllowed ?? form.submissionAllowed; - const showOverview = submissionAllowedSpec !== SUBMISSION_ALLOWED.noWithoutOverview; - 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 = form.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(form.steps, submission, currentPathname) - // then, filter out the non-applicable steps if they should not be displayed - .filter(step => showNonApplicableSteps || step.isApplicable); - - // the statusUrl is put in the router state once the summary page is confirmed and the - // submission is completed. - const isCompleted = !!routerState?.statusUrl; - 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 ? ( - - ) : null; - - // 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 ( - + }> { + 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 ( + + ); +}; + +FormProgressIndicator.propTypes = { + submission: Types.Submission, +}; + +export default FormProgressIndicator; From 865b7d0b760f8dc2f72454a350debb35a8f3e198 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Sat, 18 Jan 2025 22:00:58 +0100 Subject: [PATCH 31/35] :wrench: Disable coverage on test helpers Mocks, test helpers etc. shouldn't bring down code coverage - they're helper tools. --- codecov.yml | 5 +++++ vite.config.mts | 1 + 2 files changed, 6 insertions(+) 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/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, ], From 8933bf9fb6b7e5c6a62b9e391a63945a9f54d121 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Sat, 18 Jan 2025 22:07:58 +0100 Subject: [PATCH 32/35] :recycle: Replace useQuery hook with useSearchParams from react-router --- src/components/App.jsx | 7 +++---- src/components/LoginOptions/index.jsx | 6 +++--- src/components/appointments/CreateAppointment/routes.jsx | 8 +++----- src/components/appointments/cancel/CancelAppointment.jsx | 9 ++++----- src/components/appointments/steps/ChooseProductStep.jsx | 7 +++---- src/components/auth/AuthenticationErrors/index.jsx | 6 +++--- src/components/auth/AuthenticationOutage.jsx | 6 +++--- src/hooks/useQuery.js | 8 -------- src/hooks/useRecycleSubmission.js | 7 +++---- src/hooks/useStartSubmission.js | 8 ++++---- 10 files changed, 29 insertions(+), 43 deletions(-) delete mode 100644 src/hooks/useQuery.js diff --git a/src/components/App.jsx b/src/components/App.jsx index 238688802..4fca30a82 100644 --- a/src/components/App.jsx +++ b/src/components/App.jsx @@ -1,4 +1,4 @@ -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'; @@ -11,7 +11,6 @@ import { } 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 = [ @@ -54,7 +53,7 @@ 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 +68,7 @@ const App = () => { replace to={{ pathname: '../afspraak-maken', - search: `?${query}`, + search: `?${params}`, }} /> ); 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/appointments/CreateAppointment/routes.jsx b/src/components/appointments/CreateAppointment/routes.jsx index c142d5c35..c53135537 100644 --- a/src/components/appointments/CreateAppointment/routes.jsx +++ b/src/components/appointments/CreateAppointment/routes.jsx @@ -1,7 +1,5 @@ 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'; @@ -37,13 +35,13 @@ export const APPOINTMENT_STEPS = [ export const APPOINTMENT_STEP_PATHS = APPOINTMENT_STEPS.map(s => s.path); const LandingPage = () => { - const query = useQuery(); + const [params] = useSearchParams(); return ( ); 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/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/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; From a5d285605eb17dd8120e3e0dc8afe756d30e24f5 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Mon, 20 Jan 2025 11:40:12 +0100 Subject: [PATCH 33/35] :truck: [open-formulieren/open-forms#4929] Move all route definitions into a central location This should make it easier to map out the URL/route structure and figure out what gets rendered where and when. --- .prettierrc.json | 2 +- src/components/App.jsx | 12 ++---- src/components/CoSign/Cosign.spec.jsx | 4 +- src/components/CoSign/index.jsx | 3 +- .../appointments/CreateAppointment/index.jsx | 1 - .../{routes.jsx => steps.jsx} | 23 +---------- .../appointments/ManageAppointment/index.jsx | 1 - .../appointments/ManageAppointment/routes.jsx | 18 -------- src/components/appointments/index.jsx | 3 +- src/routes/appointments.jsx | 41 +++++++++++++++++++ .../CoSign/routes.jsx => routes/cosign.jsx} | 6 +-- .../formRoutes.jsx => routes/form.jsx} | 4 +- src/routes/index.jsx | 3 ++ 13 files changed, 59 insertions(+), 62 deletions(-) rename src/components/appointments/CreateAppointment/{routes.jsx => steps.jsx} (74%) delete mode 100644 src/components/appointments/ManageAppointment/index.jsx delete mode 100644 src/components/appointments/ManageAppointment/routes.jsx create mode 100644 src/routes/appointments.jsx rename src/{components/CoSign/routes.jsx => routes/cosign.jsx} (56%) rename src/{components/formRoutes.jsx => routes/form.jsx} (97%) create mode 100644 src/routes/index.jsx 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/src/components/App.jsx b/src/components/App.jsx index 4fca30a82..5def60eb4 100644 --- a/src/components/App.jsx +++ b/src/components/App.jsx @@ -1,17 +1,13 @@ import {Navigate, Outlet, useMatch, useSearchParams} from 'react-router-dom'; -import {Cosign, cosignRoutes} from 'components/CoSign'; +import {Cosign} 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 {CreateAppointment} from 'components/appointments'; import useFormContext from 'hooks/useFormContext'; import useZodErrorMap from 'hooks/useZodErrorMap'; +import {cosignRoutes, createAppointmentRoutes, formRoutes, manageAppointmentRoutes} from 'routes'; export const routes = [ { @@ -21,7 +17,7 @@ export const routes = [ { path: 'afspraak-maken/*', element: , - children: appointmentRoutes, + children: createAppointmentRoutes, }, { path: 'cosign/*', diff --git a/src/components/CoSign/Cosign.spec.jsx b/src/components/CoSign/Cosign.spec.jsx index 146c579d7..f5d06824b 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'; import Cosign from './Cosign'; -import {default as nestedRoutes} from './routes'; beforeEach(() => { localStorage.clear(); @@ -55,7 +55,7 @@ const routes = [ { path: '/cosign/*', element: , - 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/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 74% rename from src/components/appointments/CreateAppointment/routes.jsx rename to src/components/appointments/CreateAppointment/steps.jsx index c53135537..b62c44e51 100644 --- a/src/components/appointments/CreateAppointment/routes.jsx +++ b/src/components/appointments/CreateAppointment/steps.jsx @@ -2,8 +2,6 @@ import {defineMessage} from 'react-intl'; 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 = [ { @@ -34,7 +32,7 @@ export const APPOINTMENT_STEPS = [ export const APPOINTMENT_STEP_PATHS = APPOINTMENT_STEPS.map(s => s.path); -const LandingPage = () => { +export const LandingPage = () => { const [params] = useSearchParams(); return ( { /> ); }; - -/** - * Route subtree for appointment forms. - */ -export const routes = [ - { - path: '', - element: , - }, - ...APPOINTMENT_STEPS.map(({path, element}) => ({path, element})), - { - path: 'overzicht', - element: , - }, - { - path: 'bevestiging', - element: , - }, -]; 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: ( - - - - ), - }, - { - path: 'succes', - element: , - }, -]; 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/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: , + }, + ...APPOINTMENT_STEPS.map(({path, element}) => ({path, element})), + { + path: 'overzicht', + element: , + }, + { + path: 'bevestiging', + element: , + }, +]; + +const manageAppointmentRoutes = [ + { + path: '', + element: ( + + + + ), + }, + { + path: 'succes', + element: , + }, +]; + +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 97% rename from src/components/formRoutes.jsx rename to src/routes/form.jsx index b20123d11..c367b6e57 100644 --- a/src/components/formRoutes.jsx +++ b/src/routes/form.jsx @@ -8,7 +8,7 @@ import RequireSubmission from 'components/RequireSubmission'; import {SessionTrackerModal} from 'components/Sessions'; import {SubmissionSummary} from 'components/Summary'; -const formRoutes = [ +const routes = [ { path: '', element: , @@ -69,4 +69,4 @@ const formRoutes = [ }, ]; -export default formRoutes; +export default routes; diff --git a/src/routes/index.jsx b/src/routes/index.jsx new file mode 100644 index 000000000..40b13f84e --- /dev/null +++ b/src/routes/index.jsx @@ -0,0 +1,3 @@ +export {createAppointmentRoutes, manageAppointmentRoutes} from './appointments'; +export {default as cosignRoutes} from './cosign'; +export {default as formRoutes} from './form'; From 161f4aa4d0c7ddf65d3b51146ea0dc9b292f41ce Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Mon, 20 Jan 2025 14:07:43 +0100 Subject: [PATCH 34/35] :truck: [open-formulieren/open-forms#4929] Declare app routes We now have a single entry point for the SDK routes that can easily be re-used in tests, storybook and by the actual application. If needed, specific submodule routes can be loaded with their appropriate import paths. One advantage of this is that component files can follow the best practice of only exporting the component as default without also exporting the routes, and the route definitions are no longer in the 'components' folder, which is more semantically correct. --- src/components/App.jsx | 41 ------------ src/components/App.stories.jsx | 10 +-- src/components/CoSign/Cosign.spec.jsx | 2 +- src/components/Form.spec.jsx | 2 +- .../progressIndicator.spec.jsx | 10 +-- .../CreateAppointment/AppointmentProgress.jsx | 2 +- .../CreateAppointment/CreateAppointment.jsx | 2 +- .../CreateAppointment.spec.jsx | 10 +-- .../CreateAppointment.stories.jsx | 5 +- .../appointments/CreateAppointment/steps.jsx | 1 + .../CancelAppointment.integration.spec.jsx | 2 +- src/routes/app.jsx | 62 +++++++++++++++++++ src/routes/index.jsx | 4 +- src/sdk.jsx | 10 +-- 14 files changed, 77 insertions(+), 86 deletions(-) create mode 100644 src/routes/app.jsx diff --git a/src/components/App.jsx b/src/components/App.jsx index 5def60eb4..c8406e627 100644 --- a/src/components/App.jsx +++ b/src/components/App.jsx @@ -1,48 +1,7 @@ import {Navigate, Outlet, useMatch, useSearchParams} from 'react-router-dom'; -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 useFormContext from 'hooks/useFormContext'; import useZodErrorMap from 'hooks/useZodErrorMap'; -import {cosignRoutes, createAppointmentRoutes, formRoutes, manageAppointmentRoutes} from 'routes'; - -export const routes = [ - { - path: 'afspraak-annuleren/*', - children: manageAppointmentRoutes, - }, - { - path: 'afspraak-maken/*', - element: , - children: createAppointmentRoutes, - }, - { - path: 'cosign/*', - element: , - children: cosignRoutes, - }, - { - path: 'sessie-verlopen', - element: ( - - - - ), - }, - // All the rest goes to the formio-based form flow - { - path: '*', - element: ( - -
- - ), - children: formRoutes, - }, -]; /* Top level router - routing between an actual form or supporting screens. 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: , - 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 f5d06824b..9a6fa5f28 100644 --- a/src/components/CoSign/Cosign.spec.jsx +++ b/src/components/CoSign/Cosign.spec.jsx @@ -7,7 +7,7 @@ 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'; +import cosignRoutes from 'routes/cosign'; import Cosign from './Cosign'; diff --git a/src/components/Form.spec.jsx b/src/components/Form.spec.jsx index 5af746a1f..034c303e4 100644 --- a/src/components/Form.spec.jsx +++ b/src/components/Form.spec.jsx @@ -17,8 +17,8 @@ import { mockSubmissionStepGet, mockSubmissionSummaryGet, } from 'api-mocks/submissions'; -import {routes} from 'components/App'; import {SUBMISSION_ALLOWED} from 'components/constants'; +import routes from 'routes'; window.scrollTo = vi.fn(); 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: , - children: nestedRoutes, - }, -]; +import routes from 'routes'; const renderApp = (form, initialRoute = '/') => { const router = createMemoryRouter(routes, { 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/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 cd9fdd443..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: , - children: nestedRoutes, - }, -]; - const renderApp = (initialRoute = '/') => { const form = buildForm({ appointmentOptions: { 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: , - children: childRoutes, + children: createAppointmentRoutes, }, ]; const router = createMemoryRouter(routes, { diff --git a/src/components/appointments/CreateAppointment/steps.jsx b/src/components/appointments/CreateAppointment/steps.jsx index b62c44e51..473aadb57 100644 --- a/src/components/appointments/CreateAppointment/steps.jsx +++ b/src/components/appointments/CreateAppointment/steps.jsx @@ -32,6 +32,7 @@ export const APPOINTMENT_STEPS = [ export const APPOINTMENT_STEP_PATHS = APPOINTMENT_STEPS.map(s => s.path); +// TODO: replace with loader that redirects at the route level export const LandingPage = () => { const [params] = useSearchParams(); return ( 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/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: , + children: [ + { + path: 'afspraak-annuleren/*', + children: manageAppointmentRoutes, + }, + { + path: 'afspraak-maken/*', + element: , + children: createAppointmentRoutes, + }, + { + path: 'cosign/*', + element: , + children: cosignRoutes, + }, + { + path: 'sessie-verlopen', + element: ( + + + + ), + }, + // All the rest goes to the formio-based form flow + { + path: '*', + element: ( + + + + ), + children: formRoutes, + }, + ], + }, +]; + +export default routes; diff --git a/src/routes/index.jsx b/src/routes/index.jsx index 40b13f84e..eae5ea402 100644 --- a/src/routes/index.jsx +++ b/src/routes/index.jsx @@ -1,3 +1 @@ -export {createAppointmentRoutes, manageAppointmentRoutes} from './appointments'; -export {default as cosignRoutes} from './cosign'; -export {default as formRoutes} from './form'; +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: , - children: nestedRoutes, - }, -]; - class OpenForm { constructor(targetNode, opts) { const { From 49c9615df1ad3a80f36ad26c0f497f72cabe5778 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Tue, 21 Jan 2025 16:58:14 +0100 Subject: [PATCH 35/35] :ok_hand: [open-formulieren/open-forms#4929] PR feedback --- src/components/AbortButton/AbortButton.jsx | 2 +- src/components/Form.spec.jsx | 2 +- src/components/PostCompletionViews/StatusUrlPoller.jsx | 6 ------ src/components/analytics/AnalyticsToolConfigProvider.jsx | 2 +- 4 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/components/AbortButton/AbortButton.jsx b/src/components/AbortButton/AbortButton.jsx index 8bb982d5f..7078b469a 100644 --- a/src/components/AbortButton/AbortButton.jsx +++ b/src/components/AbortButton/AbortButton.jsx @@ -8,7 +8,7 @@ import useFormContext from 'hooks/useFormContext'; const AbortButton = ({isAuthenticated, onDestroySession}) => { const intl = useIntl(); - const analyticsToolsConfig = useAnalyticsToolsConfig; + const analyticsToolsConfig = useAnalyticsToolsConfig(); const form = useFormContext(); const confirmationMessage = isAuthenticated diff --git a/src/components/Form.spec.jsx b/src/components/Form.spec.jsx index 034c303e4..b416e860b 100644 --- a/src/components/Form.spec.jsx +++ b/src/components/Form.spec.jsx @@ -232,7 +232,7 @@ test('Submitting the form with successful background processing', async () => { vi.useRealTimers(); await waitForElementToBeRemoved(loader); - // due to the error we get redirected back to the summary page. + // on success, the summary page must display the reference obtained from the backend expect(await screen.findByRole('heading', {name: 'Confirmation: OF-L337'})).toBeVisible(); }); diff --git a/src/components/PostCompletionViews/StatusUrlPoller.jsx b/src/components/PostCompletionViews/StatusUrlPoller.jsx index 41a86513c..685cea9e0 100644 --- a/src/components/PostCompletionViews/StatusUrlPoller.jsx +++ b/src/components/PostCompletionViews/StatusUrlPoller.jsx @@ -49,7 +49,6 @@ const StatusUrlPoller = ({statusUrl, onFailureNavigateTo, onConfirmed, children} } } ); - if (error) throw error; if (loading) { return ( @@ -79,7 +78,6 @@ const StatusUrlPoller = ({statusUrl, onFailureNavigateTo, onConfirmed, children} if (error) throw error; const { - result, paymentUrl, publicReference, reportDownloadUrl, @@ -88,10 +86,6 @@ const StatusUrlPoller = ({statusUrl, onFailureNavigateTo, onConfirmed, children} mainWebsiteUrl, } = statusResponse; - if (result === RESULT_FAILED) { - throw new Error('Failure should have been handled in the onFailure prop.'); - } - return ( { const {value} = useAsync(async () => { return await get(`${baseUrl}analytics/analytics-tools-config-info`); - }, [locale]); + }, [baseUrl, locale]); return (