diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/use_async_validation_data.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/use_async_validation_data.mdx new file mode 100644 index 0000000000000..8020a54596b46 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/docs/core/use_async_validation_data.mdx @@ -0,0 +1,36 @@ +--- +id: formLibCoreUseAsyncValidationData +slug: /form-lib/core/use-async-validation-data +title: useAsyncValidationData() +summary: Provide dynamic data to your validators... asynchronously +tags: ['forms', 'kibana', 'dev'] +date: 2021-08-20 +--- + +**Returns:** `[Observable, (nextValue: T|undefined) => void]` + +This hook creates for you an observable and a handler to update its value. You can then pass the observable directly to . + +See an example on how to use this hook in the section. + +## Options + +### state (optional) + +**Type:** `any` + +If you provide a state when calling the hook, the observable value will keep in sync with the state. + +```js +const MyForm = () => { + ... + const [indices, setIndices] = useState([]); + // Whenever the "indices" state changes, the "indices$" Observable will be updated + const [indices$] = useAsyncValidationData(indices); + + ... + + + +} +``` \ No newline at end of file diff --git a/src/plugins/es_ui_shared/static/forms/docs/core/use_field.mdx b/src/plugins/es_ui_shared/static/forms/docs/core/use_field.mdx index b1d70d05c8d27..fd5f3b26cdf0d 100644 --- a/src/plugins/es_ui_shared/static/forms/docs/core/use_field.mdx +++ b/src/plugins/es_ui_shared/static/forms/docs/core/use_field.mdx @@ -336,6 +336,18 @@ If you provide a `component` you can pass here any prop you want to forward to t By default if you don't provide a `defaultValue` prop to ``, it will try to read the default value on . If you want to prevent this behaviour you can set `readDefaultValueOnForm` to false. This can be usefull for dynamic fields, as . +### validationData + +Use this prop to pass down dynamic data to your field validator. The data is then accessible in the validator through the `customData.value` property. + +See an example on how to use this prop in the section. + +### validationData$ + +Use this prop to pass down an Observable into which you can send, asynchronously, dynamic data required inside your validation. + +See an example on how to use this prop in the section. + ### onChange **Type:** `(value:T) => void` diff --git a/src/plugins/es_ui_shared/static/forms/docs/examples/validation.mdx b/src/plugins/es_ui_shared/static/forms/docs/examples/validation.mdx index bbd89d707e4fe..8526a8912ba08 100644 --- a/src/plugins/es_ui_shared/static/forms/docs/examples/validation.mdx +++ b/src/plugins/es_ui_shared/static/forms/docs/examples/validation.mdx @@ -272,3 +272,138 @@ export const MyComponent = () => { ``` Great, but that's **a lot** of code for a simple tags field input. Fortunatelly the `` helper component takes care of all the heavy lifting for us. . + +## Dynamic data inside your validation + +If your validator requires dynamic data you can provide it through the `validationData` prop on the `` component. The data is then available in the validator through the `customData.value` property. + +```typescript +// Form schema +const schema = { + name: { + validations: [{ + validator: ({ customData: { value } }) => { + // value === [1, 2 ,3] as passed below + } + }] + } +}; + +// Component JSX + +``` + +### Asynchronous dynamic data in the validator + +There might be times where you validator requires dynamic data asynchronously that is not immediately available when the field value changes (and the validation is triggered) but at a later stage. + +Let's imagine that you have a form with an `indexName` text field and that you want to display below the form the list of indices in your cluster that match the index name entered by the user. + +You would probably have something like this + +```js +const MyForm = () => { + const { form } = useForm(); + const [{ indexName }] = useFormData({ watch: 'indexName' }); + const [indices, setIndices] = useState([]); + + const fetchIndices = useCallback(async () => { + const result = await httpClient.get(`/api/search/${indexName}`); + setIndices(result); + }, [indexName]); + + // Whenever the indexName changes we fetch the indices + useEffet(() => { + fetchIndices(); + }, [fetchIndices]); + + return ( + <> +
+ + + + /* Display the list of indices that match the index name entered */ +
    + {indices.map((index, i) =>
  • {index}
  • )} +
+ <> + ); +} +``` + +Great. Now let's imagine that you want to add a validation to the `indexName` field and mark it as invalid if it does not match at least one index in the cluster. For that you need to provide dynamic data (the list of indices fetched) which is not immediately accesible when the field value changes (and the validation kicks in). We need to ask the validation to **wait** until we have fetched the indices and then have access to the dynamic data. + +For that we will use the `validationData$` Observable that you can pass to the field. Whenever a value is sent to the observable (**after** the field value has changed, important!), it will be available in the validator through the `customData.provider()` handler. + +```js +// form.schema.ts +const schema = { + indexName: { + validations: [{ + validator: async ({ value, customData: { provider } }) => { + // Whenever a new value is sent to the `validationData$` Observable + // the Promise will resolve with that value + const indices = await provider(); + + if (!indices.include(value)) { + return { + message: `This index does not match any of your indices` + } + } + } + }] + } as FieldConfig +} + +// myform.tsx +const MyForm = () => { + ... + const [indices, setIndices] = useState([]); + const [indices$, nextIndices] = useAsyncValidationData(); // Use the provided hook to create the Observable + + const fetchIndices = useCallback(async () => { + const result = await httpClient.get(`/api/search/${indexName}`); + setIndices(result); + nextIndices(result); // Send the indices to your validator "provider()" + }, [indexName]); + + // Whenever the indexName changes we fetch the indices + useEffet(() => { + fetchIndices(); + }, [fetchIndices]); + + return ( + <> +
+ /* Pass the Observable to your field */ + + + + ... + <> + ); +} +``` + +Et voilĂ ! We have provided dynamic data asynchronously to our validator. + +The above example could be simplified a bit by using the optional `state` argument of the `useAsyncValidationData(/* state */)` hook. + +```js +const MyForm = () => { + ... + const [indices, setIndices] = useState([]); + // We don't need the second element of the array (the "nextIndices()" handler) + // as whenever the "indices" state changes the "indices$" Observable will receive its value + const [indices$] = useAsyncValidationData(indices); + + ... + + const fetchIndices = useCallback(async () => { + const result = await httpClient.get(`/api/search/${indexName}`); + setIndices(result); // This will also update the Observable + }, [indexName]); + + ... +``` \ No newline at end of file diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx index 2106bd50dad03..0950f2dabb1b7 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx @@ -6,16 +6,25 @@ * Side Public License, v 1. */ -import React, { useEffect, FunctionComponent } from 'react'; +import React, { useEffect, FunctionComponent, useState } from 'react'; import { act } from 'react-dom/test-utils'; import { registerTestBed, TestBed } from '../shared_imports'; import { FormHook, OnUpdateHandler, FieldConfig, FieldHook } from '../types'; import { useForm } from '../hooks/use_form'; +import { useAsyncValidationData } from '../hooks/use_async_validation_data'; import { Form } from './form'; import { UseField } from './use_field'; describe('', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + test('should read the default value from the prop and fallback to the config object', () => { const onFormData = jest.fn(); @@ -195,26 +204,54 @@ describe('', () => { describe('validation', () => { let formHook: FormHook | null = null; + let fieldHook: FieldHook | null = null; beforeEach(() => { formHook = null; + fieldHook = null; }); const onFormHook = (form: FormHook) => { formHook = form; }; + const onFieldHook = (field: FieldHook) => { + fieldHook = field; + }; + const getTestComp = (fieldConfig: FieldConfig) => { - const TestComp = ({ onForm }: { onForm: (form: FormHook) => void }) => { + const TestComp = () => { const { form } = useForm(); + const [isFieldActive, setIsFieldActive] = useState(true); + + const unmountField = () => { + setIsFieldActive(false); + }; useEffect(() => { - onForm(form); - }, [onForm, form]); + onFormHook(form); + }, [form]); return (
- + {isFieldActive && ( + + {(field) => { + onFieldHook(field); + + return ( + + ); + }} + + )} + ); }; @@ -224,7 +261,6 @@ describe('', () => { const setup = (fieldConfig: FieldConfig) => { return registerTestBed(getTestComp(fieldConfig), { memoryRouter: { wrapComponent: false }, - defaultProps: { onForm: onFormHook }, })() as TestBed; }; @@ -278,6 +314,289 @@ describe('', () => { ({ isValid } = formHook); expect(isValid).toBe(false); }); + + test('should not update the state if the field has unmounted while validating', async () => { + const fieldConfig: FieldConfig = { + validations: [ + { + validator: () => { + // The validation will return its value after 5s + return new Promise((resolve) => { + setTimeout(() => { + resolve({ message: 'Invalid field' }); + }, 5000); + }); + }, + }, + ], + }; + + const { + find, + form: { setInputValue }, + } = setup(fieldConfig); + + expect(fieldHook?.isValidating).toBe(false); + + // Trigger validation... + await act(async () => { + setInputValue('myField', 'changedValue'); + }); + + expect(fieldHook?.isValidating).toBe(true); + + // Unmount the field + await act(async () => { + find('unmountFieldBtn').simulate('click'); + }); + + const originalConsoleError = console.error; // eslint-disable-line no-console + const spyConsoleError = jest.fn((message) => { + originalConsoleError(message); + }); + console.error = spyConsoleError; // eslint-disable-line no-console + + // Move the timer to resolve the validator + await act(async () => { + jest.advanceTimersByTime(5000); + }); + + // The test should not display any warning + // "Can't perform a React state update on an unmounted component." + expect(spyConsoleError.mock.calls.length).toBe(0); + + console.error = originalConsoleError; // eslint-disable-line no-console + }); + + describe('dynamic data', () => { + let nameFieldHook: FieldHook | null = null; + let lastNameFieldHook: FieldHook | null = null; + + const schema = { + name: { + validations: [ + { + validator: async ({ customData: { provider } }) => { + // Async validator that requires the observable to emit a value + // to complete the validation. Once it emits a value, the dataProvider + // Promise fullfills. + const dynamicData = await provider(); + if (dynamicData === 'bad') { + return { + message: 'Invalid dynamic data', + }; + } + }, + }, + ], + } as FieldConfig, + lastName: { + validations: [ + { + validator: ({ customData: { value: validationData } }) => { + // Sync validator that receives the validationData passed through + // props on + if (validationData === 'bad') { + return { + message: `Invalid dynamic data: ${validationData}`, + }; + } + }, + }, + ], + } as FieldConfig, + }; + + const onNameFieldHook = (field: FieldHook) => { + nameFieldHook = field; + }; + const onLastNameFieldHook = (field: FieldHook) => { + lastNameFieldHook = field; + }; + + interface DynamicValidationDataProps { + validationData?: unknown; + } + + const TestComp = ({ validationData }: DynamicValidationDataProps) => { + const { form } = useForm({ schema }); + const [stateValue, setStateValue] = useState('initialValue'); + const [validationData$, next] = useAsyncValidationData(stateValue); + + const setInvalidDynamicData = () => { + next('bad'); + }; + + const setValidDynamicData = () => { + next('good'); + }; + + // Updating the state should emit a new value in the observable + // which in turn should be available in the validation and allow it to complete. + const setStateValueWithValidValue = () => { + setStateValue('good'); + }; + + const setStateValueWithInValidValue = () => { + setStateValue('bad'); + }; + + return ( +
+ <> + {/* Dynamic async validation data with an observable. The validation + will complete **only after** the observable has emitted a value. */} + path="name" validationData$={validationData$}> + {(field) => { + onNameFieldHook(field); + return ( + + ); + }} + + + {/* Dynamic validation data passed synchronously through props */} + path="lastName" validationData={validationData}> + {(field) => { + onLastNameFieldHook(field); + return ( + + ); + }} + + + + + + + + + ); + }; + + const setupDynamicData = (defaultProps?: Partial) => { + return registerTestBed(TestComp, { + memoryRouter: { wrapComponent: false }, + defaultProps, + })() as TestBed; + }; + + beforeEach(() => { + nameFieldHook = null; + }); + + test('it should access dynamic data provided **after** the field value changed', async () => { + const { form, find } = setupDynamicData(); + + await act(async () => { + form.setInputValue('nameField', 'newValue'); + }); + // If the field is validating this will prevent the form from being submitted as + // it will wait for all the fields to finish validating to return the form validity. + expect(nameFieldHook?.isValidating).toBe(true); + + // Let's wait 10 sec to make sure the validation does not complete + // until the observable receives a value + await act(async () => { + jest.advanceTimersByTime(10000); + }); + // The field is still validating as no value has been sent to the observable + expect(nameFieldHook?.isValidating).toBe(true); + + // We now send a valid value to the observable + await act(async () => { + find('setValidValueBtn').simulate('click'); + }); + + expect(nameFieldHook?.isValidating).toBe(false); + expect(nameFieldHook?.isValid).toBe(true); + + // Let's change the input value to trigger the validation once more + await act(async () => { + form.setInputValue('nameField', 'anotherValue'); + }); + expect(nameFieldHook?.isValidating).toBe(true); + + // And send an invalid value to the observable + await act(async () => { + find('setInvalidValueBtn').simulate('click'); + }); + expect(nameFieldHook?.isValidating).toBe(false); + expect(nameFieldHook?.isValid).toBe(false); + expect(nameFieldHook?.getErrorsMessages()).toBe('Invalid dynamic data'); + }); + + test('it should access dynamic data coming after the field value changed, **in sync** with a state change', async () => { + const { form, find } = setupDynamicData(); + + await act(async () => { + form.setInputValue('nameField', 'newValue'); + }); + expect(nameFieldHook?.isValidating).toBe(true); + + // We now update the state with a valid value + // this should update the observable + await act(async () => { + find('setValidStateValueBtn').simulate('click'); + }); + + expect(nameFieldHook?.isValidating).toBe(false); + expect(nameFieldHook?.isValid).toBe(true); + + // Let's change the input value to trigger the validation once more + await act(async () => { + form.setInputValue('nameField', 'anotherValue'); + }); + expect(nameFieldHook?.isValidating).toBe(true); + + // And change the state with an invalid value + await act(async () => { + find('setInvalidStateValueBtn').simulate('click'); + }); + + expect(nameFieldHook?.isValidating).toBe(false); + expect(nameFieldHook?.isValid).toBe(false); + }); + + test('it should access dynamic data provided through props', async () => { + let { form } = setupDynamicData({ validationData: 'good' }); + + await act(async () => { + form.setInputValue('lastNameField', 'newValue'); + }); + // As this is a sync validation it should not be validating anymore at this stage + expect(lastNameFieldHook?.isValidating).toBe(false); + expect(lastNameFieldHook?.isValid).toBe(true); + + // Now let's provide invalid dynamic data through props + ({ form } = setupDynamicData({ validationData: 'bad' })); + await act(async () => { + form.setInputValue('lastNameField', 'newValue'); + }); + expect(lastNameFieldHook?.isValidating).toBe(false); + expect(lastNameFieldHook?.isValid).toBe(false); + expect(lastNameFieldHook?.getErrorsMessages()).toBe('Invalid dynamic data: bad'); + }); + }); }); describe('serializer(), deserializer(), formatter()', () => { diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.tsx index 45fa2e977a6c7..89eacfc0cb9df 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.tsx @@ -7,6 +7,7 @@ */ import React, { FunctionComponent } from 'react'; +import { Observable } from 'rxjs'; import { FieldHook, FieldConfig, FormData } from '../types'; import { useField } from '../hooks'; @@ -19,6 +20,31 @@ export interface Props { component?: FunctionComponent; componentProps?: Record; readDefaultValueOnForm?: boolean; + /** + * Use this prop to pass down dynamic data **asynchronously** to your validators. + * Your validator accesses the dynamic data by resolving the provider() Promise. + * The Promise will resolve **when a new value is sent** to the validationData$ Observable. + * + * ```typescript + * validator: ({ customData }) => { + * // Wait until a value is sent to the "validationData$" Observable + * const dynamicData = await customData.provider(); + * } + * ``` + */ + validationData$?: Observable; + /** + * Use this prop to pass down dynamic data to your validators. The validation data + * is then accessible in your validator inside the `customData.value` property. + * + * ```typescript + * validator: ({ customData: { value: dynamicData } }) => { + * // Validate with the dynamic data + * if (dynamicData) { .. } + * } + * ``` + */ + validationData?: unknown; onChange?: (value: I) => void; onError?: (errors: string[] | null) => void; children?: (field: FieldHook) => JSX.Element | null; @@ -36,6 +62,8 @@ function UseFieldComp(props: Props(props: Props(form, path, fieldConfig, onChange, onError); + const field = useField(form, path, fieldConfig, onChange, onError, { + customValidationData$, + customValidationData, + }); // Children prevails over anything else provided. if (children) { diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/index.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/index.ts index 3afb5bf6a20c2..8438e5de871bd 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/index.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/index.ts @@ -10,3 +10,4 @@ export { useField, InternalFieldConfig } from './use_field'; export { useForm } from './use_form'; export { useFormData } from './use_form_data'; export { useFormIsModified } from './use_form_is_modified'; +export { useAsyncValidationData } from './use_async_validation_data'; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_async_validation_data.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_async_validation_data.ts new file mode 100644 index 0000000000000..21d5e101536ae --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_async_validation_data.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { useCallback, useRef, useMemo, useEffect } from 'react'; +import { Subject, Observable } from 'rxjs'; + +export const useAsyncValidationData = (state?: T) => { + const validationData$ = useRef>(); + + const getValidationData$ = useCallback(() => { + if (validationData$.current === undefined) { + validationData$.current = new Subject(); + } + return validationData$.current; + }, []); + + const hook: [Observable, (value?: T) => void] = useMemo(() => { + const subject = getValidationData$(); + + const observable = subject.asObservable(); + const next = subject.next.bind(subject); + + return [observable, next]; + }, [getValidationData$]); + + // Whenever the state changes we update the observable + useEffect(() => { + getValidationData$().next(state); + }, [state, getValidationData$]); + + return hook; +}; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts index 806c60a66aa1d..ececf724db45d 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts @@ -7,6 +7,8 @@ */ import { useMemo, useState, useEffect, useRef, useCallback } from 'react'; +import { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; import { FormHook, @@ -29,7 +31,11 @@ export const useField = ( path: string, config: FieldConfig & InternalFieldConfig = {}, valueChangeListener?: (value: I) => void, - errorChangeListener?: (errors: string[] | null) => void + errorChangeListener?: (errors: string[] | null) => void, + { + customValidationData$, + customValidationData = null, + }: { customValidationData$?: Observable; customValidationData?: unknown } = {} ) => { const { type = FIELD_TYPES.TEXT, @@ -81,6 +87,12 @@ export const useField = ( const hasBeenReset = useRef(false); const inflightValidation = useRef<(Promise & { cancel?(): void }) | null>(null); const debounceTimeout = useRef(null); + // Keep a ref of the last state (value and errors) notified to the consumer so he does + // not get tons of updates whenever he does not wrap the "onChange()" and "onError()" handlers with a useCallback + const lastNotifiedState = useRef<{ value?: I; errors: string[] | null }>({ + value: undefined, + errors: null, + }); // ---------------------------------- // -- HELPERS @@ -131,11 +143,6 @@ export const useField = ( setPristine(false); setIsChangingValue(true); - // Notify listener - if (valueChangeListener) { - valueChangeListener(value); - } - // Update the form data observable __updateFormDataAt(path, value); @@ -171,7 +178,6 @@ export const useField = ( }, [ path, value, - valueChangeListener, valueChangeDebounceTime, fieldsToValidateOnChange, __updateFormDataAt, @@ -232,6 +238,12 @@ export const useField = ( return false; }; + let dataProvider: () => Promise = () => Promise.resolve(null); + + if (customValidationData$) { + dataProvider = () => customValidationData$.pipe(first()).toPromise(); + } + const runAsync = async () => { const validationErrors: ValidationError[] = []; @@ -254,6 +266,7 @@ export const useField = ( form: { getFormData, getFields }, formData, path, + customData: { provider: dataProvider, value: customValidationData }, }) as Promise; const validationResult = await inflightValidation.current; @@ -297,6 +310,7 @@ export const useField = ( form: { getFormData, getFields }, formData, path, + customData: { provider: dataProvider, value: customValidationData }, }); if (!validationResult) { @@ -334,7 +348,15 @@ export const useField = ( // We first try to run the validations synchronously return runSync(); }, - [cancelInflightValidation, validations, getFormData, getFields, path] + [ + cancelInflightValidation, + validations, + getFormData, + getFields, + path, + customValidationData, + customValidationData$, + ] ); // ---------------------------------- @@ -376,7 +398,7 @@ export const useField = ( const validateIteration = ++validateCounter.current; const onValidationResult = (_validationErrors: ValidationError[]): FieldValidateResponse => { - if (validateIteration === validateCounter.current) { + if (validateIteration === validateCounter.current && isMounted.current) { // This is the most recent invocation setValidating(false); // Update the errors array @@ -566,6 +588,18 @@ export const useField = ( }; }, [path, __removeField]); + // Notify listener whenever the value changes + useEffect(() => { + if (!isMounted.current) { + return; + } + + if (valueChangeListener && value !== lastNotifiedState.current.value) { + valueChangeListener(value); + lastNotifiedState.current.value = value; + } + }, [value, valueChangeListener]); + useEffect(() => { // If the field value has been reset, we don't want to call the "onValueChange()" // as it will set the "isPristine" state to true or validate the field, which we don't want @@ -602,8 +636,12 @@ export const useField = ( if (!isMounted.current) { return; } - if (errorChangeListener) { - errorChangeListener(errors.length ? errors.map((error) => error.message) : null); + + const errorMessages = errors.length ? errors.map((error) => error.message) : null; + + if (errorChangeListener && lastNotifiedState.current.errors !== errorMessages) { + errorChangeListener(errorMessages); + lastNotifiedState.current.errors = errorMessages; } }, [errors, errorChangeListener]); diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts index b42b3211871ba..864579a8c71f3 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts @@ -166,7 +166,7 @@ export function useForm( return { areFieldsValid: true, isFormValid: true }; } - const areFieldsValid = validationResult.every(Boolean); + const areFieldsValid = validationResult.every((res) => res.isValid); const validationResultByPath = fieldsToValidate.reduce((acc, field, i) => { acc[field.path] = validationResult[i].isValid; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.ts index af3da45868b5a..7ad98bc2483bb 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { FormData, FormHook } from '../types'; import { unflattenObject } from '../lib'; @@ -24,6 +24,9 @@ export const useFormData = ( ): HookReturn => { const { watch, form } = options; const ctx = useFormDataContext(); + const watchToArray: string[] = watch === undefined ? [] : Array.isArray(watch) ? watch : [watch]; + // We will use "stringifiedWatch" to compare if the array has changed in the useMemo() below + const stringifiedWatch = watchToArray.join('.'); let getFormData: Context['getFormData']; let getFormData$: Context['getFormData$']; @@ -54,16 +57,14 @@ export const useFormData = ( // eslint-disable-next-line react-hooks/exhaustive-deps }, [getFormData, formData]); - useEffect(() => { - const subscription = getFormData$().subscribe((raw) => { + const subscription = useMemo(() => { + return getFormData$().subscribe((raw) => { if (!isMounted.current && Object.keys(raw).length === 0) { return; } - if (watch) { - const pathsToWatchArray: string[] = Array.isArray(watch) ? watch : [watch]; - - if (pathsToWatchArray.some((path) => previousRawData.current[path] !== raw[path])) { + if (watchToArray.length > 0) { + if (watchToArray.some((path) => previousRawData.current[path] !== raw[path])) { previousRawData.current = raw; // Only update the state if one of the field we watch has changed. setFormData(unflattenObject(raw)); @@ -72,8 +73,13 @@ export const useFormData = ( setFormData(unflattenObject(raw)); } }); + // To compare we use the stringified version of the "watchToArray" array + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stringifiedWatch, getFormData$]); + + useEffect(() => { return subscription.unsubscribe; - }, [getFormData$, watch]); + }, [subscription]); useEffect(() => { isMounted.current = true; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/index.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/index.ts index 19121bb6753a0..b5c7f5b4214e0 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/index.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/index.ts @@ -8,7 +8,7 @@ // We don't export the "useField" hook as it is for internal use. // The consumer of the library must use the component to create a field -export { useForm, useFormData, useFormIsModified } from './hooks'; +export { useForm, useFormData, useFormIsModified, useAsyncValidationData } from './hooks'; export { getFieldValidityAndErrorMessage } from './helpers'; export * from './form_context'; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts index 151adea30c4f1..cfb211b702ed6 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts @@ -193,6 +193,11 @@ export interface ValidationFuncArg { }; formData: I; errors: readonly ValidationError[]; + customData: { + /** Async handler that will resolve whenever a value is sent to the `validationData$` Observable */ + provider: () => Promise; + value: unknown; + }; } export type ValidationFunc< diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.test.tsx index 3266d6f61eeed..0513f3754d3d5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.test.tsx @@ -83,6 +83,7 @@ describe('stepRuleActions schema', () => { form: {} as FormHook, formData: jest.fn(), errors: [], + customData: { value: null, provider: () => Promise.resolve(null) }, }); expect(result).toEqual(undefined); @@ -105,6 +106,7 @@ describe('stepRuleActions schema', () => { form: {} as FormHook, formData: jest.fn(), errors: [], + customData: { value: null, provider: () => Promise.resolve(null) }, }); expect(result).toEqual({ @@ -147,6 +149,7 @@ describe('stepRuleActions schema', () => { form: {} as FormHook, formData: jest.fn(), errors: [], + customData: { value: null, provider: () => Promise.resolve(null) }, }); expect(result).toEqual({