Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🐛 Handle server side redirects #586

Merged
merged 1 commit into from
Nov 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {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 @@
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 @@

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 @@
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);
}
);
});