diff --git a/src/Context.js b/src/Context.js index 9bd99b470..165d19a77 100644 --- a/src/Context.js +++ b/src/Context.js @@ -23,6 +23,12 @@ const FormContext = React.createContext({ }); FormContext.displayName = 'FormContext'; +const AnalyticsToolsConfigContext = React.createContext({ + govmetricSourceId: '', + govmetricSecureGuid: '', + enableGovmetricAnalytics: false, +}); + const ConfigContext = React.createContext({ baseUrl: '', clientBaseUrl: window.location.href, @@ -43,4 +49,10 @@ FormioTranslations.displayName = 'FormioTranslations'; const SubmissionContext = React.createContext({submission: null}); SubmissionContext.displayName = 'SubmissionContext'; -export {FormContext, ConfigContext, FormioTranslations, SubmissionContext}; +export { + FormContext, + ConfigContext, + FormioTranslations, + SubmissionContext, + AnalyticsToolsConfigContext, +}; diff --git a/src/api-mocks/analytics.js b/src/api-mocks/analytics.js new file mode 100644 index 000000000..560f78356 --- /dev/null +++ b/src/api-mocks/analytics.js @@ -0,0 +1,15 @@ +import {rest} from 'msw'; + +import {BASE_URL} from './base'; + +export const mockAnalyticsToolConfigGet = (overrides = {}) => + rest.get(`${BASE_URL}analytics/analytics_tools_config_info`, (req, res, ctx) => + res( + ctx.json({ + govmetricSourceId: '', + govmetricSecureGuid: '', + enableGovmetricAnalytics: false, + ...overrides, + }) + ) + ); diff --git a/src/api-mocks/index.js b/src/api-mocks/index.js index 667b865a1..de04d3314 100644 --- a/src/api-mocks/index.js +++ b/src/api-mocks/index.js @@ -1,3 +1,4 @@ export {BASE_URL} from './base'; export {buildForm, mockFormGet} from './forms'; export {buildSubmission} from './submissions'; +export {mockAnalyticsToolConfigGet} from './analytics'; diff --git a/src/components/AbortionButton/AbortionButton.js b/src/components/AbortionButton/AbortionButton.js new file mode 100644 index 000000000..106d15206 --- /dev/null +++ b/src/components/AbortionButton/AbortionButton.js @@ -0,0 +1,66 @@ +import PropTypes from 'prop-types'; +import React, {useContext} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import {AnalyticsToolsConfigContext} from 'Context'; +import {OFButton} from 'components/Button'; +import {buildGovMetricUrl} from 'components/analytics/utils'; +import useFormContext from 'hooks/useFormContext'; + +const AbortionButton = ({isAuthenticated, onDestroySession}) => { + const intl = useIntl(); + const analyticsToolsConfig = useContext(AnalyticsToolsConfigContext); + const form = useFormContext(); + + const confirmationMessage = isAuthenticated + ? intl.formatMessage({ + description: 'log out confirmation prompt', + defaultMessage: 'Are you sure that you want to logout?', + }) + : intl.formatMessage({ + description: 'Abort confirmation prompt', + defaultMessage: + 'Are you sure that you want to abort this submission? You will lose your progress if you continue.', + }); + + const callback = async event => { + event.preventDefault(); + + if (!window.confirm(confirmationMessage)) return; + + await onDestroySession(); + + if (analyticsToolsConfig.enableGovmetricAnalytics) { + const govmetricUrl = buildGovMetricUrl( + analyticsToolsConfig.govmetricSourceId, + form.slug, + analyticsToolsConfig.govmetricSecureGuid + ); + + window.open(govmetricUrl); + } + }; + + const message = isAuthenticated ? ( + + ) : ( + + ); + + if (!isAuthenticated && !analyticsToolsConfig.enableGovmetricAnalytics) return null; + + return ( + + {message} + + ); +}; + +AbortionButton.propTypes = { + isAuthenticated: PropTypes.bool.isRequired, + onDestroySession: PropTypes.func.isRequired, +}; +export default AbortionButton; diff --git a/src/components/AbortionButton/AbortionButton.mdx b/src/components/AbortionButton/AbortionButton.mdx new file mode 100644 index 000000000..60dab2cc9 --- /dev/null +++ b/src/components/AbortionButton/AbortionButton.mdx @@ -0,0 +1,35 @@ +import {ArgTypes, Canvas, Meta, Story} from '@storybook/blocks'; + +import * as AbortionButtonStories from './AbortionButton.stories'; + + + +# Abortion Button + +The abortion button can be used to abort a submission whether the user is authenticated or not. + +- If the user is authenticated, then the button will have the text 'Log out' to comply with Logius + directives that whenever a user is logged in with DigiD a 'Log out' option must be available. When + GovMetric is enabled, a new window will open where the user can give feedback. +- If the analytics tool 'GovMetric' is enabled, aborting the submission will open a new window where + the user can give feedback about why they decided to not fill the form until the end. + + + + + + + + + + + + + + + + + +## Props + + diff --git a/src/components/AbortionButton/AbortionButton.stories.js b/src/components/AbortionButton/AbortionButton.stories.js new file mode 100644 index 000000000..6681a6ebe --- /dev/null +++ b/src/components/AbortionButton/AbortionButton.stories.js @@ -0,0 +1,74 @@ +import {expect} from '@storybook/jest'; +import {within} from '@storybook/testing-library'; + +import {AnalyticsToolsDecorator} from 'story-utils/decorators'; + +import AbortionButton from './AbortionButton'; + +export default { + title: 'Private API / Abortion button', + component: AbortionButton, + decorators: [AnalyticsToolsDecorator], +}; + +export const Authenticated = { + args: { + isAuthenticated: true, + }, + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + + const abortButton = await canvas.findByRole('button', {name: 'Uitloggen'}); + await expect(abortButton).toBeVisible(); + }, +}; + +export const GovMetricEnabled = { + args: { + isAuthenticated: false, + }, + parameters: { + analyticsToolsParams: { + govmetricSourceId: '1234', + govmetricSecureGuid: '', + enableGovmetricAnalytics: true, + }, + }, + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + + const abortButton = await canvas.findByRole('button', {name: 'Abort submission'}); + await expect(abortButton).toBeVisible(); + }, +}; + +export const AuthenticatedAndGovmetric = { + args: { + isAuthenticated: true, + }, + parameters: { + analyticsToolsParams: { + govmetricSourceId: '1234', + govmetricSecureGuid: '', + enableGovmetricAnalytics: true, + }, + }, + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + + const abortButton = await canvas.findByRole('button', {name: 'Uitloggen'}); + await expect(abortButton).toBeVisible(); + }, +}; + +export const NotAuthenticatedNoGovMetric = { + args: { + isAuthenticated: false, + }, + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + + const abortButton = await canvas.queryByRole('button'); + await expect(abortButton).toBeNull(); + }, +}; diff --git a/src/components/AbortionButton/index.js b/src/components/AbortionButton/index.js new file mode 100644 index 000000000..d9d353e4e --- /dev/null +++ b/src/components/AbortionButton/index.js @@ -0,0 +1,3 @@ +import AbortionButton from './AbortionButton'; + +export default AbortionButton; diff --git a/src/components/App.stories.js b/src/components/App.stories.js index 3c50e8e9f..e910aa6e8 100644 --- a/src/components/App.stories.js +++ b/src/components/App.stories.js @@ -3,7 +3,7 @@ import {userEvent, waitForElementToBeRemoved, within} from '@storybook/testing-l import {RouterProvider, createMemoryRouter} from 'react-router-dom'; import {FormContext} from 'Context'; -import {BASE_URL} from 'api-mocks'; +import {BASE_URL, mockAnalyticsToolConfigGet} from 'api-mocks'; import {buildForm} from 'api-mocks'; import { mockSubmissionCheckLogicPost, @@ -79,6 +79,7 @@ export default { {code: 'en', name: 'English'}, ]), mockLanguageChoicePut, + mockAnalyticsToolConfigGet(), ], }, }, @@ -189,6 +190,7 @@ export const ActiveSubmission = { {code: 'en', name: 'English'}, ]), mockLanguageChoicePut, + mockAnalyticsToolConfigGet(), ], }, }, diff --git a/src/components/ButtonsToolbar/index.js b/src/components/ButtonsToolbar/index.js index 8caf0f08d..8f682a5fa 100644 --- a/src/components/ButtonsToolbar/index.js +++ b/src/components/ButtonsToolbar/index.js @@ -5,7 +5,6 @@ import {OFButton} from 'components/Button'; import Link from 'components/Link'; import {Literal, LiteralsProvider} from 'components/Literal'; import Loader from 'components/Loader'; -import LogoutButton from 'components/LogoutButton'; import {Toolbar, ToolbarList} from 'components/Toolbar'; import {SUBMISSION_ALLOWED} from 'components/constants'; @@ -14,12 +13,10 @@ const ButtonsToolbar = ({ canSubmitStep, canSubmitForm, canSuspendForm, - isAuthenticated, isLastStep, isCheckingLogic, onNavigatePrevPage, onFormSave, - onLogout, previousPage, }) => { const showSubmitButton = !(canSubmitForm === SUBMISSION_ALLOWED.noWithoutOverview && isLastStep); @@ -65,7 +62,6 @@ const ButtonsToolbar = ({ - {isAuthenticated ? : null} ); }; @@ -76,11 +72,9 @@ ButtonsToolbar.propTypes = { canSubmitForm: PropTypes.string.isRequired, canSuspendForm: PropTypes.bool.isRequired, isLastStep: PropTypes.bool.isRequired, - isAuthenticated: PropTypes.bool.isRequired, isCheckingLogic: PropTypes.bool.isRequired, onNavigatePrevPage: PropTypes.func, onFormSave: PropTypes.func.isRequired, - onLogout: PropTypes.func.isRequired, previousPage: PropTypes.string, }; diff --git a/src/components/ExistingSubmissionOptions.js b/src/components/ExistingSubmissionOptions.js index 5cb11daab..ea0eeb35c 100644 --- a/src/components/ExistingSubmissionOptions.js +++ b/src/components/ExistingSubmissionOptions.js @@ -1,16 +1,30 @@ import PropTypes from 'prop-types'; import React from 'react'; -import {FormattedMessage} from 'react-intl'; +import {FormattedMessage, useIntl} from 'react-intl'; import {useNavigate} from 'react-router-dom'; import {OFButton} from 'components/Button'; import {Toolbar, ToolbarList} from 'components/Toolbar'; import Types from 'types'; -const ExistingSubmissionOptions = ({form, onFormAbort}) => { +const ExistingSubmissionOptions = ({form, onDestroySession}) => { const navigate = useNavigate(); + const intl = useIntl(); const firstStepRoute = `/stap/${form.steps[0].slug}`; + const confirmationMessage = intl.formatMessage({ + description: 'Abort confirmation prompt', + defaultMessage: + 'Are you sure that you want to abort this submission? You will lose your progress if you continue.', + }); + + const onFormAbort = async event => { + event.preventDefault(); + + if (!window.confirm(confirmationMessage)) return; + + await onDestroySession(); + }; return ( @@ -36,7 +50,7 @@ const ExistingSubmissionOptions = ({form, onFormAbort}) => { ExistingSubmissionOptions.propTypes = { form: Types.Form.isRequired, - onFormAbort: PropTypes.func.isRequired, + onDestroySession: PropTypes.func.isRequired, }; export default ExistingSubmissionOptions; diff --git a/src/components/Form.js b/src/components/Form.js index 63211ae6d..b015e02cd 100644 --- a/src/components/Form.js +++ b/src/components/Form.js @@ -2,10 +2,11 @@ import React, {useContext, useEffect} from 'react'; import {useIntl} from 'react-intl'; import {Navigate, Route, Routes, useLocation, useMatch, useNavigate} from 'react-router-dom'; import {usePrevious} from 'react-use'; +import {useAsync} from 'react-use'; import {useImmerReducer} from 'use-immer'; -import {ConfigContext} from 'Context'; -import {destroy} from 'api'; +import {AnalyticsToolsConfigContext, ConfigContext} from 'Context'; +import {destroy, get} from 'api'; import ErrorBoundary from 'components/Errors/ErrorBoundary'; import FormStart from 'components/FormStart'; import FormStep from 'components/FormStep'; @@ -140,6 +141,10 @@ const Form = () => { flagNoActiveSubmission(); }); + const {value: analyticsToolsConfigInfo, loading: loadingAnalyticsConfig} = useAsync(async () => { + return await get(`${config.baseUrl}analytics/analytics_tools_config_info`); + }, [intl.locale]); + useEffect( () => { if (prevLocale === undefined) return; @@ -210,40 +215,15 @@ const Form = () => { } }; - const destroySession = async confirmationMessage => { - if (!window.confirm(confirmationMessage)) { - return; - } - + const onDestroySession = async () => { await destroy(`${config.baseUrl}authentication/${state.submission.id}/session`); removeSubmissionId(); - navigate('/'); - // TODO: replace with a proper reset of the state instead of a page reload. - window.location.reload(); - }; - - const onFormAbort = async event => { - event.preventDefault(); - - const confirmationQuestion = intl.formatMessage({ - description: 'Abort confirmation prompt', - defaultMessage: - 'Are you sure that you want to abort this submission? You will lose your progress if you continue.', - }); - - await destroySession(confirmationQuestion); - }; - - const onLogout = async event => { - event.preventDefault(); - - const confirmationQuestion = intl.formatMessage({ - description: 'log out confirmation prompt', - defaultMessage: 'Are you sure that you want to logout?', + dispatch({ + type: 'RESET', + payload: initialStateFromProps, }); - - await destroySession(confirmationQuestion); + navigate('/'); }; const onProcessingFailure = errorMessage => { @@ -269,7 +249,7 @@ const Form = () => { ); } - if (loading || shouldAutomaticallyRedirect) { + if (loading || loadingAnalyticsConfig || shouldAutomaticallyRedirect) { return ; } @@ -358,7 +338,7 @@ const Form = () => { form={form} hasActiveSubmission={!!state.submission} onFormStart={onFormStart} - onFormAbort={onFormAbort} + onDestroySession={onDestroySession} /> } @@ -374,9 +354,9 @@ const Form = () => { form={form} processingError={state.processingError} onConfirm={onSubmitForm} - onLogout={onLogout} component={SubmissionSummary} onClearProcessingErrors={() => dispatch({type: 'CLEAR_PROCESSING_ERROR'})} + onDestroySession={onDestroySession} /> @@ -425,13 +405,13 @@ const Form = () => { dispatch({type: 'SUBMISSION_LOADED', payload: submission}) } onStepSubmitted={onStepSubmitted} - onLogout={onLogout} onSessionDestroyed={() => { resetSession(); navigate('/'); dispatch({type: 'RESET', payload: initialStateFromProps}); }} component={FormStep} + onDestroySession={onDestroySession} /> @@ -441,7 +421,13 @@ const Form = () => { ); // render the form step if there's an active submission (and no summary) - return {router}; + return ( + + + {router} + + + ); }; Form.propTypes = {}; diff --git a/src/components/FormStart/index.js b/src/components/FormStart/index.js index 69fb24dc8..6686426ca 100644 --- a/src/components/FormStart/index.js +++ b/src/components/FormStart/index.js @@ -40,7 +40,7 @@ const FormStartMessage = ({form}) => { * This is shown when the form is initially loaded and provides the explicit user * action to start the form, or present the login button (DigiD, eHerkenning...) */ -const FormStart = ({form, hasActiveSubmission, onFormStart, onFormAbort}) => { +const FormStart = ({form, hasActiveSubmission, onFormStart, onDestroySession}) => { const doStart = useStartSubmission(); const outagePluginId = useDetectAuthenticationOutage(); const authErrors = useDetectAuthErrorMessages(); @@ -126,7 +126,7 @@ const FormStart = ({form, hasActiveSubmission, onFormStart, onFormAbort}) => { {hasActiveSubmission ? ( - + ) : ( )} @@ -139,7 +139,7 @@ FormStart.propTypes = { form: Types.Form.isRequired, hasActiveSubmission: PropTypes.bool.isRequired, onFormStart: PropTypes.func.isRequired, - onFormAbort: PropTypes.func.isRequired, + onDestroySession: PropTypes.func.isRequired, }; export default FormStart; diff --git a/src/components/FormStart/tests.spec.js b/src/components/FormStart/tests.spec.js index 941aa3903..dac3e20f8 100644 --- a/src/components/FormStart/tests.spec.js +++ b/src/components/FormStart/tests.spec.js @@ -45,7 +45,7 @@ it('Form start page start if _start parameter is present', () => { useQuery.mockReturnValue(testLocation); const onFormStart = jest.fn(); - const onFormAbort = jest.fn(); + const onDestroySession = jest.fn(); act(() => { root.render( @@ -53,7 +53,7 @@ it('Form start page start if _start parameter is present', () => { @@ -65,7 +65,7 @@ it('Form start page start if _start parameter is present', () => { it('Form start does not start if there are auth errors', () => { const onFormStart = jest.fn(); - const onFormAbort = jest.fn(); + const onDestroySession = jest.fn(); const testQueries = { '_digid-message=error': @@ -86,7 +86,7 @@ it('Form start does not start if there are auth errors', () => { @@ -101,14 +101,14 @@ it('Form start does not start if there are auth errors', () => { it('Form start page does not show login buttons if an active submission is present', () => { useQuery.mockReturnValue(new URLSearchParams()); const onFormStart = jest.fn(); - const onFormAbort = jest.fn(); + const onDestroySession = jest.fn(); renderTest( , diff --git a/src/components/FormStep/FormStep.stories.js b/src/components/FormStep/FormStep.stories.js index 4a98f3174..63c9aeabc 100644 --- a/src/components/FormStep/FormStep.stories.js +++ b/src/components/FormStep/FormStep.stories.js @@ -1,10 +1,12 @@ +import {expect} from '@storybook/jest'; +import {within} from '@storybook/testing-library'; import produce from 'immer'; import {getWorker} from 'msw-storybook-addon'; import {withRouter} from 'storybook-addon-react-router-v6'; import {v4 as uuid4} from 'uuid'; import {buildForm, buildSubmission} from 'api-mocks'; -import {ConfigDecorator} from 'story-utils/decorators'; +import {AnalyticsToolsDecorator, ConfigDecorator} from 'story-utils/decorators'; import FormStep from '.'; import { @@ -16,7 +18,7 @@ import { export default { title: 'Private API / FormStep', component: FormStep, - decorators: [ConfigDecorator, withRouter], + decorators: [ConfigDecorator, withRouter, AnalyticsToolsDecorator], argTypes: { submission: {control: false}, form: {control: false}, @@ -41,8 +43,8 @@ const render = ({ submission, onLogicChecked, onStepSubmitted, - onLogout, onSessionDestroyed, + onDestroySession, // story args formioConfiguration, }) => { @@ -66,8 +68,8 @@ const render = ({ submission={submission} onLogicChecked={onLogicChecked} onStepSubmitted={onStepSubmitted} - onLogout={onLogout} onSessionDestroyed={onSessionDestroyed} + onDestroySession={onDestroySession} /> ); }; @@ -128,3 +130,45 @@ export const SuspensionDisallowed = { submission: buildSubmission(), }, }; + +export const govmetricEnabled = { + name: 'GovMetric enabled', + render, + args: { + formioConfiguration: { + display: 'form', + components: [ + { + type: 'textfield', + key: 'text1', + label: 'Simple text field', + description: 'A help text for the text field', + }, + { + type: 'radio', + key: 'radio1', + label: 'Radio choices', + values: [ + {value: 'option1', label: 'Option1'}, + {value: 'option2', label: 'Option2'}, + ], + }, + ], + }, + form: buildForm(), + submission: buildSubmission(), + }, + parameters: { + analyticsToolsParams: { + govmetricSourceId: '1234', + govmetricSecureGuid: '', + enableGovmetricAnalytics: true, + }, + }, + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + + const abortButton = await canvas.findByRole('button', {name: 'Abort submission'}); + await expect(abortButton).toBeVisible(); + }, +}; diff --git a/src/components/FormStep/index.js b/src/components/FormStep/index.js index 07c0c17cb..8ebbe5f7f 100644 --- a/src/components/FormStep/index.js +++ b/src/components/FormStep/index.js @@ -33,12 +33,14 @@ import {useNavigate, useParams} from 'react-router-dom'; import {useAsync} from 'react-use'; import {useImmerReducer} from 'use-immer'; -import {ConfigContext, FormioTranslations} from 'Context'; +import {AnalyticsToolsConfigContext, ConfigContext, FormioTranslations} from 'Context'; import {get, post, put} from 'api'; +import AbortionButton from 'components/AbortionButton'; import ButtonsToolbar from 'components/ButtonsToolbar'; import Card, {CardTitle} from 'components/Card'; import FormStepDebug from 'components/FormStepDebug'; import Loader from 'components/Loader'; +import {Toolbar, ToolbarList} from 'components/Toolbar'; import FormStepSaveModal from 'components/modals/FormStepSaveModal'; import { eventTriggeredBySubmitButton, @@ -47,11 +49,10 @@ import { } from 'components/utils'; import {ValidationError} from 'errors'; import {PREFIX} from 'formio/constants'; +import hooks from 'formio/hooks'; import useTitle from 'hooks/useTitle'; import Types from 'types'; -import hooks from '../../formio/hooks'; - /** * Debounce interval in milliseconds (1000ms equals 1s) to prevent excessive amount of logic checks. * @type {number} @@ -287,7 +288,7 @@ const reducer = (draft, action) => { * @param {Object} submission * @param {Function} onLogicChecked * @param {Function} onStepSubmitted - * @param {Function} onLogout + * @param {Function} onDestroySession * @param {Function} onSessionDestroyed * @throws {Error} Throws errors from state so the error boundaries can pick them up. * @return {React.ReactNode} @@ -297,12 +298,13 @@ const FormStep = ({ submission, onLogicChecked, onStepSubmitted, - onLogout, onSessionDestroyed, + onDestroySession, }) => { const intl = useIntl(); const config = useContext(ConfigContext); const formioTranslations = useContext(FormioTranslations); + const analyticsToolsConfig = useContext(AnalyticsToolsConfigContext); /* component state */ const formRef = useRef(null); @@ -818,6 +820,9 @@ const FormStep = ({ dispatch({type: 'FORMIO_CHANGE_HANDLED'}); }; + const showExtraToolbar = + submission.isAuthenticated || analyticsToolsConfig.enableGovmetricAnalytics; + const isLoadingSomething = loading || isNavigating; return ( <> @@ -871,15 +876,23 @@ const FormStep = ({ canSubmitStep={canSubmit} canSubmitForm={submission.submissionAllowed} canSuspendForm={form.suspensionAllowed} - isAuthenticated={submission.isAuthenticated} isLastStep={isLastStep(currentStepIndex, submission)} isCheckingLogic={logicChecking} loginRequired={form.loginRequired} onFormSave={onFormSave} - onLogout={onLogout} onNavigatePrevPage={onPrevPage} previousPage={getPreviousPageHref()} /> + {showExtraToolbar && ( + + + + + + )} ) : null} @@ -902,8 +915,8 @@ FormStep.propTypes = { submission: PropTypes.object.isRequired, onLogicChecked: PropTypes.func.isRequired, onStepSubmitted: PropTypes.func.isRequired, - onLogout: PropTypes.func.isRequired, onSessionDestroyed: PropTypes.func.isRequired, + onDestroySession: PropTypes.func.isRequired, }; export default FormStep; diff --git a/src/components/LogoutButton.js b/src/components/LogoutButton.js deleted file mode 100644 index 6c476fd7f..000000000 --- a/src/components/LogoutButton.js +++ /dev/null @@ -1,24 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import {FormattedMessage} from 'react-intl'; - -import {OFButton} from 'components/Button'; -import {Toolbar, ToolbarList} from 'components/Toolbar'; - -const LogoutButton = ({onLogout}) => { - return ( - - - - - - - - ); -}; - -LogoutButton.propTypes = { - onLogout: PropTypes.func.isRequired, -}; - -export default LogoutButton; diff --git a/src/components/PostCompletionViews/ConfirmationView.js b/src/components/PostCompletionViews/ConfirmationView.js index 2e280bd99..87070a498 100644 --- a/src/components/PostCompletionViews/ConfirmationView.js +++ b/src/components/PostCompletionViews/ConfirmationView.js @@ -5,6 +5,7 @@ import {useLocation} from 'react-router-dom'; import Body from 'components/Body'; import ErrorMessage from 'components/Errors/ErrorMessage'; +import {GovMetricSnippet} from 'components/analytics'; import PostCompletionView from './PostCompletionView'; import StatusUrlPoller, {SubmissionStatusContext} from './StatusUrlPoller'; @@ -88,6 +89,7 @@ const ConfirmationViewDisplay = ({downloadPDFText}) => { body={body} mainWebsiteUrl={mainWebsiteUrl} reportDownloadUrl={reportDownloadUrl} + extraBottom={} /> ); }; diff --git a/src/components/PostCompletionViews/ConfirmationView.stories.js b/src/components/PostCompletionViews/ConfirmationView.stories.js index 57f773a2d..1b6b02263 100644 --- a/src/components/PostCompletionViews/ConfirmationView.stories.js +++ b/src/components/PostCompletionViews/ConfirmationView.stories.js @@ -2,14 +2,14 @@ import {expect} from '@storybook/jest'; import {within} from '@storybook/testing-library'; import {withRouter} from 'storybook-addon-react-router-v6'; -import {withSubmissionPollInfo} from 'story-utils/decorators'; +import {AnalyticsToolsDecorator, withForm, withSubmissionPollInfo} from 'story-utils/decorators'; import {ConfirmationViewDisplay} from './ConfirmationView'; export default { title: 'Private API / Post completion views / Confirmation view', component: ConfirmationViewDisplay, - decorators: [withSubmissionPollInfo, withRouter], + decorators: [withForm, AnalyticsToolsDecorator, withSubmissionPollInfo, withRouter], argTypes: { paymentUrl: {control: false}, }, @@ -81,3 +81,34 @@ export const WithFailedPayment = { expect(errorNode).toHaveClass('utrecht-alert__message'); }, }; + +export const WithGovMetric = { + name: 'With GovMetric', + args: { + paymentUrl: '', + publicReference: 'OF-1234', + reportDownloadUrl: '#', + confirmationPageContent: 'Your answers are submitted. Hurray!', + mainWebsiteUrl: '#', + downloadPDFText: 'Download a PDF of your submitted answers', + form: { + slug: 'a-test-form', + }, + }, + parameters: { + analyticsToolsParams: { + govmetricSourceId: '1234', + govmetricSecureGuid: '', + enableGovmetricAnalytics: true, + }, + }, + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + + const feedbackLink = await canvas.findByRole('link', {name: 'Give feedback'}); + await expect(feedbackLink).toBeVisible(); + await expect(feedbackLink.href).toEqual( + 'https://websurveys2.govmetric.com/theme/kf/1234?Q_Formid=a-test-form' + ); + }, +}; diff --git a/src/components/PostCompletionViews/PostCompletionView.js b/src/components/PostCompletionViews/PostCompletionView.js index 8ae21f5bc..243c847ea 100644 --- a/src/components/PostCompletionViews/PostCompletionView.js +++ b/src/components/PostCompletionViews/PostCompletionView.js @@ -17,6 +17,7 @@ const PostCompletionView = ({ body, mainWebsiteUrl, reportDownloadUrl, + extraBottom, }) => { useTitle(pageTitle); @@ -49,6 +50,7 @@ const PostCompletionView = ({ ) : null} + {extraBottom} ); }; @@ -60,6 +62,7 @@ PostCompletionView.propTypes = { body: PropTypes.node, mainWebsiteUrl: PropTypes.string, reportDownloadUrl: PropTypes.string, + extraBottom: PropTypes.node, }; export default PostCompletionView; diff --git a/src/components/PostCompletionViews/index.mdx b/src/components/PostCompletionViews/index.mdx index fa667df02..c3f17870c 100644 --- a/src/components/PostCompletionViews/index.mdx +++ b/src/components/PostCompletionViews/index.mdx @@ -20,6 +20,11 @@ When no payment is required, after submitting the form the user sees this view: +In the case where GovMetric (analytics tool) is enabled, then there is an extra button to provide +feedback about the experience of filling in the form. + + + ## `ConfirmationViewDisplay` with payment required After a successful payment, the user is redirected to this view: diff --git a/src/components/ProgressIndicator/progressIndicator.spec.js b/src/components/ProgressIndicator/progressIndicator.spec.js index 6ad2c7dd8..312d102e3 100644 --- a/src/components/ProgressIndicator/progressIndicator.spec.js +++ b/src/components/ProgressIndicator/progressIndicator.spec.js @@ -5,7 +5,7 @@ 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 {BASE_URL, buildForm, mockAnalyticsToolConfigGet} from 'api-mocks'; import {getDefaultFactory} from 'api-mocks/base'; import {FORM_DEFAULTS} from 'api-mocks/forms'; import mswServer from 'api-mocks/msw-server'; @@ -57,7 +57,7 @@ afterEach(() => { describe('The progress indicator component', () => { it('displays the available submission/form steps and hardcoded steps (without payment)', async () => { - mswServer.use(mockSubmissionPost(buildSubmission())); + mswServer.use(mockSubmissionPost(buildSubmission()), mockAnalyticsToolConfigGet()); const user = userEvent.setup({delay: null}); const form = buildForm(); @@ -78,7 +78,7 @@ describe('The progress indicator component', () => { }); it('displays the available submission/form steps and hardcoded steps (with payment)', async () => { - mswServer.use(mockSubmissionPost(buildSubmission())); + mswServer.use(mockSubmissionPost(buildSubmission()), mockAnalyticsToolConfigGet()); const user = userEvent.setup({delay: null}); const form = buildForm({paymentRequired: true}); @@ -101,7 +101,7 @@ describe('The progress indicator component', () => { }); it('renders steps in the correct order', async () => { - mswServer.use(mockSubmissionPost(buildSubmission())); + mswServer.use(mockSubmissionPost(buildSubmission()), mockAnalyticsToolConfigGet()); const user = userEvent.setup({delay: null}); const form = buildForm(); diff --git a/src/components/Summary/GenericSummary.js b/src/components/Summary/GenericSummary.js index 6962d51d7..aaf42b80e 100644 --- a/src/components/Summary/GenericSummary.js +++ b/src/components/Summary/GenericSummary.js @@ -2,13 +2,14 @@ import {Form, Formik} from 'formik'; import PropTypes from 'prop-types'; import React from 'react'; +import AbortionButton from 'components/AbortionButton'; import Card from 'components/Card'; import ErrorMessage from 'components/Errors/ErrorMessage'; import FormStepSummary from 'components/FormStepSummary'; import Loader from 'components/Loader'; -import LogoutButton from 'components/LogoutButton'; import Price from 'components/Price'; import SummaryConfirmation from 'components/SummaryConfirmation'; +import {Toolbar, ToolbarList} from 'components/Toolbar'; import {SUBMISSION_ALLOWED} from 'components/constants'; const GenericSummary = ({ @@ -16,6 +17,7 @@ const GenericSummary = ({ submissionAllowed, summaryData = [], showPaymentInformation, + showExtraToolbar, amountToPay, editStepText, isLoading, @@ -23,7 +25,7 @@ const GenericSummary = ({ prevPage, errors = [], onSubmit, - onLogout, + onDestroySession, onPrevPage = null, }) => { const Wrapper = submissionAllowed === SUBMISSION_ALLOWED.yes ? Form : 'div'; @@ -60,7 +62,16 @@ const GenericSummary = ({ prevPage={prevPage} onPrevPage={onPrevPage} /> - {isAuthenticated ? : null} + {showExtraToolbar && ( + + + + + + )} @@ -94,6 +105,7 @@ GenericSummary.propTypes = { }) ), showPaymentInformation: PropTypes.bool, + showExtraToolbar: PropTypes.bool, amountToPay: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), editStepText: PropTypes.string, isLoading: PropTypes.bool, @@ -101,7 +113,7 @@ GenericSummary.propTypes = { errors: PropTypes.arrayOf(PropTypes.string), prevPage: PropTypes.string, onSubmit: PropTypes.func.isRequired, - onLogout: PropTypes.func.isRequired, + onDestroySession: PropTypes.func.isRequired, onPrevPage: PropTypes.func, }; diff --git a/src/components/Summary/SubmissionSummary.js b/src/components/Summary/SubmissionSummary.js index 89029c3a5..afc33947e 100644 --- a/src/components/Summary/SubmissionSummary.js +++ b/src/components/Summary/SubmissionSummary.js @@ -1,10 +1,11 @@ import PropTypes from 'prop-types'; -import React from 'react'; +import React, {useContext} from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; import {useNavigate} from 'react-router-dom'; import {useAsync} from 'react-use'; import {useImmerReducer} from 'use-immer'; +import {AnalyticsToolsConfigContext} from 'Context'; import {post} from 'api'; import {LiteralsProvider} from 'components/Literal'; import {SUBMISSION_ALLOWED} from 'components/constants'; @@ -37,12 +38,13 @@ const SubmissionSummary = ({ submission, processingError = '', onConfirm, - onLogout, onClearProcessingErrors, + onDestroySession, }) => { const [state, dispatch] = useImmerReducer(reducer, initialState); const navigate = useNavigate(); const intl = useIntl(); + const analyticsToolsConfig = useContext(AnalyticsToolsConfigContext); const refreshedSubmission = useRefreshSubmission(submission); @@ -109,6 +111,9 @@ const SubmissionSummary = ({ return errors; }; + const showExtraToolbar = + refreshedSubmission.isAuthenticated || analyticsToolsConfig.enableGovmetricAnalytics; + return ( ); @@ -140,8 +146,8 @@ SubmissionSummary.propTypes = { submission: Types.Submission.isRequired, processingError: PropTypes.string, onConfirm: PropTypes.func.isRequired, - onLogout: PropTypes.func.isRequired, onClearProcessingErrors: PropTypes.func.isRequired, + onDestroySession: PropTypes.func.isRequired, }; export default SubmissionSummary; diff --git a/src/components/Summary/Summary.stories.js b/src/components/Summary/Summary.stories.js index 16e73824b..0f6db0cf2 100644 --- a/src/components/Summary/Summary.stories.js +++ b/src/components/Summary/Summary.stories.js @@ -95,6 +95,7 @@ export default { }, ], showPaymentInformation: true, + showExtraToolbar: true, amountToPay: 54.05, showPreviousPageLink: true, isLoading: false, @@ -139,6 +140,7 @@ const render = ({ submissionAllowed, summaryData, showPaymentInformation, + showExtraToolbar, amountToPay, editStepText, isLoading, @@ -172,6 +174,7 @@ const render = ({ submissionAllowed={submissionAllowed} summaryData={summaryData} showPaymentInformation={showPaymentInformation} + showExtraToolbar={showExtraToolbar} amountToPay={amountToPay} editStepText={editStepText} isLoading={isLoading} diff --git a/src/components/Summary/test.spec.js b/src/components/Summary/test.spec.js index a58e534fe..74740ca70 100644 --- a/src/components/Summary/test.spec.js +++ b/src/components/Summary/test.spec.js @@ -62,7 +62,7 @@ it('Summary displays logout button if isAuthenticated is true', () => { ...SUBMISSION, isAuthenticated: true, }; - const onLogout = jest.fn(); + const onDestroySession = jest.fn(); const onConfirm = jest.fn(); useAsync.mockReturnValue({loading: false, value: []}); @@ -75,7 +75,7 @@ it('Summary displays logout button if isAuthenticated is true', () => { form={testForm} submission={SUBMISSION} onConfirm={onConfirm} - onLogout={onLogout} + onDestroySession={onDestroySession} onClearProcessingErrors={() => {}} /> @@ -90,11 +90,11 @@ it('Summary does not display logout button if loginRequired is false', () => { ...testForm, loginRequired: false, }; - const onLogout = jest.fn(); + const onDestroySession = jest.fn(); const onConfirm = jest.fn(); useAsync.mockReturnValue({loading: false, value: []}); - useRefreshSubmission.mockReturnValue(SUBMISSION); + useRefreshSubmission.mockReturnValue({...SUBMISSION, isAuthenticated: false}); act(() => { root.render( @@ -103,7 +103,7 @@ it('Summary does not display logout button if loginRequired is false', () => { form={formLoginRequired} submission={SUBMISSION} onConfirm={onConfirm} - onLogout={onLogout} + onDestroySession={onDestroySession} onClearProcessingErrors={() => {}} /> diff --git a/src/components/analytics/GovMetricSnippet.js b/src/components/analytics/GovMetricSnippet.js new file mode 100644 index 000000000..1b1fb8621 --- /dev/null +++ b/src/components/analytics/GovMetricSnippet.js @@ -0,0 +1,43 @@ +import {ButtonLink} from '@utrecht/component-library-react'; +import {useContext} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import {AnalyticsToolsConfigContext} from 'Context'; +import {Toolbar, ToolbarList} from 'components/Toolbar'; +import useFormContext from 'hooks/useFormContext'; + +import {buildGovMetricUrl} from './utils'; + +const GovMetricSnippet = () => { + const analyticsToolsConfig = useContext(AnalyticsToolsConfigContext); + const form = useFormContext(); + const intl = useIntl(); + + if (!analyticsToolsConfig.enableGovmetricAnalytics) return null; + + const govmetricUrl = buildGovMetricUrl( + analyticsToolsConfig.govmetricSourceId, + form.slug, + analyticsToolsConfig.govmetricSecureGuid + ); + + return ( + + + + + + + + ); +}; + +export default GovMetricSnippet; diff --git a/src/components/analytics/constants.js b/src/components/analytics/constants.js new file mode 100644 index 000000000..af0d19815 --- /dev/null +++ b/src/components/analytics/constants.js @@ -0,0 +1 @@ +export const GOVMETRIC_URL = 'https://websurveys2.govmetric.com'; diff --git a/src/components/analytics/index.js b/src/components/analytics/index.js new file mode 100644 index 000000000..15498d7e2 --- /dev/null +++ b/src/components/analytics/index.js @@ -0,0 +1,3 @@ +import GovMetricSnippet from './GovMetricSnippet'; + +export {GovMetricSnippet}; diff --git a/src/components/analytics/utils.js b/src/components/analytics/utils.js new file mode 100644 index 000000000..87ef5b1c5 --- /dev/null +++ b/src/components/analytics/utils.js @@ -0,0 +1,13 @@ +import {GOVMETRIC_URL} from './constants'; + +const buildGovMetricUrl = (govmetricSourceId, formSlug, govmetricSecureGuid) => { + let govmetricStopknop = new URL(`/theme/kf/${govmetricSourceId}`, GOVMETRIC_URL); + govmetricStopknop.searchParams.append('Q_Formid', formSlug); + if (govmetricSecureGuid) { + govmetricStopknop.searchParams.append('GUID', govmetricSecureGuid); + } + + return govmetricStopknop.href; +}; + +export {buildGovMetricUrl}; diff --git a/src/components/analytics/utils.spec.js b/src/components/analytics/utils.spec.js new file mode 100644 index 000000000..da9cdbfc3 --- /dev/null +++ b/src/components/analytics/utils.spec.js @@ -0,0 +1,17 @@ +import {buildGovMetricUrl} from './utils'; + +describe('Test analytics utils', () => { + test('Test build URL without GUID', () => { + const url = buildGovMetricUrl('1234', 'form-slug'); + + expect(url).toEqual('https://websurveys2.govmetric.com/theme/kf/1234?Q_Formid=form-slug'); + }); + + test('Test build URL with GUID', () => { + const url = buildGovMetricUrl('1234', 'form-slug', '4321'); + + expect(url).toEqual( + 'https://websurveys2.govmetric.com/theme/kf/1234?Q_Formid=form-slug&GUID=4321' + ); + }); +}); diff --git a/src/sdk.spec.js b/src/sdk.spec.js index c2ff4e1e6..0febfe035 100644 --- a/src/sdk.spec.js +++ b/src/sdk.spec.js @@ -1,12 +1,12 @@ import {act, waitForElementToBeRemoved, within} from '@testing-library/react'; -import {BASE_URL, buildForm, mockFormGet} from 'api-mocks'; +import {BASE_URL, buildForm, mockAnalyticsToolConfigGet, mockFormGet} from 'api-mocks'; import mswServer from 'api-mocks/msw-server'; import {mockFormioTranslations, mockLanguageInfoGet} from 'components/LanguageSelection/mocks'; import {OpenForm} from './sdk.js'; -// scrollIntoView is not not supported in Jest +// scrollIntoView is not supported in Jest let scrollIntoViewMock = jest.fn(); window.HTMLElement.prototype.scrollIntoView = scrollIntoViewMock; @@ -19,6 +19,7 @@ const apiMocks = [ mockFormGet(buildForm({translationEnabled: true})), mockLanguageInfoGet(LANGUAGES), mockFormioTranslations, + mockAnalyticsToolConfigGet(), ]; describe('OpenForm', () => { @@ -90,7 +91,8 @@ describe('OpenForm', () => { mockFormGet(formNL, true), mockFormGet(formEN, true), mockLanguageInfoGet(LANGUAGES), - mockFormioTranslations + mockFormioTranslations, + mockAnalyticsToolConfigGet() ); const form = new OpenForm(formRoot, { diff --git a/src/story-utils/decorators.js b/src/story-utils/decorators.js index c6246d5da..9a8728296 100644 --- a/src/story-utils/decorators.js +++ b/src/story-utils/decorators.js @@ -3,7 +3,7 @@ import {Formik} from 'formik'; import merge from 'lodash/merge'; import {MemoryRouter, Route, Routes} from 'react-router-dom'; -import {ConfigContext, FormContext} from 'Context'; +import {AnalyticsToolsConfigContext, ConfigContext, FormContext} from 'Context'; import {BASE_URL, buildForm} from 'api-mocks'; import Card from 'components/Card'; import {LiteralsProvider} from 'components/Literal'; @@ -30,6 +30,20 @@ export const ConfigDecorator = (Story, {parameters}) => { ); }; +export const AnalyticsToolsDecorator = (Story, {parameters, args}) => { + const defaults = { + govmetricSourceId: '', + govmetricSecureGuid: '', + enableGovmetricAnalytics: false, + }; + + return ( + + + + ); +}; + const RouterStoryWrapper = ({route = '', children}) => { if (!route) { return <>{children};