From ef7246f157100a1454c7a974d5de93ee9bddf65a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Tue, 1 Sep 2020 14:48:11 +0200 Subject: [PATCH] [Form lib] Add useFormData() hook to listen to fields value changes (#76107) --- .../components/fields/combobox_field.tsx | 2 +- .../forms/hook_form_lib/components/form.tsx | 17 +- .../components/form_data_provider.test.tsx | 19 +- .../components/form_data_provider.ts | 45 +--- .../forms/hook_form_lib/form_data_context.tsx | 50 ++++ .../static/forms/hook_form_lib/hooks/index.ts | 1 + .../forms/hook_form_lib/hooks/use_field.ts | 18 +- .../hook_form_lib/hooks/use_form.test.tsx | 2 +- .../forms/hook_form_lib/hooks/use_form.ts | 8 + .../hooks/use_form_data.test.tsx | 234 ++++++++++++++++++ .../hook_form_lib/hooks/use_form_data.ts | 91 +++++++ .../static/forms/hook_form_lib/index.ts | 2 +- .../static/forms/hook_form_lib/types.ts | 8 + .../fields/edit_field/edit_field.tsx | 4 +- .../template_form/steps/step_logistics.tsx | 82 +++--- .../template_form/template_form.tsx | 2 +- .../template_form/template_form_schemas.tsx | 13 +- .../index_management/public/shared_imports.ts | 1 + 18 files changed, 482 insertions(+), 117 deletions(-) create mode 100644 src/plugins/es_ui_shared/static/forms/hook_form_lib/form_data_context.tsx create mode 100644 src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.test.tsx create mode 100644 src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.ts diff --git a/src/plugins/es_ui_shared/static/forms/components/fields/combobox_field.tsx b/src/plugins/es_ui_shared/static/forms/components/fields/combobox_field.tsx index 9fb804eb7fafa..b2f1a70341315 100644 --- a/src/plugins/es_ui_shared/static/forms/components/fields/combobox_field.tsx +++ b/src/plugins/es_ui_shared/static/forms/components/fields/combobox_field.tsx @@ -74,7 +74,7 @@ export const ComboBoxField = ({ field, euiFieldProps = {}, ...rest }: Props) => }; const onSearchComboChange = (value: string) => { - if (value) { + if (value !== undefined) { field.clearErrors(VALIDATION_TYPES.ARRAY_ITEM); } }; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form.tsx index b3a15fea8b187..287ac56243446 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form.tsx @@ -21,6 +21,7 @@ import React, { ReactNode } from 'react'; import { EuiForm } from '@elastic/eui'; import { FormProvider } from '../form_context'; +import { FormDataContextProvider } from '../form_data_context'; import { FormHook } from '../types'; interface Props { @@ -30,8 +31,14 @@ interface Props { [key: string]: any; } -export const Form = ({ form, FormWrapper = EuiForm, ...rest }: Props) => ( - - - -); +export const Form = ({ form, FormWrapper = EuiForm, ...rest }: Props) => { + const { getFormData, __getFormData$ } = form; + + return ( + + + + + + ); +}; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.test.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.test.tsx index 25448dff18e8a..d9095944eaa33 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.test.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.test.tsx @@ -75,16 +75,7 @@ describe('', () => { setInputValue('lastNameField', 'updated value'); }); - /** - * The children will be rendered three times: - * - Twice for each input value that has changed - * - once because after updating both fields, the **form** isValid state changes (from "undefined" to "true") - * causing a new "form" object to be returned and thus a re-render. - * - * When the form object will be memoized (in a future PR), te bellow call count should only be 2 as listening - * to form data changes should not receive updates when the "isValid" state of the form changes. - */ - expect(onFormData.mock.calls.length).toBe(3); + expect(onFormData).toBeCalledTimes(2); const [formDataUpdated] = onFormData.mock.calls[onFormData.mock.calls.length - 1] as Parameters< OnUpdateHandler @@ -130,7 +121,7 @@ describe('', () => { find, } = setup() as TestBed; - expect(onFormData.mock.calls.length).toBe(0); // Not present in the DOM yet + expect(onFormData).toBeCalledTimes(0); // Not present in the DOM yet // Make some changes to the form fields await act(async () => { @@ -188,7 +179,7 @@ describe('', () => { setInputValue('lastNameField', 'updated value'); }); - expect(onFormData.mock.calls.length).toBe(0); + expect(onFormData).toBeCalledTimes(0); }); test('props.pathsToWatch (Array): should not re-render the children when the field that changed is not in the watch list', async () => { @@ -228,14 +219,14 @@ describe('', () => { }); // No re-render - expect(onFormData.mock.calls.length).toBe(0); + expect(onFormData).toBeCalledTimes(0); // Make some changes to fields in the watch list await act(async () => { setInputValue('nameField', 'updated value'); }); - expect(onFormData.mock.calls.length).toBe(1); + expect(onFormData).toBeCalledTimes(1); onFormData.mockReset(); diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.ts index 3630b902f0564..ac141baf8fc71 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.ts @@ -17,10 +17,10 @@ * under the License. */ -import React, { useState, useEffect, useRef, useCallback } from 'react'; +import React from 'react'; import { FormData } from '../types'; -import { useFormContext } from '../form_context'; +import { useFormData } from '../hooks'; interface Props { children: (formData: FormData) => JSX.Element | null; @@ -28,46 +28,9 @@ interface Props { } export const FormDataProvider = React.memo(({ children, pathsToWatch }: Props) => { - const form = useFormContext(); - const { subscribe } = form; - const previousRawData = useRef(form.__getFormData$().value); - const isMounted = useRef(false); - const [formData, setFormData] = useState(previousRawData.current); + const { 0: formData, 2: isReady } = useFormData({ watch: pathsToWatch }); - const onFormData = useCallback( - ({ data: { raw } }) => { - // To avoid re-rendering the children for updates on the form data - // that we are **not** interested in, we can specify one or multiple path(s) - // to watch. - if (pathsToWatch) { - const valuesToWatchArray = Array.isArray(pathsToWatch) - ? (pathsToWatch as string[]) - : ([pathsToWatch] as string[]); - - if (valuesToWatchArray.some((value) => previousRawData.current[value] !== raw[value])) { - previousRawData.current = raw; - setFormData(raw); - } - } else { - setFormData(raw); - } - }, - [pathsToWatch] - ); - - useEffect(() => { - const subscription = subscribe(onFormData); - return subscription.unsubscribe; - }, [subscribe, onFormData]); - - useEffect(() => { - isMounted.current = true; - return () => { - isMounted.current = false; - }; - }, []); - - if (!isMounted.current && Object.keys(formData).length === 0) { + if (!isReady) { // No field has mounted yet, don't render anything return null; } diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/form_data_context.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/form_data_context.tsx new file mode 100644 index 0000000000000..0e6a75e9c5065 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/form_data_context.tsx @@ -0,0 +1,50 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { createContext, useContext, useMemo } from 'react'; + +import { FormData, FormHook } from './types'; +import { Subject } from './lib'; + +export interface Context { + getFormData$: () => Subject; + getFormData: FormHook['getFormData']; +} + +const FormDataContext = createContext | undefined>(undefined); + +interface Props extends Context { + children: React.ReactNode; +} + +export const FormDataContextProvider = ({ children, getFormData$, getFormData }: Props) => { + const value = useMemo( + () => ({ + getFormData, + getFormData$, + }), + [getFormData, getFormData$] + ); + + return {children}; +}; + +export function useFormDataContext() { + return useContext | undefined>(FormDataContext); +} 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 6a04a592227f9..45c11dd6272e4 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 @@ -19,3 +19,4 @@ export { useField } from './use_field'; export { useForm } from './use_form'; +export { useFormData } from './use_form_data'; 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 9d22e4eb2ee5e..fa29f900af2ef 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 @@ -254,6 +254,8 @@ export const useField = ( validationErrors.push({ ...validationResult, + // See comment below that explains why we add "__isBlocking__". + __isBlocking__: validationResult.__isBlocking__ ?? validation.isBlocking, validationType: validationType || VALIDATION_TYPES.FIELD, }); @@ -306,6 +308,11 @@ export const useField = ( validationErrors.push({ ...(validationResult as ValidationError), + // We add an "__isBlocking__" property to know if this error is a blocker or no. + // Most validation errors are blockers but in some cases a validation is more a warning than an error + // like with the ComboBox items when they are added. + __isBlocking__: + (validationResult as ValidationError).__isBlocking__ ?? validation.isBlocking, validationType: validationType || VALIDATION_TYPES.FIELD, }); @@ -394,7 +401,13 @@ export const useField = ( ); const _setErrors: FieldHook['setErrors'] = useCallback((_errors) => { - setErrors(_errors.map((error) => ({ validationType: VALIDATION_TYPES.FIELD, ...error }))); + setErrors( + _errors.map((error) => ({ + validationType: VALIDATION_TYPES.FIELD, + __isBlocking__: true, + ...error, + })) + ); }, []); /** @@ -463,7 +476,8 @@ export const useField = ( [setValue, deserializeValue, defaultValue] ); - const isValid = errors.length === 0; + // Don't take into account non blocker validation. Some are just warning (like trying to add a wrong ComboBox item) + const isValid = errors.filter((e) => e.__isBlocking__ !== false).length === 0; const field = useMemo>(() => { return { diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx index 007e492243bac..4a880415b6d22 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx @@ -39,7 +39,7 @@ const onFormHook = (_form: FormHook) => { formHook = _form; }; -describe('use_form() hook', () => { +describe('useForm() hook', () => { beforeEach(() => { formHook = null; }); 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 35bac5b9a58c6..7b72a9eeacf7b 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 @@ -240,6 +240,12 @@ export function useForm( if (!field.isValidated) { setIsValid(undefined); + + // When we submit the form (and set "isSubmitted" to "true"), we validate **all fields**. + // If a field is added and it is not validated it means that we have swapped fields and added new ones: + // --> we have basically have a new form in front of us. + // For that reason we make sure that the "isSubmitted" state is false. + setIsSubmitted(false); } }, [updateFormDataAt] @@ -389,6 +395,7 @@ export function useForm( isValid, id, submit: submitForm, + validate: validateAllFields, subscribe, setFieldValue, setFieldErrors, @@ -428,6 +435,7 @@ export function useForm( addField, removeField, validateFields, + validateAllFields, ]); useEffect(() => { diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.test.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.test.tsx new file mode 100644 index 0000000000000..0fb65daecf2f4 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.test.tsx @@ -0,0 +1,234 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useEffect } from 'react'; +import { act } from 'react-dom/test-utils'; + +import { registerTestBed, TestBed } from '../shared_imports'; +import { Form, UseField } from '../components'; +import { useForm } from './use_form'; +import { useFormData, HookReturn } from './use_form_data'; + +interface Props { + onChange(data: HookReturn): void; + watch?: string | string[]; +} + +describe('useFormData() hook', () => { + const HookListenerComp = React.memo(({ onChange, watch }: Props) => { + const hookValue = useFormData({ watch }); + + useEffect(() => { + onChange(hookValue); + }, [hookValue, onChange]); + + return null; + }); + + describe('form data updates', () => { + let testBed: TestBed; + let onChangeSpy: jest.Mock; + + const getLastMockValue = () => { + return onChangeSpy.mock.calls[onChangeSpy.mock.calls.length - 1][0] as HookReturn; + }; + + const TestComp = (props: Props) => { + const { form } = useForm(); + + return ( +
+ + + + ); + }; + + const setup = registerTestBed(TestComp, { + memoryRouter: { wrapComponent: false }, + }); + + beforeEach(() => { + onChangeSpy = jest.fn(); + testBed = setup({ onChange: onChangeSpy }) as TestBed; + }); + + test('should return the form data', () => { + // Called twice: + // once when the hook is called and once when the fields have mounted and updated the form data + expect(onChangeSpy).toBeCalledTimes(2); + const [data] = getLastMockValue(); + expect(data).toEqual({ title: 'titleInitialValue' }); + }); + + test('should listen to field changes', async () => { + const { + form: { setInputValue }, + } = testBed; + + await act(async () => { + setInputValue('titleField', 'titleChanged'); + }); + + expect(onChangeSpy).toBeCalledTimes(3); + const [data] = getLastMockValue(); + expect(data).toEqual({ title: 'titleChanged' }); + }); + }); + + describe('format form data', () => { + let onChangeSpy: jest.Mock; + + const getLastMockValue = () => { + return onChangeSpy.mock.calls[onChangeSpy.mock.calls.length - 1][0] as HookReturn; + }; + + const TestComp = (props: Props) => { + const { form } = useForm(); + + return ( +
+ + + + + ); + }; + + const setup = registerTestBed(TestComp, { + memoryRouter: { wrapComponent: false }, + }); + + beforeEach(() => { + onChangeSpy = jest.fn(); + setup({ onChange: onChangeSpy }); + }); + + test('should expose a handler to build the form data', () => { + const { 1: format } = getLastMockValue(); + expect(format()).toEqual({ + user: { + firstName: 'John', + lastName: 'Snow', + }, + }); + }); + }); + + describe('options', () => { + describe('watch', () => { + let testBed: TestBed; + let onChangeSpy: jest.Mock; + + const getLastMockValue = () => { + return onChangeSpy.mock.calls[onChangeSpy.mock.calls.length - 1][0] as HookReturn; + }; + + const TestComp = (props: Props) => { + const { form } = useForm(); + + return ( +
+ + + + + ); + }; + + const setup = registerTestBed(TestComp, { + memoryRouter: { wrapComponent: false }, + }); + + beforeEach(() => { + onChangeSpy = jest.fn(); + testBed = setup({ watch: 'title', onChange: onChangeSpy }) as TestBed; + }); + + test('should not listen to changes on fields we are not interested in', async () => { + const { + form: { setInputValue }, + } = testBed; + + await act(async () => { + // Changing a field we are **not** interested in + setInputValue('subTitleField', 'subTitleChanged'); + // Changing a field we **are** interested in + setInputValue('titleField', 'titleChanged'); + }); + + const [data] = getLastMockValue(); + expect(data).toEqual({ title: 'titleChanged', subTitle: 'subTitleInitialValue' }); + }); + }); + + describe('form', () => { + let testBed: TestBed; + let onChangeSpy: jest.Mock; + + const getLastMockValue = () => { + return onChangeSpy.mock.calls[onChangeSpy.mock.calls.length - 1][0] as HookReturn; + }; + + const TestComp = ({ onChange }: Props) => { + const { form } = useForm(); + const hookValue = useFormData({ form }); + + useEffect(() => { + onChange(hookValue); + }, [hookValue, onChange]); + + return ( +
+ + + ); + }; + + const setup = registerTestBed(TestComp, { + memoryRouter: { wrapComponent: false }, + }); + + beforeEach(() => { + onChangeSpy = jest.fn(); + testBed = setup({ onChange: onChangeSpy }) as TestBed; + }); + + test('should allow a form to be provided when the hook is called outside of the FormDataContext', async () => { + const { + form: { setInputValue }, + } = testBed; + + const [initialData] = getLastMockValue(); + expect(initialData).toEqual({ title: 'titleInitialValue' }); + + await act(async () => { + setInputValue('titleField', 'titleChanged'); + }); + + const [updatedData] = getLastMockValue(); + expect(updatedData).toEqual({ title: 'titleChanged' }); + }); + }); + }); +}); 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 new file mode 100644 index 0000000000000..fb4a0984438ad --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.ts @@ -0,0 +1,91 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useState, useEffect, useRef, useCallback } from 'react'; + +import { FormData, FormHook } from '../types'; +import { useFormDataContext, Context } from '../form_data_context'; + +interface Options { + watch?: string | string[]; + form?: FormHook; +} + +export type HookReturn = [FormData, () => T, boolean]; + +export const useFormData = (options: Options = {}): HookReturn => { + const { watch, form } = options; + const ctx = useFormDataContext(); + + let getFormData: Context['getFormData']; + let getFormData$: Context['getFormData$']; + + if (form !== undefined) { + getFormData = form.getFormData; + getFormData$ = form.__getFormData$; + } else if (ctx !== undefined) { + ({ getFormData, getFormData$ } = ctx); + } else { + throw new Error( + 'useFormData() must be used within a or you need to pass FormHook object in the options.' + ); + } + + const initialValue = getFormData$().value; + + const previousRawData = useRef(initialValue); + const isMounted = useRef(false); + const [formData, setFormData] = useState(previousRawData.current); + + const formatFormData = useCallback(() => { + return getFormData({ unflatten: true }); + }, [getFormData]); + + useEffect(() => { + const subscription = getFormData$().subscribe((raw) => { + if (watch) { + const valuesToWatchArray = Array.isArray(watch) + ? (watch as string[]) + : ([watch] as string[]); + + if (valuesToWatchArray.some((value) => previousRawData.current[value] !== raw[value])) { + previousRawData.current = raw; + // Only update the state if one of the field we watch has changed. + setFormData(raw); + } + } else { + setFormData(raw); + } + }); + return subscription.unsubscribe; + }, [getFormData$, watch]); + + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + }; + }, []); + + if (!isMounted.current && Object.keys(formData).length === 0) { + // No field has mounted yet + return [formData, formatFormData, false]; + } + + return [formData, formatFormData, 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 3079814c9ad14..8d6b57fbeb315 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 @@ -19,7 +19,7 @@ // Only export the useForm hook. The "useField" hook is for internal use // as the consumer of the library must use the component -export { useForm } from './hooks'; +export { useForm, useFormData } 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 dc495f6eb56b4..4b343ec5e9f2e 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 @@ -30,6 +30,7 @@ export interface FormHook { readonly isValid: boolean | undefined; readonly id: string; submit: (e?: FormEvent | MouseEvent) => Promise<{ data: T; isValid: boolean }>; + validate: () => Promise; subscribe: (handler: OnUpdateHandler) => Subscription; setFieldValue: (fieldName: string, value: FieldValue) => void; setFieldErrors: (fieldName: string, errors: ValidationError[]) => void; @@ -147,6 +148,7 @@ export interface ValidationError { message: string; code?: T; validationType?: string; + __isBlocking__?: boolean; [key: string]: any; } @@ -185,5 +187,11 @@ type FieldValue = unknown; export interface ValidationConfig { validator: ValidationFunc; type?: string; + /** + * By default all validation are blockers, which means that if they fail, the field is invalid. + * In some cases, like when trying to add an item to the ComboBox, if the item is not valid we want + * to show a validation error. But this validation is **not** blocking. Simply, the item has not been added. + */ + isBlocking?: boolean; exitOnFail?: boolean; } diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx index 6b5a848ce85d3..95575124b6abd 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx @@ -163,7 +163,7 @@ export const EditField = React.memo(({ form, field, allFields, exitEdit, updateF - {form.isSubmitted && form.isValid === false && ( + {form.isSubmitted && !form.isValid && ( <> {i18n.translate('xpack.idxMgmt.mappingsEditor.editFieldUpdateButtonLabel', { diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx index fcc9795617ebb..56f040fc59a7b 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx @@ -17,13 +17,13 @@ import { i18n } from '@kbn/i18n'; import { useForm, + useFormData, Form, getUseField, getFormRow, Field, Forms, JsonEditorField, - FormDataProvider, } from '../../../../shared_imports'; import { documentationService } from '../../../services/documentation'; import { schemas, nameConfig, nameConfigWithoutValidations } from '../template_form_schemas'; @@ -118,9 +118,7 @@ interface LogisticsForm { } interface LogisticsFormInternal extends LogisticsForm { - __internal__: { - addMeta: boolean; - }; + addMeta: boolean; } interface Props { @@ -133,14 +131,12 @@ interface Props { function formDeserializer(formData: LogisticsForm): LogisticsFormInternal { return { ...formData, - __internal__: { - addMeta: Boolean(formData._meta && Object.keys(formData._meta).length), - }, + addMeta: Boolean(formData._meta && Object.keys(formData._meta).length), }; } function formSerializer(formData: LogisticsFormInternal): LogisticsForm { - const { __internal__, ...rest } = formData; + const { addMeta, ...rest } = formData; return rest; } @@ -153,7 +149,18 @@ export const StepLogistics: React.FunctionComponent = React.memo( serializer: formSerializer, deserializer: formDeserializer, }); - const { subscribe, submit, isSubmitted, isValid: isFormValid, getErrors: getFormErrors } = form; + const { + submit, + isSubmitted, + isValid: isFormValid, + getErrors: getFormErrors, + getFormData, + } = form; + + const [{ addMeta }] = useFormData({ + form, + watch: 'addMeta', + }); /** * When the consumer call validate() on this step, we submit the form so it enters the "isSubmitted" state @@ -164,15 +171,12 @@ export const StepLogistics: React.FunctionComponent = React.memo( }, [submit]); useEffect(() => { - const subscription = subscribe(({ data, isValid }) => { - onChange({ - isValid, - validate, - getData: data.format, - }); + onChange({ + isValid: isFormValid, + getData: getFormData, + validate, }); - return subscription.unsubscribe; - }, [onChange, validate, subscribe]); + }, [onChange, isFormValid, validate, getFormData]); const { name, indexPatterns, dataStream, order, priority, version } = getFieldsMeta( documentationService.getEsDocsBase() @@ -296,34 +300,28 @@ export const StepLogistics: React.FunctionComponent = React.memo( defaultMessage="Use the _meta field to store any metadata you want." /> - + } > - - {({ '__internal__.addMeta': addMeta }) => { - return ( - addMeta && ( - - ) - ); - }} - + {addMeta && ( + + )} )} diff --git a/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx b/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx index 537f421173358..3a03835e85970 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx @@ -192,8 +192,8 @@ export const TemplateForm = ({ wizardData: WizardContent ): TemplateDeserialized => { const outputTemplate = { - ...initialTemplate, ...wizardData.logistics, + _kbnMeta: initialTemplate._kbnMeta, composedOf: wizardData.components, template: { settings: wizardData.settings, diff --git a/x-pack/plugins/index_management/public/application/components/template_form/template_form_schemas.tsx b/x-pack/plugins/index_management/public/application/components/template_form/template_form_schemas.tsx index 0d9ce57a64c84..c85126f08685e 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/template_form_schemas.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/template_form_schemas.tsx @@ -125,6 +125,7 @@ export const schemas: Record = { { validator: indexPatternField(i18n), type: VALIDATION_TYPES.ARRAY_ITEM, + isBlocking: false, }, ], }, @@ -213,13 +214,11 @@ export const schemas: Record = { } }, }, - __internal__: { - addMeta: { - type: FIELD_TYPES.TOGGLE, - label: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.addMetadataLabel', { - defaultMessage: 'Add metadata', - }), - }, + addMeta: { + type: FIELD_TYPES.TOGGLE, + label: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.addMetadataLabel', { + defaultMessage: 'Add metadata', + }), }, }, }; diff --git a/x-pack/plugins/index_management/public/shared_imports.ts b/x-pack/plugins/index_management/public/shared_imports.ts index 2ba2a5c493c49..f7f992a090501 100644 --- a/x-pack/plugins/index_management/public/shared_imports.ts +++ b/x-pack/plugins/index_management/public/shared_imports.ts @@ -21,6 +21,7 @@ export { VALIDATION_TYPES, FieldConfig, useForm, + useFormData, Form, getUseField, UseField,