From 54d11985f0413ff711df4446694fb3c9c0b167ee Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Thu, 2 Nov 2023 17:04:09 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20[open-formulieren/open-forms#336?= =?UTF-8?q?2]=20Handle=20server=20side=20redirects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend is now agnostic to URL routing/hash based routing - it will always redirect back to the URL that was used to start the form and provide action & action params query arguments that specify the target for the frontend. The frontend parses this action & the params and maps it to the intended client side routes, effectively decoupling implementation details between backend and frontend. Note that this requires the SDK to operate correctly in two of the tree main steps in this flow: 1. SDK must correctly derive the 'base URL' for the form, irrespective of whether hash based routing is used or not. Fragments should *not* be sent to the backend, since they are ignored anyway. 2. The backend uses the URL supplied from 1. and append the action/action params from the context of the backend action/validation that was performed. 3. The SDK must correctly interpret the action and its params and route to the appropriate part of the application. TODO: using this pattern, we can probably refactor the _start=1 flow from the backend too, this can likely be converted to _of_action=startSubmission. --- src/components/routingActions.js | 27 +++++++ src/sdk.js | 64 ++++++++++++++--- src/sdk.spec.js | 119 +++++++++++++++++++++++++++++-- 3 files changed, 195 insertions(+), 15 deletions(-) create mode 100644 src/components/routingActions.js diff --git a/src/components/routingActions.js b/src/components/routingActions.js new file mode 100644 index 000000000..74946e50c --- /dev/null +++ b/src/components/routingActions.js @@ -0,0 +1,27 @@ +/** + * Get the correct redirect path for an action. + * @param {string} action The action to be performed. + * @param {Record} actionParams The params linked to the action. + * @returns {{path: string, query?: URLSearchParams}} An object containing the pathname to be used, + * alongside with optional query parameters. + */ +export const getRedirectParams = (action, actionParams) => { + switch (action) { + case 'cosign': + return { + path: 'cosign/check', + query: new URLSearchParams(actionParams), + }; + case 'afspraak-annuleren': + return { + path: 'afspraak-annuleren', + query: new URLSearchParams(actionParams), + }; + case 'afspraak-maken': + return {path: 'afspraak-maken'}; + case 'resume': + return {path: `stap/${actionParams.next_step}`}; + default: + return {}; + } +}; diff --git a/src/sdk.js b/src/sdk.js index 22deef75b..f020ccd6f 100644 --- a/src/sdk.js +++ b/src/sdk.js @@ -11,6 +11,7 @@ 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'; @@ -95,24 +96,65 @@ class OpenForm { CSPNonce.setValue(CSPNonceValue); initialiseSentry(sentryDSN, sentryEnv); - // ensure that the basename has no trailing slash (for react router) - let pathname = basePath || window.location.pathname; + let pathname = this.useHashRouting ? '' : basePath || window.location.pathname; + if (pathname.endsWith('/')) { + // ensure that the pathname has no trailing slash (for react router) pathname = pathname.slice(0, pathname.length - 1); } - this.basePath = pathname; + this.routerBasePath = pathname; + this.browserBasePath = this.useHashRouting ? window.location.pathname : pathname; + this.makeRedirect(); this.calculateClientBaseUrl(); } + makeRedirect() { + // Perform pre-redirect based on this action: this is decoupled from the backend + const query = new URLSearchParams(document.location.search); + const action = query.get('_of_action'); + if (action) { + const actionParamsQuery = query.get('_of_action_params'); + const actionParams = actionParamsQuery ? JSON.parse(actionParamsQuery) : {}; + query.delete('_of_action'); + query.delete('_of_action_params'); + + const {path: redirectPath, query: redirectQuery = new URLSearchParams()} = getRedirectParams( + action, + actionParams + ); + const newUrl = new URL(this.browserBasePath, window.location.origin); + if (!this.useHashRouting) { + newUrl.pathname += `${!newUrl.pathname.endsWith('/') ? '/' : ''}${redirectPath}`; + // We first append query params from the redirect action + for (let [key, val] of redirectQuery.entries()) { + newUrl.searchParams.append(key, val); + } + // And extra unrelated query params + for (let [key, val] of query.entries()) { + newUrl.searchParams.append(key, val); + } + } else { + // First add extra unrelated query params, before hash (`#`) + for (let [key, val] of query.entries()) { + newUrl.searchParams.append(key, val); + } + + // Then add our custom path as the hash part. Our query parameters are added here, + // but are only parsed as such by react-router, e.g. location.searchParams + // will not include them (as per RFC). This is why unrelated query params were added before hash. + // TODO use query.size once we have better browser support + newUrl.hash = `/${redirectPath}${[...redirectQuery].length ? '?' + redirectQuery : ''}`; + } + + window.history.replaceState(null, '', newUrl); + } + } + calculateClientBaseUrl() { // calculate the client-side base URL, as this is recorded in backend calls for // submissions. - const clientBase = resolvePath(this.basePath).pathname; // has leading slash - const prefix = this.useHashRouting ? window.location.pathname : ''; // may have trailing slash - this.clientBaseUrl = new URL( - this.useHashRouting ? `${prefix}#${clientBase}` : clientBase, - window.location.origin - ).href; + const clientBase = resolvePath(this.browserBasePath).pathname; // has leading slash + this.clientBaseUrl = new URL(clientBase, window.location.origin).href; } async init() { @@ -133,7 +175,7 @@ class OpenForm { render() { const createRouter = this.useHashRouting ? createHashRouter : createBrowserRouter; - const router = createRouter(routes, {basename: this.basePath}); + const router = createRouter(routes, {basename: this.routerBasePath}); // render the wrapping React component this.root.render( @@ -143,7 +185,7 @@ class OpenForm { value={{ baseUrl: this.baseUrl, clientBaseUrl: this.clientBaseUrl, - basePath: this.basePath, + basePath: this.routerBasePath, baseTitle: this.baseTitle, displayComponents: this.displayComponents, // XXX: deprecate and refactor usage to use useFormContext? diff --git a/src/sdk.spec.js b/src/sdk.spec.js index e9e4e7d15..a66445cf2 100644 --- a/src/sdk.spec.js +++ b/src/sdk.spec.js @@ -125,17 +125,128 @@ describe('OpenForm', () => { expect(form.clientBaseUrl).toEqual('http://localhost/some-subpath'); }); - it('should correctly set the formUrl (hash fragment routing)', () => { + it("shouldn't take basepath into account (hash based routing)", () => { mswServer.use(...apiMocks); - window.history.pushState({}, 'Dummy title', '/some-server-side/path'); + window.history.pushState({}, '', '/some-path'); const formRoot = document.createElement('div'); const form = new OpenForm(formRoot, { baseUrl: BASE_URL, - basePath: '/some-subpath/', + basePath: '/i-must-be-ignored', formId: '81a22589-abce-4147-a2a3-62e9a56685aa', useHashRouting: true, }); - expect(form.clientBaseUrl).toEqual('http://localhost/some-server-side/path#/some-subpath'); + expect(form.clientBaseUrl).toEqual('http://localhost/some-path'); }); + + it.each([ + [ + `/some-subpath?_of_action=afspraak-annuleren&_of_action_params=${encodeURIComponent( + JSON.stringify({time: '2021-07-21T12:00:00+00:00'}) + )}`, + 'http://localhost/some-subpath/afspraak-annuleren?time=2021-07-21T12%3A00%3A00%2B00%3A00', + ], + [ + '/some-subpath?_of_action=afspraak-maken', + 'http://localhost/some-subpath/afspraak-maken/producten', // SDK redirects to producten + ], + [ + `/some-subpath?_of_action=cosign&_of_action_params=${encodeURIComponent( + JSON.stringify({submission_uuid: 'abc'}) + )}`, + 'http://localhost/some-subpath/cosign/check?submission_uuid=abc', + ], + [ + `/some-subpath?_of_action=resume&_of_action_params=${encodeURIComponent( + JSON.stringify({next_step: 'step-1'}) + )}`, + 'http://localhost/some-subpath/startpagina', // SDK redirects to start page + ], + ])('should handle action redirects correctly', async (initialUrl, expected) => { + mswServer.use(...apiMocks); + const formRoot = document.createElement('div'); + window.history.pushState(null, '', initialUrl); + const form = new OpenForm(formRoot, { + baseUrl: BASE_URL, + basePath: '/some-subpath', + formId: '81a22589-abce-4147-a2a3-62e9a56685aa', + useHashRouting: false, + lang: 'nl', + }); + await act(async () => await form.init()); + + // wait for the loader to be removed when all network requests have completed + await waitForElementToBeRemoved(() => within(formRoot).getByRole('status')); + expect(location.href).toEqual(expected); + }); + + it.each([ + // With a base path: + [ + // Omitting submission_uuid for simplicity + `/base-path/?_of_action=afspraak-annuleren&unrelated_q=1&_of_action_params=${encodeURIComponent( + JSON.stringify({time: '2021-07-21T12:00:00+00:00'}) + )}`, + 'http://localhost/base-path/?unrelated_q=1#/afspraak-annuleren?time=2021-07-21T12%3A00%3A00%2B00%3A00', + ], + [ + '/base-path/?_of_action=afspraak-maken&unrelated_q=1', + 'http://localhost/base-path/?unrelated_q=1#/afspraak-maken/producten', + ], + [ + `/base-path/?_of_action=cosign&_of_action_params=${encodeURIComponent( + JSON.stringify({submission_uuid: 'abc'}) + )}&unrelated_q=1`, + 'http://localhost/base-path/?unrelated_q=1#/cosign/check?submission_uuid=abc', + ], + [ + `/base-path/?_of_action=resume&_of_action_params=${encodeURIComponent( + JSON.stringify({next_step: 'step-1'}) + )}&unrelated_q=1`, + 'http://localhost/base-path/?unrelated_q=1#/startpagina', // SDK redirects to start page + ], + // Without a base path: + [ + // Omitting submission_uuid for simplicity + `/?_of_action=afspraak-annuleren&unrelated_q=1&_of_action_params=${encodeURIComponent( + JSON.stringify({time: '2021-07-21T12:00:00+00:00'}) + )}`, + 'http://localhost/?unrelated_q=1#/afspraak-annuleren?time=2021-07-21T12%3A00%3A00%2B00%3A00', + ], + [ + '/?_of_action=afspraak-maken&unrelated_q=1', + 'http://localhost/?unrelated_q=1#/afspraak-maken/producten', // SDK redirects to producten + ], + [ + `/?_of_action=cosign&_of_action_params=${encodeURIComponent( + JSON.stringify({submission_uuid: 'abc'}) + )}&unrelated_q=1`, + 'http://localhost/?unrelated_q=1#/cosign/check?submission_uuid=abc', + ], + [ + `/?_of_action=resume&_of_action_params=${encodeURIComponent( + JSON.stringify({next_step: 'step-1'}) + )}&unrelated_q=1`, + 'http://localhost/?unrelated_q=1#/startpagina', // SDK redirects to start page + ], + ])( + 'should handle action redirects correctly (hash based routing)', + async (initialUrl, expected) => { + mswServer.use(...apiMocks); + const formRoot = document.createElement('div'); + window.history.pushState(null, '', initialUrl); + const form = new OpenForm(formRoot, { + baseUrl: BASE_URL, + basePath: '/i-must-be-ignored', + formId: '81a22589-abce-4147-a2a3-62e9a56685aa', + useHashRouting: true, + lang: 'nl', + }); + await act(async () => await form.init()); + + // wait for the loader to be removed when all network requests have completed + await waitForElementToBeRemoved(() => within(formRoot).getByRole('status')); + expect(location.href).toEqual(expected); + } + ); });