diff --git a/x-pack/legacy/plugins/index_management/public/components/json_editor/json_editor.tsx b/x-pack/legacy/plugins/index_management/public/components/json_editor/json_editor.tsx index 7c9f0ea3e7c62..8292c93bb9df7 100644 --- a/x-pack/legacy/plugins/index_management/public/components/json_editor/json_editor.tsx +++ b/x-pack/legacy/plugins/index_management/public/components/json_editor/json_editor.tsx @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { EuiFormRow, EuiCodeEditor } from '@elastic/eui'; +import { debounce } from 'lodash'; import { useJson, OnUpdateHandler } from './use_json'; @@ -17,46 +18,44 @@ interface Props { euiCodeEditorProps?: { [key: string]: any }; } -export const JsonEditor = ({ - label, - helpText, - onUpdate, - defaultValue, - euiCodeEditorProps, -}: Props) => { - const { content, setContent, error } = useJson({ - defaultValue, - onUpdate, - }); +export const JsonEditor = React.memo( + ({ label, helpText, onUpdate, defaultValue, euiCodeEditorProps }: Props) => { + const { content, setContent, error } = useJson({ + defaultValue, + onUpdate, + }); - return ( - - { - setContent(udpated); - }} - {...euiCodeEditorProps} - /> - - ); -}; + const debouncedSetContent = useCallback(debounce(setContent, 300), [setContent]); + + return ( + + { + debouncedSetContent(updated); + }} + {...euiCodeEditorProps} + /> + + ); + } +); diff --git a/x-pack/legacy/plugins/index_management/public/components/mappings_editor/components/document_fields/document_fields_json_editor.tsx b/x-pack/legacy/plugins/index_management/public/components/mappings_editor/components/document_fields/document_fields_json_editor.tsx new file mode 100644 index 0000000000000..f304ec455b73f --- /dev/null +++ b/x-pack/legacy/plugins/index_management/public/components/mappings_editor/components/document_fields/document_fields_json_editor.tsx @@ -0,0 +1,28 @@ +/* + * 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, { useRef, useCallback } from 'react'; + +import { useDispatch } from '../../mappings_state'; +import { JsonEditor } from '../../../json_editor'; + +export interface Props { + defaultValue: object; +} + +export const DocumentFieldsJsonEditor = ({ defaultValue }: Props) => { + const dispatch = useDispatch(); + const defaultValueRef = useRef(defaultValue); + const onUpdate = useCallback( + ({ data, isValid }) => + dispatch({ + type: 'fieldsJsonEditor.update', + value: { json: data.format(), isValid }, + }), + [dispatch] + ); + return ; +}; diff --git a/x-pack/legacy/plugins/index_management/public/components/mappings_editor/components/document_fields/editor_toggle_controls.tsx b/x-pack/legacy/plugins/index_management/public/components/mappings_editor/components/document_fields/editor_toggle_controls.tsx new file mode 100644 index 0000000000000..d1e9738617105 --- /dev/null +++ b/x-pack/legacy/plugins/index_management/public/components/mappings_editor/components/document_fields/editor_toggle_controls.tsx @@ -0,0 +1,84 @@ +/* + * 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 { EuiButton, EuiText } from '@elastic/eui'; + +import { useDispatch, useState } from '../../mappings_state'; +import { FieldsEditor } from '../../types'; +import { canUseMappingsEditor, normalize } from '../../lib'; + +interface Props { + editor: FieldsEditor; +} + +/* TODO: Review toggle controls UI */ +export const EditorToggleControls = ({ editor }: Props) => { + const dispatch = useDispatch(); + const { fieldsJsonEditor } = useState(); + + const [showMaxDepthWarning, setShowMaxDepthWarning] = React.useState(false); + const [showValidityWarning, setShowValidityWarning] = React.useState(false); + + const clearWarnings = () => { + if (showMaxDepthWarning) { + setShowMaxDepthWarning(false); + } + + if (showValidityWarning) { + setShowValidityWarning(false); + } + }; + + if (editor === 'default') { + clearWarnings(); + return ( + { + dispatch({ type: 'documentField.changeEditor', value: 'json' }); + }} + > + Use JSON Editor + + ); + } + + return ( + <> + { + clearWarnings(); + const { isValid } = fieldsJsonEditor; + if (!isValid) { + setShowValidityWarning(true); + } else { + const deNormalizedFields = fieldsJsonEditor.format(); + const { maxNestedDepth } = normalize(deNormalizedFields); + const canUseDefaultEditor = canUseMappingsEditor(maxNestedDepth); + + if (canUseDefaultEditor) { + dispatch({ type: 'documentField.changeEditor', value: 'default' }); + } else { + setShowMaxDepthWarning(true); + } + } + }} + > + Use Mappings Editor + + {showMaxDepthWarning ? ( + + Max depth for Mappings Editor exceeded + + ) : null} + {showValidityWarning && !fieldsJsonEditor.isValid ? ( + + JSON is invalid + + ) : null} + + ); +}; diff --git a/x-pack/legacy/plugins/index_management/public/components/mappings_editor/components/document_fields/index.ts b/x-pack/legacy/plugins/index_management/public/components/mappings_editor/components/document_fields/index.ts index a9d1620c56921..0687bedcc3667 100644 --- a/x-pack/legacy/plugins/index_management/public/components/mappings_editor/components/document_fields/index.ts +++ b/x-pack/legacy/plugins/index_management/public/components/mappings_editor/components/document_fields/index.ts @@ -7,3 +7,7 @@ export * from './document_fields'; export * from './document_fields_header'; + +export * from './document_fields_json_editor'; + +export * from './editor_toggle_controls'; diff --git a/x-pack/legacy/plugins/index_management/public/components/mappings_editor/lib/utils.test.ts b/x-pack/legacy/plugins/index_management/public/components/mappings_editor/lib/utils.test.ts new file mode 100644 index 0000000000000..39912a0cf0c86 --- /dev/null +++ b/x-pack/legacy/plugins/index_management/public/components/mappings_editor/lib/utils.test.ts @@ -0,0 +1,63 @@ +/* + * 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. + */ + +jest.mock('../constants', () => ({ DATA_TYPE_DEFINITION: {} })); + +import { determineIfValid } from '.'; + +describe('Mappings Editor form validity', () => { + let components: any; + it('handles base case', () => { + components = { + fieldsJsonEditor: { isValid: undefined }, + configuration: { isValid: undefined }, + fieldForm: undefined, + }; + expect(determineIfValid(components)).toBe(undefined); + }); + + it('handles combinations of true, false and undefined', () => { + components = { + fieldsJsonEditor: { isValid: false }, + configuration: { isValid: true }, + fieldForm: undefined, + }; + + expect(determineIfValid(components)).toBe(false); + + components = { + fieldsJsonEditor: { isValid: false }, + configuration: { isValid: undefined }, + fieldForm: undefined, + }; + + expect(determineIfValid(components)).toBe(undefined); + + components = { + fieldsJsonEditor: { isValid: true }, + configuration: { isValid: undefined }, + fieldForm: undefined, + }; + + expect(determineIfValid(components)).toBe(undefined); + + components = { + fieldsJsonEditor: { isValid: true }, + configuration: { isValid: false }, + fieldForm: undefined, + }; + + expect(determineIfValid(components)).toBe(false); + + components = { + fieldsJsonEditor: { isValid: false }, + configuration: { isValid: true }, + fieldForm: { isValid: true }, + }; + + expect(determineIfValid(components)).toBe(false); + }); +}); diff --git a/x-pack/legacy/plugins/index_management/public/components/mappings_editor/lib/utils.ts b/x-pack/legacy/plugins/index_management/public/components/mappings_editor/lib/utils.ts index 8962cd25af74e..6e426b686bff0 100644 --- a/x-pack/legacy/plugins/index_management/public/components/mappings_editor/lib/utils.ts +++ b/x-pack/legacy/plugins/index_management/public/components/mappings_editor/lib/utils.ts @@ -15,7 +15,8 @@ import { SubType, ChildFieldName, } from '../types'; -import { DATA_TYPE_DEFINITION } from '../constants'; +import { DATA_TYPE_DEFINITION, MAX_DEPTH_DEFAULT_EDITOR } from '../constants'; +import { State } from '../reducer'; export const getUniqueId = () => { return ( @@ -243,3 +244,27 @@ export const shouldDeleteChildFieldsAfterTypeChange = ( return false; }; + +export const canUseMappingsEditor = (maxNestedDepth: number) => + maxNestedDepth < MAX_DEPTH_DEFAULT_EDITOR; + +const stateWithValidity: Array = ['configuration', 'fieldsJsonEditor', 'fieldForm']; + +export const determineIfValid = (state: State): boolean | undefined => + Object.entries(state) + .filter(([key]) => stateWithValidity.includes(key as keyof State)) + .reduce( + (isValid, { 1: value }) => { + if (value === undefined) { + return isValid; + } + + // If one section validity of the state is "undefined", the mappings validity is also "undefined" + if (isValid === undefined || value.isValid === undefined) { + return undefined; + } + + return isValid && value.isValid; + }, + true as undefined | boolean + ); diff --git a/x-pack/legacy/plugins/index_management/public/components/mappings_editor/mappings_editor.tsx b/x-pack/legacy/plugins/index_management/public/components/mappings_editor/mappings_editor.tsx index e5af0e7fd8b1e..93c5fde37650e 100644 --- a/x-pack/legacy/plugins/index_management/public/components/mappings_editor/mappings_editor.tsx +++ b/x-pack/legacy/plugins/index_management/public/components/mappings_editor/mappings_editor.tsx @@ -5,13 +5,15 @@ */ import React, { useMemo } from 'react'; +import { EuiSpacer } from '@elastic/eui'; -import { JsonEditor } from '../json_editor'; import { ConfigurationForm, CONFIGURATION_FIELDS, DocumentFieldsHeaders, DocumentFields, + DocumentFieldsJsonEditor, + EditorToggleControls, } from './components'; import { MappingsState, Props as MappingsStateProps, Types } from './mappings_state'; @@ -38,25 +40,26 @@ export const MappingsEditor = React.memo(({ onUpdate, defaultValue }: Props) => ); const fieldsDefaultValue = defaultValue === undefined ? {} : defaultValue.properties; - // Temporary logic - const onJsonEditorUpdate = (args: any) => { - // eslint-disable-next-line - console.log(args); - }; - return ( - {({ editor, getProperties }) => ( - <> - - - {editor === 'json' ? ( - - ) : ( - - )} - - )} + {({ editor, getProperties }) => { + const renderEditor = () => { + if (editor === 'json') { + return ; + } + return ; + }; + + return ( + <> + + + {renderEditor()} + + + + ); + }} ); }); diff --git a/x-pack/legacy/plugins/index_management/public/components/mappings_editor/mappings_state.tsx b/x-pack/legacy/plugins/index_management/public/components/mappings_editor/mappings_state.tsx index e93f549ba59a5..9c5834c231da6 100644 --- a/x-pack/legacy/plugins/index_management/public/components/mappings_editor/mappings_state.tsx +++ b/x-pack/legacy/plugins/index_management/public/components/mappings_editor/mappings_state.tsx @@ -7,9 +7,8 @@ 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'; +import { normalize, deNormalize, canUseMappingsEditor } from './lib'; type Mappings = MappingsConfiguration & { properties: MappingsFields; @@ -44,6 +43,7 @@ export interface Props { export const MappingsState = React.memo(({ children, onUpdate, defaultValue }: Props) => { const { byId, rootLevelFields, maxNestedDepth } = normalize(defaultValue.fields); + const canUseDefaultEditor = canUseMappingsEditor(maxNestedDepth); const initialState: State = { isValid: undefined, configuration: { @@ -60,7 +60,11 @@ export const MappingsState = React.memo(({ children, onUpdate, defaultValue }: P }, documentFields: { status: 'idle', - editor: maxNestedDepth >= MAX_DEPTH_DEFAULT_EDITOR ? 'json' : 'default', + editor: canUseDefaultEditor ? 'default' : 'json', + }, + fieldsJsonEditor: { + format: () => ({}), + isValid: true, }, }; @@ -71,15 +75,20 @@ export const MappingsState = React.memo(({ children, onUpdate, defaultValue }: P onUpdate({ getData: () => ({ ...state.configuration.data.format(), - properties: deNormalize(state.fields), + properties: + // Pull the mappings properties from the current editor + state.documentFields.editor === 'json' + ? state.fieldsJsonEditor.format() + : deNormalize(state.fields), }), validate: async () => { if (state.fieldForm === undefined) { - return await state.configuration.validate(); + return (await state.configuration.validate()) && state.fieldsJsonEditor.isValid; } return Promise.all([state.configuration.validate(), state.fieldForm.validate()]).then( - ([isConfigurationValid, isFormFieldValid]) => isConfigurationValid && isFormFieldValid + ([isConfigurationValid, isFormFieldValid]) => + isConfigurationValid && isFormFieldValid && state.fieldsJsonEditor.isValid ); }, isValid: state.isValid, diff --git a/x-pack/legacy/plugins/index_management/public/components/mappings_editor/reducer.ts b/x-pack/legacy/plugins/index_management/public/components/mappings_editor/reducer.ts index 8105f66a8cdf9..e7623394390a5 100644 --- a/x-pack/legacy/plugins/index_management/public/components/mappings_editor/reducer.ts +++ b/x-pack/legacy/plugins/index_management/public/components/mappings_editor/reducer.ts @@ -11,6 +11,8 @@ import { shouldDeleteChildFieldsAfterTypeChange, getAllChildFields, getMaxNestedDepth, + determineIfValid, + normalize, } from './lib'; export interface MappingsConfiguration { @@ -39,6 +41,10 @@ export interface State { documentFields: DocumentFieldsState; fields: NormalizedFields; fieldForm?: OnFormUpdateArg; + fieldsJsonEditor: { + format(): MappingsFields; + isValid: boolean; + }; } export type Action = @@ -50,34 +56,30 @@ export type Action = | { type: 'documentField.createField'; value?: string } | { type: 'documentField.editField'; value: string } | { type: 'documentField.changeStatus'; value: DocumentFieldsStatus } - | { type: 'documentField.changeEditor'; value: FieldsEditor }; + | { type: 'documentField.changeEditor'; value: FieldsEditor } + | { type: 'fieldsJsonEditor.update'; value: { json: { [key: string]: any }; isValid: boolean } }; 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, + isValid: determineIfValid({ + ...state, + configuration: action.value, + }), 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, + isValid: determineIfValid({ + ...state, + fieldForm: action.value, + }), fieldForm: action.value, }; } @@ -112,8 +114,22 @@ export const reducer = (state: State, action: Action): State => { fieldToEdit: undefined, }, }; - case 'documentField.changeEditor': - return { ...state, documentFields: { ...state.documentFields, editor: action.value } }; + case 'documentField.changeEditor': { + const switchingToDefault = action.value === 'default'; + const fields = switchingToDefault ? normalize(state.fieldsJsonEditor.format()) : state.fields; + return { + ...state, + fields, + fieldForm: undefined, + documentFields: { + ...state.documentFields, + status: 'idle', + fieldToAddFieldTo: undefined, + fieldToEdit: undefined, + editor: action.value, + }, + }; + } case 'field.add': { const id = getUniqueId(); const { fieldToAddFieldTo } = state.documentFields; @@ -148,7 +164,7 @@ export const reducer = (state: State, action: Action): State => { return { ...state, - isValid: state.configuration.isValid, + isValid: determineIfValid(state), fields: { ...state.fields, rootLevelFields, maxNestedDepth }, }; } @@ -224,7 +240,7 @@ export const reducer = (state: State, action: Action): State => { return { ...state, - isValid: state.configuration.isValid, + isValid: determineIfValid(state), fieldForm: undefined, documentFields: { ...state.documentFields, @@ -240,6 +256,21 @@ export const reducer = (state: State, action: Action): State => { }, }; } + case 'fieldsJsonEditor.update': { + const nextState = { + ...state, + fieldsJsonEditor: { + format() { + return action.value.json; + }, + isValid: action.value.isValid, + }, + }; + + nextState.isValid = determineIfValid(nextState); + + return nextState; + } default: throw new Error(`Action "${action!.type}" not recognized.`); }