Skip to content

Commit

Permalink
🐛 [open-formulieren/open-forms#3362] Handle server side redirects
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Viicos authored and sergei-maertens committed Nov 17, 2023
1 parent dd342fd commit 54d1198
Show file tree
Hide file tree
Showing 3 changed files with 195 additions and 15 deletions.
27 changes: 27 additions & 0 deletions src/components/routingActions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Get the correct redirect path for an action.
* @param {string} action The action to be performed.
* @param {Record<string, string>} 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 {};

Check warning on line 25 in src/components/routingActions.js

View check run for this annotation

Codecov / codecov/patch

src/components/routingActions.js#L25

Added line #L25 was not covered by tests
}
};
64 changes: 53 additions & 11 deletions src/sdk.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);

Check warning on line 134 in src/sdk.js

View check run for this annotation

Codecov / codecov/patch

src/sdk.js#L134

Added line #L134 was not covered by tests
}
} 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() {
Expand All @@ -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(
Expand All @@ -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?
Expand Down
119 changes: 115 additions & 4 deletions src/sdk.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
);
});

0 comments on commit 54d1198

Please sign in to comment.