diff --git a/.storybook/test-runner.js b/.storybook/test-runner.js new file mode 100644 index 000000000..58c7cd9ca --- /dev/null +++ b/.storybook/test-runner.js @@ -0,0 +1,27 @@ +const {getStoryContext} = require('@storybook/test-runner'); +const {MINIMAL_VIEWPORTS} = require('@storybook/addon-viewport'); + +const DEFAULT_VIEWPORT_SIZE = {width: 1280, height: 720}; + +module.exports = { + async preRender(page, story) { + const context = await getStoryContext(page, story); + const viewportName = context.parameters?.viewport?.defaultViewport; + const viewportParameter = MINIMAL_VIEWPORTS[viewportName]; + + if (viewportParameter) { + const viewportSize = Object.entries(viewportParameter.styles).reduce( + (acc, [screen, size]) => ({ + ...acc, + // make sure your viewport config in Storybook only uses numbers, not percentages + [screen]: parseInt(size), + }), + {} + ); + + page.setViewportSize(viewportSize); + } else { + page.setViewportSize(DEFAULT_VIEWPORT_SIZE); + } + }, +}; diff --git a/src/api-mocks/submissions.js b/src/api-mocks/submissions.js index 8858b7e53..1708aba32 100644 --- a/src/api-mocks/submissions.js +++ b/src/api-mocks/submissions.js @@ -27,6 +27,29 @@ const SUBMISSION_DETAILS = { }, }; +// mock for /api/v2/submissions/{submission_uuid}/steps/{step_uuid} +const SUBMISSION_STEP_DETAILS = { + id: '58aad9c3-29c7-4568-9047-3ac7ceb0f0ff', + slug: 'step-1', + formStep: { + index: 0, + configuration: { + components: [ + { + id: 'asdiwj', + type: 'textfield', + key: 'component1', + label: 'Component 1', + }, + ], + }, + }, + data: null, + isApplicable: true, + completed: false, + canSubmit: true, +}; + /** * Return a submission object as if it would be returned from the backend API. * @param {Object} overrides Key-value mapping with overrides from the defaults. These @@ -41,6 +64,25 @@ export const mockSubmissionPost = (submission = buildSubmission()) => return res(ctx.status(201), ctx.json(submission)); }); +export const mockSubmissionGet = () => + rest.get(`${BASE_URL}submissions/:uuid`, (req, res, ctx) => { + return res(ctx.status(200), ctx.json(SUBMISSION_DETAILS)); + }); + +export const mockSubmissionStepGet = () => + rest.get(`${BASE_URL}submissions/:uuid/steps/:uuid`, (req, res, ctx) => { + return res(ctx.status(200), ctx.json(SUBMISSION_STEP_DETAILS)); + }); + +export const mockSubmissionCheckLogicPost = () => + rest.post(`${BASE_URL}submissions/:uuid/steps/:uuid/_check_logic`, (req, res, ctx) => { + const responseData = { + submission: SUBMISSION_DETAILS, + step: SUBMISSION_STEP_DETAILS, + }; + return res(ctx.status(200), ctx.json(responseData)); + }); + /** * Simulate a succesful backend processing status without payment. */ diff --git a/src/components/Anchor/Anchor.mdx b/src/components/Anchor/Anchor.mdx index e568db0e7..120dce313 100644 --- a/src/components/Anchor/Anchor.mdx +++ b/src/components/Anchor/Anchor.mdx @@ -31,6 +31,7 @@ The standard look of the anchor component without modifiers. + ## Props diff --git a/src/components/Anchor/Anchor.stories.js b/src/components/Anchor/Anchor.stories.js index 4fdc55e89..d95889291 100644 --- a/src/components/Anchor/Anchor.stories.js +++ b/src/components/Anchor/Anchor.stories.js @@ -70,3 +70,11 @@ export const Inherit = { label: 'Inherit', }, }; + +export const Placeholder = { + render, + args: { + label: 'placeholder', + placeholder: true, + }, +}; diff --git a/src/components/App.stories.js b/src/components/App.stories.js index 6fa3b353b..5c14ab79d 100644 --- a/src/components/App.stories.js +++ b/src/components/App.stories.js @@ -1,13 +1,21 @@ import {expect} from '@storybook/jest'; -import {waitForElementToBeRemoved, within} from '@storybook/testing-library'; +import {userEvent, waitForElementToBeRemoved, within} from '@storybook/testing-library'; import {RouterProvider, createMemoryRouter} from 'react-router-dom'; import {FormContext} from 'Context'; +import {BASE_URL} from 'api-mocks'; import {buildForm} from 'api-mocks'; +import { + mockSubmissionCheckLogicPost, + mockSubmissionGet, + mockSubmissionPost, + mockSubmissionStepGet, +} from 'api-mocks/submissions'; import {mockLanguageChoicePut, mockLanguageInfoGet} from 'components/LanguageSelection/mocks'; import {ConfigDecorator, LayoutDecorator} from 'story-utils/decorators'; import App, {routes as nestedRoutes} from './App'; +import {SUBMISSION_ALLOWED} from './constants'; export default { title: 'Private API / App', @@ -15,10 +23,50 @@ export default { decorators: [LayoutDecorator, ConfigDecorator], args: { 'form.translationEnabled': true, + submissionAllowed: SUBMISSION_ALLOWED.yes, + hideNonApplicableSteps: false, + steps: [ + { + uuid: '9e6eb3c5-e5a4-4abf-b64a-73d3243f2bf5', + slug: 'step-1', + formDefinition: 'Step 1', + index: 0, + literals: { + previousText: {resolved: 'Previous', value: ''}, + saveText: {resolved: 'Save', value: ''}, + nextText: {resolved: 'Next', value: ''}, + }, + url: `${BASE_URL}forms/mock/steps/9e6eb3c5-e5a4-4abf-b64a-73d3243f2bf5`, + isApplicable: true, + completed: false, + }, + { + uuid: '98980oi8-e5a4-4abf-b64a-76j3j3ki897', + slug: 'step-2', + formDefinition: 'Step 2', + index: 0, + literals: { + previousText: {resolved: 'Previous', value: ''}, + saveText: {resolved: 'Save', value: ''}, + nextText: {resolved: 'Next', value: ''}, + }, + url: `${BASE_URL}forms/mock/steps/98980oi8-e5a4-4abf-b64a-76j3j3ki897`, + isApplicable: false, + completed: false, + }, + ], }, argTypes: { form: {table: {disable: true}}, noDebug: {table: {disable: true}}, + submissionAllowed: { + options: Object.values(SUBMISSION_ALLOWED), + control: {type: 'radio'}, + 'submission.submissionAllowed': { + options: Object.values(SUBMISSION_ALLOWED), + control: {type: 'radio'}, + }, + }, }, parameters: { msw: { @@ -56,6 +104,9 @@ const render = args => { const form = buildForm({ translationEnabled: args['form.translationEnabled'], explanationTemplate: '

Toelichtingssjabloon...

', + submissionAllowed: args['submissionAllowed'], + hideNonApplicableSteps: args['hideNonApplicableSteps'], + steps: args['steps'], }); return ; }; @@ -92,3 +143,61 @@ export const TranslationDisabled = { await expect(langSelector).toBeNull(); }, }; + +export const ActiveSubmission = { + name: 'Active submission', + render, + decorators: [ + // remove the window.localStorage entry, UUID value is from `api-mocks/forms.js`. + // it gets set because of the play function which starts a submission. + Story => { + const key = 'e450890a-4166-410e-8d64-0a54ad30ba01'; + window.localStorage.removeItem(key); + return ; + }, + ], + args: { + steps: [ + { + uuid: '9e6eb3c5-e5a4-4abf-b64a-73d3243f2bf5', + slug: 'step-1', + formDefinition: 'Step 1', + index: 0, + literals: { + previousText: {resolved: 'Previous', value: ''}, + saveText: {resolved: 'Save', value: ''}, + nextText: {resolved: 'Next', value: ''}, + }, + url: `${BASE_URL}forms/mock/steps/9e6eb3c5-e5a4-4abf-b64a-73d3243f2bf5`, + isApplicable: true, + completed: false, + }, + ], + }, + argTypes: { + hideNonApplicableSteps: {table: {disable: true}}, + submissionAllowed: {table: {disable: true}}, + }, + parameters: { + msw: { + handlers: [ + mockSubmissionPost(), + mockSubmissionGet(), + mockSubmissionStepGet(), + mockSubmissionCheckLogicPost(), + mockLanguageInfoGet([ + {code: 'nl', name: 'Nederlands'}, + {code: 'en', name: 'English'}, + ]), + mockLanguageChoicePut, + ], + }, + }, + + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + + const beginButton = await canvas.findByRole('button', {name: 'Begin'}); + await userEvent.click(beginButton); + }, +}; diff --git a/src/components/Form.js b/src/components/Form.js index ef46d593d..73f9221b8 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'; @@ -27,6 +27,9 @@ import useQuery from 'hooks/useQuery'; import useRecycleSubmission from 'hooks/useRecycleSubmission'; import useSessionTimeout from 'hooks/useSessionTimeout'; +import {addFixedSteps, getStepsInfo} from './ProgressIndicator/utils'; +import {PI_TITLE, STEP_LABELS, SUBMISSION_ALLOWED} from './constants'; + const initialState = { submission: null, submittedSubmission: null, @@ -97,6 +100,12 @@ const Form = () => { usePageViews(); const intl = useIntl(); const prevLocale = usePrevious(intl.locale); + const {pathname: currentPathname} = useLocation(); + + // TODO replace absolute path check with relative + const stepMatch = useMatch('/stap/:step'); + const summaryMatch = useMatch('/overzicht'); + const confirmationMatch = useMatch('/bevestiging'); // extract the declared properties and configuration const {steps} = form; @@ -260,14 +269,72 @@ const Form = () => { return ; } + // Progress Indicator + + const isStartPage = !summaryMatch && stepMatch == null && !confirmationMatch; + 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 isCompleted = state.completed; + const formName = form.name; + + // 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 = intl.formatMessage(STEP_LABELS.login); + } else if (summaryMatch) { + activeStepTitle = intl.formatMessage(STEP_LABELS.overview); + } else if (confirmationMatch) { + activeStepTitle = intl.formatMessage(STEP_LABELS.confirmation); + } else { + const step = 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} + ); + + let applicableSteps = []; + if (form.hideNonApplicableSteps) { + applicableSteps = steps.filter(step => step.isApplicable); + } + + const updatedSteps = getStepsInfo( + applicableSteps.length > 0 ? applicableSteps : form.steps, + submission, + currentPathname + ); + const stepsToRender = addFixedSteps( + intl, + updatedSteps, + submission, + currentPathname, + showOverview, + showConfirmation, + isCompleted + ); + const progressIndicator = form.showProgressIndicator ? ( ) : null; diff --git a/src/components/Link.js b/src/components/Link.js index 9931b5578..6486e2d25 100644 --- a/src/components/Link.js +++ b/src/components/Link.js @@ -6,28 +6,35 @@ import Anchor from 'components/Anchor'; /** * Custom Link component using the design system component, replacing react-router's Link. */ -const Link = React.forwardRef(({onClick, replace = false, state, target, to, ...rest}, ref) => { - const href = useHref(to); - const handleClick = useLinkClickHandler(to, { - replace, - state, - target, - }); - return ( - { - onClick?.(event); - if (!event.defaultPrevented) { - handleClick(event); - } - }} - ref={ref} - target={target} - /> - ); -}); +const Link = React.forwardRef( + ({onClick, placeholder, replace = false, state, target, to, ...rest}, ref) => { + const href = useHref(to); + const handleClick = useLinkClickHandler(to, { + replace, + state, + target, + }); + return ( + { + if (placeholder) { + event.preventDefault(); + return; + } + onClick?.(event); + if (!event.defaultPrevented) { + handleClick(event); + } + }} + ref={ref} + target={target} + placeholder={placeholder} + /> + ); + } +); // Prop types deliberately unspecified, please use the typescript definitions of // react-router-dom instead. diff --git a/src/components/ProgressIndicator/MobileButton.js b/src/components/ProgressIndicator/MobileButton.js new file mode 100644 index 000000000..13a0909bf --- /dev/null +++ b/src/components/ProgressIndicator/MobileButton.js @@ -0,0 +1,42 @@ +import PropTypes from 'prop-types'; + +import FAIcon from 'components/FAIcon'; +import {getBEMClassName} from 'utils'; + +const MobileButton = ({ + ariaMobileIconLabel, + accessibleToggleStepsLabel, + formTitle, + expanded, + onExpandClick, +}) => { + return ( + + ); +}; + +MobileButton.propTypes = { + ariaMobileIconLabel: PropTypes.string.isRequired, + accessibleToggleStepsLabel: PropTypes.string.isRequired, + formTitle: PropTypes.string.isRequired, + expanded: PropTypes.bool, + onExpandClick: PropTypes.func.isRequired, +}; + +export default MobileButton; diff --git a/src/components/ProgressIndicator/ProgressIndicator.mdx b/src/components/ProgressIndicator/ProgressIndicator.mdx index 6741c2222..232ee43d3 100644 --- a/src/components/ProgressIndicator/ProgressIndicator.mdx +++ b/src/components/ProgressIndicator/ProgressIndicator.mdx @@ -2,7 +2,6 @@ import {ArgTypes, Canvas, Meta, Story} from '@storybook/blocks'; import ProgressIndicator from '.'; import * as ProgressIndicatorStories from './ProgressIndicator.stories'; -import ProgressIndicatorDisplay from './ProgressIndicatorDisplay'; @@ -12,24 +11,34 @@ The progress indicator displays the progression through a particular form's step ## Presentation -The `ProgressIndicatorDisplay` component shows the different steps in a form. The style of each step -varies depending on whether it is applicable, it is completed or it is the currently active step. +The `ProgressIndicator` component shows the different steps (`ProgressIndicatorItem`) in a form. The +style of each step varies depending on whether it is applicable, it is completed or it is the +currently active step. - +**Warning** -### Props - - +Since SDK 2.1 we've restructured the `ProgressIndicator` component - swapping out an alternative +component now requires you to specify `progressIndicator` rather than `progressIndicatorDisplay`. +The interface has also changed. ## Functional -The `ProgressIndicator` component handles the 'smart' behaviour. It checks: +The parent component must handle all the 'smart' behaviour such as constructing the list of items +each with their individual states. The `Form` component is an example of this behaviour, it checks: - If the steps are currently active, completed, applicable or if a step can be clicked to navigate to it. - If the current page is the start page, the summary page or the confirmation page. - If all the applicable steps have been completed. +The `ProgressIndicator` component passes through all the individual steps (dynamic and fixed) to the +`ProgressIndicatorItem` component and checks: + +- If the container should be shown for a desktop or a mobile version. + +The `ProgressIndicatorItem` component renders all the steps as links or as simple texts depending on +the props. + It is responsible for rendering the `ProgressIndicatorDisplay` component. The current step is detected through the URL route match, based on the `slug` of an individual step. @@ -39,15 +48,11 @@ passed as prop through the component. Note that a particular step may become not-applicable while filling out the form because of certain logic rules. The details of each step's status are available in `submission.steps[@index]`. - - ## Form start On initial load, no submission is available yet, and the overview is rendered based on the form rather than the submission. - - ## Props diff --git a/src/components/ProgressIndicator/ProgressIndicator.stories.js b/src/components/ProgressIndicator/ProgressIndicator.stories.js index 4d23b5321..ce780c26b 100644 --- a/src/components/ProgressIndicator/ProgressIndicator.stories.js +++ b/src/components/ProgressIndicator/ProgressIndicator.stories.js @@ -1,203 +1,85 @@ -import {useArgs} from '@storybook/client-api'; -import omit from 'lodash/omit'; +import {MINIMAL_VIEWPORTS} from '@storybook/addon-viewport'; +import {expect} from '@storybook/jest'; +import {userEvent, within} from '@storybook/testing-library'; import {withRouter} from 'storybook-addon-react-router-v6'; -import {buildSubmission} from 'api-mocks'; -import {SUBMISSION_ALLOWED} from 'components/constants'; - import ProgressIndicator from '.'; -import ProgressIndicatorDisplay from './ProgressIndicatorDisplay'; export default { - title: 'Composites / ProgressIndicator', + title: 'Private API / ProgressIndicator', component: ProgressIndicator, decorators: [withRouter], - argTypes: { - submissionAllowed: { - options: Object.values(SUBMISSION_ALLOWED), - control: {type: 'radio'}, - }, - 'submission.submissionAllowed': { - options: Object.values(SUBMISSION_ALLOWED), - control: {type: 'radio'}, - }, - submission: {table: {disable: true}}, - }, -}; - -export const Display = { - render: args => { - const [_, updateArgs] = useArgs(); - return ( - updateArgs({...args, expanded: !args.expanded})} - {...args} - /> - ); - }, args: { - activeStepTitle: 'Stap 2', - formTitle: 'Storybookformulier', + title: 'Progress', + formTitle: 'Formulier', steps: [ { - uuid: '111', - slug: 'stap1', - formDefinition: 'Stap 1', + to: 'start-page', + label: 'Start page', + isCompleted: true, + isApplicable: true, + isCurrent: false, + canNavigateTo: true, + }, + { + to: 'first-step', + label: 'Stap 1', isCompleted: true, isApplicable: true, isCurrent: false, canNavigateTo: true, }, { - uuid: '222', - slug: 'stap2', - formDefinition: 'Stap 2', + to: 'second-step', + label: 'Stap 2', isCompleted: false, isApplicable: true, isCurrent: true, canNavigateTo: true, }, { - uuid: '333', - slug: 'stap3', - formDefinition: 'Stap 3', + to: 'summary-page', + label: 'Summary', isCompleted: false, - isApplicable: false, + isApplicable: true, isCurrent: false, canNavigateTo: false, }, - ], - hasSubmission: true, - isStartPage: false, - isSummary: false, - isConfirmation: false, - isSubmissionComplete: false, - areApplicableStepsCompleted: false, - showOverview: true, - showConfirmation: false, - expanded: false, - }, - argTypes: { - title: {table: {disable: true}}, - 'submission.submissionAllowed': {table: {disable: true}}, - submissionAllowed: {table: {disable: true}}, - completed: {table: {disable: true}}, - }, -}; - -const render = ({ - title, - submissionAllowed, - completed, - steps = [], - hideNonApplicableSteps = false, - withoutSubmission = false, - ...args -}) => { - const _submission = buildSubmission({ - submissionAllowed: args['submission.submissionAllowed'], - steps, - payment: { - isRequired: false, - hasPaid: false, - }, - }); - return ( - omit(step, ['completed', 'isApplicable']))} - submissionAllowed={submissionAllowed} - completed={completed} - hideNonApplicableSteps={hideNonApplicableSteps} - /> - ); -}; - -export const ActiveSubmission = { - name: 'Active submission', - render, - args: { - // story args - steps: [ { - url: 'https://backend/api/v1/form/9d49e773/steps/d6cab0dd', - uuid: 'd6cab0dd', - index: 0, - slug: 'first-step', - formDefinition: 'Stap 1', + to: 'confirmation-page', + label: 'Confirmation', + isCompleted: false, isApplicable: true, - completed: true, - }, - { - url: 'https://backend/api/v1/form/9d49e773/steps/8e62d7cf', - uuid: '8e62d7cf', - index: 1, - slug: 'second-step', - formDefinition: 'Stap 2', - isApplicable: false, - completed: false, + isCurrent: false, + canNavigateTo: false, }, ], - 'submission.submissionAllowed': SUBMISSION_ALLOWED.yes, - // component props - title: 'Storybookformulier', - submissionAllowed: SUBMISSION_ALLOWED.yes, - completed: false, - hideNonApplicableSteps: false, - }, - argTypes: { - submissionAllowed: {table: {disable: true}}, + ariaMobileIconLabel: 'Progress step indicator toggle icon (mobile)', + accessibleToggleStepsLabel: 'Current step in form Formulier: Stap 2', }, parameters: { - reactRouter: { - routePath: '/stap/:step', - routeParams: {step: 'first-step'}, + viewport: { + viewports: MINIMAL_VIEWPORTS, }, }, }; -export const InitialFormLoad = { - name: 'Initial form load', - render, - args: { - // story args - steps: [ - { - url: 'https://backend/api/v1/form/9d49e773/steps/d6cab0dd', - uuid: 'd6cab0dd', - index: 0, - slug: 'first-step', - formDefinition: 'Stap 1', - isApplicable: true, - completed: false, - }, - { - url: 'https://backend/api/v1/form/9d49e773/steps/8e62d7cf', - uuid: '8e62d7cf', - index: 1, - slug: 'second-step', - formDefinition: 'Stap 2', - isApplicable: true, - completed: false, - }, - ], - 'submission.submissionAllowed': SUBMISSION_ALLOWED.yes, - withoutSubmission: true, - // component props - title: 'Initial load', - submissionAllowed: SUBMISSION_ALLOWED.yes, - completed: false, - }, - argTypes: { - 'submission.submissionAllowed': {table: {disable: true}}, - withoutSubmission: {table: {disable: true}}, - completed: {table: {disable: true}}, - hideNonApplicableSteps: {table: {disable: true}}, - }, +export const Default = {}; + +export const MobileViewport = { + name: 'Mobile version', parameters: { - reactRouter: { - routePath: '/startpagina', + // TODO enable viewport in chromatic + chromatic: {disableSnapshot: true}, + viewport: { + defaultViewport: 'mobile1', }, }, + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + + const toggleButton = await canvas.findByRole('button'); + await expect(toggleButton).toHaveAttribute('aria-pressed', 'false'); + await userEvent.click(toggleButton); + }, }; diff --git a/src/components/ProgressIndicator/ProgressIndicatorDisplay.js b/src/components/ProgressIndicator/ProgressIndicatorDisplay.js deleted file mode 100644 index 53123177d..000000000 --- a/src/components/ProgressIndicator/ProgressIndicatorDisplay.js +++ /dev/null @@ -1,234 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import {FormattedMessage, useIntl} from 'react-intl'; - -import Anchor from 'components/Anchor'; -import Body from 'components/Body'; -import Caption from 'components/Caption'; -import Card from 'components/Card'; -import FAIcon from 'components/FAIcon'; -import Link from 'components/Link'; -import List from 'components/List'; -import {getBEMClassName} from 'utils'; - -import ProgressItem from './ProgressItem'; -import {STEP_LABELS} from './constants'; - -const getLinkModifiers = (active, isApplicable) => { - return [ - 'inherit', - 'hover', - active ? 'active' : undefined, - isApplicable ? undefined : 'muted', - ].filter(mod => mod !== undefined); -}; - -const LinkOrSpan = ({isActive, isApplicable, to, useLink, children, ...props}) => { - if (useLink) { - return ( - - {children} - - ); - } - - return ( - - {children} - - ); -}; - -LinkOrSpan.propTypes = { - to: PropTypes.string.isRequired, - useLink: PropTypes.bool.isRequired, - isActive: PropTypes.bool.isRequired, - isApplicable: PropTypes.bool.isRequired, -}; - -const SidebarStepStatus = ({ - to, - formDefinition, - canNavigate, - isCurrent, - isApplicable = false, - completed = false, -}) => { - return ( - - - - - - ); -}; - -SidebarStepStatus.propTypes = { - to: PropTypes.string.isRequired, - formDefinition: PropTypes.string.isRequired, - isCurrent: PropTypes.bool.isRequired, - completed: PropTypes.bool, - canNavigate: PropTypes.bool, - isApplicable: PropTypes.bool, -}; - -const ProgressIndicatorDisplay = ({ - activeStepTitle, - formTitle, - steps, - hideNonApplicableSteps, - hasSubmission, - isStartPage, - isSummary, - isConfirmation, - isSubmissionComplete, - areApplicableStepsCompleted, - showOverview, - showConfirmation, - expanded = false, - onExpandClick, - sticky = true, - summaryTo = '/overzicht', -}) => { - const intl = useIntl(); - // aria-labels are passed to DOM element, which can't handle , so we - // use imperative API - if (hideNonApplicableSteps) { - steps = steps.filter(step => step.isApplicable); - } - 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 (sticky) { - modifiers.push('sticky'); - } - if (!expanded) { - modifiers.push('mobile-collapsed'); - } - return ( - - - - ); -}; - -// TODO: clean up props to be more generic and apply mapping in call-sites -ProgressIndicatorDisplay.propTypes = { - activeStepTitle: PropTypes.node, - formTitle: PropTypes.string, - steps: PropTypes.arrayOf( - PropTypes.shape({ - uuid: PropTypes.string.isRequired, - slug: PropTypes.string, - to: PropTypes.string, - formDefinition: PropTypes.string, - isCompleted: PropTypes.bool, - isApplicable: PropTypes.bool, - isCurrent: PropTypes.bool, - canNavigateTo: PropTypes.bool, - }) - ), - hasSubmission: PropTypes.bool, - isStartPage: PropTypes.bool, - isSummary: PropTypes.bool, - isConfirmation: PropTypes.bool, - isSubmissionComplete: PropTypes.bool, - areApplicableStepsCompleted: PropTypes.bool, - showOverview: PropTypes.bool, - showConfirmation: PropTypes.bool, - expanded: PropTypes.bool, - onExpandClick: PropTypes.func.isRequired, - sticky: PropTypes.bool, - hideNonApplicableSteps: PropTypes.bool, - summaryTo: PropTypes.string, -}; - -export default ProgressIndicatorDisplay; diff --git a/src/components/ProgressIndicator/ProgressIndicatorItem.js b/src/components/ProgressIndicator/ProgressIndicatorItem.js new file mode 100644 index 000000000..886649fc6 --- /dev/null +++ b/src/components/ProgressIndicator/ProgressIndicatorItem.js @@ -0,0 +1,73 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import Link from 'components/Link'; +import {getBEMClassName} from 'utils'; + +import CompletionMark from './CompletionMark'; + +const getLinkModifiers = (isActive, isApplicable, isCompleted) => { + return ['inherit', 'hover', isActive ? 'active' : undefined].filter(mod => mod !== undefined); +}; + +/** + * A single progress indicator item. + * + * Displays a link (which *may* be a placeholder depending on the `canNavigateTo` prop) + * to the specified route with the provided label. + * + * If the item is not applicable, the label is suffixed to mark it as such. Depending on + * server-side configuration, the item may be hidden alltogether (rather than showing it + * with the suffix). + * + * Once a step is completed, it is displayed with a completion checkmark in front of it. + */ +export const ProgressIndicatorItem = ({ + label, + to, + isActive, + isCompleted, + canNavigateTo, + isApplicable, +}) => { + return ( +
+
+ +
+
+ + + +
+
+ ); +}; + +ProgressIndicatorItem.propTypes = { + label: PropTypes.node.isRequired, + to: PropTypes.string, + isActive: PropTypes.bool, + isCompleted: PropTypes.bool, + canNavigateTo: PropTypes.bool, + isApplicable: PropTypes.bool, +}; + +export default ProgressIndicatorItem; diff --git a/src/components/ProgressIndicator/ProgressIndicatorItem.stories.js b/src/components/ProgressIndicator/ProgressIndicatorItem.stories.js new file mode 100644 index 000000000..b776e532d --- /dev/null +++ b/src/components/ProgressIndicator/ProgressIndicatorItem.stories.js @@ -0,0 +1,117 @@ +import {withRouter} from 'storybook-addon-react-router-v6'; + +import {ProgressIndicatorItem} from './ProgressIndicatorItem'; + +export default { + title: 'Private API / ProgressIndicator / ProgressIndicatorItem', + component: ProgressIndicatorItem, + decorators: [withRouter], + args: { + label: 'Stap 1', + to: '/dummy', + isActive: false, + isCompleted: true, + canNavigateTo: true, + isApplicable: true, + }, + tags: ['autodocs'], +}; + +export const Default = {}; + +/** + * The step is applicable, completed but it is not the currently active step. + */ +export const ApplicableNavigableCompletedNotActive = { + name: 'Applicable, navigable, completed, not active', + args: { + label: 'Stap 1', + to: '/dummy', + isActive: false, + isCompleted: true, + canNavigateTo: true, + isApplicable: true, + }, +}; + +/** + * The step is applicable, completed and currently active/shown in the main body. + */ +export const ApplicableNavigableCompletedActive = { + name: 'Applicable, navigable, completed, active', + args: { + label: 'Stap 1', + to: '/dummy', + isActive: true, + isCompleted: true, + canNavigateTo: true, + isApplicable: true, + }, +}; + +/** + * A step that is not completed yet, but is applicable and can be navigated to. + * + * E.g. if you are currently on step 2 making changes, and step 3 is unlocked - this + * can happen when you navigate back from step 3 to step 2. + */ +export const ApplicableNavigableNotCompletedNotActive = { + name: 'Applicable, navigable, not completed, not active', + args: { + label: 'Stap 1', + to: '/dummy', + isActive: false, + isCompleted: false, + canNavigateTo: true, + isApplicable: true, + }, +}; + +/** + * An active, uncompleted step. The step itself is displayed in the main body. + * + * This is the state when you submit step x and it takes you to step x+1. + */ +export const ApplicableNavigableNotCompletedActive = { + name: 'Applicable, navigable, not completed, active', + args: { + label: 'Stap 1', + to: '/dummy', + isActive: true, + isCompleted: false, + canNavigateTo: true, + isApplicable: true, + }, +}; + +/** + * A step that is relevant but not available yet, e.g. because the previous step needs + * to be completed first. + */ +export const ApplicableNotNavigable = { + name: 'Applicable, not navigable', + args: { + label: 'Stap 1', + to: '/dummy', + isActive: false, + isCompleted: false, + canNavigateTo: false, + isApplicable: true, + }, +}; + +/** + * A step that is not relevant (due to logic, for example). It can be a past or future + * step. + */ +export const NotApplicableNotNavigable = { + name: 'Not applicable, not navigable', + args: { + label: 'Stap 1', + to: '/dummy', + isActive: false, + isCompleted: false, + canNavigateTo: false, + isApplicable: false, + }, +}; diff --git a/src/components/ProgressIndicator/ProgressItem.js b/src/components/ProgressIndicator/ProgressItem.js deleted file mode 100644 index 73f85d33b..000000000 --- a/src/components/ProgressIndicator/ProgressItem.js +++ /dev/null @@ -1,25 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; - -import {getBEMClassName} from 'utils'; - -import CompletionMark from './CompletionMark'; - -const ProgressItem = ({completed, children}) => { - return ( -
-
- -
- -
{children}
-
- ); -}; - -ProgressItem.propTypes = { - completed: PropTypes.bool.isRequired, - children: PropTypes.node, -}; - -export default ProgressItem; diff --git a/src/components/ProgressIndicator/constants.js b/src/components/ProgressIndicator/constants.js deleted file mode 100644 index ee81dac25..000000000 --- a/src/components/ProgressIndicator/constants.js +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; -import {FormattedMessage} from 'react-intl'; - -const STEP_LABELS = { - login: , - overview: , - confirmation: ( - - ), -}; - -export {STEP_LABELS}; diff --git a/src/components/ProgressIndicator/index.js b/src/components/ProgressIndicator/index.js index 9bd4254f4..773f2dc6b 100644 --- a/src/components/ProgressIndicator/index.js +++ b/src/components/ProgressIndicator/index.js @@ -1,31 +1,29 @@ import PropTypes from 'prop-types'; -import React, {useContext, useEffect, useState} from 'react'; -import {useLocation, useMatch} from 'react-router-dom'; +import React, {useEffect, useState} from 'react'; +import {useLocation} from 'react-router-dom'; -import {ConfigContext} from 'Context'; -import {SUBMISSION_ALLOWED} from 'components/constants'; -import {IsFormDesigner} from 'headers'; -import Types from 'types'; +import Caption from 'components/Caption'; +import Card from 'components/Card'; +import List from 'components/List'; -import ProgressIndicatorDisplay from './ProgressIndicatorDisplay'; -import {STEP_LABELS} from './constants'; +import MobileButton from './MobileButton'; +import ProgressIndicatorItem from './ProgressIndicatorItem'; const ProgressIndicator = ({ title, - submission = null, + formTitle, steps, - submissionAllowed, - completed = false, - hideNonApplicableSteps = false, + ariaMobileIconLabel, + accessibleToggleStepsLabel, }) => { - const {pathname} = useLocation(); - const config = useContext(ConfigContext); - const summaryMatch = !!useMatch('/overzicht'); - const stepMatch = useMatch('/stap/:step'); - const confirmationMatch = !!useMatch('/bevestiging'); - const isStartPage = !summaryMatch && stepMatch == null && !confirmationMatch; + const {pathname: currentPathname} = useLocation(); const [expanded, setExpanded] = useState(false); + 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. @@ -34,109 +32,52 @@ const ProgressIndicator = ({ setExpanded(false); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pathname]); - - // figure out the slug from the currently active step IF we're looking at a step - const stepSlug = stepMatch ? stepMatch.params.step : ''; - 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 title for the mobile menu based on the state - let activeStepTitle; - if (isStartPage) { - activeStepTitle = STEP_LABELS.login; - } else if (summaryMatch) { - activeStepTitle = STEP_LABELS.overview; - } else if (confirmationMatch) { - 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; - }; - - const getStepsInfo = steps => { - return steps.map((step, index) => ({ - uuid: step.uuid, - slug: step.slug, - to: step.href || `/stap/${step.slug}`, - formDefinition: step.formDefinition, - isCompleted: submission ? submission.steps[index].completed : false, - isApplicable: submission ? submission.steps[index].isApplicable : step.isApplicable ?? true, - isCurrent: step.slug === stepSlug, - canNavigateTo: canNavigateToStep(index), - })); - }; - - // try to get the value from the submission if provided, otherwise - const submissionAllowedSpec = submission?.submissionAllowed ?? submissionAllowed; - const showOverview = submissionAllowedSpec !== SUBMISSION_ALLOWED.noWithoutOverview; - const showConfirmation = submissionAllowedSpec === SUBMISSION_ALLOWED.yes; - - const ProgressIndicatorDisplayComponent = - config?.displayComponents?.progressIndicator ?? ProgressIndicatorDisplay; + }, [currentPathname]); return ( - setExpanded(!expanded)} - /> + + + ); }; ProgressIndicator.propTypes = { - title: PropTypes.string, - submission: Types.Submission, + title: PropTypes.node.isRequired, + formTitle: PropTypes.string.isRequired, steps: PropTypes.arrayOf( PropTypes.shape({ - url: PropTypes.string.isRequired, - uuid: PropTypes.string.isRequired, - index: PropTypes.number.isRequired, - slug: PropTypes.string.isRequired, - href: PropTypes.string, - formDefinition: PropTypes.string.isRequired, + to: PropTypes.string.isRequired, + label: PropTypes.node.isRequired, + isCompleted: PropTypes.bool, isApplicable: PropTypes.bool, + isCurrent: PropTypes.bool, + canNavigateTo: PropTypes.bool, }) ).isRequired, - submissionAllowed: PropTypes.oneOf(Object.values(SUBMISSION_ALLOWED)).isRequired, - completed: PropTypes.bool, - hideNonApplicableSteps: PropTypes.bool, + ariaMobileIconLabel: PropTypes.string.isRequired, + accessibleToggleStepsLabel: PropTypes.string.isRequired, }; export default ProgressIndicator; diff --git a/src/components/ProgressIndicator/progressIndicator.spec.js b/src/components/ProgressIndicator/progressIndicator.spec.js new file mode 100644 index 000000000..04912c2b8 --- /dev/null +++ b/src/components/ProgressIndicator/progressIndicator.spec.js @@ -0,0 +1,96 @@ +import {render, screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import messagesEN from 'i18n/compiled/en.json'; +import {IntlProvider} from 'react-intl'; +import {RouterProvider, createMemoryRouter} from 'react-router-dom'; + +import {ConfigContext, FormContext} from 'Context'; +import {BASE_URL, buildForm} from 'api-mocks'; +import mswServer from 'api-mocks/msw-server'; +import {buildSubmission, mockSubmissionPost} from 'api-mocks/submissions'; +import App, {routes as nestedRoutes} from 'components/App'; + +const routes = [ + { + path: '*', + element: , + children: nestedRoutes, + }, +]; + +const renderApp = (initialRoute = '/') => { + const form = buildForm(); + const router = createMemoryRouter(routes, { + initialEntries: [initialRoute], + initialIndex: [0], + }); + render( + + + + + + + + ); +}; + +beforeEach(() => { + sessionStorage.clear(); + localStorage.clear(); +}); + +afterEach(() => { + sessionStorage.clear(); + localStorage.clear(); +}); + +describe('The progress indicator component', () => { + it('displays the available submission/form steps and hardcoded steps', async () => { + mswServer.use(mockSubmissionPost(buildSubmission())); + const user = userEvent.setup({delay: null}); + + renderApp(); + + const startFormLink = await screen.findByRole('link', {name: 'Start page'}); + user.click(startFormLink); + + const progressIndicator = await screen.findByText('Progress'); + expect(progressIndicator).toBeVisible(); + + const startPageItem = await screen.findByText('Start page'); + expect(startPageItem).toBeVisible(); + const stepPageItem = await screen.findByText('Step 1'); + expect(stepPageItem).toBeVisible(); + const summaryPageItem = await screen.findByText('Summary'); + expect(summaryPageItem).toBeVisible(); + const confirmationPageItem = await screen.findByText('Confirmation'); + expect(confirmationPageItem).toBeVisible(); + }); + + it('renders steps in the correct order', async () => { + mswServer.use(mockSubmissionPost(buildSubmission())); + const user = userEvent.setup({delay: null}); + + renderApp(); + + const startFormLink = await screen.findByRole('link', {name: 'Start page'}); + user.click(startFormLink); + + const progressIndicatorSteps = screen.getAllByRole('listitem'); + + expect(progressIndicatorSteps[0]).toHaveTextContent('Start page'); + expect(progressIndicatorSteps[1]).toHaveTextContent('Step 1'); + expect(progressIndicatorSteps[2]).toHaveTextContent('Summary'); + expect(progressIndicatorSteps[3]).toHaveTextContent('Confirmation'); + }); +}); diff --git a/src/components/ProgressIndicator/test.spec.js b/src/components/ProgressIndicator/test.spec.js deleted file mode 100644 index 6c32bb671..000000000 --- a/src/components/ProgressIndicator/test.spec.js +++ /dev/null @@ -1,399 +0,0 @@ -import {screen} from '@testing-library/react'; -import messagesNL from 'i18n/compiled/nl.json'; -import React from 'react'; -import {createRoot} from 'react-dom/client'; -import {act} from 'react-dom/test-utils'; -import {IntlProvider} from 'react-intl'; -import {MemoryRouter} from 'react-router-dom'; - -import {SUBMISSION_ALLOWED} from 'components/constants'; -import {IsFormDesigner} from 'headers'; - -import ProgressIndicator from './index'; - -jest.mock('headers'); - -let container = null; -let root = null; -beforeEach(() => { - // setup a DOM element as a render target - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); -}); - -afterEach(() => { - // cleanup on exiting - act(() => { - root.unmount(); - container.remove(); - root = null; - container = null; - }); -}); - -const submissionDefaults = { - id: 'some-id', - url: 'https://some-url', - form: 'https://some-form', - steps: [], - payment: { - isRequired: false, - amount: '', - hasPaid: false, - }, -}; - -it('Progress Indicator submission allowed', () => { - act(() => { - root.render( - - - - - - ); - }); - - const progressIndicatorSteps = container.getElementsByTagName('ol')[0]; - expect(progressIndicatorSteps.textContent).toContain('Startpagina'); - expect(progressIndicatorSteps.textContent).toContain('Overzicht'); - expect(progressIndicatorSteps.textContent).toContain('Bevestiging'); -}); - -it('Progress Indicator submission not allowed, with overview page', () => { - act(() => { - root.render( - - - - - - ); - }); - - const progressIndicatorSteps = container.getElementsByTagName('ol')[0]; - expect(progressIndicatorSteps.textContent).toContain('Startpagina'); - expect(progressIndicatorSteps.textContent).toContain('Overzicht'); - expect(progressIndicatorSteps.textContent).not.toContain('Bevestiging'); -}); - -it('Progress Indicator submission not allowed, without overview page', () => { - act(() => { - root.render( - - - - - - ); - }); - - const progressIndicatorSteps = container.getElementsByTagName('ol')[0]; - expect(progressIndicatorSteps.textContent).toContain('Startpagina'); - expect(progressIndicatorSteps.textContent).not.toContain('Overzicht'); - expect(progressIndicatorSteps.textContent).not.toContain('Bevestiging'); -}); - -it('Form landing page, no submission present in session', () => { - act(() => { - root.render( - - - - - - ); - }); - - const progressIndicatorSteps = container.getElementsByTagName('ol')[0]; - expect(progressIndicatorSteps.textContent).toContain('Startpagina'); - expect(progressIndicatorSteps.textContent).toContain('Overzicht'); - expect(progressIndicatorSteps.textContent).toContain('Bevestiging'); -}); - -it('Progress indicator does NOT let you navigate between steps if you are NOT a form designer', async () => { - const steps = [ - { - slug: 'step-1', - formDefinition: 'Step 1', - index: 0, - isApplicable: true, - url: 'http://test.nl/api/v1/forms/111/steps/111', - uuid: '111', - completed: true, - }, - { - slug: 'step-2', - formDefinition: 'Step 2', - index: 1, - isApplicable: true, - url: 'http://test.nl/api/v1/forms/111/steps/222', - uuid: '222', - completed: false, - }, - { - slug: 'step-3', - formDefinition: 'Step 3', - index: 2, - isApplicable: true, - url: 'http://test.nl/api/v1/forms/111/steps/333', - uuid: '333', - completed: false, - }, - ]; - - IsFormDesigner.getValue.mockReturnValue(false); - - act(() => { - root.render( - - - - - - ); - }); - - expect(await screen.findByRole('link', {name: 'Step 1'})).toHaveTextContent('Step 1'); - // span element: `generic` role: - expect(await screen.findByRole('generic', {name: 'Step 3'})).toHaveTextContent('Step 3'); - expect(await screen.queryByRole('link', {name: 'Step 3'})).toBeNull(); -}); - -it('Progress indicator DOES let you navigate between steps if you ARE a form designer', async () => { - const steps = [ - { - slug: 'step-1', - formDefinition: 'Step 1', - index: 0, - isApplicable: true, - url: 'http://test.nl/api/v1/forms/111/steps/111', - uuid: '111', - completed: true, - }, - { - slug: 'step-2', - formDefinition: 'Step 2', - index: 1, - isApplicable: true, - url: 'http://test.nl/api/v1/forms/111/steps/222', - uuid: '222', - completed: false, - }, - { - slug: 'step-3', - formDefinition: 'Step 3', - index: 2, - isApplicable: true, - url: 'http://test.nl/api/v1/forms/111/steps/333', - uuid: '333', - completed: false, - }, - ]; - - IsFormDesigner.getValue.mockReturnValue(true); - - act(() => { - root.render( - - - - - - ); - }); - - expect(await screen.findByRole('link', {name: 'Step 1'})).toHaveTextContent('Step 1'); - expect(await screen.findByRole('link', {name: 'Step 3'})).toHaveTextContent('Step 3'); -}); - -it('Progress indicator DOES let you navigate between steps if you are NOT a form designer but a step is NOT applicable', async () => { - const steps = [ - { - slug: 'step-1', - formDefinition: 'Step 1', - index: 0, - isApplicable: true, - url: 'http://test.nl/api/v1/forms/111/steps/111', - uuid: '111', - completed: true, - }, - { - slug: 'step-2', - formDefinition: 'Step 2', - index: 1, - isApplicable: false, - url: 'http://test.nl/api/v1/forms/111/steps/222', - uuid: '222', - completed: false, - }, - { - slug: 'step-3', - formDefinition: 'Step 3', - index: 2, - isApplicable: true, - url: 'http://test.nl/api/v1/forms/111/steps/333', - uuid: '333', - completed: false, - }, - ]; - - IsFormDesigner.getValue.mockReturnValue(false); - - act(() => { - root.render( - - - - - - ); - }); - - expect(await screen.findByRole('link', {name: 'Step 3'})).toBeDefined(); - expect(await screen.findByRole('link', {name: 'Step 3'})).toHaveTextContent('Step 3'); -}); - -it('Progress indicator does NOT let you navigate to the overview if a step is blocked', async () => { - const steps = [ - { - slug: 'step-1', - formDefinition: 'Step 1', - index: 0, - isApplicable: true, - url: 'http://test.nl/api/v1/forms/111/steps/111', - uuid: '111', - completed: true, - canSubmit: false, - }, - ]; - - IsFormDesigner.getValue.mockReturnValue(false); - - act(() => { - root.render( - - - - - - ); - }); - - const overview = container.getElementsByTagName('li')[2]; - - const overviewLink = overview.getElementsByTagName('a')[0]; - const overviewSpan = overview.getElementsByTagName('span')[0]; - - // Check that the overview is not a link since a step cannot be submitted - expect(overviewLink).toBeUndefined(); - expect(overviewSpan).not.toBeUndefined(); - expect(overviewSpan.textContent).toContain('Overzicht'); -}); - -it('Progress indicator takes into account default step applicability when on start page', async () => { - const steps = [ - { - slug: 'step-1', - formDefinition: 'Step 1', - index: 0, - isApplicable: true, - url: 'http://test.nl/api/v1/forms/111/steps/111', - uuid: '111', - completed: true, - canSubmit: false, - }, - { - slug: 'step-2', - formDefinition: 'Step 2', - index: 0, - isApplicable: false, - url: 'http://test.nl/api/v1/forms/111/steps/111', - uuid: '222', - completed: true, - canSubmit: false, - }, - ]; - - IsFormDesigner.getValue.mockReturnValue(true); - - act(() => { - root.render( - - - - - - ); - }); - - expect(await screen.findByRole('link', {name: 'Step 1'})).toBeDefined(); - expect(await screen.queryByRole('link', {name: 'Step 2'})).toBeNull(); -}); diff --git a/src/components/ProgressIndicator/utils.js b/src/components/ProgressIndicator/utils.js new file mode 100644 index 000000000..18a044a4f --- /dev/null +++ b/src/components/ProgressIndicator/utils.js @@ -0,0 +1,83 @@ +import {STEP_LABELS} from 'components/constants'; +import {checkMatchesPath} from 'components/utils/routers'; +import {IsFormDesigner} from 'headers'; + +const canNavigateToStep = (index, submission) => { + // 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; +}; + +const getStepsInfo = (formSteps, submission, currentPathname) => { + return formSteps.map((step, index) => ({ + to: `/stap/${step.slug}`, + label: step.formDefinition, + isCompleted: submission ? submission.steps[index].completed : false, + isApplicable: submission ? submission.steps[index].isApplicable : step.isApplicable ?? true, + isCurrent: checkMatchesPath(currentPathname, step.slug), + canNavigateTo: canNavigateToStep(index, submission), + })); +}; + +const addFixedSteps = ( + intl, + steps, + submission, + currentPathname, + showOverview, + showConfirmation, + completed = false +) => { + const hasSubmission = !!submission; + const isConfirmation = checkMatchesPath(currentPathname, 'bevestiging'); + const applicableSteps = hasSubmission ? submission.steps.filter(step => step.isApplicable) : []; + const applicableAndCompletedSteps = applicableSteps.filter(step => step.completed); + const applicableCompleted = + hasSubmission && applicableSteps.length === applicableAndCompletedSteps.length; + + const startPageStep = { + to: 'startpagina', + label: intl.formatMessage(STEP_LABELS.login), + isCompleted: hasSubmission, + isApplicable: true, + canNavigateTo: true, + isCurrent: checkMatchesPath(currentPathname, 'startpagina'), + }; + + const summaryStep = { + to: 'overzicht', + label: intl.formatMessage(STEP_LABELS.overview), + isCompleted: isConfirmation, + isApplicable: true, + isCurrent: checkMatchesPath(currentPathname, 'overzicht'), + canNavigateTo: applicableCompleted, + }; + + const confirmationStep = { + to: 'bevestiging', + label: intl.formatMessage(STEP_LABELS.confirmation), + isCompleted: completed, + isCurrent: checkMatchesPath(currentPathname, 'bevestiging'), + }; + + const finalSteps = [ + startPageStep, + ...steps, + showOverview && summaryStep, + showConfirmation && confirmationStep, + ]; + + return finalSteps; +}; + +export {addFixedSteps, getStepsInfo}; diff --git a/src/components/ProgressIndicator/utils.spec.js b/src/components/ProgressIndicator/utils.spec.js new file mode 100644 index 000000000..74185c6b9 --- /dev/null +++ b/src/components/ProgressIndicator/utils.spec.js @@ -0,0 +1,68 @@ +import messagesEN from 'i18n/compiled/en.json'; +import {createIntl, createIntlCache} from 'react-intl'; + +import {buildSubmission} from 'api-mocks/submissions'; + +import {addFixedSteps, getStepsInfo} from './utils'; + +const cache = createIntlCache(); +const intl = createIntl({locale: 'en', messages: messagesEN}, cache); + +const formSteps = [ + { + slug: 'step-1', + formDefinition: 'Step 1', + isCompleted: false, + isApplicable: true, + isCurrent: true, + canNavigateTo: true, + }, +]; + +describe('Transforming form steps and injecting fixed steps', () => { + it('prepends start page and appends summary and confirmation steps', () => { + const submission = buildSubmission(); + const updatedSteps = getStepsInfo(formSteps, submission, '/stap/step-1'); + const stepsToRender = addFixedSteps(intl, updatedSteps, submission, '/stap/step-1', true, true); + + expect(stepsToRender.length).toEqual(4); + expect(stepsToRender[0].to).toEqual('startpagina'); + + expect(stepsToRender[1].to).toEqual('/stap/step-1'); + expect(stepsToRender[1].label).toEqual('Step 1'); + expect(stepsToRender[1].isCompleted).toEqual(false); + expect(stepsToRender[1].isApplicable).toEqual(true); + expect(stepsToRender[1].isCurrent).toEqual(true); + expect(stepsToRender[1].canNavigateTo).toEqual(true); + + expect(stepsToRender[2].to).toEqual('overzicht'); + expect(stepsToRender[3].to).toEqual('bevestiging'); + }); + + it('accepts parameters to not append summary or confirmation', () => { + const submission = buildSubmission(); + const updatedSteps = getStepsInfo(formSteps, submission, '/stap/step-1'); + const stepsToRender = addFixedSteps( + intl, + updatedSteps, + submission, + '/stap/step-1', + false, + false + ); + + expect(stepsToRender.length).toEqual(4); + + expect(stepsToRender[0].to).toEqual('startpagina'); + + expect(stepsToRender[1].to).toEqual('/stap/step-1'); + expect(stepsToRender[1].label).toEqual('Step 1'); + expect(stepsToRender[1].isCompleted).toEqual(false); + expect(stepsToRender[1].isApplicable).toEqual(true); + expect(stepsToRender[1].isCurrent).toEqual(true); + expect(stepsToRender[1].canNavigateTo).toEqual(true); + + expect(stepsToRender[2]).toBeFalsy(); + expect(stepsToRender[3]).toBeFalsy(); + }); +}); diff --git a/src/components/appointments/CreateAppointment/AppointmentProgress.js b/src/components/appointments/CreateAppointment/AppointmentProgress.js index facd1a7e9..175d3aebf 100644 --- a/src/components/appointments/CreateAppointment/AppointmentProgress.js +++ b/src/components/appointments/CreateAppointment/AppointmentProgress.js @@ -1,13 +1,15 @@ import PropTypes from 'prop-types'; -import React, {useContext, useState} from 'react'; +import React, {useContext} from 'react'; import {useIntl} from 'react-intl'; import {useLocation} from 'react-router-dom'; import {ConfigContext} from 'Context'; -import ProgressIndicatorDisplay from 'components/ProgressIndicator/ProgressIndicatorDisplay'; +import ProgressIndicator from 'components/ProgressIndicator'; +import {PI_TITLE, STEP_LABELS} from 'components/constants'; +import {checkMatchesPath} from 'components/utils/routers'; import {useCreateAppointmentContext} from './CreateAppointmentState'; -import {APPOINTMENT_STEPS, APPOINTMENT_STEP_PATHS, checkMatchesPath} from './routes'; +import {APPOINTMENT_STEPS, APPOINTMENT_STEP_PATHS} from './routes'; const AppointmentProgress = ({title, currentStep}) => { const config = useContext(ConfigContext); @@ -15,10 +17,8 @@ const AppointmentProgress = ({title, currentStep}) => { const intl = useIntl(); const {pathname: currentPathname} = useLocation(); - const isSummary = checkMatchesPath(currentPathname, 'overzicht'); const isConfirmation = checkMatchesPath(currentPathname, 'bevestiging'); - - const [expanded, setExpanded] = useState(false); + const isSummary = checkMatchesPath(currentPathname, 'overzicht'); const isSubmissionComplete = isConfirmation && submission === null; @@ -33,8 +33,8 @@ const AppointmentProgress = ({title, currentStep}) => { const stepCompleted = submittedSteps.includes(path); return { - uuid: `appointments-${path}`, to: path, + label: intl.formatMessage(name), isCompleted: stepCompleted || isSubmissionComplete, isApplicable: true, isCurrent: checkMatchesPath(currentPathname, path), @@ -43,30 +43,60 @@ const AppointmentProgress = ({title, currentStep}) => { previousStepCompleted || index === currentStepIndex || isSubmissionComplete, - formDefinition: intl.formatMessage(name), }; }); - const ProgressIndicatorDisplayComponent = - config?.displayComponents?.progressIndicator ?? ProgressIndicatorDisplay; + // Add the fixed steps to the the original steps array + const finalSteps = [ + ...steps, + { + to: 'overzicht', + label: intl.formatMessage(STEP_LABELS.overview), + isCompleted: isConfirmation, + isApplicable: true, + isCurrent: checkMatchesPath(currentPathname, 'overzicht'), + canNavigateTo: false, + }, + { + to: 'bevestiging', + label: intl.formatMessage(STEP_LABELS.confirmation), + isCompleted: isSubmissionComplete, + isCurrent: checkMatchesPath(currentPathname, 'bevestiging'), + }, + ]; + + // Figure out the title for the mobile menu based on the state + let activeStepTitle; + if (isSummary) { + activeStepTitle = intl.formatMessage(STEP_LABELS.overview); + } else if (isConfirmation) { + activeStepTitle = intl.formatMessage(STEP_LABELS.confirmation); + } else { + activeStepTitle = currentStep; + } + + 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 {title}: {activeStepTitle}', + }, + {title, activeStepTitle} + ); + + const ProgressIndicatorComponent = + config?.displayComponents?.progressIndicator ?? ProgressIndicator; return ( - setExpanded(!expanded)} + steps={finalSteps} + ariaMobileIconLabel={ariaMobileIconLabel} + accessibleToggleStepsLabel={accessibleToggleStepsLabel} /> ); }; diff --git a/src/components/appointments/CreateAppointment/CreateAppointment.js b/src/components/appointments/CreateAppointment/CreateAppointment.js index 22fb26eb5..f98b29933 100644 --- a/src/components/appointments/CreateAppointment/CreateAppointment.js +++ b/src/components/appointments/CreateAppointment/CreateAppointment.js @@ -8,6 +8,7 @@ import FormDisplay from 'components/FormDisplay'; import {LiteralsProvider} from 'components/Literal'; import Loader from 'components/Loader'; import {RequireSession} from 'components/Sessions'; +import {checkMatchesPath} from 'components/utils/routers'; import useFormContext from 'hooks/useFormContext'; import useGetOrCreateSubmission from 'hooks/useGetOrCreateSubmission'; import useSessionTimeout from 'hooks/useSessionTimeout'; @@ -15,7 +16,7 @@ import useSessionTimeout from 'hooks/useSessionTimeout'; import {AppointmentConfigContext} from '../Context'; import AppointmentProgress from './AppointmentProgress'; import CreateAppointmentState from './CreateAppointmentState'; -import {APPOINTMENT_STEP_PATHS, checkMatchesPath} from './routes'; +import {APPOINTMENT_STEP_PATHS} from './routes'; const useIsConfirmation = () => { // useMatch requires absolute paths... and react-router are NOT receptive to changing that. diff --git a/src/components/appointments/CreateAppointment/routes.js b/src/components/appointments/CreateAppointment/routes.js index 30ede3302..c142d5c35 100644 --- a/src/components/appointments/CreateAppointment/routes.js +++ b/src/components/appointments/CreateAppointment/routes.js @@ -1,5 +1,5 @@ import {defineMessage} from 'react-intl'; -import {Navigate, matchPath, resolvePath} from 'react-router-dom'; +import {Navigate} from 'react-router-dom'; import useQuery from 'hooks/useQuery'; @@ -67,19 +67,3 @@ export const routes = [ element: , }, ]; - -/** - * 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) => { - // we need to transform the path into a parent-route lookup, instead of using the - // default relative ./ behaviour. The idea is that this component is mounted - // somewhere in a larger route definition but the exact parent route is not relevant. - const resolvedPath = resolvePath(`../${path}`, currentPathname); - // if the relative path is not the current URL, matchPath returns null, otherwise - // a match object. - const match = matchPath(resolvedPath.pathname, currentPathname); - return match !== null; -}; diff --git a/src/components/constants.js b/src/components/constants.js index 9823078be..780bd3789 100644 --- a/src/components/constants.js +++ b/src/components/constants.js @@ -1,11 +1,38 @@ +import {FormattedMessage, defineMessages} from 'react-intl'; + const SUBMISSION_ALLOWED = { yes: 'yes', noWithOverview: 'no_with_overview', noWithoutOverview: 'no_without_overview', }; +const STEP_LABELS = defineMessages({ + login: { + description: 'Start page title', + defaultMessage: 'Start page', + }, + overview: { + description: 'Summary page title', + defaultMessage: 'Summary', + }, + confirmation: { + description: 'Confirmation page title', + defaultMessage: '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}; +const PI_TITLE = ( + +); + +export { + SUBMISSION_ALLOWED, + START_FORM_QUERY_PARAM, + SUBMISSION_UUID_QUERY_PARAM, + STEP_LABELS, + PI_TITLE, +}; diff --git a/src/components/utils/routers.js b/src/components/utils/routers.js new file mode 100644 index 000000000..af3210bd2 --- /dev/null +++ b/src/components/utils/routers.js @@ -0,0 +1,17 @@ +import {matchPath, resolvePath} 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) => { + // we need to transform the path into a parent-route lookup, instead of using the + // default relative ./ behaviour. The idea is that this component is mounted + // somewhere in a larger route definition but the exact parent route is not relevant. + const resolvedPath = resolvePath(`../${path}`, currentPathname); + // if the relative path is not the current URL, matchPath returns null, otherwise + // a match object. + const match = matchPath(resolvedPath.pathname, currentPathname); + return match !== null; +}; diff --git a/src/hooks/useRecycleSubmission.js b/src/hooks/useRecycleSubmission.js index 3de996f4d..e4db277de 100644 --- a/src/hooks/useRecycleSubmission.js +++ b/src/hooks/useRecycleSubmission.js @@ -11,6 +11,8 @@ const useRecycleSubmission = (form, currentSubmission, onSubmissionLoaded, onErr const location = useLocation(); const config = useContext(ConfigContext); const queryParams = useQuery(); + // XXX: use sessionStorage instead of localStorage for this, so that it's scoped to + // a single tab/window? let [submissionId, setSubmissionId, removeSubmissionId] = useLocalStorage(form.uuid, ''); // If no submissionID is in the localStorage see if one can be retrieved from the query param diff --git a/src/i18n/compiled/en.json b/src/i18n/compiled/en.json index 3934b1d00..d2849121f 100644 --- a/src/i18n/compiled/en.json +++ b/src/i18n/compiled/en.json @@ -1241,26 +1241,14 @@ "value": "Times are in your local time" } ], - "hHS5vj": [ - { - "type": 0, - "value": "Something went wrong while submitting the form." - } - ], - "hQRQCS": [ - { - "type": 0, - "value": "Er is een fout opgetreden in de communicatie met DigiD. Probeert u het later nogmaals. Indien deze fout blijft aanhouden, kijk dan op de website https://www.digid.nl voor de laatste informatie." - } - ], - "hR1LxA": [ + "hFqzG6": [ { "type": 0, "value": "Current step in form " }, { "type": 1, - "value": "formTitle" + "value": "title" }, { "type": 0, @@ -1271,6 +1259,18 @@ "value": "activeStepTitle" } ], + "hHS5vj": [ + { + "type": 0, + "value": "Something went wrong while submitting the form." + } + ], + "hQRQCS": [ + { + "type": 0, + "value": "Er is een fout opgetreden in de communicatie met DigiD. Probeert u het later nogmaals. Indien deze fout blijft aanhouden, kijk dan op de website https://www.digid.nl voor de laatste informatie." + } + ], "hkZ8N1": [ { "type": 0, @@ -1341,6 +1341,24 @@ "value": "Your products" } ], + "jdzNwf": [ + { + "type": 0, + "value": "Current step in form " + }, + { + "type": 1, + "value": "formName" + }, + { + "type": 0, + "value": ": " + }, + { + "type": 1, + "value": "activeStepTitle" + } + ], "jnYRyz": [ { "type": 0, diff --git a/src/i18n/compiled/nl.json b/src/i18n/compiled/nl.json index 02671942d..d64910fca 100644 --- a/src/i18n/compiled/nl.json +++ b/src/i18n/compiled/nl.json @@ -1245,26 +1245,14 @@ "value": "U wordt op dit tijdstip op de afspraak verwacht" } ], - "hHS5vj": [ - { - "type": 0, - "value": "Er is iets fout gegaan bij het verzenden." - } - ], - "hQRQCS": [ - { - "type": 0, - "value": "Er is een fout opgetreden in de communicatie met DigiD. Probeert u het later nogmaals. Indien deze fout blijft aanhouden, kijk dan op de website https://www.digid.nl voor de laatste informatie." - } - ], - "hR1LxA": [ + "hFqzG6": [ { "type": 0, "value": "Huidige stap in formulier " }, { "type": 1, - "value": "formTitle" + "value": "title" }, { "type": 0, @@ -1275,6 +1263,18 @@ "value": "activeStepTitle" } ], + "hHS5vj": [ + { + "type": 0, + "value": "Er is iets fout gegaan bij het verzenden." + } + ], + "hQRQCS": [ + { + "type": 0, + "value": "Er is een fout opgetreden in de communicatie met DigiD. Probeert u het later nogmaals. Indien deze fout blijft aanhouden, kijk dan op de website https://www.digid.nl voor de laatste informatie." + } + ], "hkZ8N1": [ { "type": 0, @@ -1345,6 +1345,24 @@ "value": "Uw producten" } ], + "jdzNwf": [ + { + "type": 0, + "value": "Huidige stap in formulier " + }, + { + "type": 1, + "value": "formName" + }, + { + "type": 0, + "value": ": " + }, + { + "type": 1, + "value": "activeStepTitle" + } + ], "jnYRyz": [ { "type": 0, diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 03e72a82a..05f1d9c15 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -599,6 +599,11 @@ "description": "Appoinments: time select help text", "originalDefault": "Times are in your local time" }, + "hFqzG6": { + "defaultMessage": "Current step in form {title}: {activeStepTitle}", + "description": "Active step accessible label in mobile progress indicator", + "originalDefault": "Current step in form {title}: {activeStepTitle}" + }, "hHS5vj": { "defaultMessage": "Something went wrong while submitting the form.", "description": "Generic submission error", @@ -609,11 +614,6 @@ "description": "DigiD error message. MUST BE THIS EXACT STRING!", "originalDefault": "Er is een fout opgetreden in de communicatie met DigiD. Probeert u het later nogmaals. Indien deze fout blijft aanhouden, kijk dan op de website https://www.digid.nl voor de laatste informatie." }, - "hR1LxA": { - "defaultMessage": "Current step in form {formTitle}: {activeStepTitle}", - "description": "Active step accessible label in mobile progress indicator", - "originalDefault": "Current step in form {formTitle}: {activeStepTitle}" - }, "hkZ8N1": { "defaultMessage": "Invalid input.", "description": "ZOD 'custom' error message", @@ -659,6 +659,11 @@ "description": "Product summary on appointments location and time step heading", "originalDefault": "Your products" }, + "jdzNwf": { + "defaultMessage": "Current step in form {formName}: {activeStepTitle}", + "description": "Active step accessible label in mobile progress indicator", + "originalDefault": "Current step in form {formName}: {activeStepTitle}" + }, "jnYRyz": { "defaultMessage": "Expected {expected}, received {received}.", "description": "ZOD 'invalid_type' error message", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index af0937c2b..7fbe46c98 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -605,6 +605,11 @@ "description": "Appoinments: time select help text", "originalDefault": "Times are in your local time" }, + "hFqzG6": { + "defaultMessage": "Huidige stap in formulier {title}: {activeStepTitle}", + "description": "Active step accessible label in mobile progress indicator", + "originalDefault": "Current step in form {title}: {activeStepTitle}" + }, "hHS5vj": { "defaultMessage": "Er is iets fout gegaan bij het verzenden.", "description": "Generic submission error", @@ -616,11 +621,6 @@ "isTranslated": true, "originalDefault": "Er is een fout opgetreden in de communicatie met DigiD. Probeert u het later nogmaals. Indien deze fout blijft aanhouden, kijk dan op de website https://www.digid.nl voor de laatste informatie." }, - "hR1LxA": { - "defaultMessage": "Huidige stap in formulier {formTitle}: {activeStepTitle}", - "description": "Active step accessible label in mobile progress indicator", - "originalDefault": "Current step in form {formTitle}: {activeStepTitle}" - }, "hkZ8N1": { "defaultMessage": "Ongeldige invoer.", "description": "ZOD 'custom' error message", @@ -666,6 +666,11 @@ "description": "Product summary on appointments location and time step heading", "originalDefault": "Your products" }, + "jdzNwf": { + "defaultMessage": "Huidige stap in formulier {formName}: {activeStepTitle}", + "description": "Active step accessible label in mobile progress indicator", + "originalDefault": "Current step in form {formName}: {activeStepTitle}" + }, "jnYRyz": { "defaultMessage": "Verwachtte type {expected} maar kreeg {received}.", "description": "ZOD 'invalid_type' error message", diff --git a/src/scss/components/_anchor.scss b/src/scss/components/_anchor.scss index d16ae1af2..da3661679 100644 --- a/src/scss/components/_anchor.scss +++ b/src/scss/components/_anchor.scss @@ -22,7 +22,6 @@ var(--of-typography-sans-serif-font-family, var(--utrecht-document-font-family)) ); margin: 0; - cursor: pointer; } // swap the hover/no-hover text decoration rules compared to the non-modified variant diff --git a/src/scss/components/_progress-indicator.scss b/src/scss/components/_progress-indicator.scss index 8dd13892b..81513562c 100644 --- a/src/scss/components/_progress-indicator.scss +++ b/src/scss/components/_progress-indicator.scss @@ -14,11 +14,6 @@ .#{prefix('progress-indicator')} { @extend .openforms-card; // TODO -> syntax highlighting trips on #{prefix('card')} - @include bem.modifier('sticky') { - position: sticky; - top: var(--of-progress-indicator-sticky-spacing); - } - @include bem.element('mobile-header') { @include body; @include body--big;