diff --git a/src/components/Form.js b/src/components/Form.js index ef46d593d..dbd6a3809 100644 --- a/src/components/Form.js +++ b/src/components/Form.js @@ -1,6 +1,6 @@ import React, {useContext, useEffect} from 'react'; import {useIntl} from 'react-intl'; -import {Navigate, Route, Routes, useMatch, useNavigate} from 'react-router-dom'; +import {Navigate, Route, Routes, useLocation, useMatch, useNavigate} from 'react-router-dom'; import {usePrevious} from 'react-use'; import {useImmerReducer} from 'use-immer'; @@ -12,7 +12,7 @@ import FormStart from 'components/FormStart'; import FormStep from 'components/FormStep'; import Loader from 'components/Loader'; import PaymentOverview from 'components/PaymentOverview'; -import ProgressIndicator from 'components/ProgressIndicator'; +import ProgressIndicatorNew from 'components/ProgressIndicatorNew/index'; import RequireSubmission from 'components/RequireSubmission'; import {RequireSession} from 'components/Sessions'; import SubmissionConfirmation from 'components/SubmissionConfirmation'; @@ -20,6 +20,7 @@ import SubmissionSummary from 'components/Summary'; import {START_FORM_QUERY_PARAM} from 'components/constants'; import {findNextApplicableStep} from 'components/utils'; import {createSubmission, flagActiveSubmission, flagNoActiveSubmission} from 'data/submissions'; +import {IsFormDesigner} from 'headers'; import useAutomaticRedirect from 'hooks/useAutomaticRedirect'; import useFormContext from 'hooks/useFormContext'; import usePageViews from 'hooks/usePageViews'; @@ -27,6 +28,9 @@ import useQuery from 'hooks/useQuery'; import useRecycleSubmission from 'hooks/useRecycleSubmission'; import useSessionTimeout from 'hooks/useSessionTimeout'; +import {STEP_LABELS, SUBMISSION_ALLOWED} from './constants'; +import {checkMatchesPath} from './utils/routes'; + const initialState = { submission: null, submittedSubmission: null, @@ -97,6 +101,8 @@ const Form = () => { usePageViews(); const intl = useIntl(); const prevLocale = usePrevious(intl.locale); + const {pathname: currentPathname} = useLocation(); + const stepMatch = useMatch('/stap/:step'); // extract the declared properties and configuration const {steps} = form; @@ -260,14 +266,118 @@ const Form = () => { return ; } - const progressIndicator = form.showProgressIndicator ? ( - step.isApplicable) : []; + const applicableAndCompletedSteps = applicableSteps.filter(step => step.completed); + const applicableCompleted = + hasSubmission && applicableSteps.length === applicableAndCompletedSteps.length; + + // If any step cannot be submitted, there should NOT be an active link to the overview page. + const canSubmitSteps = hasSubmission + ? submission.steps.filter(step => !step.canSubmit).length === 0 + : false; + + // 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 (isStartPage) { + activeStepTitle = STEP_LABELS.login; + } else if (isSummary) { + activeStepTitle = STEP_LABELS.overview; + } else if (isConfirmation) { + activeStepTitle = STEP_LABELS.confirmation; + } else { + const step = steps.find(step => step.slug === stepSlug); + activeStepTitle = step.formDefinition; + } + + const canNavigateToStep = index => { + // The user can navigate to a step when: + // 1. All previous steps have been completed + // 2. The user is a form designer + if (IsFormDesigner.getValue()) return true; + + if (!submission) return false; + + const previousSteps = submission.steps.slice(0, index); + const previousApplicableButNotCompletedSteps = previousSteps.filter( + step => step.isApplicable && !step.completed + ); + + return !previousApplicableButNotCompletedSteps.length; + }; + + // prepare steps - add the fixed steps-texts as well + const getStepsInfo = () => { + return form.steps.map((step, index) => ({ + uuid: step.uuid, + slug: step.slug, + to: `/stap/${step.slug}` || '#', + formDefinition: step.formDefinition, + isCompleted: submission ? submission.steps[index].completed : false, + isApplicable: submission ? submission.steps[index].isApplicable : step.isApplicable ?? true, + isCurrent: checkMatchesPath(currentPathname, `/stap/${step.slug}`), + canNavigateTo: canNavigateToStep(index), + })); + }; + + const updatedSteps = getStepsInfo(); + + updatedSteps.splice(0, 0, { + slug: 'startpagina', + to: '#', + formDefinition: 'Start page', + isCompleted: hasSubmission, + isApplicable: true, + canNavigateTo: true, + isCurrent: checkMatchesPath(currentPathname, 'startpagina'), + fixedText: STEP_LABELS.login, + }); + + if (showOverview) { + updatedSteps.splice(updatedSteps.length, 0, { + slug: 'overzicht', + to: 'overzicht', + formDefinition: 'Summary', + isCompleted: isConfirmation, + isApplicable: applicableCompleted && canSubmitSteps, + isCurrent: checkMatchesPath(currentPathname, 'overzicht'), + fixedText: STEP_LABELS.overview, + }); + const summaryPage = updatedSteps[updatedSteps.length - 1]; + summaryPage.canNavigateTo = canNavigateToStep(updatedSteps.length - 1); + } + + if (showConfirmation) { + updatedSteps.splice(updatedSteps.length, 0, { + slug: 'bevestiging', + to: 'bevestiging', + formDefinition: 'Confirmation', + isCompleted: state ? state.completed : false, + isCurrent: checkMatchesPath(currentPathname, 'bevestiging'), + fixedText: STEP_LABELS.confirmation, + }); + } + + const progressIndicatorNew = form.showProgressIndicator ? ( + ) : null; @@ -366,7 +476,7 @@ const Form = () => { return ( diff --git a/src/components/ProgressIndicatorNew/CompletionMark.js b/src/components/ProgressIndicatorNew/CompletionMark.js new file mode 100644 index 000000000..a244d16f6 --- /dev/null +++ b/src/components/ProgressIndicatorNew/CompletionMark.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import {useIntl} from 'react-intl'; + +import FAIcon from 'components/FAIcon'; + +const CompletionMark = ({completed = false}) => { + const intl = useIntl(); + // Wrapper may be a DOM element, which can't handle + const ariaLabel = intl.formatMessage({ + description: 'Step completion marker icon label', + defaultMessage: 'Completed', + }); + + if (!completed) return null; + + // provide a text alternative with aria-hidden="true" attribute on the icon and include text with an + // additional element, such as a , with appropriate CSS to visually hide the element while keeping it + // accessible to assistive technologies. Only here where the Completion mark icon actually has a meaning. + return ( + <> + + {ariaLabel} + + ); +}; + +CompletionMark.propTypes = { + completed: PropTypes.bool, +}; + +export default CompletionMark; diff --git a/src/components/ProgressIndicatorNew/MobileButton.js b/src/components/ProgressIndicatorNew/MobileButton.js new file mode 100644 index 000000000..d769cfce0 --- /dev/null +++ b/src/components/ProgressIndicatorNew/MobileButton.js @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import {FormattedMessage} from 'react-intl'; + +import FAIcon from 'components/FAIcon'; +import {getBEMClassName} from 'utils'; + +const MobileButton = ({ + ariaIconLabel, + accessibleToggleStepsLabel, + formTitle, + expanded, + onExpandClick, +}) => { + return ( + + ); +}; + +MobileButton.propTypes = { + ariaIconLabel: PropTypes.string.isRequired, + accessibleToggleStepsLabel: PropTypes.oneOfType([PropTypes.string, FormattedMessage]), + formTitle: PropTypes.string.isRequired, + expanded: PropTypes.bool.isRequired, + onExpandClick: PropTypes.func.isRequired, +}; + +export default MobileButton; diff --git a/src/components/ProgressIndicatorNew/ProgressIndicatorItem.js b/src/components/ProgressIndicatorNew/ProgressIndicatorItem.js new file mode 100644 index 000000000..054c041bc --- /dev/null +++ b/src/components/ProgressIndicatorNew/ProgressIndicatorItem.js @@ -0,0 +1,76 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import Body from 'components/Body'; +import Link from 'components/Link'; +import {STEP_LABELS} from 'components/constants'; +import {getBEMClassName} from 'utils'; + +import CompletionMark from './CompletionMark'; + +const ProgressIndicatorItem = ({ + text, + href, + isActive, + isCompleted, + canNavigateTo, + isApplicable, + fixedText = null, +}) => { + const getLinkModifiers = (isActive, isApplicable) => { + return [ + 'inherit', + 'hover', + isActive ? 'active' : undefined, + isApplicable ? undefined : 'muted', + ].filter(mod => mod !== undefined); + }; + + return ( +
+
+ +
+
+ {isApplicable && canNavigateTo ? ( + + + + ) : ( + + {fixedText || text} + + )} +
+
+ ); +}; + +ProgressIndicatorItem.propTypes = { + text: PropTypes.string.isRequired, + href: PropTypes.string, + isActive: PropTypes.bool, + isCompleted: PropTypes.bool, + canNavigateTo: PropTypes.bool, + isApplicable: PropTypes.bool, + fixedText: PropTypes.oneOf(Object.values(STEP_LABELS)), +}; + +export default ProgressIndicatorItem; diff --git a/src/components/ProgressIndicatorNew/ProgressIndicatorItem.stories.js b/src/components/ProgressIndicatorNew/ProgressIndicatorItem.stories.js new file mode 100644 index 000000000..a4e89fa87 --- /dev/null +++ b/src/components/ProgressIndicatorNew/ProgressIndicatorItem.stories.js @@ -0,0 +1,20 @@ +import {withRouter} from 'storybook-addon-react-router-v6'; + +import ProgressIndicatorItem from './ProgressIndicatorItem'; + +export default { + title: 'Private API / ProgressIndicatorNew / ProgressIndicatorItem', + component: ProgressIndicatorItem, + decorators: [withRouter], + args: { + text: 'Stap 1', + href: '#', + isActive: false, + isCompleted: true, + canNavigateTo: false, + isApplicable: true, + fixedText: null, + }, +}; + +export const Default = {}; diff --git a/src/components/ProgressIndicatorNew/ProgressIndicatorNew.stories.js b/src/components/ProgressIndicatorNew/ProgressIndicatorNew.stories.js new file mode 100644 index 000000000..aa1afd223 --- /dev/null +++ b/src/components/ProgressIndicatorNew/ProgressIndicatorNew.stories.js @@ -0,0 +1,70 @@ +import {withRouter} from 'storybook-addon-react-router-v6'; + +import {STEP_LABELS} from 'components/constants'; + +import ProgressIndicatorNew from '.'; + +export default { + title: 'Private API / ProgressIndicatorNew', + component: ProgressIndicatorNew, + decorators: [withRouter], + args: { + progressIndicatorTitle: 'Progress', + formTitle: 'Formulier', + steps: [ + { + slug: 'start-page', + to: 'start-page', + formDefinition: 'Start page', + isCompleted: true, + isApplicable: true, + isCurrent: false, + canNavigateTo: true, + fixedText: STEP_LABELS.login, + }, + { + uuid: 'd6cab0dd', + slug: 'first-step', + to: 'first-step', + formDefinition: 'Stap 1', + isCompleted: true, + isApplicable: true, + isCurrent: false, + canNavigateTo: true, + }, + { + uuid: '8e62d7cf', + slug: 'second-step', + to: 'second-step', + formDefinition: 'Stap 2', + isCompleted: false, + isApplicable: true, + isCurrent: true, + canNavigateTo: true, + }, + { + slug: 'confirmation-page', + to: 'confirmation-page', + formDefinition: 'Confirmation', + isCompleted: false, + isApplicable: false, + isCurrent: false, + canNavigateTo: true, + fixedText: STEP_LABELS.confirmation, + }, + { + slug: 'summary-page', + to: 'summary-page', + formDefinition: 'Summary', + isCompleted: false, + isApplicable: false, + isCurrent: false, + canNavigateTo: true, + fixedText: STEP_LABELS.overview, + }, + ], + activeStepTitle: 'Stap 2', + }, +}; + +export const Default = {}; diff --git a/src/components/ProgressIndicatorNew/index.js b/src/components/ProgressIndicatorNew/index.js new file mode 100644 index 000000000..60879ef07 --- /dev/null +++ b/src/components/ProgressIndicatorNew/index.js @@ -0,0 +1,102 @@ +import PropTypes from 'prop-types'; +import React, {useEffect, useState} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import {useLocation} from 'react-router-dom'; + +import Caption from 'components/Caption'; +import Card from 'components/Card'; +import List from 'components/List'; +import {STEP_LABELS} from 'components/constants'; + +import MobileButton from './MobileButton'; +import ProgressIndicatorItem from './ProgressIndicatorItem'; + +const ProgressIndicatorNew = ({progressIndicatorTitle, formTitle, steps, activeStepTitle}) => { + const intl = useIntl(); + const {pathname: currentPathname} = useLocation(); + const [expanded, setExpanded] = useState(false); + + const ariaIconLabel = 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 {formTitle}: {activeStepTitle}', + }, + {formTitle, activeStepTitle} + ); + + const modifiers = []; + if (!expanded) { + modifiers.push('mobile-collapsed'); + } + + // collapse the expanded progress indicator if nav occurred, see + // open-formulieren/open-forms#2673. It's important that *only* the pathname triggers + // the effect, which is why exhaustive deps is ignored. + useEffect(() => { + if (expanded) { + setExpanded(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentPathname]); + + return ( + + + + ); +}; + +ProgressIndicatorNew.propTypes = { + progressIndicatorTitle: PropTypes.string.isRequired, + formTitle: PropTypes.string, + steps: PropTypes.arrayOf( + PropTypes.shape({ + uuid: PropTypes.string, + slug: PropTypes.string.isRequired, + to: PropTypes.string, + formDefinition: PropTypes.string.isRequired, + isCompleted: PropTypes.bool, + isApplicable: PropTypes.bool, + isCurrent: PropTypes.bool, + canNavigateTo: PropTypes.bool, + fixedText: PropTypes.oneOf(Object.values(STEP_LABELS)), + }) + ).isRequired, + activeStepTitle: PropTypes.string, +}; + +export default ProgressIndicatorNew; diff --git a/src/components/constants.js b/src/components/constants.js index 9823078be..444ddc31e 100644 --- a/src/components/constants.js +++ b/src/components/constants.js @@ -1,11 +1,21 @@ +import {FormattedMessage} from 'react-intl'; + const SUBMISSION_ALLOWED = { yes: 'yes', noWithOverview: 'no_with_overview', noWithoutOverview: 'no_without_overview', }; +const STEP_LABELS = { + login: , + overview: , + confirmation: ( + + ), +}; + const START_FORM_QUERY_PARAM = '_start'; const SUBMISSION_UUID_QUERY_PARAM = 'submission_uuid'; -export {SUBMISSION_ALLOWED, START_FORM_QUERY_PARAM, SUBMISSION_UUID_QUERY_PARAM}; +export {SUBMISSION_ALLOWED, START_FORM_QUERY_PARAM, SUBMISSION_UUID_QUERY_PARAM, STEP_LABELS}; diff --git a/src/components/utils/routes.js b/src/components/utils/routes.js new file mode 100644 index 000000000..d62a3a66c --- /dev/null +++ b/src/components/utils/routes.js @@ -0,0 +1,11 @@ +import {matchPath} from 'react-router-dom'; + +/** + * Check if a given relative path from the routes matches the current location. + * @param currentPathname The current router location.pathname, from useLocation. + * @param path The relative path to check for matches + */ +export const checkMatchesPath = (currentPathname, path) => { + const match = matchPath(path, currentPathname); + return match !== null; +};