Skip to content

Commit e195fd2

Browse files
committed
fix(benefit): fix boolean field error messages
Boolean fields did not give error message. Fixed by adding proper properties. Refs: HL-1840
1 parent 452dd65 commit e195fd2

3 files changed

Lines changed: 320 additions & 12 deletions

File tree

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
import { act, renderHook } from '@testing-library/react';
2+
import { APPLICATION_FIELD_KEYS } from 'benefit/handler/constants';
3+
import DeMinimisContext from 'benefit/handler/context/DeMinimisContext';
4+
import { useApplicationFormContext } from 'benefit/handler/hooks/useApplicationFormContext';
5+
import useApplicationQueryWithState from 'benefit/handler/hooks/useApplicationQueryWithState';
6+
import useFormActions from 'benefit/handler/hooks/useFormActions';
7+
import { useSteps } from 'benefit/handler/hooks/useSteps';
8+
import useUserQuery from 'benefit/handler/hooks/useUserQuery';
9+
import { APPLICATION_ORIGINS } from 'benefit-shared/constants';
10+
import { useFormik } from 'formik';
11+
import { useRouter } from 'next/router';
12+
import { useTranslation } from 'next-i18next';
13+
import React from 'react';
14+
import { focusAndScroll } from 'shared/utils/dom.utils';
15+
16+
import { useApplicationForm } from '../useApplicationForm';
17+
import {
18+
getApplication,
19+
getDates,
20+
getFields,
21+
getSubsidyOptions,
22+
handleErrorFieldKeys,
23+
requiredAttachments,
24+
} from '../utils/applicationForm';
25+
26+
jest.mock('next/router', () => ({
27+
useRouter: jest.fn(),
28+
}));
29+
30+
jest.mock('next-i18next', () => ({
31+
useTranslation: jest.fn(),
32+
}));
33+
34+
jest.mock('formik', () => ({
35+
useFormik: jest.fn(),
36+
}));
37+
38+
jest.mock('benefit/handler/hooks/useApplicationFormContext', () => ({
39+
useApplicationFormContext: jest.fn(),
40+
}));
41+
42+
jest.mock('benefit/handler/hooks/useApplicationQueryWithState', () => ({
43+
__esModule: true,
44+
default: jest.fn(),
45+
}));
46+
47+
jest.mock('benefit/handler/hooks/useFormActions', () => ({
48+
__esModule: true,
49+
default: jest.fn(),
50+
}));
51+
52+
jest.mock('benefit/handler/hooks/useSteps', () => ({
53+
useSteps: jest.fn(),
54+
}));
55+
56+
jest.mock('benefit/handler/hooks/useUserQuery', () => ({
57+
__esModule: true,
58+
default: jest.fn(),
59+
}));
60+
61+
jest.mock('shared/utils/dom.utils', () => ({
62+
focusAndScroll: jest.fn(),
63+
}));
64+
65+
jest.mock('../utils/validation', () => ({
66+
getValidationSchema: jest.fn(() => ({})),
67+
}));
68+
69+
jest.mock('../utils/applicationForm', () => ({
70+
errorToast: jest.fn(),
71+
getApplication: jest.fn(),
72+
getDates: jest.fn(),
73+
getFields: jest.fn(),
74+
getSubsidyOptions: jest.fn(),
75+
handleErrorFieldKeys: jest.fn(),
76+
requiredAttachments: jest.fn(),
77+
}));
78+
79+
const onSave = jest.fn();
80+
const onQuietSave = jest.fn();
81+
const onSubmit = jest.fn();
82+
const onNext = jest.fn();
83+
const onDelete = jest.fn();
84+
const dispatchStep = jest.fn();
85+
const validateForm = jest.fn();
86+
const setTouched = jest.fn();
87+
const submitForm = jest.fn();
88+
const setFieldValue = jest.fn();
89+
90+
const application = {
91+
id: 'application-id',
92+
applicationOrigin: APPLICATION_ORIGINS.HANDLER,
93+
company: {
94+
organizationType: 'company',
95+
},
96+
attachments: [],
97+
applicantTermsInEffect: {
98+
applicantConsents: [],
99+
},
100+
deMinimisAidSet: [],
101+
};
102+
103+
const formikMock = {
104+
values: {
105+
...application,
106+
deMinimisAid: false,
107+
},
108+
validateForm,
109+
setTouched,
110+
submitForm,
111+
setFieldValue,
112+
};
113+
114+
const wrapper: React.FC<React.PropsWithChildren> = ({ children }) => (
115+
<DeMinimisContext.Provider
116+
value={{
117+
deMinimisAids: [],
118+
setDeMinimisAids: jest.fn(),
119+
unfinishedDeMinimisAidRow: false,
120+
}}
121+
>
122+
{children}
123+
</DeMinimisContext.Provider>
124+
);
125+
126+
describe('useApplicationForm', () => {
127+
beforeEach(() => {
128+
jest.clearAllMocks();
129+
130+
(useRouter as jest.Mock).mockReturnValue({
131+
query: {},
132+
push: jest.fn(),
133+
});
134+
135+
(useTranslation as jest.Mock).mockReturnValue({
136+
t: (key: string) => key,
137+
});
138+
139+
(useApplicationFormContext as jest.Mock).mockReturnValue({
140+
isFormActionNew: true,
141+
isFormActionEdit: false,
142+
});
143+
144+
(useSteps as jest.Mock).mockReturnValue({
145+
stepState: {
146+
activeStepIndex: 1,
147+
steps: [],
148+
},
149+
dispatchStep,
150+
activeStep: 1,
151+
});
152+
153+
(useFormActions as jest.Mock).mockReturnValue({
154+
onSave,
155+
onQuietSave,
156+
onSubmit,
157+
onNext,
158+
onDelete,
159+
});
160+
161+
(useApplicationQueryWithState as jest.Mock).mockReturnValue({
162+
status: 'idle',
163+
data: undefined,
164+
error: undefined,
165+
});
166+
167+
(useUserQuery as jest.Mock).mockReturnValue({
168+
data: {
169+
id: 'user-id',
170+
},
171+
});
172+
173+
(getApplication as jest.Mock).mockReturnValue(application);
174+
175+
(getFields as jest.Mock).mockReturnValue({
176+
endDate: {
177+
name: APPLICATION_FIELD_KEYS.END_DATE,
178+
},
179+
});
180+
181+
(getDates as jest.Mock).mockReturnValue({
182+
minEndDate: new Date('2024-01-01'),
183+
minEndDateFormatted: '1.1.2024',
184+
maxEndDate: undefined,
185+
isEndDateEligible: true,
186+
});
187+
188+
(getSubsidyOptions as jest.Mock).mockReturnValue([]);
189+
190+
(requiredAttachments as jest.Mock).mockReturnValue(true);
191+
192+
(handleErrorFieldKeys as jest.Mock).mockImplementation(
193+
(fieldKey) => fieldKey
194+
);
195+
196+
(useFormik as jest.Mock).mockReturnValue(formikMock);
197+
});
198+
199+
it('marks invalid top-level and nested fields as touched when validation fails', async () => {
200+
validateForm.mockResolvedValue({
201+
[APPLICATION_FIELD_KEYS.PURCHASED_SERVICE]: 'required',
202+
employee: {
203+
firstName: 'required',
204+
},
205+
});
206+
207+
const { result } = renderHook(() => useApplicationForm(), { wrapper });
208+
209+
let isValid = true;
210+
211+
await act(async () => {
212+
isValid = await result.current.handleValidation();
213+
});
214+
215+
expect(isValid).toBe(false);
216+
expect(setTouched).toHaveBeenCalledWith(
217+
{
218+
[APPLICATION_FIELD_KEYS.PURCHASED_SERVICE]: true,
219+
employee: {
220+
firstName: true,
221+
},
222+
},
223+
true
224+
);
225+
expect(focusAndScroll).toHaveBeenCalledWith(
226+
APPLICATION_FIELD_KEYS.PURCHASED_SERVICE
227+
);
228+
});
229+
230+
it('does not mark fields as touched when validation has no errors', async () => {
231+
validateForm.mockResolvedValue({});
232+
233+
const { result } = renderHook(() => useApplicationForm(), { wrapper });
234+
235+
let isValid = false;
236+
237+
await act(async () => {
238+
isValid = await result.current.handleValidation();
239+
});
240+
241+
expect(isValid).toBe(true);
242+
expect(setTouched).not.toHaveBeenCalled();
243+
expect(focusAndScroll).not.toHaveBeenCalled();
244+
});
245+
246+
it('marks invalid fields as touched and does not submit when saving invalid form', async () => {
247+
validateForm.mockResolvedValue({
248+
[APPLICATION_FIELD_KEYS.CO_OPERATION_NEGOTIATIONS]: 'required',
249+
});
250+
251+
const { result } = renderHook(() => useApplicationForm(), { wrapper });
252+
253+
await act(() => result.current.handleSave() as unknown as Promise<void>);
254+
255+
expect(setTouched).toHaveBeenCalledWith(
256+
{
257+
[APPLICATION_FIELD_KEYS.CO_OPERATION_NEGOTIATIONS]: true,
258+
},
259+
true
260+
);
261+
expect(focusAndScroll).toHaveBeenCalledWith(
262+
APPLICATION_FIELD_KEYS.CO_OPERATION_NEGOTIATIONS
263+
);
264+
expect(submitForm).not.toHaveBeenCalled();
265+
});
266+
267+
it('submits the form when saving valid form', async () => {
268+
validateForm.mockResolvedValue({});
269+
270+
const { result } = renderHook(() => useApplicationForm(), { wrapper });
271+
272+
await act(() => result.current.handleSave() as unknown as Promise<void>);
273+
274+
expect(setTouched).not.toHaveBeenCalled();
275+
expect(focusAndScroll).not.toHaveBeenCalled();
276+
expect(submitForm).toHaveBeenCalledTimes(1);
277+
});
278+
});

frontend/benefit/handler/src/components/applicationForm/formContent/companySection/CompanySection.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,8 +394,10 @@ const CompanySection: React.FC<Props> = ({
394394
id={`${fields.purchasedService.name}`}
395395
label={fields.purchasedService.label}
396396
direction="vertical"
397-
required
397+
invalid={!!getErrorMessage(fields.purchasedService.name)}
398398
errorText={getErrorMessage(fields.purchasedService.name)}
399+
aria-invalid={!!getErrorMessage(fields.purchasedService.name)}
400+
required
399401
>
400402
<$RadioButton
401403
id={`${fields.purchasedService.name}False`}
@@ -585,6 +587,8 @@ const CompanySection: React.FC<Props> = ({
585587
direction="vertical"
586588
required
587589
errorText={getErrorMessage(fields.coOperationNegotiations.name)}
590+
invalid={!!getErrorMessage(fields.coOperationNegotiations.name)}
591+
aria-invalid={!!getErrorMessage(fields.coOperationNegotiations.name)}
588592
>
589593
<$RadioButton
590594
id={`${fields.coOperationNegotiations.name}False`}

frontend/benefit/handler/src/components/applicationForm/useApplicationForm.ts

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
ApplicationData,
2626
DeMinimisAid,
2727
} from 'benefit-shared/types/application';
28-
import { FormikErrors, FormikProps, useFormik } from 'formik';
28+
import { FormikErrors, FormikProps, FormikTouched, useFormik } from 'formik';
2929
import cloneDeep from 'lodash/cloneDeep';
3030
import isEqual from 'lodash/isEqual';
3131
import { NextRouter, useRouter } from 'next/router';
@@ -59,10 +59,10 @@ type ExtendedComponentProps = {
5959
application: Application;
6060
formik: FormikProps<Partial<Application>>;
6161
fields: ApplicationFields;
62-
handleSave: () => void;
63-
handleSaveDraft: () => void;
62+
handleSave: () => Promise<void>;
63+
handleSaveDraft: () => Promise<void>;
6464
handleDelete: () => void;
65-
handleSubmit: () => void;
65+
handleSubmit: () => Promise<void>;
6666
handleQuietSave: () => Promise<ApplicationData | void>;
6767
handleValidation: () => Promise<boolean>;
6868
showDeminimisSection: boolean;
@@ -83,6 +83,26 @@ type ExtendedComponentProps = {
8383
user: User | undefined;
8484
};
8585

86+
const getTouchedFromErrors = <TValues,>(
87+
errors: FormikErrors<TValues>
88+
): FormikTouched<TValues> =>
89+
Object.keys(errors).reduce((touched, key) => {
90+
const error = errors[key as keyof FormikErrors<TValues>];
91+
92+
return {
93+
...touched,
94+
[key]: Array.isArray(error)
95+
? error.map((item) =>
96+
item && typeof item === 'object'
97+
? getTouchedFromErrors(item as FormikErrors<unknown>)
98+
: true
99+
)
100+
: error && typeof error === 'object'
101+
? getTouchedFromErrors(error as FormikErrors<unknown>)
102+
: true,
103+
};
104+
}, {} as FormikTouched<TValues>);
105+
86106
// eslint-disable-next-line sonarjs/cognitive-complexity
87107
export const useApplicationForm = (): ExtendedComponentProps => {
88108
const translationsBase = 'common:applications.actions';
@@ -299,18 +319,24 @@ export const useApplicationForm = (): ExtendedComponentProps => {
299319
return;
300320
}
301321

322+
if (Object.keys(errors).length > 0) {
323+
await formik.setTouched(getTouchedFromErrors(errors), true);
324+
}
325+
302326
if (!errorActions(errors)) {
303327
await formik.submitForm();
304328
}
305329
};
306330

307-
const handleValidation = (): Promise<boolean> =>
308-
formik.validateForm().then((errors) => {
309-
if (!errorActions(errors)) {
310-
return true;
311-
}
312-
return false;
313-
});
331+
const handleValidation = async (): Promise<boolean> => {
332+
const errors = await formik.validateForm();
333+
334+
if (Object.keys(errors).length > 0) {
335+
await formik.setTouched(getTouchedFromErrors(errors), true);
336+
}
337+
338+
return !errorActions(errors);
339+
};
314340

315341
const handleSubmit = async (): Promise<void> => {
316342
await onSubmit(values, id ?? undefined);

0 commit comments

Comments
 (0)