Skip to content

Commit

Permalink
Merge pull request #544 from open-formulieren/feature/preselect-produ…
Browse files Browse the repository at this point in the history
…ct-via-query

Pre-select product via query string
  • Loading branch information
sergei-maertens authored Sep 5, 2023
2 parents 6d99309 + 94f907e commit 42a8a63
Show file tree
Hide file tree
Showing 5 changed files with 97 additions and 5 deletions.
12 changes: 11 additions & 1 deletion src/components/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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/*');
Expand All @@ -70,7 +72,15 @@ const App = ({noDebug = false}) => {

const isAppointment = form.appointmentOptions?.isAppointment ?? false;
if (isAppointment && !appointmentMatch && !appointmentCancelMatch) {
return <Navigate replace to="../afspraak-maken" />;
return (
<Navigate
replace
to={{
pathname: '../afspraak-maken',
search: `?${query}`,
}}
/>
);
}

return (
Expand Down
12 changes: 11 additions & 1 deletion src/components/appointments/ChooseProductStep.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -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
Expand All @@ -189,7 +199,7 @@ const ChooseProductStep = ({navigateTo = null}) => {
modifiers={['padded']}
/>
<Formik
initialValues={{...INITIAL_VALUES, ...stepData}}
initialValues={{...initialValues, ...stepData}}
initialErrors={initialErrors}
initialTouched={initialTouched}
validateOnChange={false}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

<Story of={CreateAppointmentStories.Default} />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
17 changes: 16 additions & 1 deletion src/components/appointments/CreateAppointment/routes.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 (
<Navigate
replace
to={{
pathname: APPOINTMENT_STEP_PATHS[0],
search: `?${query}`,
}}
/>
);
};

/**
* Route subtree for appointment forms.
*/
export const routes = [
{
path: '',
element: <Navigate replace to={APPOINTMENT_STEP_PATHS[0]} />,
element: <LandingPage />,
},
...APPOINTMENT_STEPS.map(({path, element}) => ({path, element})),
{
Expand Down

0 comments on commit 42a8a63

Please sign in to comment.