Skip to content

Commit

Permalink
Merge pull request #586 from open-formulieren/issue/3362-hash-redirects
Browse files Browse the repository at this point in the history
🐛 Handle server side redirects
  • Loading branch information
sergei-maertens authored Nov 17, 2023
2 parents dd342fd + 54d1198 commit 59447c0
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 {};
}
};
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);
}
} 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 59447c0

Please sign in to comment.