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 8a1012404b377..a24a2640789dd 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 @@ -36,13 +36,15 @@ 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 = + typeof defaultValue === 'function' ? deserializer(defaultValue()) : deserializer(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); @@ -262,6 +264,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 @@ -275,12 +278,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, @@ -358,6 +359,15 @@ export const useField = (form: FormHook, path: string, config: FieldConfig = {}) return errorMessages ? errorMessages : null; }; + const reset = () => { + setPristine(true); + setValidating(false); + setIsChangingValue(false); + setIsValidated(false); + setErrors([]); + setValue(initialValue); + }; + const serializeOutput: FieldHook['__serializeOutput'] = (rawValue = value) => serializer(rawValue); @@ -388,6 +398,7 @@ export const useField = (form: FormHook, path: string, config: FieldConfig = {}) form, isPristine, isValidating, + isValidated, isChangingValue, onChange, getErrorsMessages, @@ -395,6 +406,7 @@ export const useField = (form: FormHook, path: string, config: FieldConfig = {}) setErrors: _setErrors, clearErrors, validate, + reset, __serializeOutput: serializeOutput, }; 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 360182368ae63..5e6df7cee6549 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 } 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 = { @@ -47,10 +47,11 @@ export function useForm( const formOptions = { ...DEFAULT_OPTIONS, ...options }; const defaultValueDeserialized = Object.keys(defaultValue).length === 0 ? defaultValue : deserializer(defaultValue); - const [isSubmitted, setSubmitted] = useState(false); + 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([]); // 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 @@ -59,6 +60,13 @@ export function useForm( // and updating its state to trigger the necessary view render. const formData$ = useRef>(new Subject(flattenObject(defaultValue) as T)); + useEffect(() => { + return () => { + formUpdateSubscribers.current.forEach(subscription => subscription.unsubscribe()); + formUpdateSubscribers.current = []; + }; + }, []); + // -- HELPERS // ---------------------------------- const fieldsToArray = () => Object.values(fieldsRefs.current); @@ -78,6 +86,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 }) => @@ -93,34 +107,52 @@ 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 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; + } + + 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 true; + } + const formData = getFormData({ unflatten: false }); await Promise.all(fieldsToValidate.map(field => field.validate({ formData }))); + updateFormValidity(); - const isFormValid = fieldsToArray().every( - field => field.getErrorsMessages() === null && !field.isValidating - ); - setIsValid(isFormValid); + return fieldsToValidate.every(isFieldValid); + }; - return 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!; + } + + await validateFields(fieldsToValidate.map(field => field.path)); + + return updateFormValidity()!; }; const addField: FormHook['__addField'] = field => { @@ -170,20 +202,45 @@ 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 validate = async () => await validateAllFields(); + + const subscription = formData$.current.subscribe(raw => { + handler({ isValid, data: { raw, format }, validate }); + }); + + 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'] = () => { + Object.entries(fieldsRefs.current).forEach(([path, field]) => { + field.reset(); + }); + setIsSubmitted(false); + setSubmitting(false); + setIsValid(undefined); }; const form: FormHook = { @@ -191,11 +248,13 @@ export function useForm( isSubmitting, isValid, submit: submitForm, + subscribe, setFieldValue, setFieldErrors, getFields, getFormData, getFieldDefaultValue, + reset, __options: formOptions, __formData$: formData$, __updateFormDataAt: updateFormDataAt, 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 66c3e8d983f98..06e2a69a8afd8 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 28e2a346bd5c4..495ab04c0167d 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,7 +18,7 @@ */ 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 @@ -27,18 +27,20 @@ type Required = T extends object ? { [P in keyof T]-?: NonNullable } : 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; + reset: () => void; readonly __options: Required; readonly __formData$: MutableRefObject>; __addField: (field: FieldHook) => void; __removeField: (fieldNames: string | string[]) => void; - __validateFields: (fieldNames?: string[]) => Promise; + __validateFields: (fieldNames: string[]) => Promise; __updateFormDataAt: (field: string, value: unknown) => T; __readFieldConfigFromSchema: (fieldName: string) => FieldConfig; } @@ -46,6 +48,7 @@ export interface FormHook { export interface FormSchema { [key: string]: FormSchemaEntry; } + type FormSchemaEntry = | FieldConfig | Array> @@ -60,6 +63,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; /** @@ -77,6 +91,7 @@ export interface FieldHook { readonly errors: ValidationError[]; readonly isPristine: boolean; readonly isValidating: boolean; + readonly isValidated: boolean; readonly isChangingValue: boolean; readonly form: FormHook; getErrorsMessages: (args?: { @@ -92,6 +107,7 @@ export interface FieldHook { value?: unknown; validationType?: string; }) => FieldValidateResponse | Promise; + reset: () => void; __serializeOutput: (rawValue?: unknown) => unknown; } diff --git a/x-pack/legacy/plugins/index_management/public/components/template_form/steps/step_logistics.tsx b/x-pack/legacy/plugins/index_management/public/components/template_form/steps/step_logistics.tsx index 1b20da3a0ee4c..60bc5f139dea9 100644 --- a/x-pack/legacy/plugins/index_management/public/components/template_form/steps/step_logistics.tsx +++ b/x-pack/legacy/plugins/index_management/public/components/template_form/steps/step_logistics.tsx @@ -83,7 +83,7 @@ export const StepLogistics: React.FunctionComponent = ({ }); useEffect(() => { - onStepValidityChange(form.isValid); + onStepValidityChange(form.isValid === undefined ? true : form.isValid); }, [form.isValid]); useEffect(() => { diff --git a/x-pack/legacy/plugins/index_management/public/index.scss b/x-pack/legacy/plugins/index_management/public/index.scss index 939c9fd840421..1589f121c6965 100644 --- a/x-pack/legacy/plugins/index_management/public/index.scss +++ b/x-pack/legacy/plugins/index_management/public/index.scss @@ -10,4 +10,5 @@ // indChart__legend--small // indChart__legend-isLoading -@import 'index_management'; \ No newline at end of file +@import 'index_management'; +@import '../static/ui/styles' diff --git a/x-pack/legacy/plugins/index_management/static/ui/_styles.scss b/x-pack/legacy/plugins/index_management/static/ui/_styles.scss new file mode 100644 index 0000000000000..70d8c2e1977d2 --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/_styles.scss @@ -0,0 +1 @@ +@import "./components/mappings_editor/styles" diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor.tsx b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor.tsx deleted file mode 100644 index cfc3aba4314c1..0000000000000 --- a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { useState, useEffect, Fragment } from 'react'; -import { EuiCodeEditor, EuiSpacer, EuiCallOut } from '@elastic/eui'; - -interface Props { - setGetDataHandler: (handler: () => { isValid: boolean; data: Mappings }) => void; - FormattedMessage: typeof ReactIntl.FormattedMessage; - defaultValue?: Mappings; - areErrorsVisible?: boolean; -} - -export interface Mappings { - [key: string]: any; -} - -export const MappingsEditor = ({ - setGetDataHandler, - FormattedMessage, - areErrorsVisible = true, - defaultValue = {}, -}: Props) => { - const [mappings, setMappings] = useState(JSON.stringify(defaultValue, null, 2)); - const [error, setError] = useState(null); - - const getFormData = () => { - setError(null); - try { - const parsed: Mappings = JSON.parse(mappings); - return { - data: parsed, - isValid: true, - }; - } catch (e) { - setError(e.message); - return { - isValid: false, - data: {}, - }; - } - }; - - useEffect(() => { - setGetDataHandler(getFormData); - }, [mappings]); - - return ( - - - } - onChange={(value: string) => { - setMappings(value); - }} - data-test-subj="mappingsEditor" - /> - {areErrorsVisible && error && ( - - - - } - color="danger" - iconType="alert" - > -

{error}

-
-
- )} -
- ); -}; diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/configuration_form/configuration_form.tsx b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/configuration_form/configuration_form.tsx new file mode 100644 index 0000000000000..dc9ae6c158242 --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/configuration_form/configuration_form.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useEffect } from 'react'; + +import { useForm, getUseField, Form, OnFormUpdateArg } from '../../shared_imports'; +import { FormRow, Field } from '../../shared_imports'; +import { DYNAMIC_SETTING_OPTIONS } from '../../constants'; +import { Types, useDispatch } from '../../mappings_state'; +import { schema } from './form.schema'; + +type MappingsConfiguration = Types['MappingsConfiguration']; + +export type ConfigurationUpdateHandler = (arg: OnFormUpdateArg) => void; + +interface Props { + defaultValue?: MappingsConfiguration; +} + +const UseField = getUseField({ component: Field }); + +export const ConfigurationForm = React.memo(({ defaultValue }: Props) => { + const { form } = useForm({ schema, defaultValue }); + const dispatch = useDispatch(); + + useEffect(() => { + const subscription = form.subscribe(updatedConfiguration => { + dispatch({ type: 'configuration.update', value: updatedConfiguration }); + }); + return subscription.unsubscribe; + }, [form]); + + return ( +
+ + + + + + +
+ ); +}); diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/configuration_form/form.schema.ts b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/configuration_form/form.schema.ts new file mode 100644 index 0000000000000..2c61524365411 --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/configuration_form/form.schema.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FormSchema, FIELD_TYPES, VALIDATION_TYPES, fieldValidators } from '../../shared_imports'; +import { MappingsConfiguration } from '../../reducer'; + +const { containsCharsField } = fieldValidators; + +export const schema: FormSchema = { + dynamic: { + label: 'Dynamic field', + helpText: 'Allow new fields discovery in document.', + type: FIELD_TYPES.SELECT, + defaultValue: true, + }, + date_detection: { + label: 'Date detection', + helpText: 'Check if the string field is a date.', + type: FIELD_TYPES.TOGGLE, + defaultValue: true, + }, + numeric_detection: { + label: 'Numeric field', + helpText: 'Check if the string field is a numeric value.', + type: FIELD_TYPES.TOGGLE, + defaultValue: true, + }, + dynamic_date_formats: { + label: 'Dynamic dates format', + helpText: 'The dynamic_date_formats can be customised to support your own date formats.', + type: FIELD_TYPES.COMBO_BOX, + defaultValue: [], + validations: [ + { + validator: containsCharsField({ + message: 'Spaces are not allowed.', + chars: ' ', + }), + type: VALIDATION_TYPES.ARRAY_ITEM, + }, + ], + }, +}; diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/configuration_form/index.ts b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/configuration_form/index.ts new file mode 100644 index 0000000000000..333e032a69193 --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/configuration_form/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from './form.schema'; + +export * from './configuration_form'; + +export const CONFIGURATION_FIELDS = Object.keys(schema); diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/document_fields.tsx b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/document_fields.tsx new file mode 100644 index 0000000000000..fdf0bad9cbf4c --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/document_fields.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo } from 'react'; +import { EuiButton, EuiSpacer } from '@elastic/eui'; + +import { useState, useDispatch } from '../../mappings_state'; +import { validateUniqueName } from '../../lib'; +import { DocumentFieldsHeaders } from './document_fields_header'; +import { FieldsList, CreateField, EditField } from './fields'; + +export const DocumentFields = () => { + const dispatch = useDispatch(); + const { + fields: { byId, rootLevelFields }, + documentFields: { status, fieldToAddFieldTo, fieldToEdit }, + } = useState(); + + const getField = (fieldId: string) => byId[fieldId]; + const fields = rootLevelFields.map(getField); + + const uniqueNameValidatorCreate = useMemo(() => { + return validateUniqueName({ rootLevelFields, byId }); + }, [byId, rootLevelFields]); + + const uniqueNameValidatorEdit = useMemo(() => { + if (fieldToEdit === undefined) { + return; + } + return validateUniqueName({ rootLevelFields, byId }, byId[fieldToEdit!].source.name); + }, [byId, rootLevelFields, fieldToEdit]); + + const addField = () => { + dispatch({ type: 'documentField.createField' }); + }; + + const renderCreateField = () => { + // The "fieldToAddFieldTo" is undefined when adding to the top level "properties" object. + if (status !== 'creatingField' || fieldToAddFieldTo !== undefined) { + return null; + } + return ; + }; + + const renderAddFieldButton = () => { + if (status !== 'idle') { + return null; + } + return ( + <> + + Add field + + ); + }; + + const renderEditField = () => { + if (status !== 'editingField') { + return null; + } + const field = byId[fieldToEdit!]; + return ; + }; + + return ( + <> + + + {renderCreateField()} + {renderAddFieldButton()} + {renderEditField()} + + ); +}; diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/document_fields_header.tsx b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/document_fields_header.tsx new file mode 100644 index 0000000000000..e3cade8743610 --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/document_fields_header.tsx @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { EuiTitle } from '@elastic/eui'; + +export const DocumentFieldsHeaders = () => { + return ( + +

Document fields

+
+ ); +}; diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/fields/create_field.tsx b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/fields/create_field.tsx new file mode 100644 index 0000000000000..241f3522a1824 --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/fields/create_field.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useEffect } from 'react'; +import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { + useForm, + Form, + TextField, + SelectField, + UseField, + FieldConfig, + ValidationFunc, +} from '../../../shared_imports'; +import { FIELD_TYPES_OPTIONS, PARAMETERS_DEFINITION } from '../../../constants'; +import { useDispatch } from '../../../mappings_state'; +import { Field, ParameterName } from '../../../types'; + +const formWrapper = (props: any) =>
; + +const getFieldConfig = (param: ParameterName): FieldConfig => + PARAMETERS_DEFINITION[param].fieldConfig || {}; + +interface Props { + uniqueNameValidator: ValidationFunc; +} + +export const CreateField = React.memo(({ uniqueNameValidator }: Props) => { + const { form } = useForm(); + const dispatch = useDispatch(); + + useEffect(() => { + const subscription = form.subscribe(updatedFieldForm => { + dispatch({ type: 'fieldForm.update', value: updatedFieldForm }); + }); + + return subscription.unsubscribe; + }, [form]); + + const submitForm = async (e?: React.FormEvent) => { + if (e) { + e.preventDefault(); + } + + const { isValid, data } = await form.submit(); + + if (isValid) { + form.reset(); + dispatch({ type: 'field.add', value: data }); + } + }; + + const cancel = () => { + dispatch({ type: 'documentField.changeStatus', value: 'idle' }); + }; + + const { validations, ...rest } = getFieldConfig('name'); + const nameConfig: FieldConfig = { + ...rest, + validations: [ + ...validations!, + { + validator: uniqueNameValidator, + }, + ], + }; + + return ( + + + + + + + + + + + Add + + + + Cancel + + + + ); +}); diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/fields/delete_field_provider.tsx b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/fields/delete_field_provider.tsx new file mode 100644 index 0000000000000..f1f2fc02fc5fe --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/fields/delete_field_provider.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, Fragment } from 'react'; +import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; + +import { useState as useMappingsState, useDispatch } from '../../../mappings_state'; +import { NormalizedField } from '../../../types'; + +type DeleteFieldFunc = (property: NormalizedField) => void; + +interface Props { + children: (deleteProperty: DeleteFieldFunc) => React.ReactNode; +} + +interface State { + isModalOpen: boolean; + field: NormalizedField | undefined; +} + +export const DeleteFieldProvider = ({ children }: Props) => { + const [state, setState] = useState({ isModalOpen: false, field: undefined }); + const dispatch = useDispatch(); + const { + fields: { byId }, + } = useMappingsState(); + + const closeModal = () => { + setState({ isModalOpen: false, field: undefined }); + }; + + const deleteField: DeleteFieldFunc = field => { + const { hasChildFields } = field; + + if (hasChildFields) { + setState({ isModalOpen: true, field }); + } else { + dispatch({ type: 'field.remove', value: field.id }); + } + }; + + const confirmDelete = () => { + dispatch({ type: 'field.remove', value: state.field!.id }); + closeModal(); + }; + + const renderModal = () => { + const field = state.field!; + const childFields = field.childFields!.map(childId => byId[childId]); + const title = `Remove property '${field.source.name}'?`; + + return ( + + + +

+ This will also delete the following child fields and the possible child fields under + them. +

+
    + {childFields + .map(_field => _field.source.name) + .sort() + .map(name => ( +
  • {name}
  • + ))} +
+
+
+
+ ); + }; + + return ( + + {children(deleteField)} + {state.isModalOpen && renderModal()} + + ); +}; diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/fields/edit_field.tsx b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/fields/edit_field.tsx new file mode 100644 index 0000000000000..a7bdbefa45ffe --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/fields/edit_field.tsx @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useEffect } from 'react'; +import { + EuiFlyout, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiTitle, + EuiButton, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; + +import { + useForm, + Form, + TextField, + SelectField, + UseField, + FieldConfig, + ValidationFunc, +} from '../../../shared_imports'; +import { FIELD_TYPES_OPTIONS, PARAMETERS_DEFINITION } from '../../../constants'; +import { useDispatch } from '../../../mappings_state'; +import { Field, NormalizedField, ParameterName } from '../../../types'; +import { UpdateFieldProvider, UpdateFieldFunc } from './update_field_provider'; + +const formWrapper = (props: any) =>
; + +const getFieldConfig = (param: ParameterName): FieldConfig => + PARAMETERS_DEFINITION[param].fieldConfig || {}; + +interface Props { + field: NormalizedField; + uniqueNameValidator: ValidationFunc; +} + +export const EditField = React.memo(({ field, uniqueNameValidator }: Props) => { + const { form } = useForm({ defaultValue: field.source }); + const dispatch = useDispatch(); + + useEffect(() => { + const subscription = form.subscribe(updatedFieldForm => { + dispatch({ type: 'fieldForm.update', value: updatedFieldForm }); + }); + + return subscription.unsubscribe; + }, [form]); + + const exitEdit = () => { + dispatch({ type: 'documentField.changeStatus', value: 'idle' }); + }; + + const getSubmitForm = (updateField: UpdateFieldFunc) => async (e?: React.FormEvent) => { + if (e) { + e.preventDefault(); + } + const { isValid, data } = await form.submit(); + if (isValid) { + updateField({ ...field, source: data }); + } + }; + + const cancel = () => { + exitEdit(); + }; + + const { validations, ...rest } = getFieldConfig('name'); + const nameConfig: FieldConfig = { + ...rest, + validations: [ + ...validations!, + { + validator: uniqueNameValidator, + }, + ], + }; + + const renderTempForm = () => ( + + {updateField => ( + + + + + + + + + + + + + Update + + + + Cancel + + + + )} + + ); + + return ( + + + +

Edit field

+
+
+ {renderTempForm()} +
+ ); +}); diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/fields/fields_list.tsx b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/fields/fields_list.tsx new file mode 100644 index 0000000000000..fa505a47b0a8d --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/fields/fields_list.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; + +import { FieldsListItem } from './fields_list_item'; +import { NormalizedField } from '../../../types'; + +interface Props { + fields?: NormalizedField[]; + treeDepth?: number; +} + +export const FieldsList = React.memo(({ fields = [], treeDepth = 0 }: Props) => { + return ( +
    + {fields.map(field => ( +
  • + +
  • + ))} +
+ ); +}); diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/fields/fields_list_item.tsx b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/fields/fields_list_item.tsx new file mode 100644 index 0000000000000..d2bc8e4f84df1 --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/fields/fields_list_item.tsx @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useMemo } from 'react'; +import { EuiButton, EuiButtonEmpty } from '@elastic/eui'; + +import { useState, useDispatch } from '../../../mappings_state'; +import { FieldsList } from './fields_list'; +import { CreateField } from './create_field'; +import { DeleteFieldProvider } from './delete_field_provider'; +import { NormalizedField } from '../../../types'; +import { MAX_DEPTH_DEFAULT_EDITOR } from '../../../constants'; +import { validateUniqueName } from '../../../lib'; + +interface Props { + field: NormalizedField; + treeDepth?: number; +} + +const inlineStyle = { + borderBottom: '1px solid #ddd', + display: 'flex', + flexDirection: 'column' as 'column', +}; + +export const FieldsListItem = ({ field, treeDepth = 0 }: Props) => { + const dispatch = useDispatch(); + const { + documentFields: { status, fieldToAddFieldTo }, + fields: { byId, rootLevelFields }, + } = useState(); + const getField = (propId: string) => byId[propId]; + const { id, source, childFields, hasChildFields, canHaveChildFields } = field; + const isAddFieldBtnDisabled = field.nestedDepth === MAX_DEPTH_DEFAULT_EDITOR - 1; + + const uniqueNameValidator = useMemo(() => { + return validateUniqueName({ rootLevelFields, byId }, undefined, id); + }, [byId, rootLevelFields]); + + const addField = () => { + dispatch({ + type: 'documentField.createField', + value: id, + }); + }; + + const editField = () => { + dispatch({ + type: 'documentField.editField', + value: id, + }); + }; + + const renderCreateField = () => { + if (status !== 'creatingField') { + return null; + } + + // Root level (0) has does not have the "fieldToAddFieldTo" set + if (fieldToAddFieldTo !== id) { + return null; + } + + return ( +
+ +
+ ); + }; + + const renderActionButtons = () => { + if (status !== 'idle') { + return null; + } + + return ( + <> + Edit + {canHaveChildFields && ( + <> + + Add field + + + )} + + {deleteField => deleteField(field)}>Remove} + + + ); + }; + + return ( + <> +
+
+ {source.name} | {source.type} {renderActionButtons()} +
+ {status === 'idle' && canHaveChildFields && isAddFieldBtnDisabled && ( +

+ You have reached the maximum depth for the mappings editor. Switch to the{' '} + dispatch({ type: 'documentField.changeEditor', value: 'json' })} + > + JSON editor + + to add more fields. +

+ )} +
+ + {renderCreateField()} + + {hasChildFields && ( +
+ +
+ )} + + ); +}; diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/fields/index.ts b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/fields/index.ts new file mode 100644 index 0000000000000..0d994d503c66c --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/fields/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './fields_list'; + +export * from './fields_list_item'; + +export * from './create_field'; + +export * from './edit_field'; + +export * from './delete_field_provider'; + +export * from './update_field_provider'; diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/fields/update_field_provider.tsx b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/fields/update_field_provider.tsx new file mode 100644 index 0000000000000..1245365a1965f --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/fields/update_field_provider.tsx @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, Fragment } from 'react'; +import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; + +import { useState as useMappingsState, useDispatch } from '../../../mappings_state'; +import { shouldDeleteChildFieldsAfterTypeChange } from '../../../lib'; +import { NormalizedField, DataType } from '../../../types'; + +export type UpdateFieldFunc = (field: NormalizedField) => void; + +interface Props { + children: (saveProperty: UpdateFieldFunc) => React.ReactNode; +} + +interface State { + isModalOpen: boolean; + field?: NormalizedField; +} + +export const UpdateFieldProvider = ({ children }: Props) => { + const [state, setState] = useState({ + isModalOpen: false, + }); + const dispatch = useDispatch(); + + const { + fields: { byId }, + } = useMappingsState(); + + const closeModal = () => { + setState({ isModalOpen: false }); + }; + + const updateField: UpdateFieldFunc = field => { + const previousField = byId[field.id]; + + const handleTypeChange = ( + oldType: DataType, + newType: DataType + ): { requiresConfirmation: boolean } => { + const { hasChildFields } = field; + + if (!hasChildFields) { + // No child fields will be deleted, no confirmation needed. + return { requiresConfirmation: false }; + } + + const requiresConfirmation = shouldDeleteChildFieldsAfterTypeChange(oldType, newType); + + return { requiresConfirmation }; + }; + + if (field.source.type !== previousField.source.type) { + // We need to check if, by changing the type, we need + // to delete the possible child properties ("fields" or "properties") + // and warn the user about it. + const { requiresConfirmation } = handleTypeChange( + previousField.source.type, + field.source.type + ); + + if (requiresConfirmation) { + setState({ isModalOpen: true, field }); + return; + } + } + + dispatch({ type: 'field.edit', value: field.source }); + }; + + const confirmTypeUpdate = () => { + dispatch({ type: 'field.edit', value: state.field!.source }); + closeModal(); + }; + + const renderModal = () => { + const field = state.field!; + const title = `Confirm change '${field.source.name}' type to "${field.source.type}".`; + const childFields = field.childFields!.map(childId => byId[childId]); + + return ( + + + +

+ This will delete the following child fields and the possible child fields under them. +

+
    + {childFields + .map(_field => _field.source.name) + .sort() + .map(name => ( +
  • {name}
  • + ))} +
+
+
+
+ ); + }; + + return ( + + {children(updateField)} + {state.isModalOpen && renderModal()} + + ); +}; diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/index.ts b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/index.ts new file mode 100644 index 0000000000000..be58e7c599ac0 --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/document_fields/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './document_fields'; diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/index.ts b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/index.ts new file mode 100644 index 0000000000000..4110db5a39312 --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/components/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './configuration_form'; + +export * from './document_fields'; diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/constants/data_types_definition.ts b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/constants/data_types_definition.ts new file mode 100644 index 0000000000000..0ddced236c0f4 --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/constants/data_types_definition.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MainType, DataTypeDefinition } from '../types'; + +export const DATA_TYPE_DEFINITION: { [key in MainType]: DataTypeDefinition } = { + text: { + label: 'Text', + basicParameters: ['store', 'index', 'fielddata'], + }, + keyword: { + label: 'Keyword', + basicParameters: ['store', 'index', 'doc_values'], + }, + numeric: { + label: 'Numeric', + subTypes: { + label: 'Numeric type', + types: ['long', 'integer', 'short', 'byte', 'double', 'float', 'half_float', 'scaled_float'], + }, + basicParameters: [ + ['store', 'index', 'coerce', 'doc_values', 'ignore_malformed'], + ['null_value', 'boost'], + ], + }, + date: { + label: 'Date', + subTypes: { + label: 'Date type', + types: ['date', 'date_nanos'], + }, + basicParameters: [ + ['store', 'index', 'doc_values', 'ignore_malformed'], + ['null_value', 'boost', 'locale', 'format'], + ], + }, + binary: { + label: 'Binary', + basicParameters: ['doc_values', 'store'], + }, + ip: { + label: 'IP', + basicParameters: [['store', 'index', 'doc_values'], ['null_value', 'boost']], + }, + boolean: { + label: 'Boolean', + basicParameters: [['store', 'index', 'doc_values'], ['null_value', 'boost']], + }, + range: { + label: 'Range', + subTypes: { + label: 'Range type', + types: ['integer_range', 'float_range', 'long_range', 'double_range', 'date_range'], + }, + basicParameters: [['store', 'index', 'coerce', 'doc_values'], ['boost']], + }, + object: { + label: 'Object', + basicParameters: ['dynamic', 'enabled'], + }, + nested: { + label: 'Nested', + basicParameters: ['dynamic'], + }, + rank_feature: { + label: 'Rank feature', + }, + rank_features: { + label: 'Rank features', + }, + dense_vector: { + label: 'Dense vector', + }, + sparse_vector: { + label: 'Sparse vector', + }, +}; diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/constants/field_options.ts b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/constants/field_options.ts new file mode 100644 index 0000000000000..c60fedcfb12ab --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/constants/field_options.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DATA_TYPE_DEFINITION } from './data_types_definition'; + +export const DYNAMIC_SETTING_OPTIONS = [ + { value: true, text: 'true' }, + { value: false, text: 'false' }, + { value: 'strict', text: 'strict' }, +]; + +export const FIELD_TYPES_OPTIONS = Object.entries(DATA_TYPE_DEFINITION).map( + ([dataType, { label }]) => ({ + value: dataType, + text: label, + }) +); diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/constants/index.ts b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/constants/index.ts new file mode 100644 index 0000000000000..5686e09e08b43 --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/constants/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './field_options'; + +export * from './data_types_definition'; + +export * from './parameters_definition'; + +export * from './mappings_editor'; diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/constants/mappings_editor.ts b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/constants/mappings_editor.ts new file mode 100644 index 0000000000000..fbb2b18eeb16d --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/constants/mappings_editor.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * The max nested depth allowed for child fields. + * Above this thresold, the user has to use the JSON editor. + */ +export const MAX_DEPTH_DEFAULT_EDITOR = 4; diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/constants/parameters_definition.ts b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/constants/parameters_definition.ts new file mode 100644 index 0000000000000..939ed4364c1fc --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/constants/parameters_definition.ts @@ -0,0 +1,355 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { FIELD_TYPES, fieldValidators, fieldFormatters, FieldConfig } from '../shared_imports'; +import { ParameterName, Parameter } from '../types'; + +const { toInt } = fieldFormatters; +const { emptyField, containsCharsField } = fieldValidators; + +interface ValidatorArg { + value: T; + path: string; + formData: { [key: string]: any }; +} + +export const PARAMETERS_DEFINITION: { + [key in ParameterName]: Parameter; +} = { + name: { + fieldConfig: { + label: 'Field name', + defaultValue: '', + validations: [ + { + validator: emptyField('Give a name to the field.'), + }, + { + validator: containsCharsField({ + chars: ' ', + message: 'Spaces are not allowed in the name.', + }), + }, + { + validator: fieldValidators.containsCharsField({ + chars: '.', + message: 'Cannot contain a dot (.)', + }), + }, + ], + }, + }, + type: { + fieldConfig: { + label: 'Field type', + defaultValue: 'text', + type: FIELD_TYPES.SELECT, + }, + }, + store: { + fieldConfig: { + label: 'Store', + type: FIELD_TYPES.CHECKBOX, + defaultValue: true, + }, + docs: 'https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-store.html', + }, + index: { + fieldConfig: { + label: 'Index', + type: FIELD_TYPES.CHECKBOX, + defaultValue: true, + }, + }, + doc_values: { + fieldConfig: { + label: 'Doc values', + type: FIELD_TYPES.CHECKBOX, + defaultValue: true, + }, + docs: 'https://www.elastic.co/guide/en/elasticsearch/reference/current/doc-values.html', + }, + fielddata: { + fieldConfig: { + label: 'Fielddata', + type: FIELD_TYPES.CHECKBOX, + defaultValue: false, + }, + docs: 'https://www.elastic.co/guide/en/elasticsearch/reference/current/doc-values.html', + }, + coerce: { + fieldConfig: { + label: 'Coerce', + type: FIELD_TYPES.CHECKBOX, + defaultValue: true, + }, + }, + ignore_malformed: { + fieldConfig: { + label: 'Ignore malformed', + type: FIELD_TYPES.CHECKBOX, + defaultValue: true, + }, + }, + null_value: { + fieldConfig: { + label: 'Null value', + defaultValue: '', + type: FIELD_TYPES.TEXT, + }, + }, + boost: { + fieldConfig: { + label: 'Boost', + defaultValue: 1.0, + type: FIELD_TYPES.NUMBER, + formatters: [toInt], + validations: [ + { + validator: ({ value }: ValidatorArg) => { + if (value < 0) { + return { + message: 'The value must be greater or equal than 0.', + }; + } + }, + }, + ], + } as FieldConfig, + }, + dynamic: { + fieldConfig: { + label: 'Dynamic', + type: FIELD_TYPES.CHECKBOX, + defaultValue: true, + }, + }, + enabled: { + fieldConfig: { + label: 'Enabled', + type: FIELD_TYPES.CHECKBOX, + defaultValue: true, + }, + }, + locale: { + fieldConfig: { + label: 'Locale', + defaultValue: '', + }, + }, + format: { + fieldConfig: { + label: 'Formats', + type: FIELD_TYPES.COMBO_BOX, + defaultValue: [], + serializer: (options: any[]): string | undefined => + options.length ? options.join('||') : undefined, + deSerializer: (formats?: string | any[]): any[] => + Array.isArray(formats) ? formats : (formats as string).split('||'), + }, + }, + analyzer: { + fieldConfig: { + label: 'Analyzer', + defaultValue: 'index_default', + type: FIELD_TYPES.SELECT, + }, + }, + search_analyzer: { + fieldConfig: { + label: 'Search analyzer', + defaultValue: 'index_default', + type: FIELD_TYPES.SELECT, + }, + }, + search_quote_analyzer: { + fieldConfig: { + label: 'Search quote analyzer', + defaultValue: 'index_default', + type: FIELD_TYPES.SELECT, + }, + }, + normalizer: { + fieldConfig: { + label: 'Normalizer', + defaultValue: '', + type: FIELD_TYPES.TEXT, + validations: [ + { + validator: containsCharsField({ + chars: ' ', + message: 'Spaces are not allowed.', + }), + }, + ], + }, + }, + index_options: { + fieldConfig: { + label: 'Index options', + defaultValue: 'docs', + type: FIELD_TYPES.SELECT, + }, + }, + eager_global_ordinals: { + fieldConfig: { + label: 'Eager global ordinals', + type: FIELD_TYPES.CHECKBOX, + defaultValue: false, + }, + }, + index_phrases: { + fieldConfig: { + label: 'Index phrases', + type: FIELD_TYPES.CHECKBOX, + defaultValue: false, + }, + }, + norms: { + fieldConfig: { + label: 'Norms', + type: FIELD_TYPES.CHECKBOX, + defaultValue: true, + }, + }, + term_vector: { + fieldConfig: { + label: 'Term vectors', + type: FIELD_TYPES.CHECKBOX, + defaultValue: false, + }, + }, + position_increment_gap: { + fieldConfig: { + label: 'Position increment gap', + type: FIELD_TYPES.NUMBER, + defaultValue: 100, + formatters: [toInt], + validations: [ + { + validator: emptyField('Set a position increment gap value.'), + }, + { + validator: ({ value }: ValidatorArg) => { + if (value < 0) { + return { + message: 'The value must be greater or equal than 0.', + }; + } + }, + }, + ], + } as FieldConfig, + }, + index_prefixes: { + fieldConfig: { + min_chars: { + type: FIELD_TYPES.NUMBER, + defaultValue: 2, + helpText: 'Min chars.', + formatters: [toInt], + validations: [ + { + validator: emptyField('Set a min value.'), + }, + { + validator: ({ value }: ValidatorArg) => { + if (value < 0) { + return { + message: 'The value must be greater or equal than zero.', + }; + } + }, + }, + { + validator: ({ value, path, formData }: ValidatorArg) => { + const maxPath = path.replace('.min', '.max'); + const maxValue: number | string = formData[maxPath]; + + if (maxValue === '') { + return; + } + + if (value >= maxValue) { + return { + message: 'The value must be smaller than the max value.', + }; + } + }, + }, + ], + }, + max_chars: { + type: FIELD_TYPES.NUMBER, + defaultValue: 5, + helpText: 'Max chars.', + formatters: [toInt], + validations: [ + { + validator: emptyField('Set a max value.'), + }, + { + validator: ({ value }: ValidatorArg) => { + if (value > 20) { + return { + message: 'The value must be smaller or equal than 20.', + }; + } + }, + }, + { + validator: ({ value, path, formData }: ValidatorArg) => { + const minPath = path.replace('.max', '.min'); + const minValue: number | string = formData[minPath]; + + if (minValue === '') { + return; + } + + if (value <= minValue) { + return { + message: 'The value must be greater than the min value.', + }; + } + }, + }, + ], + }, + } as { [key: string]: FieldConfig }, + }, + similarity: { + fieldConfig: { + label: 'Similarity algorithm', + defaultValue: 'BM25', + type: FIELD_TYPES.SELECT, + }, + }, + split_queries_on_whitespace: { + fieldConfig: { + label: 'Split queries on whitespace', + type: FIELD_TYPES.CHECKBOX, + defaultValue: false, + }, + }, + ignore_above: { + fieldConfig: { + label: 'Ignore above', + defaultValue: 2147483647, + type: FIELD_TYPES.NUMBER, + formatters: [toInt], + validations: [ + { + validator: ({ value }: ValidatorArg) => { + if ((value as number) < 0) { + return { + message: 'The value must be greater or equal than 0.', + }; + } + }, + }, + ], + } as FieldConfig, + }, +}; diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/index.ts b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/index.ts new file mode 100644 index 0000000000000..8b0e8b7612cc0 --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './mappings_editor'; + +export { OnUpdateHandler, Types } from './mappings_state'; diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/lib/index.ts b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/lib/index.ts new file mode 100644 index 0000000000000..afa3a5b4093cb --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/lib/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './utils'; + +export * from './validators'; diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/lib/utils.ts b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/lib/utils.ts new file mode 100644 index 0000000000000..8962cd25af74e --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/lib/utils.ts @@ -0,0 +1,245 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + DataType, + Fields, + Field, + NormalizedFields, + NormalizedField, + FieldMeta, + MainType, + SubType, + ChildFieldName, +} from '../types'; +import { DATA_TYPE_DEFINITION } from '../constants'; + +export const getUniqueId = () => { + return ( + '_' + + (Number(String(Math.random()).slice(2)) + Date.now() + Math.round(performance.now())).toString( + 36 + ) + ); +}; + +const getChildFieldsName = (dataType: DataType): ChildFieldName | undefined => { + if (dataType === 'text' || dataType === 'keyword') { + return 'fields'; + } else if (dataType === 'object' || dataType === 'nested') { + return 'properties'; + } + return undefined; +}; + +export const getFieldMeta = (field: Field): FieldMeta => { + const childFieldsName = getChildFieldsName(field.type); + const canHaveChildFields = Boolean(childFieldsName); + const hasChildFields = + childFieldsName !== undefined && + Boolean(field[childFieldsName]) && + Object.keys(field[childFieldsName]!).length > 0; + + return { + hasChildFields, + childFieldsName, + canHaveChildFields, + }; +}; + +/** + * Return a map of subType -> mainType + * + * @example + * + * { + * long: 'numeric', + * integer: 'numeric', + * short: 'numeric', + * } + */ +const subTypesMapToType = Object.entries(DATA_TYPE_DEFINITION).reduce( + (acc, [type, definition]) => { + if ({}.hasOwnProperty.call(definition, 'subTypes')) { + definition.subTypes!.types.forEach(subType => { + acc[subType] = type; + }); + } + return acc; + }, + {} as Record +); + +export const getTypeFromSubType = (subType: SubType): MainType => + subTypesMapToType[subType] as MainType; + +/** + * In order to better work with the recursive pattern of the mappings `properties`, this method flatten the fields + * to a `byId` object where the key is the **path** to the field and the value is a `NormalizedField`. + * The `NormalizedField` contains the field data under `source` and meta information about the capability of the field. + * + * @example + +// original +{ + myObject: { + type: 'object', + properties: { + name: { + type: 'text' + } + } + } +} + +// normalized +{ + rootLevelFields: ['_uniqueId123'], + byId: { + '_uniqueId123': { + source: { type: 'object' }, + id: '_uniqueId123', + parentId: undefined, + hasChildFields: true, + childFieldsName: 'properties', // "object" type have their child fields under "properties" + canHaveChildFields: true, + childFields: ['_uniqueId456'], + }, + '_uniqueId456': { + source: { type: 'text' }, + id: '_uniqueId456', + parentId: '_uniqueId123', + hasChildFields: false, + childFieldsName: 'fields', // "text" type have their child fields under "fields" + canHaveChildFields: true, + childFields: undefined, + }, + }, +} + * + * @param fieldsToNormalize The "properties" object from the mappings (or "fields" object for `text` and `keyword` types) + */ +export const normalize = (fieldsToNormalize: Fields): NormalizedFields => { + let maxNestedDepth = 0; + + const normalizeFields = ( + props: Fields, + to: NormalizedFields['byId'] = {}, + idsArray: string[], + nestedDepth: number, + parentId?: string + ): Record => + Object.entries(props).reduce((acc, [propName, value]) => { + const id = getUniqueId(); + idsArray.push(id); + const field = { name: propName, ...value } as Field; + const meta = getFieldMeta(field); + const { childFieldsName } = meta; + + if (childFieldsName && field[childFieldsName]) { + meta.childFields = []; + maxNestedDepth = Math.max(maxNestedDepth, nestedDepth + 1); + normalizeFields(field[meta.childFieldsName!]!, to, meta.childFields, nestedDepth + 1, id); + } + + const { properties, fields, ...rest } = field; + + const normalizedField: NormalizedField = { + id, + parentId, + nestedDepth, + source: rest, + ...meta, + }; + + acc[id] = normalizedField; + + return acc; + }, to); + + const rootLevelFields: string[] = []; + const byId = normalizeFields(fieldsToNormalize, {}, rootLevelFields, 0); + + return { + byId, + rootLevelFields, + maxNestedDepth, + }; +}; + +export const deNormalize = (normalized: NormalizedFields): Fields => { + const deNormalizePaths = (ids: string[], to: Fields = {}) => { + ids.forEach(id => { + const { source, childFields, childFieldsName } = normalized.byId[id]; + const { name, ...normalizedField } = source; + const field: Omit = normalizedField; + to[name] = field; + if (childFields) { + field[childFieldsName!] = {}; + return deNormalizePaths(childFields, field[childFieldsName!]); + } + }); + return to; + }; + + return deNormalizePaths(normalized.rootLevelFields); +}; + +/** + * Retrieve recursively all the children fields of a field + * + * @param field The field to return the children from + * @param byId Map of all the document fields + */ +export const getAllChildFields = ( + field: NormalizedField, + byId: NormalizedFields['byId'] +): NormalizedField[] => { + const getChildFields = (_field: NormalizedField, to: NormalizedField[] = []) => { + if (_field.hasChildFields) { + _field + .childFields!.map(fieldId => byId[fieldId]) + .forEach(childField => { + to.push(childField); + getChildFields(childField, to); + }); + } + return to; + }; + + return getChildFields(field); +}; + +/** + * Return the max nested depth of the document fields + * + * @param byId Map of all the document fields + */ +export const getMaxNestedDepth = (byId: NormalizedFields['byId']): number => + Object.values(byId).reduce((maxDepth, field) => { + return Math.max(maxDepth, field.nestedDepth); + }, 0); + +/** + * When changing the type of a field, in most cases we want to delete all its child fields. + * There are some exceptions, when changing from "text" to "keyword" as both have the same "fields" property. + */ +export const shouldDeleteChildFieldsAfterTypeChange = ( + oldType: DataType, + newType: DataType +): boolean => { + if (oldType === 'text' && newType !== 'keyword') { + return true; + } else if (oldType === 'keyword' && newType !== 'text') { + return true; + } else if (oldType === 'object' && newType !== 'nested') { + return true; + } else if (oldType === 'nested' && newType !== 'object') { + return true; + } + + return false; +}; diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/lib/validators.ts b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/lib/validators.ts new file mode 100644 index 0000000000000..98f34da40c245 --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/lib/validators.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ValidationFunc } from '../shared_imports'; +import { NormalizedFields } from '../types'; + +export const validateUniqueName = ( + { rootLevelFields, byId }: Pick, + initialName: string | undefined = '', + parentId?: string +) => { + const validator: ValidationFunc = ({ value }) => { + const existingNames = parentId + ? Object.values(byId) + .filter(field => field.parentId === parentId) + .map(field => field.source.name) + : rootLevelFields.map(fieldId => byId[fieldId].source.name); + + if (existingNames.filter(name => name !== initialName).includes(value as string)) { + return { + message: 'There is already a field with this name.', + }; + } + }; + + return validator; +}; diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/mappings_editor.tsx b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/mappings_editor.tsx new file mode 100644 index 0000000000000..f53a5f1a430e8 --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/mappings_editor.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { ConfigurationForm, CONFIGURATION_FIELDS, DocumentFields } from './components'; +import { MappingsState, Props as MappingsStateProps, Types } from './mappings_state'; + +interface Props { + onUpdate: MappingsStateProps['onUpdate']; + defaultValue?: { [key: string]: any }; +} + +export const MappingsEditor = React.memo(({ onUpdate, defaultValue = {} }: Props) => { + const configurationDefaultValue = Object.entries(defaultValue) + .filter(([key]) => CONFIGURATION_FIELDS.includes(key)) + .reduce( + (acc, [key, value]) => ({ + ...acc, + [key]: value, + }), + {} as Types['MappingsConfiguration'] + ); + const fieldsDefaultValue = defaultValue.properties || {}; + + const renderJsonEditor = () => { + return

JSON editor

; + }; + + return ( + + {fieldsEditor => ( + <> + + {fieldsEditor === 'json' ? renderJsonEditor() : } + + )} + + ); +}); diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/mappings_state.tsx b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/mappings_state.tsx new file mode 100644 index 0000000000000..7a17da4c39f4f --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/mappings_state.tsx @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useReducer, useEffect, createContext, useContext } from 'react'; + +import { reducer, MappingsConfiguration, MappingsFields, State, Dispatch } from './reducer'; +import { MAX_DEPTH_DEFAULT_EDITOR } from './constants'; +import { Field, FieldsEditor } from './types'; +import { normalize, deNormalize } from './lib'; + +type Mappings = MappingsConfiguration & { + properties: MappingsFields; +}; + +export interface Types { + Mappings: Mappings; + MappingsConfiguration: MappingsConfiguration; + MappingsFields: MappingsFields; +} + +export interface OnUpdateHandlerArg { + isValid?: boolean; + getData: () => Mappings; + validate: () => Promise; +} + +export type OnUpdateHandler = (arg: OnUpdateHandlerArg) => void; + +const StateContext = createContext(undefined); +const DispatchContext = createContext(undefined); + +export interface Props { + children: (editor: FieldsEditor) => React.ReactNode; + defaultValue: { fields: { [key: string]: Field } }; + onUpdate: OnUpdateHandler; +} + +export const MappingsState = React.memo(({ children, onUpdate, defaultValue }: Props) => { + const { byId, rootLevelFields, maxNestedDepth } = normalize(defaultValue.fields); + + const initialState: State = { + isValid: undefined, + configuration: { + data: { + raw: {}, + format: () => ({} as Mappings), + }, + validate: () => Promise.resolve(true), + }, + fields: { + byId, + rootLevelFields, + maxNestedDepth, + }, + documentFields: { + status: 'idle', + editor: maxNestedDepth >= MAX_DEPTH_DEFAULT_EDITOR ? 'json' : 'default', + }, + }; + + const [state, dispatch] = useReducer(reducer, initialState); + + useEffect(() => { + // console.log('State update', state); + onUpdate({ + getData: () => ({ + ...state.configuration.data.format(), + properties: deNormalize(state.fields), + }), + validate: async () => { + if (state.fieldForm === undefined) { + return await state.configuration.validate(); + } + + return Promise.all([state.configuration.validate(), state.fieldForm.validate()]).then( + ([isConfigurationValid, isFormFieldValid]) => isConfigurationValid && isFormFieldValid + ); + }, + isValid: state.isValid, + }); + }, [state]); + + return ( + + + {children(state.documentFields.editor)} + + + ); +}); + +export const useState = () => { + const ctx = useContext(StateContext); + if (ctx === undefined) { + throw new Error('useState must be used within a '); + } + return ctx; +}; + +export const useDispatch = () => { + const ctx = useContext(DispatchContext); + if (ctx === undefined) { + throw new Error('useDispatch must be used within a '); + } + return ctx; +}; diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/reducer.ts b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/reducer.ts new file mode 100644 index 0000000000000..8105f66a8cdf9 --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/reducer.ts @@ -0,0 +1,246 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { OnFormUpdateArg } from './shared_imports'; +import { Field, NormalizedFields, NormalizedField, FieldsEditor } from './types'; +import { + getFieldMeta, + getUniqueId, + shouldDeleteChildFieldsAfterTypeChange, + getAllChildFields, + getMaxNestedDepth, +} from './lib'; + +export interface MappingsConfiguration { + dynamic: boolean | string; + date_detection: boolean; + numeric_detection: boolean; + dynamic_date_formats: string[]; +} + +export interface MappingsFields { + [key: string]: any; +} + +type DocumentFieldsStatus = 'idle' | 'editingField' | 'creatingField'; + +interface DocumentFieldsState { + status: DocumentFieldsStatus; + editor: FieldsEditor; + fieldToEdit?: string; + fieldToAddFieldTo?: string; +} + +export interface State { + isValid: boolean | undefined; + configuration: OnFormUpdateArg; + documentFields: DocumentFieldsState; + fields: NormalizedFields; + fieldForm?: OnFormUpdateArg; +} + +export type Action = + | { type: 'configuration.update'; value: OnFormUpdateArg } + | { type: 'fieldForm.update'; value: OnFormUpdateArg } + | { type: 'field.add'; value: Field } + | { type: 'field.remove'; value: string } + | { type: 'field.edit'; value: Field } + | { type: 'documentField.createField'; value?: string } + | { type: 'documentField.editField'; value: string } + | { type: 'documentField.changeStatus'; value: DocumentFieldsStatus } + | { type: 'documentField.changeEditor'; value: FieldsEditor }; + +export type Dispatch = (action: Action) => void; + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case 'configuration.update': { + const fieldFormValidity = state.fieldForm === undefined ? true : state.fieldForm.isValid; + const isValid = + action.value.isValid === undefined || fieldFormValidity === undefined + ? undefined + : action.value.isValid && fieldFormValidity; + + return { + ...state, + isValid, + configuration: action.value, + }; + } + case 'fieldForm.update': { + const isValid = + action.value.isValid === undefined || state.configuration.isValid === undefined + ? undefined + : action.value.isValid && state.configuration.isValid; + + return { + ...state, + isValid, + fieldForm: action.value, + }; + } + case 'documentField.createField': + return { + ...state, + documentFields: { + ...state.documentFields, + fieldToAddFieldTo: action.value, + status: 'creatingField', + }, + }; + case 'documentField.editField': + return { + ...state, + documentFields: { + ...state.documentFields, + status: 'editingField', + fieldToEdit: action.value, + }, + }; + case 'documentField.changeStatus': + const isValid = action.value === 'idle' ? state.configuration.isValid : state.isValid; + return { + ...state, + isValid, + fieldForm: undefined, + documentFields: { + ...state.documentFields, + status: action.value, + fieldToAddFieldTo: undefined, + fieldToEdit: undefined, + }, + }; + case 'documentField.changeEditor': + return { ...state, documentFields: { ...state.documentFields, editor: action.value } }; + case 'field.add': { + const id = getUniqueId(); + const { fieldToAddFieldTo } = state.documentFields; + const addToRootLevel = fieldToAddFieldTo === undefined; + const parentField = addToRootLevel ? undefined : state.fields.byId[fieldToAddFieldTo!]; + + const rootLevelFields = addToRootLevel + ? [...state.fields.rootLevelFields, id] + : state.fields.rootLevelFields; + + const nestedDepth = parentField ? parentField.nestedDepth + 1 : 0; + const maxNestedDepth = Math.max(state.fields.maxNestedDepth, nestedDepth); + + state.fields.byId[id] = { + id, + parentId: fieldToAddFieldTo, + source: action.value, + nestedDepth, + ...getFieldMeta(action.value), + }; + + if (parentField) { + const childFields = parentField.childFields || []; + + // Update parent field with new children + state.fields.byId[fieldToAddFieldTo!] = { + ...parentField, + childFields: [id, ...childFields], + hasChildFields: true, + }; + } + + return { + ...state, + isValid: state.configuration.isValid, + fields: { ...state.fields, rootLevelFields, maxNestedDepth }, + }; + } + case 'field.remove': { + const field = state.fields.byId[action.value]; + const { id, parentId, hasChildFields } = field; + let { rootLevelFields } = state.fields; + if (parentId) { + // Deleting a child field + const parentField = state.fields.byId[parentId]; + parentField.childFields = parentField.childFields!.filter(childId => childId !== id); + parentField.hasChildFields = Boolean(parentField.childFields.length); + } else { + // Deleting a root level field + rootLevelFields = rootLevelFields.filter(childId => childId !== id); + } + + if (hasChildFields) { + const allChildFields = getAllChildFields(field, state.fields.byId); + allChildFields!.forEach(childField => { + delete state.fields.byId[childField.id]; + }); + } + delete state.fields.byId[id]; + + const maxNestedDepth = getMaxNestedDepth(state.fields.byId); + + return { + ...state, + fields: { + ...state.fields, + rootLevelFields, + maxNestedDepth, + }, + }; + } + case 'field.edit': { + const fieldToEdit = state.documentFields.fieldToEdit!; + const previousField = state.fields.byId[fieldToEdit!]; + + let newField: NormalizedField = { + ...previousField, + source: action.value, + }; + + if (newField.source.type !== previousField.source.type) { + // The field `type` has changed, we need to update its meta information + // and delete all its children fields. + + newField = { + ...newField, + ...getFieldMeta(action.value), + hasChildFields: previousField.hasChildFields, // we need to put that back from our previous field + }; + + const shouldDeleteChildFields = shouldDeleteChildFieldsAfterTypeChange( + previousField.source.type, + newField.source.type + ); + + if (shouldDeleteChildFields) { + newField.childFields = undefined; + newField.hasChildFields = false; + + if (previousField.childFields) { + const allChildFields = getAllChildFields(previousField, state.fields.byId); + allChildFields!.forEach(childField => { + delete state.fields.byId[childField.id]; + }); + } + } + } + + return { + ...state, + isValid: state.configuration.isValid, + fieldForm: undefined, + documentFields: { + ...state.documentFields, + fieldToEdit: undefined, + status: 'idle', + }, + fields: { + ...state.fields, + byId: { + ...state.fields.byId, + [fieldToEdit]: newField, + }, + }, + }; + } + default: + throw new Error(`Action "${action!.type}" not recognized.`); + } +}; diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/shared_imports.ts b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/shared_imports.ts new file mode 100644 index 0000000000000..a816fea3b7678 --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/shared_imports.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + useForm, + UseField, + getUseField, + Form, + FormSchema, + FIELD_TYPES, + VALIDATION_TYPES, + OnFormUpdateArg, + ValidationFunc, + FieldConfig, +} from '../../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; + +export { + FormRow, + Field, + TextField, + SelectField, +} from '../../../../../../../../src/plugins/es_ui_shared/static/forms/components'; + +export { + fieldValidators, + fieldFormatters, +} from '../../../../../../../../src/plugins/es_ui_shared/static/forms/helpers'; diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/types.ts b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/types.ts new file mode 100644 index 0000000000000..5f63b139f8e92 --- /dev/null +++ b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor/types.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { FieldConfig } from './shared_imports'; + +export interface DataTypeDefinition { + label: string; + subTypes?: { label: string; types: SubType[] }; + configuration?: ParameterName[]; + basicParameters?: ParameterName[] | ParameterName[][]; + hasAdvancedParameters?: boolean; + hasMultiFields?: boolean; +} + +export type MainType = + | 'text' + | 'keyword' + | 'numeric' + | 'date' + | 'binary' + | 'boolean' + | 'range' + | 'object' + | 'nested' + | 'ip' + | 'rank_feature' + | 'rank_features' + | 'dense_vector' + | 'sparse_vector'; + +export type SubType = NumericType | DateType | RangeType; + +export type DataType = MainType | SubType; + +export type NumericType = + | 'long' + | 'integer' + | 'short' + | 'byte' + | 'double' + | 'float' + | 'half_float' + | 'scaled_float'; + +export type DateType = 'date' | 'date_nanos'; + +export type RangeType = + | 'integer_range' + | 'float_range' + | 'long_range' + | 'double_range' + | 'date_range'; + +export type ParameterName = + | 'name' + | 'type' + | 'store' + | 'index' + | 'fielddata' + | 'doc_values' + | 'coerce' + | 'ignore_malformed' + | 'null_value' + | 'dynamic' + | 'enabled' + | 'boost' + | 'locale' + | 'format' + | 'analyzer' + | 'search_analyzer' + | 'search_quote_analyzer' + | 'index_options' + | 'eager_global_ordinals' + | 'index_prefixes' + | 'index_phrases' + | 'norms' + | 'term_vector' + | 'position_increment_gap' + | 'similarity' + | 'normalizer' + | 'ignore_above' + | 'split_queries_on_whitespace'; + +export interface Parameter { + fieldConfig?: FieldConfig | { [key: string]: FieldConfig }; + paramName?: string; + docs?: string; +} + +export interface Fields { + [key: string]: Omit; +} + +export interface Field { + name: string; + type: DataType; + properties?: { [key: string]: Field }; + fields?: { [key: string]: Field }; +} + +export interface FieldMeta { + childFieldsName: ChildFieldName | undefined; + canHaveChildFields: boolean; + hasChildFields: boolean; + childFields?: string[]; +} + +export interface NormalizedFields { + byId: { + [id: string]: NormalizedField; + }; + rootLevelFields: string[]; + maxNestedDepth: number; +} + +export interface NormalizedField extends FieldMeta { + id: string; + parentId?: string; + nestedDepth: number; + source: Omit; +} + +export type ChildFieldName = 'properties' | 'fields'; + +export type FieldsEditor = 'default' | 'json'; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 5b1b43c405f3a..d3dc9dbd3aae5 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4741,8 +4741,6 @@ "xpack.idxMgmt.indexTemplatesList.loadingIndexTemplatesDescription": "テンプレートを読み込み中…", "xpack.idxMgmt.indexTemplatesList.loadingIndexTemplatesErrorMessage": "テンプレートの読み込み中にエラーが発生", "xpack.idxMgmt.indexTemplatesTable.systemIndexTemplatesSwitchLabel": "システムテンプレートを含める", - "xpack.idxMgmt.mappingsEditor.formatError": "JSON フォーマットエラー", - "xpack.idxMgmt.mappingsEditor.mappingsEditorAriaLabel": "インデックスマッピングエディター", "xpack.idxMgmt.templateCreate.loadingTemplateToCloneDescription": "クローンを作成するテンプレートを読み込み中…", "xpack.idxMgmt.templateCreate.loadingTemplateToCloneErrorMessage": "クローンを作成するテンプレートを読み込み中にエラーが発生", "xpack.idxMgmt.templateDetails.aliasesTab.noAliasesTitle": "エイリアスが定義されていません。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index fef51c7bb58ab..7f7c5425ad2ac 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4744,8 +4744,6 @@ "xpack.idxMgmt.indexTemplatesList.loadingIndexTemplatesDescription": "正在加载模板……", "xpack.idxMgmt.indexTemplatesList.loadingIndexTemplatesErrorMessage": "加载模板时出错", "xpack.idxMgmt.indexTemplatesTable.systemIndexTemplatesSwitchLabel": "包括系统模板", - "xpack.idxMgmt.mappingsEditor.formatError": "JSON 格式错误", - "xpack.idxMgmt.mappingsEditor.mappingsEditorAriaLabel": "索引映射编辑器", "xpack.idxMgmt.templateCreate.loadingTemplateToCloneDescription": "正在加载要克隆的模板……", "xpack.idxMgmt.templateCreate.loadingTemplateToCloneErrorMessage": "加载要克隆的模板时出错", "xpack.idxMgmt.templateDetails.aliasesTab.noAliasesTitle": "未定义任何别名。",