From 6a3db2c776cb1eaa97edf7fe612b771ea3890b3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Thu, 14 May 2020 09:34:11 +0200 Subject: [PATCH] [form lib] Fix issues + add test coverage (#64647) --- .../helpers/enzyme_helpers.tsx | 208 +++++++++++ .../helpers/find_test_subject.ts | 65 ++++ .../test_utils_temp/helpers/index.ts | 26 ++ .../test_utils_temp/helpers/redux_helpers.tsx | 27 ++ .../helpers/router_helpers.tsx | 85 +++++ .../test_utils_temp/index.ts | 21 ++ .../test_utils_temp/lib/index.ts | 20 ++ .../test_utils_temp/lib/utils.ts | 31 ++ .../test_utils_temp/testbed/index.ts | 21 ++ .../testbed/mount_component.tsx | 74 ++++ .../test_utils_temp/testbed/testbed.ts | 327 ++++++++++++++++++ .../test_utils_temp/testbed/types.ts | 159 +++++++++ .../forms/components/fields/toggle_field.tsx | 2 +- .../components/form_data_provider.test.tsx | 200 +++++++++++ .../components/form_data_provider.ts | 16 +- .../components/use_field.test.tsx | 64 ++++ .../hook_form_lib/components/use_field.tsx | 111 +++--- .../components/use_multi_fields.tsx | 4 +- .../static/forms/hook_form_lib/helpers.ts | 2 +- .../forms/hook_form_lib/hooks/use_field.ts | 50 +-- .../hook_form_lib/hooks/use_form.test.tsx | 233 +++++++++++++ .../forms/hook_form_lib/hooks/use_form.ts | 87 +++-- .../static/forms/hook_form_lib/lib/subject.ts | 6 +- .../forms/hook_form_lib/shared_imports.ts | 24 ++ .../static/forms/hook_form_lib/types.ts | 23 +- .../datatypes/shape_datatype.test.tsx | 39 +-- .../datatypes/text_datatype.test.tsx | 179 ++++------ .../client_integration/edit_field.test.tsx | 66 ++-- .../helpers/mappings_editor.helpers.tsx | 97 +++--- .../client_integration/mapped_fields.test.tsx | 44 ++- .../mappings_editor.test.tsx | 86 +++-- .../components/mappings_editor/_index.scss | 4 + .../configuration_form/configuration_form.tsx | 33 +- .../document_fields/field_parameters/index.ts | 2 + .../field_parameters/subtype_parameter.tsx | 95 +++++ .../field_parameters/type_parameter.tsx | 24 +- .../fields/create_field/create_field.tsx | 204 +++-------- .../fields/edit_field/edit_field.tsx | 301 ++++++++-------- .../edit_field/edit_field_header_form.tsx | 140 ++------ .../load_mappings_provider.test.tsx | 28 +- .../templates_form/templates_form.tsx | 29 +- .../mappings_editor/lib/serializers.ts | 24 +- .../components/mappings_editor/lib/utils.ts | 31 +- .../components/mappings_editor/reducer.ts | 26 +- .../pipeline_form/pipeline_form.tsx | 2 +- .../tabs/tab_documents.tsx | 2 +- .../__jest__/client_integration/home.test.ts | 8 +- .../client_integration/repository_add.test.ts | 8 +- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - x-pack/test_utils/testbed/mount_component.tsx | 2 +- x-pack/test_utils/testbed/testbed.ts | 10 +- x-pack/test_utils/testbed/types.ts | 3 +- 53 files changed, 2452 insertions(+), 923 deletions(-) create mode 100644 src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/helpers/enzyme_helpers.tsx create mode 100644 src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/helpers/find_test_subject.ts create mode 100644 src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/helpers/index.ts create mode 100644 src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/helpers/redux_helpers.tsx create mode 100644 src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/helpers/router_helpers.tsx create mode 100644 src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/index.ts create mode 100644 src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/lib/index.ts create mode 100644 src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/lib/utils.ts create mode 100644 src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/testbed/index.ts create mode 100644 src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/testbed/mount_component.tsx create mode 100644 src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/testbed/testbed.ts create mode 100644 src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/testbed/types.ts create mode 100644 src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.test.tsx create mode 100644 src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx create mode 100644 src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx create mode 100644 src/plugins/es_ui_shared/static/forms/hook_form_lib/shared_imports.ts create mode 100644 x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/subtype_parameter.tsx diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/helpers/enzyme_helpers.tsx b/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/helpers/enzyme_helpers.tsx new file mode 100644 index 0000000000000..d2e13fe7622f9 --- /dev/null +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/helpers/enzyme_helpers.tsx @@ -0,0 +1,208 @@ +/* + * 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. + */ + +/** + * Components using the react-intl module require access to the intl context. + * This is not available when mounting single components in Enzyme. + * These helper functions aim to address that and wrap a valid, + * intl context around them. + */ + +import { I18nProvider, InjectedIntl, intlShape } from '@kbn/i18n/react'; +import { mount, ReactWrapper, render, shallow } from 'enzyme'; +import React, { ReactElement, ValidationMap } from 'react'; +import { act as reactAct } from 'react-dom/test-utils'; + +// Use fake component to extract `intl` property to use in tests. +const { intl } = (mount( + +
+
+).find('IntlProvider') as ReactWrapper<{}, {}, import('react-intl').IntlProvider>) + .instance() + .getChildContext(); + +function getOptions(context = {}, childContextTypes = {}, props = {}) { + return { + context: { + ...context, + intl, + }, + childContextTypes: { + ...childContextTypes, + intl: intlShape, + }, + ...props, + }; +} + +/** + * When using React-Intl `injectIntl` on components, props.intl is required. + */ +function nodeWithIntlProp(node: ReactElement): ReactElement { + return React.cloneElement(node, { intl }); +} + +/** + * Creates the wrapper instance using shallow with provided intl object into context + * + * @param node The React element or cheerio wrapper + * @param options properties to pass into shallow wrapper + * @return The wrapper instance around the rendered output with intl object in context + */ +export function shallowWithIntl( + node: ReactElement, + { + context, + childContextTypes, + ...props + }: { + context?: any; + childContextTypes?: ValidationMap; + } = {} +) { + const options = getOptions(context, childContextTypes, props); + + return shallow(nodeWithIntlProp(node), options); +} + +/** + * Creates the wrapper instance using mount with provided intl object into context + * + * @param node The React element or cheerio wrapper + * @param options properties to pass into mount wrapper + * @return The wrapper instance around the rendered output with intl object in context + */ +export function mountWithIntl( + node: ReactElement, + { + context, + childContextTypes, + ...props + }: { + context?: any; + childContextTypes?: ValidationMap; + } = {} +) { + const options = getOptions(context, childContextTypes, props); + + return mount(nodeWithIntlProp(node), options); +} + +/** + * Creates the wrapper instance using render with provided intl object into context + * + * @param node The React element or cheerio wrapper + * @param options properties to pass into render wrapper + * @return The wrapper instance around the rendered output with intl object in context + */ +export function renderWithIntl( + node: ReactElement, + { + context, + childContextTypes, + ...props + }: { + context?: any; + childContextTypes?: ValidationMap; + } = {} +) { + const options = getOptions(context, childContextTypes, props); + + return render(nodeWithIntlProp(node), options); +} + +/** + * A wrapper object to provide access to the state of a hook under test and to + * enable interaction with that hook. + */ +interface ReactHookWrapper { + /* Ensures that async React operations have settled before and after the + * given actor callback is called. The actor callback arguments provide easy + * access to the last hook value and allow for updating the arguments passed + * to the hook body to trigger reevaluation. + */ + act: (actor: (lastHookValue: HookValue, setArgs: (args: Args) => void) => void) => void; + /* The enzyme wrapper around the test component. */ + component: ReactWrapper; + /* The most recent value return the by test harness of the hook. */ + getLastHookValue: () => HookValue; + /* The jest Mock function that receives the hook values for introspection. */ + hookValueCallback: jest.Mock; +} + +/** + * Allows for execution of hooks inside of a test component which records the + * returned values. + * + * @param body A function that calls the hook and returns data derived from it + * @param WrapperComponent A component that, if provided, will be wrapped + * around the test component. This can be useful to provide context values. + * @return {ReactHookWrapper} An object providing access to the hook state and + * functions to interact with it. + */ +export const mountHook = ( + body: (args: Args) => HookValue, + WrapperComponent?: React.ComponentType, + initialArgs: Args = {} as Args +): ReactHookWrapper => { + const hookValueCallback = jest.fn(); + let component!: ReactWrapper; + + const act: ReactHookWrapper['act'] = actor => { + reactAct(() => { + actor(getLastHookValue(), (args: Args) => component.setProps(args)); + component.update(); + }); + }; + + const getLastHookValue = () => { + const calls = hookValueCallback.mock.calls; + if (calls.length <= 0) { + throw Error('No recent hook value present.'); + } + return calls[calls.length - 1][0]; + }; + + const HookComponent = (props: Args) => { + hookValueCallback(body(props)); + return null; + }; + const TestComponent: React.FunctionComponent = args => + WrapperComponent ? ( + + + + ) : ( + + ); + + reactAct(() => { + component = mount(); + }); + + return { + act, + component, + getLastHookValue, + hookValueCallback, + }; +}; + +export const nextTick = () => new Promise(res => process.nextTick(res)); diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/helpers/find_test_subject.ts b/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/helpers/find_test_subject.ts new file mode 100644 index 0000000000000..5bb4561391ad7 --- /dev/null +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/helpers/find_test_subject.ts @@ -0,0 +1,65 @@ +/* + * 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 { ReactWrapper } from 'enzyme'; + +type Matcher = '=' | '~=' | '|=' | '^=' | '$=' | '*='; + +const MATCHERS: Matcher[] = [ + '=', // Exact match + '~=', // Exists in a space-separated list + '|=', // Begins with substring, followed by '-' + '^=', // Begins with substring + '$=', // Ends with substring + '*=', +]; + +/** + * Find node which matches a specific test subject selector. Returns ReactWrappers around DOM element, + * https://github.com/airbnb/enzyme/tree/master/docs/api/ReactWrapper. + * Common use cases include calling simulate or getDOMNode on the returned ReactWrapper. + * + * The ~= matcher looks for the value in space-separated list, allowing support for multiple data-test-subj + * values on a single element. See https://www.w3.org/TR/selectors-3/#attribute-selectors for more + * info on the other possible matchers. + * + * @param reactWrapper The React wrapper to search in + * @param testSubjectSelector The data test subject selector + * @param matcher optional matcher + */ +export const findTestSubject = ( + reactWrapper: ReactWrapper, + testSubjectSelector: T, + matcher: Matcher = '~=' +) => { + if (!MATCHERS.includes(matcher)) { + throw new Error( + 'Matcher ' + .concat(matcher, ' not found in list of allowed matchers: ') + .concat(MATCHERS.join(' ')) + ); + } + + const testSubject = reactWrapper.find(`[data-test-subj${matcher}"${testSubjectSelector}"]`); + // Restores Enzyme 2's find behavior, which was to only return ReactWrappers around DOM elements. + // Enzyme 3 returns ReactWrappers around both DOM elements and React components. + // https://github.com/airbnb/enzyme/issues/1174 + + return testSubject.hostNodes(); +}; diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/helpers/index.ts b/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/helpers/index.ts new file mode 100644 index 0000000000000..4b80fb13e1c46 --- /dev/null +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/helpers/index.ts @@ -0,0 +1,26 @@ +/* + * 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. + */ + +export { mountWithIntl } from './enzyme_helpers'; + +export { findTestSubject } from './find_test_subject'; + +export { WithStore } from './redux_helpers'; + +export { WithMemoryRouter, WithRoute, reactRouterMock } from './router_helpers'; diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/helpers/redux_helpers.tsx b/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/helpers/redux_helpers.tsx new file mode 100644 index 0000000000000..be5563798b885 --- /dev/null +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/helpers/redux_helpers.tsx @@ -0,0 +1,27 @@ +/* + * 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, { ComponentType } from 'react'; +import { Provider } from 'react-redux'; + +export const WithStore = (store: any) => (WrappedComponent: ComponentType) => (props: any) => ( + + + +); diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/helpers/router_helpers.tsx b/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/helpers/router_helpers.tsx new file mode 100644 index 0000000000000..4621315209b55 --- /dev/null +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/helpers/router_helpers.tsx @@ -0,0 +1,85 @@ +/* + * 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, { Component, ComponentType } from 'react'; +import { MemoryRouter, Route, withRouter } from 'react-router-dom'; +import * as H from 'history'; + +export const WithMemoryRouter = (initialEntries: string[] = ['/'], initialIndex: number = 0) => ( + WrappedComponent: ComponentType +) => (props: any) => ( + + + +); + +export const WithRoute = (componentRoutePath = '/', onRouter = (router: any) => {}) => ( + WrappedComponent: ComponentType +) => { + // Create a class component that will catch the router + // and forward it to our "onRouter()" handler. + const CatchRouter = withRouter( + class extends Component { + componentDidMount() { + const { match, location, history } = this.props; + const router = { route: { match, location }, history }; + onRouter(router); + } + + render() { + return ; + } + } + ); + + return (props: any) => ( + } + /> + ); +}; + +interface Router { + history: Partial; + route: { + location: H.Location; + }; +} + +export const reactRouterMock: Router = { + history: { + push: () => {}, + createHref: location => location.pathname!, + location: { + pathname: '', + search: '', + state: '', + hash: '', + }, + }, + route: { + location: { + pathname: '', + search: '', + state: '', + hash: '', + }, + }, +}; diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/index.ts b/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/index.ts new file mode 100644 index 0000000000000..bcf408cc5af5c --- /dev/null +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/index.ts @@ -0,0 +1,21 @@ +/* + * 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. + */ + +export * from './testbed'; +export * from './lib'; diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/lib/index.ts b/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/lib/index.ts new file mode 100644 index 0000000000000..af810deaca5b0 --- /dev/null +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/lib/index.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +export { nextTick, getRandomString, getRandomNumber } from './utils'; diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/lib/utils.ts b/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/lib/utils.ts new file mode 100644 index 0000000000000..bfde6b1fdd089 --- /dev/null +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/lib/utils.ts @@ -0,0 +1,31 @@ +/* + * 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 Chance from 'chance'; + +const chance = new Chance(); +const CHARS_POOL = 'abcdefghijklmnopqrstuvwxyz'; + +export const nextTick = (time = 0) => new Promise(resolve => setTimeout(resolve, time)); + +export const getRandomNumber = (range: { min: number; max: number } = { min: 1, max: 20 }) => + chance.integer(range); + +export const getRandomString = (options = {}) => + `${chance.string({ pool: CHARS_POOL, ...options })}-${Date.now()}`; diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/testbed/index.ts b/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/testbed/index.ts new file mode 100644 index 0000000000000..f20fb9d49d943 --- /dev/null +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/testbed/index.ts @@ -0,0 +1,21 @@ +/* + * 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. + */ + +export { registerTestBed } from './testbed'; +export { TestBed, TestBedConfig, SetupFunc, UnwrapPromise } from './types'; diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/testbed/mount_component.tsx b/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/testbed/mount_component.tsx new file mode 100644 index 0000000000000..99203449427f6 --- /dev/null +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/testbed/mount_component.tsx @@ -0,0 +1,74 @@ +/* + * 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, { ComponentType } from 'react'; +import { Store } from 'redux'; +import { ReactWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; + +import { mountWithIntl, WithMemoryRouter, WithRoute, WithStore } from '../helpers'; +import { MemoryRouterConfig } from './types'; + +interface Config { + Component: ComponentType; + memoryRouter: MemoryRouterConfig; + store: Store | null; + props: any; + onRouter: (router: any) => void; +} + +const getCompFromConfig = ({ Component, memoryRouter, store, onRouter }: Config): ComponentType => { + const wrapWithRouter = memoryRouter.wrapComponent !== false; + + let Comp: ComponentType = store !== null ? WithStore(store)(Component) : Component; + + if (wrapWithRouter) { + const { componentRoutePath, initialEntries, initialIndex } = memoryRouter!; + + // Wrap the componenet with a MemoryRouter and attach it to a react-router + Comp = WithMemoryRouter( + initialEntries, + initialIndex + )(WithRoute(componentRoutePath, onRouter)(Comp)); + } + + return Comp; +}; + +export const mountComponentSync = (config: Config): ReactWrapper => { + const Comp = getCompFromConfig(config); + return mountWithIntl(); +}; + +export const mountComponentAsync = async (config: Config): Promise => { + const Comp = getCompFromConfig(config); + + let component: ReactWrapper; + + await act(async () => { + component = mountWithIntl(); + }); + + // @ts-ignore + return component; +}; + +export const getJSXComponentWithProps = (Component: ComponentType, props: any) => ( + +); diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/testbed/testbed.ts b/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/testbed/testbed.ts new file mode 100644 index 0000000000000..3fe9466dcf568 --- /dev/null +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/testbed/testbed.ts @@ -0,0 +1,327 @@ +/* + * 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 { ComponentType, ReactWrapper } from 'enzyme'; + +import { findTestSubject, reactRouterMock } from '../helpers'; +import { + mountComponentSync, + mountComponentAsync, + getJSXComponentWithProps, +} from './mount_component'; +import { TestBedConfig, TestBed, SetupFunc } from './types'; + +const defaultConfig: TestBedConfig = { + defaultProps: {}, + memoryRouter: { + wrapComponent: true, + }, + store: null, +}; + +/** + * Register a new Testbed to test a React Component. + * + * @param Component The component under test + * @param config An optional configuration object for the Testbed + * + * @example + ```typescript + import { registerTestBed } from '../../../../test_utils'; + import { RemoteClusterList } from '../../app/sections/remote_cluster_list'; + import { remoteClustersStore } from '../../app/store'; + + const setup = registerTestBed(RemoteClusterList, { store: remoteClustersStore }); + + describe(', () > { + test('it should have a table', () => { + const { exists } = setup(); + expect(exists('remoteClustersTable')).toBe(true); + }); + }); + ``` + */ +export const registerTestBed = ( + Component: ComponentType, + config?: TestBedConfig +): SetupFunc => { + const { + defaultProps = defaultConfig.defaultProps, + memoryRouter = defaultConfig.memoryRouter!, + store = defaultConfig.store, + doMountAsync = false, + } = config || {}; + + // Keep a reference to the React Router + let router: any; + + const onRouter = (_router: any) => { + router = _router; + + if (memoryRouter.onRouter) { + memoryRouter.onRouter(_router); + } + }; + + /** + * In some cases, component have some logic that interacts with the react router + * _before_ the component is mounted.(Class constructor() I'm looking at you :) + * + * By adding the following lines, we make sure there is always a router available + * when instantiating the Component. + */ + onRouter(reactRouterMock); + + const setup: SetupFunc = props => { + // If a function is provided we execute it + const storeToMount = typeof store === 'function' ? store() : store!; + const mountConfig = { + Component, + memoryRouter, + store: storeToMount, + props: { + ...defaultProps, + ...props, + }, + onRouter, + }; + + if (doMountAsync) { + return mountComponentAsync(mountConfig).then(onComponentMounted); + } + + return onComponentMounted(mountComponentSync(mountConfig)); + + // --------------------- + + function onComponentMounted(component: ReactWrapper) { + /** + * ---------------------------------------------------------------- + * Utils + * ---------------------------------------------------------------- + */ + + const find: TestBed['find'] = (testSubject: T, sourceReactWrapper = component) => { + const testSubjectToArray = testSubject.split('.'); + + return testSubjectToArray.reduce((reactWrapper, subject, i) => { + const target = findTestSubject(reactWrapper, subject); + if (!target.length && i < testSubjectToArray.length - 1) { + throw new Error( + `Can't access nested test subject "${ + testSubjectToArray[i + 1] + }" of unknown node "${subject}"` + ); + } + return target; + }, sourceReactWrapper); + }; + + const exists: TestBed['exists'] = (testSubject, count = 1) => + find(testSubject).length === count; + + const setProps: TestBed['setProps'] = updatedProps => { + if (memoryRouter.wrapComponent !== false) { + throw new Error( + 'setProps() can only be called on a component **not** wrapped by a router route.' + ); + } + if (store === null) { + return component.setProps({ ...defaultProps, ...updatedProps }); + } + // Update the props on the Redux Provider children + return component.setProps({ + children: getJSXComponentWithProps(Component, { ...defaultProps, ...updatedProps }), + }); + }; + + const waitFor: TestBed['waitFor'] = async (testSubject: T, count = 1) => { + const triggeredAt = Date.now(); + + /** + * The way jest run tests in parallel + the not deterministic DOM update from React "hooks" + * add flakiness to the tests. This is especially true for component integration tests that + * make many update to the DOM. + * + * For this reason, when we _know_ that an element should be there after we updated some state, + * we will give it 30 seconds to appear in the DOM, checking every 100 ms for its presence. + */ + const MAX_WAIT_TIME = 30000; + const WAIT_INTERVAL = 100; + + const process = async (): Promise => { + const elemFound = exists(testSubject, count); + + if (elemFound) { + // Great! nothing else to do here. + return; + } + + const timeElapsed = Date.now() - triggeredAt; + if (timeElapsed > MAX_WAIT_TIME) { + throw new Error( + `I waited patiently for the "${testSubject}" test subject to appear with no luck. It is nowhere to be found!` + ); + } + + return new Promise(resolve => setTimeout(resolve, WAIT_INTERVAL)).then(() => { + component.update(); + return process(); + }); + }; + + return process(); + }; + + /** + * ---------------------------------------------------------------- + * Forms + * ---------------------------------------------------------------- + */ + + const setInputValue: TestBed['form']['setInputValue'] = ( + input, + value, + isAsync = false + ) => { + const formInput = typeof input === 'string' ? find(input) : (input as ReactWrapper); + + if (!formInput.length) { + throw new Error(`Input "${input}" was not found.`); + } + formInput.simulate('change', { target: { value } }); + component.update(); + + if (!isAsync) { + return; + } + return new Promise(resolve => setTimeout(resolve)); + }; + + const selectCheckBox: TestBed['form']['selectCheckBox'] = ( + testSubject, + isChecked = true + ) => { + const checkBox = find(testSubject); + if (!checkBox.length) { + throw new Error(`"${testSubject}" was not found.`); + } + checkBox.simulate('change', { target: { checked: isChecked } }); + }; + + const toggleEuiSwitch: TestBed['form']['toggleEuiSwitch'] = testSubject => { + const checkBox = find(testSubject); + if (!checkBox.length) { + throw new Error(`"${testSubject}" was not found.`); + } + checkBox.simulate('click'); + }; + + const setComboBoxValue: TestBed['form']['setComboBoxValue'] = ( + comboBoxTestSubject, + value + ) => { + const comboBox = find(comboBoxTestSubject); + const formInput = findTestSubject(comboBox, 'comboBoxSearchInput'); + setInputValue(formInput, value); + + // keyCode 13 === ENTER + comboBox.simulate('keydown', { keyCode: 13 }); + component.update(); + }; + + const getErrorsMessages: TestBed['form']['getErrorsMessages'] = () => { + const errorMessagesWrappers = component.find('.euiFormErrorText'); + return errorMessagesWrappers.map(err => err.text()); + }; + + /** + * ---------------------------------------------------------------- + * Tables + * ---------------------------------------------------------------- + */ + + /** + * Parse an EUI table and return meta data information about its rows and colum content. + * + * @param tableTestSubject The data test subject of the EUI table + */ + const getMetaData: TestBed['table']['getMetaData'] = tableTestSubject => { + const table = find(tableTestSubject); + + if (!table.length) { + throw new Error(`Eui Table "${tableTestSubject}" not found.`); + } + + const rows = table + .find('tr') + .slice(1) // we remove the first row as it is the table header + .map(row => ({ + reactWrapper: row, + columns: row.find('td').map(col => ({ + reactWrapper: col, + // We can't access the td value with col.text() because + // eui adds an extra div in td on mobile => (.euiTableRowCell__mobileHeader) + value: col.find('.euiTableCellContent').text(), + })), + })); + + // Also output the raw cell values, in the following format: [[td0, td1, td2], [td0, td1, td2]] + const tableCellsValues = rows.map(({ columns }) => columns.map(col => col.value)); + return { rows, tableCellsValues }; + }; + + /** + * ---------------------------------------------------------------- + * Router + * ---------------------------------------------------------------- + */ + const navigateTo = (_url: string) => { + const url = + _url[0] === '#' + ? _url.replace('#', '') // remove the beginning hash as the memory router does not understand them + : _url; + router.history.push(url); + }; + + return { + component, + exists, + find, + setProps, + waitFor, + table: { + getMetaData, + }, + form: { + setInputValue, + selectCheckBox, + toggleEuiSwitch, + setComboBoxValue, + getErrorsMessages, + }, + router: { + navigateTo, + }, + }; + } + }; + + return setup; +}; diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/testbed/types.ts b/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/testbed/types.ts new file mode 100644 index 0000000000000..eb1e8a56f3f8f --- /dev/null +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/test_utils_temp/testbed/types.ts @@ -0,0 +1,159 @@ +/* + * 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 { Store } from 'redux'; +import { ReactWrapper } from 'enzyme'; + +export type SetupFunc = (props?: any) => TestBed | Promise>; + +export interface EuiTableMetaData { + /** Array of rows of the table. Each row exposes its reactWrapper and its columns */ + rows: Array<{ + reactWrapper: ReactWrapper; + columns: Array<{ + reactWrapper: ReactWrapper; + value: string; + }>; + }>; + /** A 2 dimensional array of rows & columns containing + * the text content of each cell of the table */ + tableCellsValues: string[][]; +} + +export interface TestBed { + /** The comonent under test */ + component: ReactWrapper; + /** + * Pass it a `data-test-subj` and it will return true if it exists or false if it does not exist. + * + * @param testSubject The data test subject to look for (can be a nested path. e.g. "detailPanel.mySection"). + * @param count The number of times the subject needs to appear in order to return "true" + */ + exists: (testSubject: T, count?: number) => boolean; + /** + * Pass it a `data-test-subj` and it will return an Enzyme reactWrapper of the node. + * You can target a nested test subject by separating it with a dot ('.'); + * + * @param testSubject The data test subject to look for + * + * @example + * + ```ts + find('nameInput'); + // or more specific, + // "nameInput" is a child of "myForm" + find('myForm.nameInput'); + ``` + */ + find: (testSubject: T, reactWrapper?: ReactWrapper) => ReactWrapper; + /** + * Update the props of the mounted component + * + * @param updatedProps The updated prop object + */ + setProps: (updatedProps: any) => void; + /** + * Helper to wait until an element appears in the DOM as hooks updates cycles are tricky. + * Useful when loading a component that fetches a resource from the server + * and we need to wait for the data to be fetched (and bypass any "loading" state). + */ + waitFor: (testSubject: T, count?: number) => Promise; + form: { + /** + * Set the value of a form text input. + * + * In some cases, changing an input value triggers an HTTP request to validate + * the field. Even if we return immediately the response on the mock server we + * still need to wait until the next tick before the DOM updates. + * Setting isAsync to "true" takes care of that. + * + * @param input The form input. Can either be a data-test-subj or a reactWrapper (can be a nested path. e.g. "myForm.myInput"). + * @param value The value to set + * @param isAsync If set to true will return a Promise that resolves on the next "tick" + */ + setInputValue: ( + input: T | ReactWrapper, + value: string, + isAsync?: boolean + ) => Promise | void; + /** + * Select or unselect a form checkbox. + * + * @param dataTestSubject The test subject of the checkbox (can be a nested path. e.g. "myForm.mySelect"). + * @param isChecked Defines if the checkobx is active or not + */ + selectCheckBox: (checkboxTestSubject: T, isChecked?: boolean) => void; + /** + * Toggle the EuiSwitch + * + * @param switchTestSubject The test subject of the EuiSwitch (can be a nested path. e.g. "myForm.mySwitch"). + */ + toggleEuiSwitch: (switchTestSubject: T, isChecked?: boolean) => void; + /** + * The EUI ComboBox is a special input as it needs the ENTER key to be pressed + * in order to register the value set. This helpers automatically does that. + * + * @param comboBoxTestSubject The data test subject of the EuiComboBox (can be a nested path. e.g. "myForm.myComboBox"). + * @param value The value to set + */ + setComboBoxValue: (comboBoxTestSubject: T, value: string) => void; + /** + * Get a list of the form error messages that are visible in the DOM. + */ + getErrorsMessages: () => string[]; + }; + table: { + getMetaData: (tableTestSubject: T) => EuiTableMetaData; + }; + router: { + /** + * Navigate to another React router + */ + navigateTo: (url: string) => void; + }; +} + +export interface TestBedConfig { + /** The default props to pass to the mounted component. */ + defaultProps?: Record; + /** Configuration object for the react-router `MemoryRouter. */ + memoryRouter?: MemoryRouterConfig; + /** An optional redux store. You can also provide a function that returns a store. */ + store?: (() => Store) | Store | null; + /* Mount the component asynchronously. When using "hooked" components with _useEffect()_ calls, you need to set this to "true". */ + doMountAsync?: boolean; +} + +export interface MemoryRouterConfig { + /** Flag to add or not the `MemoryRouter`. If set to `false`, there won't be any router and the component won't be wrapped on a ``. */ + wrapComponent?: boolean; + /** The React Router **initial entries** setting ([see documentation](https://github.com/ReactTraining/react-router/blob/master/packages/react-router/docs/api/MemoryRouter.md)) */ + initialEntries?: string[]; + /** The React Router **initial index** setting ([see documentation](https://github.com/ReactTraining/react-router/blob/master/packages/react-router/docs/api/MemoryRouter.md)) */ + initialIndex?: number; + /** The route **path** for the mounted component (defaults to `"/"`) */ + componentRoutePath?: string; + /** A callBack that will be called with the React Router instance once mounted */ + onRouter?: (router: any) => void; +} + +/** + * Utility type: extracts returned type from a Promise. + */ +export type UnwrapPromise = T extends Promise ? P : T; diff --git a/src/plugins/es_ui_shared/static/forms/components/fields/toggle_field.tsx b/src/plugins/es_ui_shared/static/forms/components/fields/toggle_field.tsx index 8a7a5700d792b..6511eb4e4b1af 100644 --- a/src/plugins/es_ui_shared/static/forms/components/fields/toggle_field.tsx +++ b/src/plugins/es_ui_shared/static/forms/components/fields/toggle_field.tsx @@ -23,7 +23,7 @@ import { EuiFormRow, EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; import { FieldHook, getFieldValidityAndErrorMessage } from '../../hook_form_lib'; interface Props { - field: FieldHook; + field: FieldHook; euiFieldProps?: Record; idAria?: string; [key: string]: any; 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 new file mode 100644 index 0000000000000..8b11b619ea8e0 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.test.tsx @@ -0,0 +1,200 @@ +/* + * 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 from 'react'; +import { act } from 'react-dom/test-utils'; + +import { registerTestBed, TestBed } from '../shared_imports'; +import { OnUpdateHandler } from '../types'; +import { useForm } from '../hooks/use_form'; +import { Form } from './form'; +import { UseField } from './use_field'; +import { FormDataProvider } from './form_data_provider'; + +describe('', () => { + test('should listen to changes in the form data and re-render the children with the updated data', async () => { + const onFormData = jest.fn(); + + const TestComp = () => { + const { form } = useForm(); + + return ( +
+ + + + {formData => { + onFormData(formData); + return null; + }} + + + ); + }; + + const setup = registerTestBed(TestComp, { + memoryRouter: { wrapComponent: false }, + }); + + const { + form: { setInputValue }, + } = setup() as TestBed; + + expect(onFormData.mock.calls.length).toBe(1); + + const [formDataInitial] = onFormData.mock.calls[onFormData.mock.calls.length - 1] as Parameters< + OnUpdateHandler + >; + + expect(formDataInitial).toEqual({ + name: 'Initial value', + lastName: 'Initial value', + }); + + onFormData.mockReset(); // Reset the counter at 0 + + // Make some changes to the form fields + await act(async () => { + setInputValue('nameField', 'updated value'); + 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); + + const [formDataUpdated] = onFormData.mock.calls[onFormData.mock.calls.length - 1] as Parameters< + OnUpdateHandler + >; + + expect(formDataUpdated).toEqual({ + name: 'updated value', + lastName: 'updated value', + }); + }); + + test('props.pathsToWatch (string): should not re-render the children when the field that changed is not the one provided', async () => { + const onFormData = jest.fn(); + + const TestComp = () => { + const { form } = useForm(); + + return ( +
+ + + + {formData => { + onFormData(formData); + return null; + }} + + + ); + }; + + const setup = registerTestBed(TestComp, { + memoryRouter: { wrapComponent: false }, + }); + + const { + form: { setInputValue }, + } = setup() as TestBed; + + onFormData.mockReset(); // Reset the calls counter at 0 + + // Make some changes to a field we are **not** interested in + await act(async () => { + setInputValue('lastNameField', 'updated value'); + }); + + expect(onFormData.mock.calls.length).toBe(0); + }); + + test('props.pathsToWatch (Array): should not re-render the children when the field that changed is not in the watch list', async () => { + const onFormData = jest.fn(); + + const TestComp = () => { + const { form } = useForm(); + + return ( +
+ + + + + {formData => { + onFormData(formData); + return null; + }} + + + ); + }; + + const setup = registerTestBed(TestComp, { + memoryRouter: { wrapComponent: false }, + }); + + const { + form: { setInputValue }, + } = setup() as TestBed; + + onFormData.mockReset(); // Reset the calls counter at 0 + + // Make some changes to fields not in the watch list + await act(async () => { + setInputValue('companyField', 'updated value'); + }); + + // No re-render + expect(onFormData.mock.calls.length).toBe(0); + + // Make some changes to fields in the watch list + await act(async () => { + setInputValue('nameField', 'updated value'); + }); + + expect(onFormData.mock.calls.length).toBe(1); + + onFormData.mockReset(); + + await act(async () => { + setInputValue('lastNameField', 'updated value'); + }); + + expect(onFormData.mock.calls.length).toBe(2); // 2 as the form "isValid" change caused a re-render + + const [formData] = onFormData.mock.calls[onFormData.mock.calls.length - 1] as Parameters< + OnUpdateHandler + >; + + expect(formData).toEqual({ + name: 'updated value', + lastName: 'updated value', + company: 'updated value', + }); + }); +}); 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 0509b8081c35b..ddf2212490476 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,7 +17,7 @@ * under the License. */ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { FormData } from '../types'; import { useFormContext } from '../form_context'; @@ -29,11 +29,11 @@ interface Props { export const FormDataProvider = React.memo(({ children, pathsToWatch }: Props) => { const form = useFormContext(); - const previousRawData = useRef(form.__formData$.current.value); + const previousRawData = useRef(form.__getFormData$().value); const [formData, setFormData] = useState(previousRawData.current); - useEffect(() => { - const subscription = form.subscribe(({ data: { raw } }) => { + 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. @@ -49,10 +49,14 @@ export const FormDataProvider = React.memo(({ children, pathsToWatch }: Props) = } else { setFormData(raw); } - }); + }, + [pathsToWatch] + ); + useEffect(() => { + const subscription = form.subscribe(onFormData); return subscription.unsubscribe; - }, [form, pathsToWatch]); + }, [form.subscribe, onFormData]); return children(formData); }); 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 new file mode 100644 index 0000000000000..7ad32cb0bc3f0 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx @@ -0,0 +1,64 @@ +/* + * 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 { registerTestBed } from '../shared_imports'; +import { OnUpdateHandler } from '../types'; +import { useForm } from '../hooks/use_form'; +import { Form } from './form'; +import { UseField } from './use_field'; + +describe('', () => { + 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(); + + useEffect(() => form.subscribe(onData).unsubscribe, [form]); + + return ( +
+ + + + ); + }; + + const setup = registerTestBed(TestComp, { + defaultProps: { onData: onFormData }, + memoryRouter: { wrapComponent: false }, + }); + + setup(); + + const [{ data }] = onFormData.mock.calls[onFormData.mock.calls.length - 1] as Parameters< + OnUpdateHandler + >; + + expect(data.raw).toEqual({ + name: 'John', + lastName: 'Snow', + }); + }); +}); 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 bdcf47c865701..136f3e7ad5688 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 @@ -23,19 +23,20 @@ import { FieldHook, FieldConfig } from '../types'; import { useField } from '../hooks'; import { useFormContext } from '../form_context'; -export interface Props { +export interface Props { path: string; - config?: FieldConfig; - defaultValue?: unknown; + config?: FieldConfig; + defaultValue?: T; component?: FunctionComponent | 'input'; componentProps?: Record; readDefaultValueOnForm?: boolean; - onChange?: (value: unknown) => void; - children?: (field: FieldHook) => JSX.Element; + onChange?: (value: T) => void; + children?: (field: FieldHook) => JSX.Element; + [key: string]: any; } -export const UseField = React.memo( - ({ +function UseFieldComp(props: Props) { + const { path, config, defaultValue, @@ -44,60 +45,70 @@ export const UseField = React.memo( readDefaultValueOnForm = true, onChange, children, - }: Props) => { - const form = useFormContext(); - component = component === undefined ? 'input' : component; - componentProps = componentProps === undefined ? {} : componentProps; + ...rest + } = props; - if (typeof defaultValue === 'undefined' && readDefaultValueOnForm) { - defaultValue = form.getFieldDefaultValue(path); - } + const form = useFormContext(); + const componentToRender = component ?? 'input'; + // For backward compatibility we merge the "componentProps" prop into the "rest" + const propsToForward = + componentProps !== undefined ? { ...componentProps, ...rest } : { ...rest }; - if (!config) { - config = form.__readFieldConfigFromSchema(path); - } + const fieldConfig = + config !== undefined + ? { ...config } + : ({ + ...form.__readFieldConfigFromSchema(path), + } as Partial>); - // Don't modify the config object - const configCopy = - typeof defaultValue !== 'undefined' ? { ...config, defaultValue } : { ...config }; + if (defaultValue === undefined && readDefaultValueOnForm) { + // Read the field default value from the "defaultValue" object passed to the form + (fieldConfig.defaultValue as any) = form.getFieldDefaultValue(path) ?? fieldConfig.defaultValue; + } else if (defaultValue !== undefined) { + // Read the field default value from the propvided prop + (fieldConfig.defaultValue as any) = defaultValue; + } - if (!configCopy.path) { - configCopy.path = path; - } else { - if (configCopy.path !== path) { - throw new Error( - `Field path mismatch. Got "${path}" but field config has "${configCopy.path}".` - ); - } + if (!fieldConfig.path) { + (fieldConfig.path as any) = path; + } else { + if (fieldConfig.path !== path) { + throw new Error( + `Field path mismatch. Got "${path}" but field config has "${fieldConfig.path}".` + ); } + } - const field = useField(form, path, configCopy, onChange); - - // Children prevails over anything else provided. - if (children) { - return children!(field); - } + const field = useField(form, path, fieldConfig, onChange); - if (component === 'input') { - return ( - - ); - } + // Children prevails over anything else provided. + if (children) { + return children!(field); + } - return component({ field, ...componentProps }); + if (componentToRender === 'input') { + return ( + + ); } -); + + return componentToRender({ field, ...propsToForward }); +} + +export const UseField = React.memo(UseFieldComp) as typeof UseFieldComp; /** * Get a component providing some common props for all instances. * @param partialProps Partial props to apply to all instances */ -export const getUseField = (partialProps: Partial) => (props: Partial) => { - const componentProps = { ...partialProps, ...props } as Props; - return ; -}; +export function getUseField(partialProps: Partial>) { + return function(props: Partial>) { + const componentProps = { ...partialProps, ...props } as Props; + return {...componentProps} />; + }; +} diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_multi_fields.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_multi_fields.tsx index b84c5585e017c..a81af924eb3bd 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_multi_fields.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_multi_fields.tsx @@ -22,10 +22,10 @@ import React from 'react'; import { UseField, Props as UseFieldProps } from './use_field'; import { FieldHook } from '../types'; -type FieldsArray = Array<{ id: string } & Omit>; +type FieldsArray = Array<{ id: string } & Omit, 'children'>>; interface Props { - fields: { [key: string]: Omit }; + fields: { [key: string]: Exclude, 'children'> }; children: (fields: { [key: string]: FieldHook }) => JSX.Element; } diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/helpers.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/helpers.ts index e71d52d6ff003..7ea42f81b43cb 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/helpers.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/helpers.ts @@ -20,7 +20,7 @@ import { FieldHook } from './types'; export const getFieldValidityAndErrorMessage = ( - field: FieldHook + field: FieldHook ): { isInvalid: boolean; errorMessage: string | null } => { const isInvalid = !field.isChangingValue && field.errors.length > 0; const errorMessage = 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 aa09fe26c29ca..3814bbe62e120 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 @@ -22,11 +22,11 @@ import { useState, useEffect, useRef, useMemo } from 'react'; import { FormHook, FieldHook, FieldConfig, FieldValidateResponse, ValidationError } from '../types'; import { FIELD_TYPES, VALIDATION_TYPES } from '../constants'; -export const useField = ( +export const useField = ( form: FormHook, path: string, - config: FieldConfig = {}, - valueChangeListener?: (value: unknown) => void + config: FieldConfig = {}, + valueChangeListener?: (value: T) => void ) => { const { type = FIELD_TYPES.TEXT, @@ -48,9 +48,9 @@ export const useField = ( ? deserializer(defaultValue()) : deserializer(defaultValue), [defaultValue] - ); + ) as T; - const [value, setStateValue] = useState(initialValue); + const [value, setStateValue] = useState(initialValue); const [errors, setErrors] = useState([]); const [isPristine, setPristine] = useState(true); const [isValidating, setValidating] = useState(false); @@ -84,16 +84,16 @@ export const useField = ( ); }; - const formatInputValue = (inputValue: unknown): unknown => { + const formatInputValue = (inputValue: unknown): T => { const isEmptyString = typeof inputValue === 'string' && inputValue.trim() === ''; if (isEmptyString) { - return inputValue; + return inputValue as T; } const formData = form.getFormData({ unflatten: false }); - return formatters.reduce((output, formatter) => formatter(output, formData), inputValue); + return formatters.reduce((output, formatter) => formatter(output, formData), inputValue) as T; }; const onValueChange = async () => { @@ -112,7 +112,7 @@ export const useField = ( // Notify listener if (valueChangeListener) { - valueChangeListener(newValue); + valueChangeListener(newValue as T); } // Update the form data observable @@ -283,7 +283,7 @@ export const useField = ( * If a validationType is provided then only that validation will be executed, * skipping the other type of validation that might exist. */ - const validate: FieldHook['validate'] = (validationData = {}) => { + const validate: FieldHook['validate'] = (validationData = {}) => { const { formData = form.getFormData({ unflatten: false }), value: valueToValidate = value, @@ -331,16 +331,16 @@ export const useField = ( * * @param newValue The new value to assign to the field */ - const setValue: FieldHook['setValue'] = newValue => { + const setValue: FieldHook['setValue'] = newValue => { if (isPristine) { setPristine(false); } - const formattedValue = formatInputValue(newValue); + const formattedValue = formatInputValue(newValue); setStateValue(formattedValue); }; - const _setErrors: FieldHook['setErrors'] = _errors => { + const _setErrors: FieldHook['setErrors'] = _errors => { setErrors(_errors.map(error => ({ validationType: VALIDATION_TYPES.FIELD, ...error }))); }; @@ -349,12 +349,12 @@ export const useField = ( * * @param event Form input change event */ - const onChange: FieldHook['onChange'] = event => { + const onChange: FieldHook['onChange'] = event => { const newValue = {}.hasOwnProperty.call(event!.target, 'checked') ? event.target.checked : event.target.value; - setValue(newValue); + setValue((newValue as unknown) as T); }; /** @@ -367,7 +367,7 @@ export const useField = ( * * @param validationType The validation type to return error messages from */ - const getErrorsMessages: FieldHook['getErrorsMessages'] = (args = {}) => { + const getErrorsMessages: FieldHook['getErrorsMessages'] = (args = {}) => { const { errorCode, validationType = VALIDATION_TYPES.FIELD } = args; const errorMessages = errors.reduce((messages, error) => { const isSameErrorCode = errorCode && error.code === errorCode; @@ -385,7 +385,7 @@ export const useField = ( return errorMessages ? errorMessages : null; }; - const reset: FieldHook['reset'] = (resetOptions = { resetValue: true }) => { + const reset: FieldHook['reset'] = (resetOptions = { resetValue: true }) => { const { resetValue = true } = resetOptions; setPristine(true); @@ -396,12 +396,18 @@ export const useField = ( if (resetValue) { setValue(initialValue); - return initialValue; + /** + * Having to call serializeOutput() is a current bug of the lib and will be fixed + * in a future PR. The serializer function should only be called when outputting + * the form data. If we need to continuously format the data while it changes, + * we need to use the field `formatter` config. + */ + return serializeOutput(initialValue); } return value; }; - const serializeOutput: FieldHook['__serializeOutput'] = (rawValue = value) => + const serializeOutput: FieldHook['__serializeOutput'] = (rawValue = value) => serializer(rawValue); // -- EFFECTS @@ -421,7 +427,7 @@ export const useField = ( }; }, [value]); - const field: FieldHook = { + const field: FieldHook = { path, type, label, @@ -445,7 +451,7 @@ export const useField = ( __serializeOutput: serializeOutput, }; - form.__addField(field); // Executed first (1) + form.__addField(field as FieldHook); // Executed first (1) useEffect(() => { /** @@ -459,7 +465,7 @@ export const useField = ( * TODO: See how we could refactor "use_field" & "use_form" to avoid having the * `form.__addField(field)` call outside the effect. */ - form.__addField(field); // Executed third (3) + form.__addField(field as FieldHook); // Executed third (3) return () => { // Remove field from the form when it is unmounted or if its path changes. 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 new file mode 100644 index 0000000000000..f332d2e6ea604 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx @@ -0,0 +1,233 @@ +/* + * 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, getRandomString, TestBed } from '../shared_imports'; + +import { Form, UseField } from '../components'; +import { FormSubmitHandler, OnUpdateHandler } from '../types'; +import { useForm } from './use_form'; + +interface MyForm { + username: string; +} + +interface Props { + onData: FormSubmitHandler; +} + +describe('use_form() hook', () => { + describe('form.submit() & config.onSubmit()', () => { + const onFormData = jest.fn(); + + afterEach(() => { + onFormData.mockReset(); + }); + + test('should receive the form data and the validity of the form', async () => { + const TestComp = ({ onData }: Props) => { + const { form } = useForm({ onSubmit: onData }); + + return ( +
+ +