From 963ead2ca652cfc0ba555b7ec4d244b36a300eba Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Sat, 18 Jan 2025 21:47:02 +0100 Subject: [PATCH] :recycle: [open-formulieren/open-forms#4929] Extract progress indicator calculations 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. --- src/components/Form.jsx | 113 ++------------------- src/components/FormProgressIndicator.jsx | 121 +++++++++++++++++++++++ 2 files changed, 130 insertions(+), 104 deletions(-) create mode 100644 src/components/FormProgressIndicator.jsx diff --git a/src/components/Form.jsx b/src/components/Form.jsx index 18f93dc41..bf48cdc6e 100644 --- a/src/components/Form.jsx +++ b/src/components/Form.jsx @@ -1,27 +1,15 @@ 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'; @@ -29,7 +17,6 @@ import usePageViews from 'hooks/usePageViews'; import useRecycleSubmission from 'hooks/useRecycleSubmission'; import FormDisplay from './FormDisplay'; -import {addFixedSteps, getStepsInfo} from './ProgressIndicator/utils'; /** * An OpenForms form. @@ -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) { @@ -121,86 +102,10 @@ const Form = () => { return ; } - // 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 ? ( - - ) : 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 ( - + }> { + 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 ( + + ); +}; + +FormProgressIndicator.propTypes = { + submission: Types.Submission, +}; + +export default FormProgressIndicator;