Skip to content

Commit 118ddf9

Browse files
feat: add "Is part of cultural route" selection to enrolment form
also: - with the request of the product owner don't restore any previous answer to "Is part of cultural route" question from local storage persisted Formik form's data, but instead always reset the question to its initial value i.e. no data given at all - update generated GraphQL types using backend running City-of-Helsinki/palvelutarjotin#509 i.e. the backend support for the `is_part_of_cultural_route` field in enrolments and queue enrolments - add tests - update playwright tests - load en/sv as well for tests instead of just fi as before - used for testing localization of "read more" url Refs: PT-453
1 parent 482517c commit 118ddf9

23 files changed

Lines changed: 439 additions & 30 deletions

File tree

public/locales/en/enrolment.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@
1717
"labelIsSharingDataAccepted": "I agree to share my information with the event organizer",
1818
"labelLanguage": "Language",
1919
"labelNotificationLanguage": "Language used in notifications",
20+
"culturalRoute": {
21+
"labelIsPartOf": "The visit is part of the cultural route",
22+
"labelNoOrUnknown": "No / I don't know",
23+
"labelYes": "Yes",
24+
"helperText": "Cultural routes are implementation proposals for the City of Helsinki's cultural education plan from early childhood education to upper secondary education. <a>Read more</a>",
25+
"readMoreUrl": "/en/cms-page/cultural-education/cultural-routes"
26+
},
2027
"person": {
2128
"labelEmailAddress": "Email address",
2229
"labelName": "Name",

public/locales/fi/enrolment.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@
1717
"labelIsSharingDataAccepted": "Hyväksyn tietojeni jakamisen tapahtuman järjestäjän kanssa",
1818
"labelLanguage": "Kieli",
1919
"labelNotificationLanguage": "Ilmoitusten kieli",
20+
"culturalRoute": {
21+
"labelIsPartOf": "Käynti sisältyy kulttuuripolkuun",
22+
"labelNoOrUnknown": "Ei / en tiedä",
23+
"labelYes": "Kyllä",
24+
"helperText": "Kulttuuripolut ovat Helsingin kaupungin kulttuurikasvatussuunnitelman toteutusehdotuksia varhaiskasvatuksesta toiselle asteelle. <a>Lue lisää kulttuuripoluista</a>",
25+
"readMoreUrl": "/fi/cms-page/kulttuurikasvatus/kulttuuripolulla-kulttuuria-kaikille"
26+
},
2027
"person": {
2128
"labelEmailAddress": "Sähköpostiosoite",
2229
"labelName": "Nimi",

public/locales/sv/enrolment.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@
1717
"labelIsSharingDataAccepted": "Jag samtycker till att dela mina uppgifter med arrangören av evenemanget.",
1818
"labelLanguage": "Språk",
1919
"labelNotificationLanguage": "Språket för meddelanden",
20+
"culturalRoute": {
21+
"labelIsPartOf": "Besöket ingår i kulturstigen",
22+
"labelNoOrUnknown": "Nej / jag vet inte",
23+
"labelYes": "Ja",
24+
"helperText": "Kulturstigarna är förslag på genomförande av Helsingfors stads kulturfostransplan från småbarnspedagogik till gymnasieutbildning. <a>Läs mer om kulturstigarna</a>",
25+
"readMoreUrl": "/sv/cms-page/kulturpedagogik/kulturstigen-kultur-for-alla"
26+
},
2027
"person": {
2128
"labelEmailAddress": "E-post",
2229
"labelName": "Namn",

src/common/components/formikPersist/FormikPersist.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { FormikProps, useFormikContext } from 'formik';
2+
import cloneDeep from 'lodash/cloneDeep';
3+
import get from 'lodash/get';
24
import isEqual from 'lodash/isEqual';
5+
import set from 'lodash/set';
36
import * as React from 'react';
47

58
import useIsMounted from '../../../hooks/useIsMounted';
@@ -26,13 +29,16 @@ export interface PersistProps {
2629
debounceTime?: number;
2730
isSessionStorage?: boolean;
2831
initialValues: Record<string, unknown>;
32+
// Dot-notation paths of fields that are restored using initial values:
33+
alwaysFreshFields?: string[];
2934
}
3035

3136
const FormikPersist = ({
3237
debounceTime = 300,
3338
isSessionStorage = false,
3439
name,
3540
initialValues,
41+
alwaysFreshFields = [],
3642
}: PersistProps): null => {
3743
const isMounted = useIsMounted();
3844
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -78,7 +84,12 @@ const FormikPersist = ({
7884
storedFormikStateObject &&
7985
objectStructureMatches(initialValues, storedFormikStateObject.values)
8086
) {
81-
formik.setValues(storedFormikStateObject.values, true);
87+
const valuesToRestore = cloneDeep(storedFormikStateObject.values);
88+
// Restore fresh fields with their initial values instead of stored ones:
89+
for (const path of alwaysFreshFields) {
90+
set(valuesToRestore, path, get(initialValues, path));
91+
}
92+
formik.setValues(valuesToRestore, true);
8293
}
8394
// eslint-disable-next-line react-hooks/exhaustive-deps
8495
}, []);
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { IsPartOfCulturalRoute } from '../enrolmentForm/constants';
2+
import { validIsPartOfCulturalRouteToBoolean } from '../utils';
3+
4+
describe('validIsPartOfCulturalRouteToBoolean', () => {
5+
test.each<IsPartOfCulturalRoute | string>([
6+
IsPartOfCulturalRoute.YES, // Test that the enum version works
7+
'YES', // Test that the string version also works
8+
])('validIsPartOfCulturalRouteToBoolean(%p) returns true', (input) => {
9+
expect(validIsPartOfCulturalRouteToBoolean(input)).toBe(true);
10+
});
11+
12+
test.each<IsPartOfCulturalRoute | string>([
13+
IsPartOfCulturalRoute.NO_OR_UNKNOWN, // Test that the enum version works
14+
'NO_OR_UNKNOWN', // Test that the string version also works
15+
])('validIsPartOfCulturalRouteToBoolean(%p) returns false', (input) => {
16+
expect(validIsPartOfCulturalRouteToBoolean(input)).toBe(false);
17+
});
18+
19+
test.each(['', ' ', 'yes', 'NO', 'unanswered', null, undefined, true, 0, 1])(
20+
'validIsPartOfCulturalRouteToBoolean(%p) throws on invalid value',
21+
(input) => {
22+
expect(() => validIsPartOfCulturalRouteToBoolean(input)).toThrow(
23+
`Invalid isPartOfCulturalRoute value ${input}`
24+
);
25+
}
26+
);
27+
});

src/domain/enrolment/enrolmentForm/EnrolmentForm.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
nameToLabelPath,
1818
} from './constants';
1919
import styles from './enrolmentForm.module.scss';
20+
import IsPartOfCulturalRouteField from './IsPartOfCulturalRouteField';
2021
import getValidationSchema from './ValidationSchema';
2122
import ErrorMessage from '../../../common/components/form/ErrorMessage';
2223
import CheckboxField from '../../../common/components/form/fields/CheckboxField';
@@ -103,6 +104,7 @@ const EnrolmentForm: React.FC<Props> = ({
103104
<FormikPersist
104105
name={FORM_NAMES.ENROLMENT_FORM}
105106
initialValues={initialValues}
107+
alwaysFreshFields={['isPartOfCulturalRoute']}
106108
/>
107109
<h2>{t('enrolment:enrolmentForm.studyGroup.titleNotifier')}</h2>
108110
<FormErrorNotification
@@ -206,6 +208,10 @@ const EnrolmentForm: React.FC<Props> = ({
206208
</FormGroup>
207209
</div>
208210

211+
<FormGroup>
212+
<IsPartOfCulturalRouteField formikFieldName="isPartOfCulturalRoute" />
213+
</FormGroup>
214+
209215
<div className={styles.rowWith2Columns}>
210216
<div>
211217
<h2>{t('enrolment:enrolmentForm.studyGroup.titleGroup')}</h2>
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { Link } from '@city-of-helsinki/react-helsinki-headless-cms';
2+
import { useField } from 'formik';
3+
import { RadioButton, SelectionGroup } from 'hds-react';
4+
import { Trans, useTranslation } from 'next-i18next';
5+
import React from 'react';
6+
7+
import { IsPartOfCulturalRoute, nameToLabelPath } from './constants';
8+
import type { I18nNamespace } from '../../../types';
9+
10+
type Props = {
11+
formikFieldName: string;
12+
};
13+
14+
/**
15+
* React component for "Is part of cultural route?" field in the enrolment form,
16+
* with choices of "No / I don't know" and "Yes".
17+
*/
18+
const IsPartOfCulturalRouteField: React.FC<Props> = ({ formikFieldName }) => {
19+
const { t } = useTranslation<I18nNamespace>();
20+
const [field, meta] = useField(formikFieldName);
21+
const errorText = meta.touched && meta.error ? t(meta.error) : undefined;
22+
const readMoreUrl = t('enrolment:enrolmentForm.culturalRoute.readMoreUrl');
23+
24+
return (
25+
<SelectionGroup
26+
label={t(nameToLabelPath['isPartOfCulturalRoute'])}
27+
direction="horizontal"
28+
required
29+
aria-required
30+
errorText={errorText}
31+
/* @ts-expect-error TS2322 SelectionGroup's types allow only string, but Trans works */
32+
helperText={
33+
<Trans
34+
t={t}
35+
i18nKey={'enrolment:enrolmentForm.culturalRoute.helperText'}
36+
components={{ a: <Link href={readMoreUrl} /> }}
37+
/>
38+
}
39+
>
40+
<RadioButton
41+
id="isPartOfCulturalRoute-noOrUnknown"
42+
name={field.name}
43+
value={IsPartOfCulturalRoute.NO_OR_UNKNOWN}
44+
label={t('enrolment:enrolmentForm.culturalRoute.labelNoOrUnknown')}
45+
checked={field.value === IsPartOfCulturalRoute.NO_OR_UNKNOWN}
46+
onChange={field.onChange}
47+
onBlur={field.onBlur}
48+
/>
49+
<RadioButton
50+
id="isPartOfCulturalRoute-yes"
51+
name={field.name}
52+
value={IsPartOfCulturalRoute.YES}
53+
label={t('enrolment:enrolmentForm.culturalRoute.labelYes')}
54+
checked={field.value === IsPartOfCulturalRoute.YES}
55+
onChange={field.onChange}
56+
onBlur={field.onBlur}
57+
/>
58+
</SelectionGroup>
59+
);
60+
};
61+
62+
export default IsPartOfCulturalRouteField;

src/domain/enrolment/enrolmentForm/ValidationSchema.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as Yup from 'yup';
22

3+
import { IsPartOfCulturalRoute } from './constants';
34
import { VALIDATION_MESSAGE_KEYS } from '../../app/forms/constants';
45

56
type EnrolmentFormValidationSchemaProps = {
@@ -14,6 +15,10 @@ export default function getValidationSchema({
1415
isQueueEnrolment,
1516
}: EnrolmentFormValidationSchemaProps) {
1617
return Yup.object().shape({
18+
isPartOfCulturalRoute: Yup.string().oneOf(
19+
[IsPartOfCulturalRoute.NO_OR_UNKNOWN, IsPartOfCulturalRoute.YES],
20+
VALIDATION_MESSAGE_KEYS.STRING_REQUIRED
21+
),
1722
hasEmailNotification: Yup.bool().oneOf(
1823
[true],
1924
'enrolment:enrolmentForm.validation.hasEmailNotification'

src/domain/enrolment/enrolmentForm/__tests__/EnrolmentForm.test.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ import {
2323
within,
2424
sleep,
2525
} from '../../../../utils/testUtils';
26-
import { defaultEnrolmentInitialValues } from '../constants';
26+
import {
27+
defaultEnrolmentInitialValues,
28+
IsPartOfCulturalRoute,
29+
} from '../constants';
2730
import EnrolmentForm, { Props } from '../EnrolmentForm';
2831

2932
configure({ defaultHidden: true });
@@ -113,6 +116,14 @@ test('renders form and user can fill it and submit and form is saved to local st
113116

114117
// // close dropdown
115118

119+
// Answer "Is part of cultural route?" question
120+
const culturalRouteGroup = await screen.findByRole('group', {
121+
name: /käynti sisältyy kulttuuripolkuun/i,
122+
});
123+
await userEvent.click(
124+
within(culturalRouteGroup).getByRole('radio', { name: /kyllä/i })
125+
);
126+
116127
await userEvent.click(screen.getByRole('combobox', { name: /luokka-aste/i }));
117128

118129
await userEvent.type(screen.getByLabelText(/lapsia/i), '10');
@@ -411,6 +422,7 @@ if (isFeatureEnabled('FORMIK_PERSIST')) {
411422
isSameResponsiblePerson: true,
412423
isSharingDataAccepted: false,
413424
isMandatoryAdditionalInformationRequired: false,
425+
isPartOfCulturalRoute: IsPartOfCulturalRoute.NO_OR_UNKNOWN,
414426
language: '',
415427
studyGroup: {
416428
person: {
@@ -504,6 +516,23 @@ if (isFeatureEnabled('FORMIK_PERSIST')) {
504516
expect(
505517
screen.getByRole('combobox', { name: /luokka-aste/i })
506518
).toHaveTextContent('1. luokka');
519+
520+
// Test that "Is part of cultural route?" question is not answered,
521+
// because this field should not be restored from local storage:
522+
const culturalRouteGroup = await screen.findByRole('group', {
523+
name: /käynti sisältyy kulttuuripolkuun/i,
524+
});
525+
const isNotPartOfCulturalRoute = within(culturalRouteGroup).getByRole(
526+
'radio',
527+
{ name: /ei \/ en tiedä/i }
528+
);
529+
const isPartOfCulturalRoute = within(culturalRouteGroup).getByRole(
530+
'radio',
531+
{ name: /kyllä/i }
532+
);
533+
expect(isNotPartOfCulturalRoute).not.toBeChecked();
534+
expect(isPartOfCulturalRoute).not.toBeChecked();
535+
507536
const childrenCountInput = screen.getByRole('spinbutton', {
508537
name: /lapsia \*/i,
509538
});
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { Formik } from 'formik';
2+
import * as React from 'react';
3+
4+
import i18n from '../../../../tests/initI18n';
5+
import {
6+
render,
7+
screen,
8+
userEvent,
9+
within,
10+
configure,
11+
} from '../../../../utils/testUtils';
12+
import { IsPartOfCulturalRoute } from '../constants';
13+
import IsPartOfCulturalRouteField from '../IsPartOfCulturalRouteField';
14+
15+
configure({ defaultHidden: true });
16+
17+
const renderComponent = (initialValue: IsPartOfCulturalRoute | '' = '') =>
18+
render(
19+
<Formik
20+
initialValues={{ isPartOfCulturalRoute: initialValue }}
21+
onSubmit={jest.fn()}
22+
>
23+
<IsPartOfCulturalRouteField formikFieldName="isPartOfCulturalRoute" />
24+
</Formik>
25+
);
26+
27+
const getGroup = () =>
28+
screen.findByRole('group', { name: /käynti sisältyy kulttuuripolkuun/i });
29+
30+
const getNoOrUnknownRadio = (group: HTMLElement) =>
31+
within(group).getByRole('radio', { name: /ei \/ en tiedä/i });
32+
33+
const getYesRadio = (group: HTMLElement) =>
34+
within(group).getByRole('radio', { name: /kyllä/i });
35+
36+
describe('IsPartOfCulturalRouteField', () => {
37+
it('renders the selection group with both radio options', async () => {
38+
renderComponent();
39+
const group = await getGroup();
40+
expect(getNoOrUnknownRadio(group)).toBeInTheDocument();
41+
expect(getYesRadio(group)).toBeInTheDocument();
42+
});
43+
44+
it('has no radio checked when initial value is empty', async () => {
45+
renderComponent('');
46+
const group = await getGroup();
47+
expect(getNoOrUnknownRadio(group)).not.toBeChecked();
48+
expect(getYesRadio(group)).not.toBeChecked();
49+
});
50+
51+
it('checks "No / I don\'t know" when initial value is NO_OR_UNKNOWN', async () => {
52+
renderComponent(IsPartOfCulturalRoute.NO_OR_UNKNOWN);
53+
const group = await getGroup();
54+
expect(getNoOrUnknownRadio(group)).toBeChecked();
55+
expect(getYesRadio(group)).not.toBeChecked();
56+
});
57+
58+
it('checks "Yes" when initial value is YES', async () => {
59+
renderComponent(IsPartOfCulturalRoute.YES);
60+
const group = await getGroup();
61+
expect(getNoOrUnknownRadio(group)).not.toBeChecked();
62+
expect(getYesRadio(group)).toBeChecked();
63+
});
64+
65+
it('checks only "Yes" radio after clicking it', async () => {
66+
renderComponent('');
67+
const group = await getGroup();
68+
await userEvent.click(getYesRadio(group));
69+
expect(getYesRadio(group)).toBeChecked();
70+
expect(getNoOrUnknownRadio(group)).not.toBeChecked();
71+
});
72+
73+
it('checks only "No / I don\'t know" radio after clicking it', async () => {
74+
renderComponent('');
75+
const group = await getGroup();
76+
await userEvent.click(getNoOrUnknownRadio(group));
77+
expect(getNoOrUnknownRadio(group)).toBeChecked();
78+
expect(getYesRadio(group)).not.toBeChecked();
79+
});
80+
81+
test.each([
82+
{
83+
lang: 'fi',
84+
groupLabel: /käynti sisältyy kulttuuripolkuun/i,
85+
linkName: 'Lue lisää kulttuuripoluista',
86+
href: '/fi/cms-page/kulttuurikasvatus/kulttuuripolulla-kulttuuria-kaikille',
87+
},
88+
{
89+
lang: 'en',
90+
groupLabel: /the visit is part of the cultural route/i,
91+
linkName: 'Read more',
92+
href: '/en/cms-page/cultural-education/cultural-routes',
93+
},
94+
{
95+
lang: 'sv',
96+
groupLabel: /besöket ingår i kulturstigen/i,
97+
linkName: 'Läs mer om kulturstigarna',
98+
href: '/sv/cms-page/kulturpedagogik/kulturstigen-kultur-for-alla',
99+
},
100+
])(
101+
'renders "read more" link with correct translated href for $lang',
102+
async ({ lang, groupLabel, linkName, href }) => {
103+
await i18n.changeLanguage(lang);
104+
renderComponent();
105+
await screen.findByRole('group', { name: groupLabel });
106+
const link = screen.getByRole('link', { name: linkName });
107+
expect(link).toHaveAttribute('href', href);
108+
await i18n.changeLanguage('fi');
109+
}
110+
);
111+
});

0 commit comments

Comments
 (0)