diff --git a/src/plugins/es_ui_shared/public/components/json_editor/index.ts b/src/plugins/es_ui_shared/public/components/json_editor/index.ts new file mode 100644 index 0000000000000..81476a65f4215 --- /dev/null +++ b/src/plugins/es_ui_shared/public/components/json_editor/index.ts @@ -0,0 +1,22 @@ +/* + * 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 './json_editor'; + +export { OnJsonEditorUpdateHandler } from './use_json'; diff --git a/src/plugins/es_ui_shared/public/components/json_editor/json_editor.tsx b/src/plugins/es_ui_shared/public/components/json_editor/json_editor.tsx new file mode 100644 index 0000000000000..8c63cc8494a8b --- /dev/null +++ b/src/plugins/es_ui_shared/public/components/json_editor/json_editor.tsx @@ -0,0 +1,111 @@ +/* + * 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, { useCallback } from 'react'; +import { EuiFormRow, EuiCodeEditor } from '@elastic/eui'; +import { debounce } from 'lodash'; + +import { isJSON } from '../../../static/validators/string'; +import { useJson, OnJsonEditorUpdateHandler } from './use_json'; + +interface Props { + onUpdate: OnJsonEditorUpdateHandler; + label?: string; + helpText?: React.ReactNode; + value?: string; + defaultValue?: { [key: string]: any }; + euiCodeEditorProps?: { [key: string]: any }; + error?: string | null; +} + +export const JsonEditor = React.memo( + ({ + label, + helpText, + onUpdate, + value, + defaultValue, + euiCodeEditorProps, + error: propsError, + }: Props) => { + const isControlled = value !== undefined; + + const { content, setContent, error: internalError } = useJson({ + defaultValue, + onUpdate, + isControlled, + }); + + const debouncedSetContent = useCallback(debounce(setContent, 300), [setContent]); + + // We let the consumer control the validation and the error message. + const error = isControlled ? propsError : internalError; + + const onEuiCodeEditorChange = useCallback( + (updated: string) => { + if (isControlled) { + onUpdate({ + data: { + raw: updated, + format() { + return JSON.parse(updated); + }, + }, + validate() { + return isJSON(updated); + }, + isValid: undefined, + }); + } else { + debouncedSetContent(updated); + } + }, + [isControlled] + ); + + return ( + + + + ); + } +); diff --git a/src/plugins/es_ui_shared/public/components/json_editor/use_json.ts b/src/plugins/es_ui_shared/public/components/json_editor/use_json.ts new file mode 100644 index 0000000000000..1b5ca5d7f4384 --- /dev/null +++ b/src/plugins/es_ui_shared/public/components/json_editor/use_json.ts @@ -0,0 +1,94 @@ +/* + * 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 { useEffect, useState, useRef } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { isJSON } from '../../../static/validators/string'; + +export type OnJsonEditorUpdateHandler = (arg: { + data: { + raw: string; + format(): T; + }; + validate(): boolean; + isValid: boolean | undefined; +}) => void; + +interface Parameters { + onUpdate: OnJsonEditorUpdateHandler; + defaultValue?: T; + isControlled?: boolean; +} + +const stringifyJson = (json: { [key: string]: any }) => + Object.keys(json).length ? JSON.stringify(json, null, 2) : '{\n\n}'; + +export const useJson = ({ + defaultValue = {} as T, + onUpdate, + isControlled = false, +}: Parameters) => { + const didMount = useRef(false); + const [content, setContent] = useState(stringifyJson(defaultValue)); + const [error, setError] = useState(null); + + const validate = () => { + // We allow empty string as it will be converted to "{}"" + const isValid = content.trim() === '' ? true : isJSON(content); + if (!isValid) { + setError( + i18n.translate('esUi.validation.string.invalidJSONError', { + defaultMessage: 'Invalid JSON', + }) + ); + } else { + setError(null); + } + return isValid; + }; + + const formatContent = () => { + const isValid = validate(); + const data = isValid && content.trim() !== '' ? JSON.parse(content) : {}; + return data as T; + }; + + useEffect(() => { + if (didMount.current) { + const isValid = isControlled ? undefined : validate(); + onUpdate({ + data: { + raw: content, + format: formatContent, + }, + validate, + isValid, + }); + } else { + didMount.current = true; + } + }, [content]); + + return { + content, + setContent, + error, + }; +}; diff --git a/src/plugins/es_ui_shared/public/index.ts b/src/plugins/es_ui_shared/public/index.ts new file mode 100644 index 0000000000000..a12c951ad13a8 --- /dev/null +++ b/src/plugins/es_ui_shared/public/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 * from './components/json_editor'; diff --git a/src/plugins/es_ui_shared/static/forms/components/field.tsx b/src/plugins/es_ui_shared/static/forms/components/field.tsx index 5b9a6dc9de002..07fca1a7f7595 100644 --- a/src/plugins/es_ui_shared/static/forms/components/field.tsx +++ b/src/plugins/es_ui_shared/static/forms/components/field.tsx @@ -17,12 +17,12 @@ * under the License. */ -import React from 'react'; +import React, { ComponentType } from 'react'; import { FieldHook, FIELD_TYPES } from '../hook_form_lib'; interface Props { field: FieldHook; - euiFieldProps?: Record; + euiFieldProps?: { [key: string]: any }; idAria?: string; [key: string]: any; } @@ -41,7 +41,7 @@ import { ToggleField, } from './fields'; -const mapTypeToFieldComponent = { +const mapTypeToFieldComponent: { [key: string]: ComponentType } = { [FIELD_TYPES.TEXT]: TextField, [FIELD_TYPES.TEXTAREA]: TextAreaField, [FIELD_TYPES.NUMBER]: NumericField, diff --git a/src/plugins/es_ui_shared/static/forms/components/fields/checkbox_field.tsx b/src/plugins/es_ui_shared/static/forms/components/fields/checkbox_field.tsx index 0443b4ff09e60..c8ba9f5ac4102 100644 --- a/src/plugins/es_ui_shared/static/forms/components/fields/checkbox_field.tsx +++ b/src/plugins/es_ui_shared/static/forms/components/fields/checkbox_field.tsx @@ -35,7 +35,7 @@ export const CheckBoxField = ({ field, euiFieldProps = {}, ...rest }: Props) => return ( { + const { errorMessage } = getFieldValidityAndErrorMessage(field); + + const { label, helpText, value, setValue } = field; + + const onJsonUpdate: OnJsonEditorUpdateHandler = useCallback( + updatedJson => { + setValue(updatedJson.data.raw); + }, + [setValue] + ); + + return ( + + ); +}; diff --git a/src/plugins/es_ui_shared/static/forms/components/fields/multi_select_field.tsx b/src/plugins/es_ui_shared/static/forms/components/fields/multi_select_field.tsx index 0f7332e3954e6..e77337e4ecf53 100644 --- a/src/plugins/es_ui_shared/static/forms/components/fields/multi_select_field.tsx +++ b/src/plugins/es_ui_shared/static/forms/components/fields/multi_select_field.tsx @@ -35,7 +35,7 @@ export const MultiSelectField = ({ field, euiFieldProps = {}, ...rest }: Props) return ( { return ( { return ( ; + euiFieldProps: { + options: Array< + { text: string | ReactNode; [key: string]: any } & OptionHTMLAttributes + >; + [key: string]: any; + }; idAria?: string; [key: string]: any; } -export const SelectField = ({ field, euiFieldProps = {}, ...rest }: Props) => { +export const SelectField = ({ field, euiFieldProps, ...rest }: Props) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); return ( ; + euiFieldProps: { + options: EuiSuperSelectProps['options']; + [key: string]: any; + }; idAria?: string; [key: string]: any; } -export const SuperSelectField = ({ field, euiFieldProps = {}, ...rest }: Props) => { +export const SuperSelectField = ({ field, euiFieldProps = { options: [] }, ...rest }: Props) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); return ( { field.setValue(value); }} - options={[]} isInvalid={isInvalid} data-test-subj="select" {...euiFieldProps} diff --git a/src/plugins/es_ui_shared/static/forms/components/fields/text_area_field.tsx b/src/plugins/es_ui_shared/static/forms/components/fields/text_area_field.tsx index b9c6424a00656..c6fccb0c0e383 100644 --- a/src/plugins/es_ui_shared/static/forms/components/fields/text_area_field.tsx +++ b/src/plugins/es_ui_shared/static/forms/components/fields/text_area_field.tsx @@ -35,7 +35,7 @@ export const TextAreaField = ({ field, euiFieldProps = {}, ...rest }: Props) => return ( { return ( { return ( ( + ...args: Parameters +): ReturnType> => { + const [{ value }] = args; + + if (typeof value !== 'string') { + return; + } + + if (!isJSON(value)) { + return { + code: 'ERR_JSON_FORMAT', + message, + }; + } +}; diff --git a/src/plugins/es_ui_shared/static/forms/helpers/field_validators/lowercase_string.ts b/src/plugins/es_ui_shared/static/forms/helpers/field_validators/lowercase_string.ts new file mode 100644 index 0000000000000..42de66b930eaa --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/helpers/field_validators/lowercase_string.ts @@ -0,0 +1,39 @@ +/* + * 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 { ValidationFunc } from '../../hook_form_lib'; +import { isLowerCaseString } from '../../../validators/string'; +import { ERROR_CODE } from './types'; + +export const lowerCaseStringField = (message: string) => ( + ...args: Parameters +): ReturnType> => { + const [{ value }] = args; + + if (typeof value !== 'string') { + return; + } + + if (!isLowerCaseString(value)) { + return { + code: 'ERR_LOWERCASE_STRING', + message, + }; + } +}; diff --git a/src/plugins/es_ui_shared/static/forms/helpers/field_validators/number_greater_than.ts b/src/plugins/es_ui_shared/static/forms/helpers/field_validators/number_greater_than.ts new file mode 100644 index 0000000000000..767302a8328c1 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/helpers/field_validators/number_greater_than.ts @@ -0,0 +1,42 @@ +/* + * 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 { ValidationFunc, ValidationError } from '../../hook_form_lib'; +import { isNumberGreaterThan } from '../../../validators/number'; +import { ERROR_CODE } from './types'; + +export const numberGreaterThanField = ({ + than, + message, + allowEquality = false, +}: { + than: number; + message: string | ((err: Partial) => string); + allowEquality?: boolean; +}) => (...args: Parameters): ReturnType> => { + const [{ value }] = args; + + return isNumberGreaterThan(than, allowEquality)(value as number) + ? undefined + : { + code: 'ERR_GREATER_THAN_NUMBER', + than, + message: typeof message === 'function' ? message({ than }) : message, + }; +}; diff --git a/src/plugins/es_ui_shared/static/forms/helpers/field_validators/number_smaller_than.ts b/src/plugins/es_ui_shared/static/forms/helpers/field_validators/number_smaller_than.ts new file mode 100644 index 0000000000000..4eab3c5b0f103 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/helpers/field_validators/number_smaller_than.ts @@ -0,0 +1,42 @@ +/* + * 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 { ValidationFunc, ValidationError } from '../../hook_form_lib'; +import { isNumberSmallerThan } from '../../../validators/number'; +import { ERROR_CODE } from './types'; + +export const numberSmallerThanField = ({ + than, + message, + allowEquality = false, +}: { + than: number; + message: string | ((err: Partial) => string); + allowEquality?: boolean; +}) => (...args: Parameters): ReturnType> => { + const [{ value }] = args; + + return isNumberSmallerThan(than, allowEquality)(value as number) + ? undefined + : { + code: 'ERR_SMALLER_THAN_NUMBER', + than, + message: typeof message === 'function' ? message({ than }) : message, + }; +}; diff --git a/src/plugins/es_ui_shared/static/forms/helpers/field_validators/types.ts b/src/plugins/es_ui_shared/static/forms/helpers/field_validators/types.ts index 25cf038ec227d..d5bceac836137 100644 --- a/src/plugins/es_ui_shared/static/forms/helpers/field_validators/types.ts +++ b/src/plugins/es_ui_shared/static/forms/helpers/field_validators/types.ts @@ -25,4 +25,8 @@ export type ERROR_CODE = | 'ERR_MIN_LENGTH' | 'ERR_MAX_LENGTH' | 'ERR_MIN_SELECTION' - | 'ERR_MAX_SELECTION'; + | 'ERR_MAX_SELECTION' + | 'ERR_LOWERCASE_STRING' + | 'ERR_JSON_FORMAT' + | 'ERR_SMALLER_THAN_NUMBER' + | 'ERR_GREATER_THAN_NUMBER'; 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 06f5c2369df10..a8d24984cec7c 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.ts @@ -17,10 +17,9 @@ * under the License. */ -import { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { FormData } from '../types'; -import { Subscription } from '../lib'; import { useFormContext } from '../form_context'; interface Props { @@ -28,14 +27,13 @@ interface Props { pathsToWatch?: string | string[]; } -export const FormDataProvider = ({ children, pathsToWatch }: Props) => { +export const FormDataProvider = React.memo(({ children, pathsToWatch }: Props) => { const [formData, setFormData] = useState({}); - const previousState = useRef({}); - const subscription = useRef(null); + const previousRawData = useRef({}); const form = useFormContext(); useEffect(() => { - subscription.current = form.__formData$.current.subscribe(data => { + const subscription = form.subscribe(({ 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. @@ -43,19 +41,17 @@ export const FormDataProvider = ({ children, pathsToWatch }: Props) => { const valuesToWatchArray = Array.isArray(pathsToWatch) ? (pathsToWatch as string[]) : ([pathsToWatch] as string[]); - if (valuesToWatchArray.some(value => previousState.current[value] !== data[value])) { - previousState.current = data; - setFormData(data); + if (valuesToWatchArray.some(value => previousRawData.current[value] !== raw[value])) { + previousRawData.current = raw; + setFormData(raw); } } else { - setFormData(data); + setFormData(raw); } }); - return () => { - subscription.current!.unsubscribe(); - }; - }, [pathsToWatch]); + return subscription.unsubscribe; + }, [form, pathsToWatch]); return children(formData); -}; +}); diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/index.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/index.ts index b5f1d18ee9e64..1088a3f82aa4f 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/index.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/index.ts @@ -19,5 +19,6 @@ export * from './form'; export * from './use_field'; +export * from './use_multi_fields'; export * from './use_array'; export * from './form_data_provider'; 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 580ec7714027c..021d52fbe9edb 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 @@ -17,80 +17,71 @@ * under the License. */ -import React, { useEffect, FunctionComponent } from 'react'; +import React, { FunctionComponent } from 'react'; import { FieldHook, FieldConfig } from '../types'; import { useField } from '../hooks'; import { useFormContext } from '../form_context'; -interface Props { +export interface Props { path: string; config?: FieldConfig; defaultValue?: unknown; component?: FunctionComponent | 'input'; componentProps?: Record; + onChange?: (value: unknown) => void; children?: (field: FieldHook) => JSX.Element; } -export const UseField = ({ - path, - config, - defaultValue, - component = 'input', - componentProps = {}, - children, -}: Props) => { - const form = useFormContext(); +export const UseField = React.memo( + ({ path, config, defaultValue, component, componentProps, onChange, children }: Props) => { + const form = useFormContext(); + component = component === undefined ? 'input' : component; + componentProps = componentProps === undefined ? {} : componentProps; - if (typeof defaultValue === 'undefined') { - defaultValue = form.getFieldDefaultValue(path); - } + if (typeof defaultValue === 'undefined') { + defaultValue = form.getFieldDefaultValue(path); + } - if (!config) { - config = form.__readFieldConfigFromSchema(path); - } + if (!config) { + config = form.__readFieldConfigFromSchema(path); + } - // Don't modify the config object - const configCopy = - typeof defaultValue !== 'undefined' ? { ...config, defaultValue } : { ...config }; + // Don't modify the config object + const configCopy = + typeof defaultValue !== 'undefined' ? { ...config, defaultValue } : { ...config }; - 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 (!configCopy.path) { + configCopy.path = path; + } else { + if (configCopy.path !== path) { + throw new Error( + `Field path mismatch. Got "${path}" but field config has "${configCopy.path}".` + ); + } } - } - const field = useField(form, path, configCopy); + const field = useField(form, path, configCopy, onChange); - // Remove field from form when it is unmounted or if its path changes - useEffect(() => { - return () => { - form.__removeField(path); - }; - }, [path]); + // Children prevails over anything else provided. + if (children) { + return children!(field); + } - // Children prevails over anything else provided. - if (children) { - return children!(field); - } + if (component === 'input') { + return ( + + ); + } - if (component === 'input') { - return ( - - ); + return component({ field, ...componentProps }); } - - return component({ field, ...componentProps }); -}; +); /** * Get a component providing some common props for all instances. 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 new file mode 100644 index 0000000000000..b84c5585e017c --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_multi_fields.tsx @@ -0,0 +1,57 @@ +/* + * 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 { UseField, Props as UseFieldProps } from './use_field'; +import { FieldHook } from '../types'; + +type FieldsArray = Array<{ id: string } & Omit>; + +interface Props { + fields: { [key: string]: Omit }; + children: (fields: { [key: string]: FieldHook }) => JSX.Element; +} + +export const UseMultiFields = ({ fields, children }: Props) => { + const fieldsArray = Object.entries(fields).reduce( + (acc, [fieldId, field]) => [...acc, { id: fieldId, ...field }], + [] as FieldsArray + ); + + const hookFields: { [key: string]: FieldHook } = {}; + + const renderField = (index: number) => { + const { id } = fieldsArray[index]; + return ( + + {field => { + hookFields[id] = field; + return index === fieldsArray.length - 1 ? children(hookFields) : renderField(index + 1); + }} + + ); + }; + + if (!Boolean(fieldsArray.length)) { + return null; + } + + return renderField(0); +}; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/form_context.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/form_context.tsx index b7c6c39e7b0c5..5dcd076b41533 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/form_context.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/form_context.tsx @@ -19,7 +19,7 @@ import React, { createContext, useContext } from 'react'; -import { FormHook } from './types'; +import { FormHook, FormData } from './types'; const FormContext = createContext | undefined>(undefined); @@ -32,7 +32,7 @@ export const FormProvider = ({ children, form }: Props) => ( {children} ); -export const useFormContext = function>() { +export const useFormContext = function() { const context = useContext(FormContext) as FormHook; if (context === undefined) { throw new Error('useFormContext must be used within a '); 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 d7ef798bf2e03..80cddb513b20a 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 @@ -17,12 +17,17 @@ * under the License. */ -import { useState, useEffect, useRef } from 'react'; +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 = (form: FormHook, path: string, config: FieldConfig = {}) => { +export const useField = ( + form: FormHook, + path: string, + config: FieldConfig = {}, + valueChangeListener?: (value: unknown) => void +) => { const { type = FIELD_TYPES.TEXT, defaultValue = '', @@ -37,17 +42,25 @@ export const useField = (form: FormHook, path: string, config: FieldConfig = {}) deserializer = (value: unknown) => value, } = config; - const [value, setStateValue] = useState( - typeof defaultValue === 'function' ? deserializer(defaultValue()) : deserializer(defaultValue) + const initialValue = useMemo( + () => + typeof defaultValue === 'function' + ? deserializer(defaultValue()) + : deserializer(defaultValue), + [defaultValue] ); + + const [value, setStateValue] = useState(initialValue); const [errors, setErrors] = useState([]); const [isPristine, setPristine] = useState(true); const [isValidating, setValidating] = useState(false); const [isChangingValue, setIsChangingValue] = useState(false); + const [isValidated, setIsValidated] = useState(false); const validateCounter = useRef(0); const changeCounter = useRef(0); const inflightValidation = useRef | null>(null); const debounceTimeout = useRef(null); + const isUnmounted = useRef(false); // -- HELPERS // ---------------------------------- @@ -77,7 +90,10 @@ export const useField = (form: FormHook, path: string, config: FieldConfig = {}) if (isEmptyString) { return inputValue; } - return formatters.reduce((output, formatter) => formatter(output), inputValue); + + const formData = form.getFormData({ unflatten: false }); + + return formatters.reduce((output, formatter) => formatter(output, formData), inputValue); }; const onValueChange = async () => { @@ -92,12 +108,23 @@ export const useField = (form: FormHook, path: string, config: FieldConfig = {}) setIsChangingValue(true); } + const newValue = serializeOutput(value); + + // Notify listener + if (valueChangeListener) { + valueChangeListener(newValue); + } + // Update the form data observable - form.__updateFormDataAt(path, serializeOutput(value)); + form.__updateFormDataAt(path, newValue); // Validate field(s) and set form.isValid flag await form.__validateFields(fieldsToValidateOnChange); + if (isUnmounted.current) { + return; + } + /** * If we have set a delay to display the error message after the field value has changed, * we first check that this is the last "change iteration" (=== the last keystroke from the user) @@ -263,6 +290,7 @@ export const useField = (form: FormHook, path: string, config: FieldConfig = {}) validationType, } = validationData; + setIsValidated(true); setValidating(true); // By the time our validate function has reached completion, it’s possible @@ -276,12 +304,10 @@ export const useField = (form: FormHook, path: string, config: FieldConfig = {}) // This is the most recent invocation setValidating(false); // Update the errors array - setErrors(previousErrors => { - // First filter out the validation type we are currently validating - const filteredErrors = filterErrors(previousErrors, validationType); - return [...filteredErrors, ..._validationErrors]; - }); + const filteredErrors = filterErrors(errors, validationType); + setErrors([...filteredErrors, ..._validationErrors]); } + return { isValid: _validationErrors.length === 0, errors: _validationErrors, @@ -359,6 +385,22 @@ export const useField = (form: FormHook, path: string, config: FieldConfig = {}) return errorMessages ? errorMessages : null; }; + const reset: FieldHook['reset'] = (resetOptions = { resetValue: true }) => { + const { resetValue = true } = resetOptions; + + setPristine(true); + setValidating(false); + setIsChangingValue(false); + setIsValidated(false); + setErrors([]); + + if (resetValue) { + setValue(initialValue); + return initialValue; + } + return value; + }; + const serializeOutput: FieldHook['__serializeOutput'] = (rawValue = value) => serializer(rawValue); @@ -390,6 +432,7 @@ export const useField = (form: FormHook, path: string, config: FieldConfig = {}) form, isPristine, isValidating, + isValidated, isChangingValue, onChange, getErrorsMessages, @@ -397,10 +440,32 @@ export const useField = (form: FormHook, path: string, config: FieldConfig = {}) setErrors: _setErrors, clearErrors, validate, + reset, __serializeOutput: serializeOutput, }; - form.__addField(field); + form.__addField(field); // Executed first (1) + + useEffect(() => { + /** + * NOTE: effect cleanup actually happens *after* the new component has been mounted, + * but before the next effect callback is run. + * Ref: https://kentcdodds.com/blog/understanding-reacts-key-prop + * + * This means that, the "form.__addField(field)" outside the effect will be called *before* + * the cleanup `form.__removeField(path);` creating a race condition. + * + * 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) + + return () => { + // Remove field from the form when it is unmounted or if its path changes. + isUnmounted.current = true; + form.__removeField(path); // Executed second (2) + }; + }, [path]); return field; }; 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 3902b0615a33d..d8b2f35e117a6 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 @@ -17,11 +17,11 @@ * under the License. */ -import { useState, useRef } from 'react'; +import { useState, useRef, useEffect, useMemo } from 'react'; import { get } from 'lodash'; -import { FormHook, FormData, FieldConfig, FieldsMap, FormConfig } from '../types'; -import { mapFormFields, flattenObject, unflattenObject, Subject } from '../lib'; +import { FormHook, FieldHook, FormData, FieldConfig, FieldsMap, FormConfig } from '../types'; +import { mapFormFields, flattenObject, unflattenObject, Subject, Subscription } from '../lib'; const DEFAULT_ERROR_DISPLAY_TIMEOUT = 500; const DEFAULT_OPTIONS = { @@ -29,35 +29,54 @@ const DEFAULT_OPTIONS = { stripEmptyFields: true, }; -interface UseFormReturn { +interface UseFormReturn { form: FormHook; } -export function useForm( +export function useForm( formConfig: FormConfig | undefined = {} ): UseFormReturn { const { onSubmit, schema, - defaultValue = {}, serializer = (data: any) => data, deserializer = (data: any) => data, options = {}, } = formConfig; + + const formDefaultValue = + formConfig.defaultValue === undefined || Object.keys(formConfig.defaultValue).length === 0 + ? {} + : Object.entries(formConfig.defaultValue as object) + .filter(({ 1: value }) => value !== undefined) + .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); + const formOptions = { ...DEFAULT_OPTIONS, ...options }; - const defaultValueDeserialized = - Object.keys(defaultValue).length === 0 ? defaultValue : deserializer(defaultValue); - const [isSubmitted, setSubmitted] = useState(false); + const defaultValueDeserialized = useMemo(() => deserializer(formDefaultValue), [ + formConfig.defaultValue, + ]); + + const [isSubmitted, setIsSubmitted] = useState(false); const [isSubmitting, setSubmitting] = useState(false); - const [isValid, setIsValid] = useState(true); + const [isValid, setIsValid] = useState(undefined); const fieldsRefs = useRef({}); + const formUpdateSubscribers = useRef([]); + const isUnmounted = useRef(false); // formData$ is an observable we can subscribe to in order to receive live // update of the raw form data. As an observable it does not trigger any React // render(). // The component is the one in charge of reading this observable // and updating its state to trigger the necessary view render. - const formData$ = useRef>(new Subject(flattenObject(defaultValue) as T)); + const formData$ = useRef>(new Subject(flattenObject(formDefaultValue) as T)); + + useEffect(() => { + return () => { + formUpdateSubscribers.current.forEach(subscription => subscription.unsubscribe()); + formUpdateSubscribers.current = []; + isUnmounted.current = true; + }; + }, []); // -- HELPERS // ---------------------------------- @@ -75,6 +94,12 @@ export function useForm( return fields; }; + const updateFormDataAt: FormHook['__updateFormDataAt'] = (path, value) => { + const currentFormData = formData$.current.value; + formData$.current.next({ ...currentFormData, [path]: value }); + return formData$.current.value; + }; + // -- API // ---------------------------------- const getFormData: FormHook['getFormData'] = (getDataOptions = { unflatten: true }) => @@ -90,43 +115,76 @@ export function useForm( {} as T ); - const updateFormDataAt: FormHook['__updateFormDataAt'] = (path, value) => { - const currentFormData = formData$.current.value; - formData$.current.next({ ...currentFormData, [path]: value }); - return formData$.current.value; + const getErrors: FormHook['getErrors'] = () => { + if (isValid === true) { + return []; + } + + return fieldsToArray().reduce((acc, field) => { + const fieldError = field.getErrorsMessages(); + if (fieldError === null) { + return acc; + } + return [...acc, fieldError]; + }, [] as string[]); + }; + + const isFieldValid = (field: FieldHook) => + field.getErrorsMessages() === null && !field.isValidating; + + const updateFormValidity = () => { + const fieldsArray = fieldsToArray(); + const areAllFieldsValidated = fieldsArray.every(field => field.isValidated); + + if (!areAllFieldsValidated) { + // If *not* all the fiels have been validated, the validity of the form is unknown, thus still "undefined" + return undefined; + } + + const isFormValid = fieldsArray.every(isFieldValid); + + setIsValid(isFormValid); + return isFormValid; }; - /** - * When a field value changes, validateFields() is called with the field name + any other fields - * declared in the "fieldsToValidateOnChange" (see the field config). - * - * When this method is called _without_ providing any fieldNames, we only need to validate fields that are pristine - * as the fields that are dirty have already been validated when their value changed. - */ const validateFields: FormHook['__validateFields'] = async fieldNames => { const fieldsToValidate = fieldNames - ? fieldNames.map(name => fieldsRefs.current[name]).filter(field => field !== undefined) - : fieldsToArray().filter(field => field.isPristine); // only validate fields that haven't been changed + .map(name => fieldsRefs.current[name]) + .filter(field => field !== undefined); - const formData = getFormData({ unflatten: false }); + if (fieldsToValidate.length === 0) { + // Nothing to validate + return { areFieldsValid: true, isFormValid: true }; + } + const formData = getFormData({ unflatten: false }); await Promise.all(fieldsToValidate.map(field => field.validate({ formData }))); - const isFormValid = fieldsToArray().every( - field => field.getErrorsMessages() === null && !field.isValidating - ); - setIsValid(isFormValid); + const isFormValid = updateFormValidity(); + const areFieldsValid = fieldsToValidate.every(isFieldValid); - return isFormValid; + return { areFieldsValid, isFormValid }; + }; + + const validateAllFields = async (): Promise => { + const fieldsToValidate = fieldsToArray().filter(field => !field.isValidated); + + if (fieldsToValidate.length === 0) { + // Nothing left to validate, all fields are already validated. + return isValid!; + } + + const { isFormValid } = await validateFields(fieldsToValidate.map(field => field.path)); + + return isFormValid!; }; const addField: FormHook['__addField'] = field => { fieldsRefs.current[field.path] = field; - // Only update the formData if the path does not exist (it is the _first_ time - // the field is added), to avoid entering an infinite loop when the form is re-rendered. if (!{}.hasOwnProperty.call(formData$.current.value, field.path)) { - updateFormDataAt(field.path, field.__serializeOutput()); + const fieldValue = field.__serializeOutput(); + updateFormDataAt(field.path, fieldValue); } }; @@ -143,10 +201,16 @@ export function useForm( }; const setFieldValue: FormHook['setFieldValue'] = (fieldName, value) => { + if (fieldsRefs.current[fieldName] === undefined) { + return; + } fieldsRefs.current[fieldName].setValue(value); }; const setFieldErrors: FormHook['setFieldErrors'] = (fieldName, errors) => { + if (fieldsRefs.current[fieldName] === undefined) { + return; + } fieldsRefs.current[fieldName].setErrors(errors); }; @@ -167,20 +231,58 @@ export function useForm( } if (!isSubmitted) { - setSubmitted(true); // User has attempted to submit the form at least once + setIsSubmitted(true); // User has attempted to submit the form at least once } setSubmitting(true); - const isFormValid = await validateFields(); + const isFormValid = await validateAllFields(); const formData = serializer(getFormData() as T); if (onSubmit) { - await onSubmit(formData, isFormValid); + await onSubmit(formData, isFormValid!); } setSubmitting(false); - return { data: formData, isValid: isFormValid }; + return { data: formData, isValid: isFormValid! }; + }; + + const subscribe: FormHook['subscribe'] = handler => { + const format = () => serializer(getFormData() as T); + + const subscription = formData$.current.subscribe(raw => { + if (!isUnmounted.current) { + handler({ isValid, data: { raw, format }, validate: validateAllFields }); + } + }); + + formUpdateSubscribers.current.push(subscription); + return subscription; + }; + + /** + * Reset all the fields of the form to their default values + * and reset all the states to their original value. + */ + const reset: FormHook['reset'] = (resetOptions = { resetValues: true }) => { + const { resetValues = true } = resetOptions; + const currentFormData = { ...formData$.current.value } as FormData; + Object.entries(fieldsRefs.current).forEach(([path, field]) => { + // By resetting the form, some field might be unmounted. In order + // to avoid a race condition, we check that the field still exists. + const isFieldMounted = fieldsRefs.current[path] !== undefined; + if (isFieldMounted) { + const fieldValue = field.reset({ resetValue: resetValues }); + currentFormData[path] = fieldValue; + } + }); + if (resetValues) { + formData$.current.next(currentFormData as T); + } + + setIsSubmitted(false); + setSubmitting(false); + setIsValid(undefined); }; const form: FormHook = { @@ -188,11 +290,14 @@ export function useForm( isSubmitting, isValid, submit: submitForm, + subscribe, setFieldValue, setFieldErrors, getFields, getFormData, + getErrors, getFieldDefaultValue, + reset, __options: formOptions, __formData$: formData$, __updateFormDataAt: updateFormDataAt, diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/lib/subject.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/lib/subject.ts index 4c0169cb526e2..7365f234d39ed 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/lib/subject.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/lib/subject.ts @@ -51,7 +51,9 @@ export class Subject { } next(value: T) { - this.value = value; - this.callbacks.forEach(fn => fn(value)); + if (value !== this.value) { + this.value = value; + this.callbacks.forEach(fn => fn(value)); + } } } 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 62867a0c07a6b..65cd7792a0189 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 @@ -33,7 +33,7 @@ export const flattenObject = ( ): Record => Object.entries(object).reduce((acc, [key, value]) => { const updatedPaths = [...paths, key]; - if (value !== null && typeof value === 'object') { + if (value !== null && !Array.isArray(value) && typeof value === 'object') { return flattenObject(value, to, updatedPaths); } acc[updatedPaths.join('.')] = value; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts index 9946020132354..8dc1e59b40c34 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts @@ -18,40 +18,47 @@ */ import { ReactNode, ChangeEvent, FormEvent, MouseEvent, MutableRefObject } from 'react'; -import { Subject } from './lib'; +import { Subject, Subscription } from './lib'; // This type will convert all optional property to required ones // Comes from https://github.com/microsoft/TypeScript/issues/15012#issuecomment-365453623 -type Required = T extends object ? { [P in keyof T]-?: NonNullable } : T; +type Required = T extends FormData ? { [P in keyof T]-?: NonNullable } : T; -export interface FormHook { +export interface FormHook { readonly isSubmitted: boolean; readonly isSubmitting: boolean; - readonly isValid: boolean; + readonly isValid: boolean | undefined; submit: (e?: FormEvent | MouseEvent) => Promise<{ data: T; isValid: boolean }>; + subscribe: (handler: OnUpdateHandler) => Subscription; setFieldValue: (fieldName: string, value: FieldValue) => void; setFieldErrors: (fieldName: string, errors: ValidationError[]) => void; getFields: () => FieldsMap; getFormData: (options?: { unflatten?: boolean }) => T; getFieldDefaultValue: (fieldName: string) => unknown; + /* Returns a list of all errors in the form */ + getErrors: () => string[]; + reset: (options?: { resetValues?: boolean }) => void; readonly __options: Required; readonly __formData$: MutableRefObject>; __addField: (field: FieldHook) => void; __removeField: (fieldNames: string | string[]) => void; - __validateFields: (fieldNames?: string[]) => Promise; + __validateFields: ( + fieldNames: string[] + ) => Promise<{ areFieldsValid: boolean; isFormValid: boolean | undefined }>; __updateFormDataAt: (field: string, value: unknown) => T; __readFieldConfigFromSchema: (fieldName: string) => FieldConfig; } -export interface FormSchema { +export interface FormSchema { [key: string]: FormSchemaEntry; } -type FormSchemaEntry = + +type FormSchemaEntry = | FieldConfig | Array> | { [key: string]: FieldConfig | Array> | FormSchemaEntry }; -export interface FormConfig { +export interface FormConfig { onSubmit?: (data: T, isFormValid: boolean) => void; schema?: FormSchema; defaultValue?: Partial; @@ -60,6 +67,17 @@ export interface FormConfig { options?: FormOptions; } +export interface OnFormUpdateArg { + data: { + raw: { [key: string]: any }; + format: () => T; + }; + validate: () => Promise; + isValid?: boolean; +} + +export type OnUpdateHandler = (arg: OnFormUpdateArg) => void; + export interface FormOptions { errorDisplayDelay?: number; /** @@ -78,6 +96,7 @@ export interface FieldHook { readonly errors: ValidationError[]; readonly isPristine: boolean; readonly isValidating: boolean; + readonly isValidated: boolean; readonly isChangingValue: boolean; readonly form: FormHook; getErrorsMessages: (args?: { @@ -93,16 +112,17 @@ export interface FieldHook { value?: unknown; validationType?: string; }) => FieldValidateResponse | Promise; + reset: (options?: { resetValue: boolean }) => unknown; __serializeOutput: (rawValue?: unknown) => unknown; } -export interface FieldConfig { +export interface FieldConfig { readonly path?: string; readonly label?: string; readonly labelAppend?: string | ReactNode; readonly helpText?: string | ReactNode; readonly type?: HTMLInputElement['type']; - readonly defaultValue?: unknown; + readonly defaultValue?: ValueType; readonly validations?: Array>; readonly formatters?: FormatterFunc[]; readonly deserializer?: SerializerFunc; @@ -124,13 +144,17 @@ export interface ValidationError { [key: string]: any; } -export type ValidationFunc = (data: { +export interface ValidationFuncArg { path: string; - value: unknown; + value: V; form: FormHook; formData: T; errors: readonly ValidationError[]; -}) => ValidationError | void | undefined | Promise | void | undefined>; +} + +export type ValidationFunc = ( + data: ValidationFuncArg +) => ValidationError | void | undefined | Promise | void | undefined>; export interface FieldValidateResponse { isValid: boolean; @@ -143,13 +167,13 @@ export interface FormData { [key: string]: any; } -type FormatterFunc = (value: any) => unknown; +type FormatterFunc = (value: any, formData: FormData) => unknown; // We set it as unknown as a form field can be any of any type // string | number | boolean | string[] ... type FieldValue = unknown; -export interface ValidationConfig { +export interface ValidationConfig { validator: ValidationFunc; type?: string; exitOnFail?: boolean; diff --git a/src/plugins/es_ui_shared/static/validators/number/greater_than.ts b/src/plugins/es_ui_shared/static/validators/number/greater_than.ts new file mode 100644 index 0000000000000..fa9024204e727 --- /dev/null +++ b/src/plugins/es_ui_shared/static/validators/number/greater_than.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 const isNumberGreaterThan = (than: number, allowEquality = false) => (value: number) => + allowEquality ? value >= than : value > than; diff --git a/src/plugins/es_ui_shared/static/validators/number/index.ts b/src/plugins/es_ui_shared/static/validators/number/index.ts new file mode 100644 index 0000000000000..64a0cdd1b5a1d --- /dev/null +++ b/src/plugins/es_ui_shared/static/validators/number/index.ts @@ -0,0 +1,22 @@ +/* + * 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 './greater_than'; + +export * from './smaller_than'; diff --git a/src/plugins/es_ui_shared/static/validators/number/smaller_than.ts b/src/plugins/es_ui_shared/static/validators/number/smaller_than.ts new file mode 100644 index 0000000000000..50b43890ebf05 --- /dev/null +++ b/src/plugins/es_ui_shared/static/validators/number/smaller_than.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 const isNumberSmallerThan = (than: number, allowEquality = false) => (value: number) => + allowEquality ? value <= than : value < than; diff --git a/src/plugins/es_ui_shared/static/validators/string/index.ts b/src/plugins/es_ui_shared/static/validators/string/index.ts index 0ddf6fdfc33e4..1e80ca0700637 100644 --- a/src/plugins/es_ui_shared/static/validators/string/index.ts +++ b/src/plugins/es_ui_shared/static/validators/string/index.ts @@ -25,3 +25,4 @@ export * from './is_empty'; export * from './is_url'; export * from './starts_with'; export * from './is_json'; +export * from './is_lowercase'; diff --git a/src/plugins/es_ui_shared/static/validators/string/is_lowercase.ts b/src/plugins/es_ui_shared/static/validators/string/is_lowercase.ts new file mode 100644 index 0000000000000..3d765a750a81a --- /dev/null +++ b/src/plugins/es_ui_shared/static/validators/string/is_lowercase.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 const isLowerCaseString = (value: string) => value.toLowerCase() === value; diff --git a/x-pack/legacy/plugins/index_management/__jest__/client_integration/helpers/constants.ts b/x-pack/legacy/plugins/index_management/__jest__/client_integration/helpers/constants.ts index db29ed844b606..ef5cffc05d8d7 100644 --- a/x-pack/legacy/plugins/index_management/__jest__/client_integration/helpers/constants.ts +++ b/x-pack/legacy/plugins/index_management/__jest__/client_integration/helpers/constants.ts @@ -26,16 +26,5 @@ export const ALIASES = { }; export const MAPPINGS = { - _source: { - enabled: false, - }, - properties: { - host_name: { - type: 'keyword', - }, - created_at: { - type: 'date', - format: 'EEE MMM dd HH:mm:ss Z yyyy', - }, - }, + properties: {}, }; diff --git a/x-pack/legacy/plugins/index_management/__jest__/client_integration/helpers/template_form.helpers.ts b/x-pack/legacy/plugins/index_management/__jest__/client_integration/helpers/template_form.helpers.ts index 1b75f4e190934..48ae51b711f9c 100644 --- a/x-pack/legacy/plugins/index_management/__jest__/client_integration/helpers/template_form.helpers.ts +++ b/x-pack/legacy/plugins/index_management/__jest__/client_integration/helpers/template_form.helpers.ts @@ -4,26 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TestBed, SetupFunc } from '../../../../../../test_utils'; +import { TestBed, SetupFunc, UnwrapPromise } from '../../../../../../test_utils'; import { Template } from '../../../common/types'; import { nextTick } from './index'; -export interface TemplateFormTestBed extends TestBed { - actions: { - clickNextButton: () => void; - clickBackButton: () => void; - clickSubmitButton: () => void; - completeStepOne: ({ name, indexPatterns, order, version }: Partial