Skip to content

Commit

Permalink
[#36] Added new progress indicator
Browse files Browse the repository at this point in the history
  • Loading branch information
vaszig committed Nov 10, 2023
1 parent 0890c27 commit e354b4b
Show file tree
Hide file tree
Showing 9 changed files with 485 additions and 12 deletions.
132 changes: 121 additions & 11 deletions src/components/Form.js
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -12,21 +12,25 @@ 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';
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';
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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -260,14 +266,118 @@ const Form = () => {
return <Loader modifiers={['centered']} />;
}

const progressIndicator = form.showProgressIndicator ? (
<ProgressIndicator
title={form.name}
steps={form.steps}
hideNonApplicableSteps={form.hideNonApplicableSteps}
submission={state.submission || state.submittedSubmission}
submissionAllowed={form.submissionAllowed}
completed={state.completed}
// Progress Indicator

const isSummary = checkMatchesPath(currentPathname, 'overzicht');
const isStep = checkMatchesPath(currentPathname, 'stap/:step');
const isConfirmation = checkMatchesPath(currentPathname, 'bevestiging');
const isStartPage = !isSummary && !isStep && !isConfirmation;
const submissionAllowedSpec = state.submission?.submissionAllowed ?? form.submissionAllowed;
const showOverview = submissionAllowedSpec !== SUBMISSION_ALLOWED.noWithoutOverview;
const showConfirmation = submissionAllowedSpec === SUBMISSION_ALLOWED.yes;
const submission = state.submission || state.submittedSubmission;
const hasSubmission = !!submission;

const applicableSteps = hasSubmission ? submission.steps.filter(step => 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 ? (
<ProgressIndicatorNew
progressIndicatorTitle="Progress"
formTitle={form.name}
steps={updatedSteps}
activeStepTitle={activeStepTitle}
/>
) : null;

Expand Down Expand Up @@ -366,7 +476,7 @@ const Form = () => {
return (
<FormDisplayComponent
router={router}
progressIndicator={progressIndicator}
progressIndicator={progressIndicatorNew}
showProgressIndicator={form.showProgressIndicator}
isPaymentOverview={!!paymentOverviewMatch}
/>
Expand Down
31 changes: 31 additions & 0 deletions src/components/ProgressIndicatorNew/CompletionMark.js
Original file line number Diff line number Diff line change
@@ -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 <FormattedMessage />
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 <span>, 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 (
<>
<FAIcon icon="check" modifiers={['small']} aria-label={ariaLabel} title={ariaLabel} />
<span className="sr-only">{ariaLabel}</span>
</>
);
};

CompletionMark.propTypes = {
completed: PropTypes.bool,
};

export default CompletionMark;
43 changes: 43 additions & 0 deletions src/components/ProgressIndicatorNew/MobileButton.js
Original file line number Diff line number Diff line change
@@ -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 (
<button
className={getBEMClassName('progress-indicator__mobile-header')}
aria-pressed={expanded ? 'true' : 'false'}
onClick={onExpandClick}
>
<FAIcon
icon={expanded ? 'chevron-up' : 'chevron-down'}
modifiers={['normal']}
aria-label={ariaIconLabel}
/>
<span
className={getBEMClassName('progress-indicator__form-title')}
aria-label={accessibleToggleStepsLabel}
>
{formTitle}
</span>
</button>
);
};

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;
76 changes: 76 additions & 0 deletions src/components/ProgressIndicatorNew/ProgressIndicatorItem.js
Original file line number Diff line number Diff line change
@@ -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 (
<div className={getBEMClassName('progress-indicator-item')}>
<div className={getBEMClassName('progress-indicator-item__marker')}>
<CompletionMark completed={isCompleted} />
</div>
<div className={getBEMClassName('progress-indicator-item__label')}>
{isApplicable && canNavigateTo ? (
<Link
to={href}
placeholder={!canNavigateTo}
modifiers={getLinkModifiers(isActive, isApplicable)}
aria-label={text}
>
<FormattedMessage
description="Step label in progress indicator"
defaultMessage={`
{isApplicable, select,
false {{label} (n/a)}
other {{label}}
}`}
values={{
label: fixedText || text,
isApplicable: isApplicable,
}}
/>
</Link>
) : (
<Body component="span" modifiers={isCompleted ? ['big'] : ['big', 'muted']}>
{fixedText || text}
</Body>
)}
</div>
</div>
);
};

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;
Original file line number Diff line number Diff line change
@@ -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 = {};
Loading

0 comments on commit e354b4b

Please sign in to comment.