Skip to content

Commit

Permalink
♻️ [open-formulieren/open-forms#4929] Extract progress indicator calc…
Browse files Browse the repository at this point in the history
…ulations from Form component

Now that we have all the necessary information available via context,
we can encapsulate all the progress indicator render logic in a single
component instead of polluting the 'container' Form component with it.
  • Loading branch information
sergei-maertens committed Jan 18, 2025
1 parent f20a1f3 commit 963ead2
Show file tree
Hide file tree
Showing 2 changed files with 130 additions and 104 deletions.
113 changes: 9 additions & 104 deletions src/components/Form.jsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,22 @@
import {useContext, useEffect, useState} from 'react';
import {useIntl} from 'react-intl';
import {
Navigate,
Outlet,
useLocation,
useMatch,
useNavigate,
useSearchParams,
} from 'react-router-dom';
import {Navigate, Outlet, useLocation, useNavigate, useSearchParams} from 'react-router-dom';
import {usePrevious} from 'react-use';

import {ConfigContext} from 'Context';
import {destroy} from 'api';
import FormProgressIndicator from 'components/FormProgressIndicator';
import Loader from 'components/Loader';
import ProgressIndicator from 'components/ProgressIndicator';
import SubmissionProvider from 'components/SubmissionProvider';
import AnalyticsToolsConfigProvider from 'components/analytics/AnalyticsToolConfigProvider';
import {
PI_TITLE,
START_FORM_QUERY_PARAM,
STEP_LABELS,
SUBMISSION_ALLOWED,
} from 'components/constants';
import {START_FORM_QUERY_PARAM} from 'components/constants';
import {flagActiveSubmission, flagNoActiveSubmission} from 'data/submissions';
import useAutomaticRedirect from 'hooks/useAutomaticRedirect';
import useFormContext from 'hooks/useFormContext';
import usePageViews from 'hooks/usePageViews';
import useRecycleSubmission from 'hooks/useRecycleSubmission';

import FormDisplay from './FormDisplay';
import {addFixedSteps, getStepsInfo} from './ProgressIndicator/utils';

/**
* An OpenForms form.
Expand All @@ -47,19 +34,13 @@ const Form = () => {
usePageViews();
const intl = useIntl();
const prevLocale = usePrevious(intl.locale);
const {pathname: currentPathname, state: routerState} = useLocation();

// TODO replace absolute path check with relative
const introductionMatch = useMatch('/introductie');
const stepMatch = useMatch('/stap/:step');
const summaryMatch = useMatch('/overzicht');
const paymentMatch = useMatch('/betalen');
const confirmationMatch = useMatch('/bevestiging');
const {state: routerState} = useLocation();

// extract the declared properties and configuration
const config = useContext(ConfigContext);

// load the state management/reducer
// figure out the submission in the state. If it's stored in the router state, extract
// it and set it in the React state to 'persist' it.
const submissionFromRouterState = routerState?.submission;
const [submission, setSubmission] = useState(null);
if (submission == null && submissionFromRouterState != null) {
Expand Down Expand Up @@ -121,86 +102,10 @@ const Form = () => {
return <Loader modifiers={['centered']} />;
}

// Progress Indicator

const isIntroductionPage = !!introductionMatch;
const isStartPage = !isIntroductionPage && !summaryMatch && stepMatch == null && !paymentMatch;
const submissionAllowedSpec = submission?.submissionAllowed ?? form.submissionAllowed;
const showOverview = submissionAllowedSpec !== SUBMISSION_ALLOWED.noWithoutOverview;
const formName = form.name;
const needsPayment = submission ? submission.payment.isRequired : form.paymentRequired;

// Figure out the slug from the currently active step IF we're looking at a step
const stepSlug = stepMatch ? stepMatch.params.step : '';

// figure out the title for the mobile menu based on the state
let activeStepTitle;
if (isIntroductionPage) {
activeStepTitle = intl.formatMessage(STEP_LABELS.introduction);
} else if (isStartPage) {
activeStepTitle = intl.formatMessage(STEP_LABELS.login);
} else if (summaryMatch) {
activeStepTitle = intl.formatMessage(STEP_LABELS.overview);
} else if (paymentMatch) {
activeStepTitle = intl.formatMessage(STEP_LABELS.payment);
} else {
const step = form.steps.find(step => step.slug === stepSlug);
activeStepTitle = step.formDefinition;
}

const ariaMobileIconLabel = intl.formatMessage({
description: 'Progress step indicator toggle icon (mobile)',
defaultMessage: 'Toggle the progress status display',
});

const accessibleToggleStepsLabel = intl.formatMessage(
{
description: 'Active step accessible label in mobile progress indicator',
defaultMessage: 'Current step in form {formName}: {activeStepTitle}',
},
{formName, activeStepTitle}
);

// process the form/submission steps information into step data that can be passed
// to the progress indicator.
// If the form is marked to not display non-applicable steps at all, filter them out.
const showNonApplicableSteps = !form.hideNonApplicableSteps;
const updatedSteps =
// first, process all the form steps in a format suitable for the PI
getStepsInfo(form.steps, submission, currentPathname)
// then, filter out the non-applicable steps if they should not be displayed
.filter(step => showNonApplicableSteps || step.isApplicable);

// the statusUrl is put in the router state once the summary page is confirmed and the
// submission is completed.
const isCompleted = !!routerState?.statusUrl;
const stepsToRender = addFixedSteps(
intl,
updatedSteps,
submission,
currentPathname,
showOverview,
needsPayment,
isCompleted,
!!form.introductionPageContent
);

// Show the progress indicator if enabled on the form AND we're not in the payment
// confirmation screen.
const progressIndicator =
form.showProgressIndicator && !confirmationMatch ? (
<ProgressIndicator
title={PI_TITLE}
formTitle={formName}
steps={stepsToRender}
ariaMobileIconLabel={ariaMobileIconLabel}
accessibleToggleStepsLabel={accessibleToggleStepsLabel}
/>
) : null;

// render the form step if there's an active submission (and no summary)
// render the container for the router and necessary context providers for deeply
// nested child components
return (
<FormDisplay progressIndicator={progressIndicator}>
<FormDisplay progressIndicator={<FormProgressIndicator submission={submission} />}>
<AnalyticsToolsConfigProvider>
<SubmissionProvider
submission={submission}
Expand Down
121 changes: 121 additions & 0 deletions src/components/FormProgressIndicator.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import {useIntl} from 'react-intl';
import {matchPath, useLocation, useMatch} from 'react-router-dom';

import ProgressIndicator from 'components/ProgressIndicator';
import {addFixedSteps, getStepsInfo} from 'components/ProgressIndicator/utils';
import {PI_TITLE, STEP_LABELS, SUBMISSION_ALLOWED} from 'components/constants';
import useFormContext from 'hooks/useFormContext';
import Types from 'types';

const getProgressIndicatorSteps = ({intl, form, submission, currentPathname, isCompleted}) => {
const submissionAllowedSpec = submission?.submissionAllowed ?? form.submissionAllowed;
const showOverview = submissionAllowedSpec !== SUBMISSION_ALLOWED.noWithoutOverview;
const needsPayment = submission?.payment.isRequired ?? form.paymentRequired;

const showNonApplicableSteps = !form.hideNonApplicableSteps;
const filteredSteps =
// first, process all the form steps in a format suitable for the PI
getStepsInfo(form.steps, submission, currentPathname)
// then, filter out the non-applicable steps if they should not be displayed
.filter(step => showNonApplicableSteps || step.isApplicable);

return addFixedSteps(
intl,
filteredSteps,
submission,
currentPathname,
showOverview,
needsPayment,
isCompleted,
!!form.introductionPageContent
);
};

/**
* Determine the 'step' title to render for the accessible mobile menu label.
* @param {IntlShape} intl The `useIntl` return value.
* @param {String} pathname The pathname ('url') of the current location.
* @param {Object} form The Open Forms form instance being rendered.
* @return {String} The (formatted) string for the step title/name.
*/
const getMobileStepTitle = (intl, pathname, form) => {
// TODO replace absolute path check with relative
if (matchPath('/introductie', pathname)) {
return intl.formatMessage(STEP_LABELS.introduction);
}
if (matchPath('/startpagina', pathname)) {
return intl.formatMessage(STEP_LABELS.login);
}

const stepMatch = matchPath('/stap/:step', pathname);
if (stepMatch) {
const slug = stepMatch.params.step;
const step = form.steps.find(step => step.slug === slug);
return step.formDefinition;
}

if (matchPath('/overzicht', pathname)) {
return intl.formatMessage(STEP_LABELS.overview);
}
if (matchPath('/betalen', pathname)) {
return intl.formatMessage(STEP_LABELS.payment);
}

// we *may* end up here in tests that haven't set up all routes and so path matches
// fail.
/* istanbul ignore next */
return '';
};

/**
* Component to configure the progress indicator for a specific form.
*
* This component encapsulates the render/no render behaviour of the progress indicator
* by looking at the form configuration settings.
*/
const FormProgressIndicator = ({submission}) => {
const form = useFormContext();
const {pathname: currentPathname, state: routerState} = useLocation();
const confirmationMatch = useMatch('/bevestiging');
const intl = useIntl();

// don't render anything if the form is configured to never display the progress
// indicator, or we're on the final confirmation page
if (!form.showProgressIndicator || confirmationMatch) {
return null;
}

// otherwise collect the necessary information to render the PI.
const isCompleted = !!routerState?.statusUrl;
const steps = getProgressIndicatorSteps({intl, form, submission, currentPathname, isCompleted});

const ariaMobileIconLabel = intl.formatMessage({
description: 'Progress step indicator toggle icon (mobile)',
defaultMessage: 'Toggle the progress status display',
});

const activeStepTitle = getMobileStepTitle(intl, currentPathname, form);
const accessibleToggleStepsLabel = intl.formatMessage(
{
description: 'Active step accessible label in mobile progress indicator',
defaultMessage: 'Current step in form {formName}: {activeStepTitle}',
},
{formName: form.name, activeStepTitle}
);

return (
<ProgressIndicator
title={PI_TITLE}
formTitle={form.name}
steps={steps}
ariaMobileIconLabel={ariaMobileIconLabel}
accessibleToggleStepsLabel={accessibleToggleStepsLabel}
/>
);
};

FormProgressIndicator.propTypes = {
submission: Types.Submission,
};

export default FormProgressIndicator;

0 comments on commit 963ead2

Please sign in to comment.