From 092c71a21b771bb6ad4468f85cf1d442fd2deea9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Tue, 12 Apr 2022 13:24:33 +0100 Subject: [PATCH] [Form lib] Fixes (#128489) --- .../components/use_array.test.tsx | 112 ++++++++++ .../hook_form_lib/components/use_array.ts | 8 +- .../components/use_field.test.tsx | 198 ++++++++++++++---- .../hook_form_lib/components/use_field.tsx | 9 + .../forms/hook_form_lib/hooks/use_field.ts | 62 +++--- .../forms/hook_form_lib/hooks/use_form.ts | 15 +- .../hooks/use_form_is_modified.test.tsx | 8 +- .../hooks/use_form_is_modified.ts | 42 ++-- .../static/forms/hook_form_lib/lib/index.ts | 4 +- .../forms/hook_form_lib/lib/utils.test.ts | 43 ++++ .../static/forms/hook_form_lib/lib/utils.ts | 43 +++- 11 files changed, 445 insertions(+), 99 deletions(-) create mode 100644 src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_array.test.tsx create mode 100644 src/plugins/es_ui_shared/static/forms/hook_form_lib/lib/utils.test.ts diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_array.test.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_array.test.tsx new file mode 100644 index 0000000000000..dc8695190bdaf --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_array.test.tsx @@ -0,0 +1,112 @@ +/* + * 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 React, { useEffect } from 'react'; +import { act } from 'react-dom/test-utils'; + +import { registerTestBed } from '../shared_imports'; +import { useForm } from '../hooks/use_form'; +import { useFormData } from '../hooks/use_form_data'; +import { Form } from './form'; +import { UseField } from './use_field'; +import { UseArray } from './use_array'; + +describe('', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + test('it should render by default 1 array item', () => { + const TestComp = () => { + const { form } = useForm(); + return ( +
+ + {({ items }) => { + return ( + <> + {items.map(({ id }) => { + return ( +

+ Array item +

+ ); + })} + + ); + }} +
+
+ ); + }; + + const setup = registerTestBed(TestComp, { + memoryRouter: { wrapComponent: false }, + }); + + const { find } = setup(); + + expect(find('arrayItem').length).toBe(1); + }); + + test('it should allow to listen to array item field value change', async () => { + const onFormData = jest.fn(); + + const TestComp = ({ onData }: { onData: (data: any) => void }) => { + const { form } = useForm(); + const [formData] = useFormData({ form, watch: 'users[0].name' }); + + useEffect(() => { + onData(formData); + }, [onData, formData]); + + return ( +
+ + {({ items }) => { + return ( + <> + {items.map(({ id, path }) => { + return ( + + ); + })} + + ); + }} + +
+ ); + }; + + const setup = registerTestBed(TestComp, { + defaultProps: { onData: onFormData }, + memoryRouter: { wrapComponent: false }, + }); + + const { + form: { setInputValue }, + } = setup(); + + await act(async () => { + setInputValue('nameField__0', 'John'); + }); + + const formData = onFormData.mock.calls[onFormData.mock.calls.length - 1][0]; + + expect(formData.users[0].name).toEqual('John'); + }); +}); diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_array.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_array.ts index 3e7b061603458..78379aa9fffbf 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_array.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_array.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import uuid from 'uuid'; import { useEffect, useRef, useCallback, useMemo } from 'react'; import { FormHook, FieldConfig } from '../types'; @@ -53,7 +54,7 @@ export interface FormArrayField { */ export const UseArray = ({ path, - initialNumberOfItems, + initialNumberOfItems = 1, validations, readDefaultValueOnForm = true, children, @@ -92,6 +93,9 @@ export const UseArray = ({ // Create an internal hook field which behaves like any other form field except that it is not // outputed in the form data (when calling form.submit() or form.getFormData()) // This allow us to run custom validations (passed to the props) on the Array items + + const internalFieldPath = useMemo(() => `${path}__${uuid.v4()}`, [path]); + const fieldConfigBase: FieldConfig & InternalFieldConfig = { defaultValue: fieldDefaultValue, initialValue: fieldDefaultValue, @@ -103,7 +107,7 @@ export const UseArray = ({ ? { validations, ...fieldConfigBase } : fieldConfigBase; - const field = useField(form, path, fieldConfig); + const field = useField(form, internalFieldPath, fieldConfig); const { setValue, value, isChangingValue, errors } = field; // Derived state from the field 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 cbf0d9d619636..36fd16209f5d4 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 @@ -26,41 +26,91 @@ describe('', () => { jest.useRealTimers(); }); - test('should read the default value from the prop and fallback to the config object', () => { - const onFormData = jest.fn(); + describe('defaultValue', () => { + test('should read the default value from the prop and fallback to the config object', () => { + const onFormData = jest.fn(); - const TestComp = ({ onData }: { onData: OnUpdateHandler }) => { - const { form } = useForm(); - const { subscribe } = form; + const TestComp = ({ onData }: { onData: OnUpdateHandler }) => { + const { form } = useForm(); + const { subscribe } = form; - useEffect(() => subscribe(onData).unsubscribe, [subscribe, onData]); + useEffect(() => subscribe(onData).unsubscribe, [subscribe, onData]); - return ( -
- - - - ); - }; + return ( +
+ + + + ); + }; + + const setup = registerTestBed(TestComp, { + defaultProps: { onData: onFormData }, + memoryRouter: { wrapComponent: false }, + }); - const setup = registerTestBed(TestComp, { - defaultProps: { onData: onFormData }, - memoryRouter: { wrapComponent: false }, + setup(); + + const [{ data }] = onFormData.mock.calls[ + onFormData.mock.calls.length - 1 + ] as Parameters; + + expect(data.internal).toEqual({ + name: 'John', + lastName: 'Snow', + }); }); - setup(); + test('should update the form.defaultValue when a field defaultValue is provided through prop', () => { + let formHook: FormHook | null = null; - const [{ data }] = onFormData.mock.calls[ - onFormData.mock.calls.length - 1 - ] as Parameters; + const TestComp = () => { + const [isFieldVisible, setIsFieldVisible] = useState(true); + const { form } = useForm(); + formHook = form; + + return ( +
+ {isFieldVisible && ( + <> + + + + + + + )} + + + ); + }; + + const setup = registerTestBed(TestComp, { + memoryRouter: { wrapComponent: false }, + }); + + const { find } = setup(); + + expect(formHook!.__getFormDefaultValue()).toEqual({ + name: 'John', + myArray: [ + { name: 'John', lastName: 'Snow' }, + { name: 'Foo', lastName: 'Bar' }, + ], + }); + + // Unmounts the field and make sure the form.defaultValue has been updated + act(() => { + find('unmountField').simulate('click'); + }); - expect(data.internal).toEqual({ - name: 'John', - lastName: 'Snow', + expect(formHook!.__getFormDefaultValue()).toEqual({}); }); }); @@ -205,7 +255,7 @@ describe('', () => { describe('validation', () => { let formHook: FormHook | null = null; - let fieldHook: FieldHook | null = null; + let fieldHook: FieldHook | null = null; beforeEach(() => { formHook = null; @@ -216,19 +266,24 @@ describe('', () => { formHook = form; }; - const onFieldHook = (field: FieldHook) => { + const onFieldHook = (field: FieldHook) => { fieldHook = field; }; - const getTestComp = (fieldConfig: FieldConfig) => { + const getTestComp = (fieldConfig?: FieldConfig) => { const TestComp = () => { - const { form } = useForm(); + const { form } = useForm(); const [isFieldActive, setIsFieldActive] = useState(true); + const [fieldPath, setFieldPath] = useState('name'); const unmountField = () => { setIsFieldActive(false); }; + const changeFieldPath = () => { + setFieldPath('newPath'); + }; + useEffect(() => { onFormHook(form); }, [form]); @@ -236,16 +291,12 @@ describe('', () => { return (
{isFieldActive && ( - + path={fieldPath} config={fieldConfig}> {(field) => { onFieldHook(field); return ( - + ); }} @@ -253,20 +304,23 @@ describe('', () => { + ); }; return TestComp; }; - const setup = (fieldConfig: FieldConfig) => { + const setup = (fieldConfig?: FieldConfig) => { return registerTestBed(getTestComp(fieldConfig), { memoryRouter: { wrapComponent: false }, })() as TestBed; }; test('should update the form validity whenever the field value changes', async () => { - const fieldConfig: FieldConfig = { + const fieldConfig: FieldConfig = { defaultValue: '', // empty string, which is not valid validations: [ { @@ -317,7 +371,7 @@ describe('', () => { }); test('should not update the state if the field has unmounted while validating', async () => { - const fieldConfig: FieldConfig = { + const fieldConfig: FieldConfig = { validations: [ { validator: () => { @@ -369,6 +423,40 @@ describe('', () => { console.error = originalConsoleError; // eslint-disable-line no-console }); + test('should not validate the field if the "path" changes but the value has not changed', async () => { + // This happens with the UseArray. When we delete an item from the array the path for + // the remaining items are recalculated and thus changed for every inside + // the array. We should not re-run the validation when adding/removing array items. + + const validator = jest.fn(); + const fieldConfig: FieldConfig = { + validations: [ + { + validator, + }, + ], + }; + + const { + find, + form: { setInputValue }, + } = setup(fieldConfig); + + await act(async () => { + setInputValue('myField', 'changedValue'); + }); + + expect(validator).toHaveBeenCalledTimes(1); + validator.mockReset(); + + await act(async () => { + // Change the field path + find('changeFieldPathBtn').simulate('click'); + }); + + expect(validator).not.toHaveBeenCalled(); + }); + describe('dynamic data', () => { let nameFieldHook: FieldHook | null = null; let lastNameFieldHook: FieldHook | null = null; @@ -708,32 +796,54 @@ describe('', () => { }); describe('change handlers', () => { + const onChange = jest.fn(); const onError = jest.fn(); beforeEach(() => { jest.resetAllMocks(); }); - const getTestComp = (fieldConfig: FieldConfig) => { + const getTestComp = (fieldConfig?: FieldConfig) => { const TestComp = () => { const { form } = useForm(); return (
- + ); }; return TestComp; }; - const setup = (fieldConfig: FieldConfig) => { + const setup = (fieldConfig?: FieldConfig) => { return registerTestBed(getTestComp(fieldConfig), { memoryRouter: { wrapComponent: false }, })() as TestBed; }; - it('calls onError when validation state changes', async () => { + test('calls onChange() prop when value state changes', async () => { + const { + form: { setInputValue }, + } = setup(); + + expect(onChange).toBeCalledTimes(0); + + await act(async () => { + setInputValue('myField', 'foo'); + }); + + expect(onChange).toBeCalledTimes(1); + expect(onChange).toBeCalledWith('foo'); + }); + + test('calls onError() prop when validation state changes', async () => { const { form: { setInputValue }, } = setup({ 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 bc4e2ccf58294..7e216e3126ed8 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 @@ -128,11 +128,20 @@ function UseFieldComp(props: Props { + let needsCleanUp = false; + if (defaultValue !== undefined) { + needsCleanUp = true; // Update the form "defaultValue" ref object. // This allows us to reset the form and put back the defaultValue of each field __updateDefaultValueAt(path, defaultValue); } + + return () => { + if (needsCleanUp) { + __updateDefaultValueAt(path, undefined); + } + }; }, [path, defaultValue, __updateDefaultValueAt]); // Children prevails over anything else provided. 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 f0c9c50c1033e..7ba06304b971b 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 @@ -26,6 +26,10 @@ export interface InternalFieldConfig { isIncludedInOutput?: boolean; } +const errorsToString = (errors: ValidationError[]): string[] | null => { + return errors.length ? errors.map((error) => error.message) : null; +}; + export const useField = ( form: FormHook, path: string, @@ -81,14 +85,13 @@ export const useField = ( const isMounted = useRef(false); const validateCounter = useRef(0); const changeCounter = useRef(0); - const hasBeenReset = useRef(false); const inflightValidation = useRef(null); const debounceTimeout = useRef(null); // Keep a ref of the last state (value and errors) notified to the consumer so they don't get // loads of updates whenever they don't wrap the "onChange()" and "onError()" handlers with a useCallback // e.g. { // inline code }} const lastNotifiedState = useRef<{ value?: I; errors: string[] | null }>({ - value: undefined, + value: initialValueDeserialized, errors: null, }); @@ -100,6 +103,9 @@ export const useField = ( [validations] ); + const valueHasChanged = value !== lastNotifiedState.current.value; + const errorsHaveChanged = lastNotifiedState.current.errors !== errorsToString(errors); + // ---------------------------------- // -- HELPERS // ---------------------------------- @@ -519,8 +525,8 @@ export const useField = ( setStateErrors([]); if (resetValue) { - hasBeenReset.current = true; const newValue = deserializeValue(updatedDefaultValue ?? defaultValue); + lastNotifiedState.current.value = newValue; setValue(newValue); return newValue; } @@ -604,36 +610,29 @@ export const useField = ( // might not be wrapped inside a "useCallback" and that would trigger a possible infinite // amount of effect executions. useEffect(() => { - if (!isMounted.current) { + if (!isMounted.current || value === undefined) { return; } - if (valueChangeListener && value !== lastNotifiedState.current.value) { + if (valueChangeListener && valueHasChanged) { valueChangeListener(value); - lastNotifiedState.current.value = value; } - }, [value, valueChangeListener]); + }, [value, valueHasChanged, valueChangeListener]); // Value change: update state and run validations useEffect(() => { - if (!isMounted.current) { + if (!isMounted.current || !valueHasChanged) { return; } - if (hasBeenReset.current) { - // If the field value has just been reset (triggering this useEffect) - // we don't want to set the "isPristine" state to true and validate the field - hasBeenReset.current = false; - } else { - setPristine(false); - setIsChangingValue(true); - - runValidationsOnValueChange(() => { - if (isMounted.current) { - setIsChangingValue(false); - } - }); - } + setPristine(false); + setIsChangingValue(true); + + runValidationsOnValueChange(() => { + if (isMounted.current) { + setIsChangingValue(false); + } + }); return () => { if (debounceTimeout.current) { @@ -641,7 +640,7 @@ export const useField = ( debounceTimeout.current = null; } }; - }, [value, runValidationsOnValueChange]); + }, [valueHasChanged, runValidationsOnValueChange]); // Value change: set "isModified" state useEffect(() => { @@ -659,13 +658,18 @@ export const useField = ( return; } - const errorMessages = errors.length ? errors.map((error) => error.message) : null; - - if (errorChangeListener && lastNotifiedState.current.errors !== errorMessages) { - errorChangeListener(errorMessages); - lastNotifiedState.current.errors = errorMessages; + if (errorChangeListener && errorsHaveChanged) { + errorChangeListener(errorsToString(errors)); } - }, [errors, errorChangeListener]); + }, [errors, errorsHaveChanged, errorChangeListener]); + + useEffect(() => { + lastNotifiedState.current.value = value; + }, [value]); + + useEffect(() => { + lastNotifiedState.current.errors = errorsToString(errors); + }, [errors]); useEffect(() => { isMounted.current = true; 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 2160c09ef720e..3966f9cc61a70 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 @@ -11,7 +11,7 @@ import { get } from 'lodash'; import { set } from '@elastic/safer-lodash-set'; import { FormHook, FieldHook, FormData, FieldsMap, FormConfig } from '../types'; -import { mapFormFields, unflattenObject, Subject, Subscription } from '../lib'; +import { mapFormFields, unflattenObject, flattenObject, Subject, Subscription } from '../lib'; const DEFAULT_OPTIONS = { valueChangeDebounceTime: 500, @@ -205,7 +205,18 @@ export function useForm( if (defaultValueDeserialized.current === undefined) { defaultValueDeserialized.current = {} as I; } - set(defaultValueDeserialized.current!, path, value); + + // We allow "undefined" to be passed to be able to remove a value from the form `defaultValue` object. + // When mounts it calls `updateDefaultValueAt("foo", "bar")` to + // update the form "defaultValue" object. When that component unmounts we want to be able to clean up and + // remove its defaultValue on the form. + if (value === undefined) { + const updated = flattenObject(defaultValueDeserialized.current!); + delete updated[path]; + defaultValueDeserialized.current = unflattenObject(updated); + } else { + set(defaultValueDeserialized.current!, path, value); + } }, [] ); diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.test.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.test.tsx index dc89cfe4f1fb6..7c0cd960999e8 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.test.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.test.tsx @@ -5,7 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { act } from 'react-dom/test-utils'; import { registerTestBed } from '../shared_imports'; @@ -36,8 +36,10 @@ describe('useFormIsModified()', () => { const [isNameVisible, setIsNameVisible] = useState(true); const [isLastNameVisible, setIsLastNameVisible] = useState(true); - // Call our jest.spy() with the latest hook value - onIsModifiedChange(isModified); + useEffect(() => { + // Call our jest.spy() with the latest hook value + onIsModifiedChange(isModified); + }, [onIsModifiedChange, isModified]); return (
diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.ts index 08f5eaf76a083..e5e0fd6d61472 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_is_modified.ts @@ -5,7 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { get } from 'lodash'; import { FieldHook, FormHook } from '../types'; @@ -36,6 +36,8 @@ export const useFormIsModified = ({ form: formFromOptions, discard: fieldPathsToDiscard = [], }: Options = {}): boolean => { + const [isFormModified, setIsFormModified] = useState(false); + // Hook calls can not be conditional we first try to access the form through context let form = useFormContext({ throwIfNotFound: false }); @@ -76,28 +78,34 @@ export const useFormIsModified = ({ ? ([path]: [string, FieldHook]) => fieldsToDiscard[path] !== true : () => true; + // Calculate next state value // 1. Check if any field has been modified - let isModified = Object.entries(getFields()) + let nextIsModified = Object.entries(getFields()) .filter(isFieldIncluded) .some(([_, field]) => field.isModified); - if (isModified) { - return isModified; - } + if (!nextIsModified) { + // 2. Check if any field has been removed. + // If somme field has been removed **and** they were originaly present on the + // form "defaultValue" then the form has been modified. + const formDefaultValue = __getFormDefaultValue(); + const fieldOnFormDefaultValue = (path: string) => Boolean(get(formDefaultValue, path)); - // 2. Check if any field has been removed. - // If somme field has been removed **and** they were originaly present on the - // form "defaultValue" then the form has been modified. - const formDefaultValue = __getFormDefaultValue(); - const fieldOnFormDefaultValue = (path: string) => Boolean(get(formDefaultValue, path)); + const fieldsRemovedFromDOM: string[] = fieldsToDiscard + ? Object.keys(__getFieldsRemoved()) + .filter((path) => fieldsToDiscard[path] !== true) + .filter(fieldOnFormDefaultValue) + : Object.keys(__getFieldsRemoved()).filter(fieldOnFormDefaultValue); - const fieldsRemovedFromDOM: string[] = fieldsToDiscard - ? Object.keys(__getFieldsRemoved()) - .filter((path) => fieldsToDiscard[path] !== true) - .filter(fieldOnFormDefaultValue) - : Object.keys(__getFieldsRemoved()).filter(fieldOnFormDefaultValue); + nextIsModified = fieldsRemovedFromDOM.length > 0; + } - isModified = fieldsRemovedFromDOM.length > 0; + // Update the state **only** if it has changed to avoid creating an infinite re-render + if (nextIsModified && !isFormModified) { + setIsFormModified(true); + } else if (!nextIsModified && isFormModified) { + setIsFormModified(false); + } - return isModified; + return isFormModified; }; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/lib/index.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/lib/index.ts index 0bbaedcf2e90e..b65dc0570acba 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/lib/index.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/lib/index.ts @@ -7,5 +7,7 @@ */ export type { Subscription } from './subject'; + export { Subject } from './subject'; -export * from './utils'; + +export { flattenObject, unflattenObject, mapFormFields } from './utils'; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/lib/utils.test.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/lib/utils.test.ts new file mode 100644 index 0000000000000..f7d7429889eb2 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/lib/utils.test.ts @@ -0,0 +1,43 @@ +/* + * 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 { flattenObject } from './utils'; + +describe('Form lib utils', () => { + describe('flattenObject', () => { + test('should flatten an object', () => { + const obj = { + a: true, + b: { + foo: 'bar', + baz: [ + { + a: false, + b: 'foo', + }, + 'bar', + true, + [1, 2, { 3: false }], + ], + }, + }; + + expect(flattenObject(obj)).toEqual({ + a: true, + 'b.baz[0].a': false, + 'b.baz[0].b': 'foo', + 'b.baz[1]': 'bar', + 'b.baz[2]': true, + 'b.foo': 'bar', + 'b.baz[3][0]': 1, + 'b.baz[3][1]': 2, + 'b.baz[3][2].3': false, + }); + }); + }); +}); diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/lib/utils.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/lib/utils.ts index 9d8801b1448c0..8df6506ec2e7b 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/lib/utils.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/lib/utils.ts @@ -9,12 +9,53 @@ import { set } from '@elastic/safer-lodash-set'; import { FieldHook } from '../types'; -export const unflattenObject = (object: object): T => +interface GenericObject { + [key: string]: any; +} + +export const unflattenObject = (object: object): T => Object.entries(object).reduce((acc, [key, value]) => { set(acc, key, value); return acc; }, {} as T); +/** + * Wrap the key with [] if it is a key from an Array + * @param key The object key + * @param isArrayItem Flag to indicate if it is the key of an Array + */ +const renderKey = (key: string, isArrayItem: boolean): string => (isArrayItem ? `[${key}]` : key); + +export const flattenObject = ( + obj: GenericObject, + prefix: string[] = [], + isArrayItem = false +): GenericObject => + Object.keys(obj).reduce((acc, k) => { + const nextValue = obj[k]; + + if (typeof nextValue === 'object' && nextValue !== null) { + const isNextValueArray = Array.isArray(nextValue); + const dotSuffix = isNextValueArray ? '' : '.'; + + if (Object.keys(nextValue).length > 0) { + return { + ...acc, + ...flattenObject( + nextValue, + [...prefix, `${renderKey(k, isArrayItem)}${dotSuffix}`], + isNextValueArray + ), + }; + } + } + + const fullPath = `${prefix.join('')}${renderKey(k, isArrayItem)}`; + acc[fullPath] = nextValue; + + return acc; + }, {}); + /** * Helper to map the object of fields to any of its value *