From aaa82338c7b7eca03c64ca585607d86a03510b5b Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Mon, 4 Sep 2023 08:57:51 +0200 Subject: [PATCH 1/4] :sparkles: [#3300] Set up redirect chain with query param retention TODO: figure out if/how this works with fragment-based routing? --- src/components/App.js | 12 +++++++++++- .../appointments/CreateAppointment/routes.js | 17 ++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/components/App.js b/src/components/App.js index 3a5255159..415e87ef9 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -12,6 +12,7 @@ import {LayoutRow} from 'components/Layout'; import {CreateAppointment, appointmentRoutes} from 'components/appointments'; import ManageAppointment from 'components/appointments/ManageAppointment'; import useFormContext from 'hooks/useFormContext'; +import useQuery from 'hooks/useQuery'; import useZodErrorMap from 'hooks/useZodErrorMap'; import {I18NContext} from 'i18n'; import {DEBUG} from 'utils'; @@ -55,6 +56,7 @@ Top level router - routing between an actual form or supporting screens. */ const App = ({noDebug = false}) => { const form = useFormContext(); + const query = useQuery(); const config = useContext(ConfigContext); const appointmentMatch = useMatch('afspraak-maken/*'); const appointmentCancelMatch = useMatch('afspraak-annuleren/*'); @@ -70,7 +72,15 @@ const App = ({noDebug = false}) => { const isAppointment = form.appointmentOptions?.isAppointment ?? false; if (isAppointment && !appointmentMatch && !appointmentCancelMatch) { - return ; + return ( + + ); } return ( diff --git a/src/components/appointments/CreateAppointment/routes.js b/src/components/appointments/CreateAppointment/routes.js index ee6cea1be..38ad4f509 100644 --- a/src/components/appointments/CreateAppointment/routes.js +++ b/src/components/appointments/CreateAppointment/routes.js @@ -1,6 +1,8 @@ import {defineMessage} from 'react-intl'; import {Navigate, matchPath, resolvePath} from 'react-router-dom'; +import useQuery from 'hooks/useQuery'; + import ChooseProductStep from '../ChooseProductStep'; import ContactDetailsStep from '../ContactDetailsStep'; import LocationAndTimeStep from '../LocationAndTimeStep'; @@ -36,13 +38,26 @@ export const APPOINTMENT_STEPS = [ export const APPOINTMENT_STEP_PATHS = APPOINTMENT_STEPS.map(s => s.path); +const LandingPage = () => { + const query = useQuery(); + return ( + + ); +}; + /** * Route subtree for appointment forms. */ export const routes = [ { path: '', - element: , + element: , }, ...APPOINTMENT_STEPS.map(({path, element}) => ({path, element})), { From 3a280cf1806a9e1fa5739128abfa30c023bc1372 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Mon, 4 Sep 2023 09:04:56 +0200 Subject: [PATCH 2/4] :sparkles: [#3300] Pass initial product ID via querystring param We can use immer to conditionally alter the Formik initial values when loading the products page. --- src/components/appointments/ChooseProductStep.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/appointments/ChooseProductStep.js b/src/components/appointments/ChooseProductStep.js index 785a64490..c545ce2da 100644 --- a/src/components/appointments/ChooseProductStep.js +++ b/src/components/appointments/ChooseProductStep.js @@ -1,4 +1,5 @@ import {FieldArray, Form, Formik} from 'formik'; +import produce from 'immer'; import PropTypes from 'prop-types'; import React, {useContext} from 'react'; import {flushSync} from 'react-dom'; @@ -11,6 +12,7 @@ import Button from 'components/Button'; import {CardTitle} from 'components/Card'; import FAIcon from 'components/FAIcon'; import {Toolbar, ToolbarList} from 'components/Toolbar'; +import useQuery from 'hooks/useQuery'; import useTitle from 'hooks/useTitle'; import {getBEMClassName} from 'utils'; @@ -170,6 +172,14 @@ const ChooseProductStep = ({navigateTo = null}) => { defaultMessage: 'Product', }) ); + const query = useQuery(); + const initialProductId = query.get('product'); + + const initialValues = produce(INITIAL_VALUES, draft => { + if (initialProductId) { + draft.products[0].productId = initialProductId; + } + }); const validationSchema = supportsMultipleProducts ? chooseMultiProductSchema @@ -189,7 +199,7 @@ const ChooseProductStep = ({navigateTo = null}) => { modifiers={['padded']} /> Date: Mon, 4 Sep 2023 09:45:31 +0200 Subject: [PATCH 3/4] :white_check_mark: [#3300] Add tests for product auto-select --- .../CreateAppointment.spec.js | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/components/appointments/CreateAppointment/CreateAppointment.spec.js b/src/components/appointments/CreateAppointment/CreateAppointment.spec.js index e646a76b4..ce230e5ff 100644 --- a/src/components/appointments/CreateAppointment/CreateAppointment.spec.js +++ b/src/components/appointments/CreateAppointment/CreateAppointment.spec.js @@ -195,3 +195,40 @@ describe('The create appointment wrapper', () => { expect(screen.queryByRole('heading', {name: 'Contact details'})).not.toBeInTheDocument(); }); }); + +describe('Preselecting a product via querystring', () => { + it('displays the preselected product in the dropdown', async () => { + mswServer.use(mockSubmissionPost(buildSubmission({steps: []})), mockAppointmentProductsGet); + + renderApp('/?product=166a5c79'); + + const productDropdown = await screen.findByRole('combobox'); + expect(productDropdown).toBeVisible(); + // and the product should be auto selected + expect(await screen.findByText('Paspoort aanvraag')).toBeVisible(); + }); + + it('does not crash on invalid product IDs', async () => { + mswServer.use(mockSubmissionPost(buildSubmission({steps: []})), mockAppointmentProductsGet); + const user = userEvent.setup({delay: null}); + + renderApp('/?product=bb72a36b-b791'); + + const productDropdown = await screen.findByRole('combobox'); + expect(productDropdown).toBeVisible(); + // nothing should be selected + expect(screen.queryByText('Paspoort aanvraag')).not.toBeInTheDocument(); + expect(screen.queryByText('Rijbewijs aanvraag (Drivers license)')).not.toBeInTheDocument(); + expect(screen.queryByText('Not available with drivers license')).not.toBeInTheDocument(); + + // now open the dropdown and select a product + await user.click(productDropdown); + await user.keyboard('[ArrowDown]'); + const option = await screen.findByText('Paspoort aanvraag'); + expect(option).toBeVisible(); + await user.click(option); + expect(screen.queryByText('Rijbewijs aanvraag (Drivers license)')).not.toBeInTheDocument(); + expect(screen.queryByText('Not available with drivers license')).not.toBeInTheDocument(); + expect(await screen.findByText('Paspoort aanvraag')).toBeVisible(); + }); +}); From 94f907e11992f74f9faffdb84ab66cd3ebd03da8 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Mon, 4 Sep 2023 10:20:59 +0200 Subject: [PATCH 4/4] :pencil: [#3300] Document appointment creation component --- .../CreateAppointment/CreateAppointment.mdx | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/components/appointments/CreateAppointment/CreateAppointment.mdx b/src/components/appointments/CreateAppointment/CreateAppointment.mdx index bafdfacbd..0bdf79969 100644 --- a/src/components/appointments/CreateAppointment/CreateAppointment.mdx +++ b/src/components/appointments/CreateAppointment/CreateAppointment.mdx @@ -11,9 +11,29 @@ The create appointment UI is used when `form.appointmentOptions.isAppointment` i brings the user in a (mostly) fixed form step progression to select their products, appointment details and provide contact details. -**NOTE** +## Features -This is currently under development. +**Multi vs. single product flow** + +The backend exposes information whether the enabled plugin for appointments supports multi-product +appointments or not. This drives the appointment flow - for multi-product appointments, you can add +additional products and the list of available products is fetched while passing the already selected +products to the backend - this allows only valid combinations to be returned. + +For single-product, the UI is slimmed down a little bit to remove the multi-product controls, and +the "amount" field is fixed to the number "1". + +**Pre-selecting a product** + +You can link to the appointments flow with an optional query string parameter `product`, which +should have the value of a valid product ID. If provided, that product will be pre-selected in the +list of all products. Note that this only applies to the first product in the case of multi-product +forms. + +For example, the following URLs will pre-select a product: + +- `https://example.com/appointment/?product=my-product-id`, for the regular routing +- `https://example.com/#/appointment/?product=my-product-id`, for hash-based routing