Skip to content

Commit ef7246f

Browse files
authored
[Form lib] Add useFormData() hook to listen to fields value changes (#76107)
1 parent 27bdc88 commit ef7246f

File tree

18 files changed

+482
-117
lines changed

18 files changed

+482
-117
lines changed

src/plugins/es_ui_shared/static/forms/components/fields/combobox_field.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export const ComboBoxField = ({ field, euiFieldProps = {}, ...rest }: Props) =>
7474
};
7575

7676
const onSearchComboChange = (value: string) => {
77-
if (value) {
77+
if (value !== undefined) {
7878
field.clearErrors(VALIDATION_TYPES.ARRAY_ITEM);
7979
}
8080
};

src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form.tsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import React, { ReactNode } from 'react';
2121
import { EuiForm } from '@elastic/eui';
2222

2323
import { FormProvider } from '../form_context';
24+
import { FormDataContextProvider } from '../form_data_context';
2425
import { FormHook } from '../types';
2526

2627
interface Props {
@@ -30,8 +31,14 @@ interface Props {
3031
[key: string]: any;
3132
}
3233

33-
export const Form = ({ form, FormWrapper = EuiForm, ...rest }: Props) => (
34-
<FormProvider form={form}>
35-
<FormWrapper {...rest} />
36-
</FormProvider>
37-
);
34+
export const Form = ({ form, FormWrapper = EuiForm, ...rest }: Props) => {
35+
const { getFormData, __getFormData$ } = form;
36+
37+
return (
38+
<FormDataContextProvider getFormData={getFormData} getFormData$={__getFormData$}>
39+
<FormProvider form={form}>
40+
<FormWrapper {...rest} />
41+
</FormProvider>
42+
</FormDataContextProvider>
43+
);
44+
};

src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.test.tsx

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -75,16 +75,7 @@ describe('<FormDataProvider />', () => {
7575
setInputValue('lastNameField', 'updated value');
7676
});
7777

78-
/**
79-
* The children will be rendered three times:
80-
* - Twice for each input value that has changed
81-
* - once because after updating both fields, the **form** isValid state changes (from "undefined" to "true")
82-
* causing a new "form" object to be returned and thus a re-render.
83-
*
84-
* When the form object will be memoized (in a future PR), te bellow call count should only be 2 as listening
85-
* to form data changes should not receive updates when the "isValid" state of the form changes.
86-
*/
87-
expect(onFormData.mock.calls.length).toBe(3);
78+
expect(onFormData).toBeCalledTimes(2);
8879

8980
const [formDataUpdated] = onFormData.mock.calls[onFormData.mock.calls.length - 1] as Parameters<
9081
OnUpdateHandler
@@ -130,7 +121,7 @@ describe('<FormDataProvider />', () => {
130121
find,
131122
} = setup() as TestBed;
132123

133-
expect(onFormData.mock.calls.length).toBe(0); // Not present in the DOM yet
124+
expect(onFormData).toBeCalledTimes(0); // Not present in the DOM yet
134125

135126
// Make some changes to the form fields
136127
await act(async () => {
@@ -188,7 +179,7 @@ describe('<FormDataProvider />', () => {
188179
setInputValue('lastNameField', 'updated value');
189180
});
190181

191-
expect(onFormData.mock.calls.length).toBe(0);
182+
expect(onFormData).toBeCalledTimes(0);
192183
});
193184

194185
test('props.pathsToWatch (Array<string>): should not re-render the children when the field that changed is not in the watch list', async () => {
@@ -228,14 +219,14 @@ describe('<FormDataProvider />', () => {
228219
});
229220

230221
// No re-render
231-
expect(onFormData.mock.calls.length).toBe(0);
222+
expect(onFormData).toBeCalledTimes(0);
232223

233224
// Make some changes to fields in the watch list
234225
await act(async () => {
235226
setInputValue('nameField', 'updated value');
236227
});
237228

238-
expect(onFormData.mock.calls.length).toBe(1);
229+
expect(onFormData).toBeCalledTimes(1);
239230

240231
onFormData.mockReset();
241232

src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.ts

Lines changed: 4 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -17,57 +17,20 @@
1717
* under the License.
1818
*/
1919

20-
import React, { useState, useEffect, useRef, useCallback } from 'react';
20+
import React from 'react';
2121

2222
import { FormData } from '../types';
23-
import { useFormContext } from '../form_context';
23+
import { useFormData } from '../hooks';
2424

2525
interface Props {
2626
children: (formData: FormData) => JSX.Element | null;
2727
pathsToWatch?: string | string[];
2828
}
2929

3030
export const FormDataProvider = React.memo(({ children, pathsToWatch }: Props) => {
31-
const form = useFormContext();
32-
const { subscribe } = form;
33-
const previousRawData = useRef<FormData>(form.__getFormData$().value);
34-
const isMounted = useRef(false);
35-
const [formData, setFormData] = useState<FormData>(previousRawData.current);
31+
const { 0: formData, 2: isReady } = useFormData({ watch: pathsToWatch });
3632

37-
const onFormData = useCallback(
38-
({ data: { raw } }) => {
39-
// To avoid re-rendering the children for updates on the form data
40-
// that we are **not** interested in, we can specify one or multiple path(s)
41-
// to watch.
42-
if (pathsToWatch) {
43-
const valuesToWatchArray = Array.isArray(pathsToWatch)
44-
? (pathsToWatch as string[])
45-
: ([pathsToWatch] as string[]);
46-
47-
if (valuesToWatchArray.some((value) => previousRawData.current[value] !== raw[value])) {
48-
previousRawData.current = raw;
49-
setFormData(raw);
50-
}
51-
} else {
52-
setFormData(raw);
53-
}
54-
},
55-
[pathsToWatch]
56-
);
57-
58-
useEffect(() => {
59-
const subscription = subscribe(onFormData);
60-
return subscription.unsubscribe;
61-
}, [subscribe, onFormData]);
62-
63-
useEffect(() => {
64-
isMounted.current = true;
65-
return () => {
66-
isMounted.current = false;
67-
};
68-
}, []);
69-
70-
if (!isMounted.current && Object.keys(formData).length === 0) {
33+
if (!isReady) {
7134
// No field has mounted yet, don't render anything
7235
return null;
7336
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* Licensed to Elasticsearch B.V. under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch B.V. licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import React, { createContext, useContext, useMemo } from 'react';
21+
22+
import { FormData, FormHook } from './types';
23+
import { Subject } from './lib';
24+
25+
export interface Context<T extends FormData = FormData> {
26+
getFormData$: () => Subject<FormData>;
27+
getFormData: FormHook<T>['getFormData'];
28+
}
29+
30+
const FormDataContext = createContext<Context<any> | undefined>(undefined);
31+
32+
interface Props extends Context {
33+
children: React.ReactNode;
34+
}
35+
36+
export const FormDataContextProvider = ({ children, getFormData$, getFormData }: Props) => {
37+
const value = useMemo<Context>(
38+
() => ({
39+
getFormData,
40+
getFormData$,
41+
}),
42+
[getFormData, getFormData$]
43+
);
44+
45+
return <FormDataContext.Provider value={value}>{children}</FormDataContext.Provider>;
46+
};
47+
48+
export function useFormDataContext<T extends FormData = FormData>() {
49+
return useContext<Context<T> | undefined>(FormDataContext);
50+
}

src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@
1919

2020
export { useField } from './use_field';
2121
export { useForm } from './use_form';
22+
export { useFormData } from './use_form_data';

src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,8 @@ export const useField = <T>(
254254

255255
validationErrors.push({
256256
...validationResult,
257+
// See comment below that explains why we add "__isBlocking__".
258+
__isBlocking__: validationResult.__isBlocking__ ?? validation.isBlocking,
257259
validationType: validationType || VALIDATION_TYPES.FIELD,
258260
});
259261

@@ -306,6 +308,11 @@ export const useField = <T>(
306308

307309
validationErrors.push({
308310
...(validationResult as ValidationError),
311+
// We add an "__isBlocking__" property to know if this error is a blocker or no.
312+
// Most validation errors are blockers but in some cases a validation is more a warning than an error
313+
// like with the ComboBox items when they are added.
314+
__isBlocking__:
315+
(validationResult as ValidationError).__isBlocking__ ?? validation.isBlocking,
309316
validationType: validationType || VALIDATION_TYPES.FIELD,
310317
});
311318

@@ -394,7 +401,13 @@ export const useField = <T>(
394401
);
395402

396403
const _setErrors: FieldHook<T>['setErrors'] = useCallback((_errors) => {
397-
setErrors(_errors.map((error) => ({ validationType: VALIDATION_TYPES.FIELD, ...error })));
404+
setErrors(
405+
_errors.map((error) => ({
406+
validationType: VALIDATION_TYPES.FIELD,
407+
__isBlocking__: true,
408+
...error,
409+
}))
410+
);
398411
}, []);
399412

400413
/**
@@ -463,7 +476,8 @@ export const useField = <T>(
463476
[setValue, deserializeValue, defaultValue]
464477
);
465478

466-
const isValid = errors.length === 0;
479+
// Don't take into account non blocker validation. Some are just warning (like trying to add a wrong ComboBox item)
480+
const isValid = errors.filter((e) => e.__isBlocking__ !== false).length === 0;
467481

468482
const field = useMemo<FieldHook<T>>(() => {
469483
return {

src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ const onFormHook = (_form: FormHook<any>) => {
3939
formHook = _form;
4040
};
4141

42-
describe('use_form() hook', () => {
42+
describe('useForm() hook', () => {
4343
beforeEach(() => {
4444
formHook = null;
4545
});

src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,12 @@ export function useForm<T extends FormData = FormData>(
240240

241241
if (!field.isValidated) {
242242
setIsValid(undefined);
243+
244+
// When we submit the form (and set "isSubmitted" to "true"), we validate **all fields**.
245+
// If a field is added and it is not validated it means that we have swapped fields and added new ones:
246+
// --> we have basically have a new form in front of us.
247+
// For that reason we make sure that the "isSubmitted" state is false.
248+
setIsSubmitted(false);
243249
}
244250
},
245251
[updateFormDataAt]
@@ -389,6 +395,7 @@ export function useForm<T extends FormData = FormData>(
389395
isValid,
390396
id,
391397
submit: submitForm,
398+
validate: validateAllFields,
392399
subscribe,
393400
setFieldValue,
394401
setFieldErrors,
@@ -428,6 +435,7 @@ export function useForm<T extends FormData = FormData>(
428435
addField,
429436
removeField,
430437
validateFields,
438+
validateAllFields,
431439
]);
432440

433441
useEffect(() => {

0 commit comments

Comments
 (0)