Skip to content

Commit

Permalink
♻️ [open-formulieren/open-forms#4929] Refactor the FormStart componen…
Browse files Browse the repository at this point in the history
…t into own route

The component is refactored with the goal of configuring it in a static
route definitions file, which means it cannot take any dynamic props
as input as that is not statically available.

The most important props are the current submission in the session,
initial data reference and callbacks for when a submission is created
or session stopped (because the user logs out, for example).

For the initial data reference, we can use the hook from earlier
refactors to grab it directly from the query parameters. The submission
and relevant callbacks are now provided by the container/wrapper Form
component that manages the submission state, through context. We have
to be careful here to properly memoize callbacks as there's quite a
substantial risk of running into infinite re-renders locking up the
browser. Hopefully in the future we can remove those footguns by using
loaders and actions of the react-router library.

The core onFormStart callback is hereby also moved from the Form
component to the FormStart component which properly localizes the
related behaviours. We only send the created submission back up to
the parent state when the creation is done.

Additionally, I've opted to use 'named arguments' for the anonymous
flag to make the code easier to read/understand. Secondly, the browser
event doesn't need to be passed to the callback, since the form
submit event is already suppressed when the callback is invoked.

Finally - the previous version of this code would also reset the
session expiry whenever a submission is started. This was required to
get rid of the session expired error message. This had become obsolete
when the session expiry was moved to its own route and component, which
already resets the session automatically. No expiry messages are
displayed when you navigate away from the expiry notice to the start
page by clicking the restart link.
  • Loading branch information
sergei-maertens committed Jan 13, 2025
1 parent f30831a commit 00d6ee7
Show file tree
Hide file tree
Showing 6 changed files with 106 additions and 77 deletions.
3 changes: 2 additions & 1 deletion src/components/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import {Navigate, Outlet, useMatch} from 'react-router-dom';

import {Cosign, cosignRoutes} from 'components/CoSign';
import ErrorBoundary from 'components/Errors/ErrorBoundary';
import Form, {formRoutes} from 'components/Form';
import Form from 'components/Form';
import SessionExpired from 'components/Sessions/SessionExpired';
import {
CreateAppointment,
appointmentRoutes,
manageAppointmentRoutes,
} from 'components/appointments';
import formRoutes from 'components/formRoutes';
import useFormContext from 'hooks/useFormContext';
import useQuery from 'hooks/useQuery';
import useZodErrorMap from 'hooks/useZodErrorMap';
Expand Down
102 changes: 40 additions & 62 deletions src/components/Form.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {useContext, useEffect} from 'react';
import PropTypes from 'prop-types';
import React, {useContext, useEffect} from 'react';
import {useIntl} from 'react-intl';
import {
Navigate,
Expand All @@ -15,7 +16,6 @@ import {useImmerReducer} from 'use-immer';
import {AnalyticsToolsConfigContext, ConfigContext} from 'Context';
import {destroy, get} from 'api';
import ErrorBoundary from 'components/Errors/ErrorBoundary';
import FormStart from 'components/FormStart';
import FormStep from 'components/FormStep';
import Loader from 'components/Loader';
import {ConfirmationView, StartPaymentView} from 'components/PostCompletionViews';
Expand All @@ -30,13 +30,14 @@ import {
SUBMISSION_ALLOWED,
} from 'components/constants';
import {findNextApplicableStep} from 'components/utils';
import {createSubmission, flagActiveSubmission, flagNoActiveSubmission} from 'data/submissions';
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 useSessionTimeout from 'hooks/useSessionTimeout';
import Types from 'types';

import FormDisplay from './FormDisplay';
import {addFixedSteps, getStepsInfo} from './ProgressIndicator/utils';
Expand Down Expand Up @@ -129,10 +130,6 @@ const Form = () => {
const {steps} = form;
const config = useContext(ConfigContext);

// This has to do with a data reference if it is provided by the external party
// It will be used in the backend for retrieving additional information from the API
const initialDataReference = queryParams?.get('initial_data_reference');

// load the state management/reducer
const initialStateFromProps = {...initialState, step: steps[0]};
const [state, dispatch] = useImmerReducer(reducer, initialStateFromProps);
Expand All @@ -155,7 +152,7 @@ const Form = () => {
onSubmissionLoaded
);

const [, expiryDate, resetSession] = useSessionTimeout();
const [, expiryDate] = useSessionTimeout();

const {value: analyticsToolsConfigInfo, loading: loadingAnalyticsConfig} = useAsync(async () => {
return await get(`${config.baseUrl}analytics/analytics-tools-config-info`);
Expand All @@ -174,48 +171,13 @@ const Form = () => {
[intl.locale, prevLocale, removeSubmissionId, state.submission] // eslint-disable-line react-hooks/exhaustive-deps
);

/**
* When the form is started, create a submission and add it to the state.
*
* @param {Event} event The DOM event, could be a button click or a custom event.
* @return {Void}
*/
const onFormStart = async (event, anonymous = false) => {
if (event) event.preventDefault();

// required to get rid of the error message saying the session is expired - once
// you start a new submission, any previous call history should be discarded.
resetSession();

if (state.submission != null) {
onSubmissionLoaded(state.submission);
return;
}

let submission;
try {
submission = await createSubmission(
config.baseUrl,
form,
config.clientBaseUrl,
null,
initialDataReference,
anonymous
);
} catch (exc) {
dispatch({type: 'STARTING_ERROR', payload: exc});
return;
}

const onSubmissionObtained = submission => {
dispatch({
type: 'SUBMISSION_LOADED',
payload: submission,
});
flagActiveSubmission();
setSubmissionId(submission.id);
// navigate to the first step
const firstStepRoute = `/stap/${form.steps[0].slug}`;
navigate(firstStepRoute);
};

const onStepSubmitted = async formStep => {
Expand Down Expand Up @@ -364,21 +326,6 @@ const Form = () => {
// Route the correct page based on URL
const router = (
<Routes>
<Route
path="startpagina"
element={
<ErrorBoundary useCard>
<FormStart
form={form}
submission={state.submission}
onFormStart={onFormStart}
onDestroySession={onDestroySession}
initialDataReference={initialDataReference}
/>
</ErrorBoundary>
}
/>

<Route
path="overzicht"
element={
Expand Down Expand Up @@ -454,14 +401,45 @@ const Form = () => {
return (
<FormDisplay progressIndicator={progressIndicator}>
<AnalyticsToolsConfigContext.Provider value={analyticsToolsConfigInfo}>
<Outlet />
{router}
<SubmissionProvider
submission={state.submission}
onSubmissionObtained={onSubmissionObtained}
onDestroySession={onDestroySession}
>
<Outlet />
{router}
</SubmissionProvider>
</AnalyticsToolsConfigContext.Provider>
</FormDisplay>
);
};

Form.propTypes = {};

const SubmissionContext = React.createContext({
submission: null,
onSubmissionObtained: () => {},
onDestroySession: () => {},
});

const SubmissionProvider = ({
submission = null,
onSubmissionObtained,
onDestroySession,
children,
}) => (
<SubmissionContext.Provider value={{submission, onSubmissionObtained, onDestroySession}}>
{children}
</SubmissionContext.Provider>
);

SubmissionProvider.propTypes = {
submission: Types.Submission,
onSubmissionObtained: PropTypes.func.isRequired,
onDestroySession: PropTypes.func.isRequired,
};

const useSubmissionContext = () => useContext(SubmissionContext);

export default Form;
export {default as formRoutes} from './formRoutes';
export {useSubmissionContext, SubmissionProvider};
3 changes: 3 additions & 0 deletions src/components/Form.spec.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const Wrapper = ({form = buildForm(), initialEntry = '/startpagina'}) => {
);
};

// TODO: move to/merge with FormStart tests
test('Start form anonymously', async () => {
const user = userEvent.setup();
mswServer.use(mockSubmissionPost(), mockAnalyticsToolConfigGet(), mockSubmissionStepGet());
Expand All @@ -76,6 +77,7 @@ test('Start form anonymously', async () => {
expect(requestBody.anonymous).toBe(true);
});

// TODO: move to/merge with FormStart tests
test('Start form as if authenticated from the backend', async () => {
mswServer.use(mockAnalyticsToolConfigGet(), mockSubmissionPost(), mockSubmissionStepGet());
let startSubmissionRequest;
Expand All @@ -94,6 +96,7 @@ test('Start form as if authenticated from the backend', async () => {
expect(requestBody.anonymous).toBe(false);
});

// TODO: move to/merge with FormStart tests
test('Start form with object reference query param', async () => {
mswServer.use(mockAnalyticsToolConfigGet(), mockSubmissionPost(), mockSubmissionStepGet());
let startSubmissionRequest;
Expand Down
63 changes: 50 additions & 13 deletions src/components/FormStart/index.jsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import PropTypes from 'prop-types';
import {useRef} from 'react';
import {useCallback, useContext, useRef} from 'react';
import {FormattedMessage} from 'react-intl';
import {useNavigate} from 'react-router-dom';
import {useAsync} from 'react-use';

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';
Expand All @@ -18,13 +20,13 @@ import {
import AuthenticationOutage, {
useDetectAuthenticationOutage,
} from 'components/auth/AuthenticationOutage';
import {createSubmission} from 'data/submissions';
import {UnprocessableEntity} from 'errors';
import {IsFormDesigner} from 'headers';
import useFormContext from 'hooks/useFormContext';
import useInitialDataReference from 'hooks/useInitialDataReference';
import useStartSubmission from 'hooks/useStartSubmission';
import useTitle from 'hooks/useTitle';
import Types from 'types';
import {getBEMClassName} from 'utils';

const FormStartMessage = ({form}) => {
Expand All @@ -44,10 +46,17 @@ const FormStartMessage = ({form}) => {
* This is shown when the form is initially loaded and provides the explicit user
* action to start the form, or present the login button (DigiD, eHerkenning...)
*/
const FormStart = ({submission, onFormStart, onDestroySession}) => {
const FormStart = () => {
const {baseUrl, clientBaseUrl} = useContext(ConfigContext);
const {initialDataReference} = useInitialDataReference();
const navigate = useNavigate();

const form = useFormContext();
const {submission, onSubmissionObtained, onDestroySession} = useSubmissionContext();

const hasActiveSubmission = !!submission;
const isAuthenticated = hasActiveSubmission && submission.isAuthenticated;

const doStart = useStartSubmission();
const outagePluginId = useDetectAuthenticationOutage();
const authErrors = useDetectAuthErrorMessages();
Expand All @@ -57,8 +66,41 @@ const FormStart = ({submission, onFormStart, onDestroySession}) => {

useTitle(form.name);

useAsync(async () => {
// if it's already called, do not call it again as this creates 'infite' cycles.
/**
* Callback invoked when a form submission must be started.
*
* It can be triggered by an HTML form submission for anonymous submissions, or
* automatically because of a magical parameter being present in the URL query params.
*
* It must be wrapped in useCallback, since onFormStart is passed as a dependency to
* useAsync.
*
* @return {Promise<Void>} Triggers side effects and state updates.
*/
const onFormStart = useCallback(
async (options = {}) => {
const {isAnonymous = false} = options;
if (submission !== null) throw new Error('There already is a submission!');

const newSubmission = await createSubmission(
baseUrl,
form,
clientBaseUrl,
null,
initialDataReference,
isAnonymous
);

onSubmissionObtained(newSubmission);

const firstStepRoute = `/stap/${form.steps[0].slug}`;
navigate(firstStepRoute);
},
[submission, baseUrl, form, clientBaseUrl, initialDataReference, onSubmissionObtained, navigate]
);

const {error} = useAsync(async () => {
// if it's already called, do not call it again as this creates 'infinite' cycles.
// This component is re-mounted/re-rendered because of parent component state changes,
// while the start marker is still in the querystring. Therefore, once we have called
// the callback, we keep track of this call being done so that it's invoked only once.
Expand All @@ -72,8 +114,7 @@ const FormStart = ({submission, onFormStart, onDestroySession}) => {
await onFormStart();
}
}, [doStart, hasAuthErrors, onFormStart]);

const {initialDataReference} = useInitialDataReference();
if (error) throw error;

// do not re-render the login options while we're redirecting
if (doStart && !hasAuthErrors) {
Expand Down Expand Up @@ -145,10 +186,6 @@ const FormStart = ({submission, onFormStart, onDestroySession}) => {
);
};

FormStart.propTypes = {
submission: Types.Submission,
onFormStart: PropTypes.func.isRequired,
onDestroySession: PropTypes.func.isRequired,
};
FormStart.propTypes = {};

export default FormStart;
2 changes: 1 addition & 1 deletion src/components/LoginOptions/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ const LoginOptions = ({form, onFormStart, extraNextParams = {}, isolateCosignOpt
: {
onSubmit: async e => {
e.preventDefault();
await onFormStart(e, true);
await onFormStart({isAnonymous: true});
},
'data-testid': 'start-form',
};
Expand Down
10 changes: 10 additions & 0 deletions src/components/formRoutes.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import ErrorBoundary from 'components/Errors/ErrorBoundary';
import FormLandingPage from 'components/FormLandingPage';
import FormStart from 'components/FormStart';
import IntroductionPage from 'components/IntroductionPage';

const formRoutes = [
Expand All @@ -10,6 +12,14 @@ const formRoutes = [
path: 'introductie',
element: <IntroductionPage />,
},
{
path: 'startpagina',
element: (
<ErrorBoundary useCard>
<FormStart />
</ErrorBoundary>
),
},
];

export default formRoutes;

0 comments on commit 00d6ee7

Please sign in to comment.