diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/errors/es_error_parser.test.ts b/src/plugins/es_ui_shared/__packages_do_not_import__/errors/es_error_parser.test.ts new file mode 100644 index 00000000000000..29369f74a459dd --- /dev/null +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/errors/es_error_parser.test.ts @@ -0,0 +1,42 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { parseEsError } from './es_error_parser'; + +describe('ES error parser', () => { + test('should return all the cause of the error', () => { + const esError = `{ + "error": { + "reason": "Houston we got a problem", + "caused_by": { + "reason": "First reason", + "caused_by": { + "reason": "Second reason", + "caused_by": { + "reason": "Third reason" + } + } + } + } + }`; + + const parsedError = parseEsError(esError); + expect(parsedError.message).toEqual('Houston we got a problem'); + expect(parsedError.cause).toEqual(['First reason', 'Second reason', 'Third reason']); + }); +}); diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/errors/es_error_parser.ts b/src/plugins/es_ui_shared/__packages_do_not_import__/errors/es_error_parser.ts new file mode 100644 index 00000000000000..800a56bc007eb5 --- /dev/null +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/errors/es_error_parser.ts @@ -0,0 +1,52 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +interface ParsedError { + message: string; + cause: string[]; +} + +const getCause = (obj: any = {}, causes: string[] = []): string[] => { + const updated = [...causes]; + + if (obj.caused_by) { + updated.push(obj.caused_by.reason); + + // Recursively find all the "caused by" reasons + return getCause(obj.caused_by, updated); + } + + return updated.filter(Boolean); +}; + +export const parseEsError = (err: string): ParsedError => { + try { + const { error } = JSON.parse(err); + const cause = getCause(error); + return { + message: error.reason, + cause, + }; + } catch (e) { + return { + message: err, + cause: [], + }; + } +}; diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/errors/index.ts b/src/plugins/es_ui_shared/__packages_do_not_import__/errors/index.ts index 484dc17868ab0d..e467930d3ad0be 100644 --- a/src/plugins/es_ui_shared/__packages_do_not_import__/errors/index.ts +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/errors/index.ts @@ -19,3 +19,4 @@ export { isEsError } from './is_es_error'; export { handleEsError } from './handle_es_error'; +export { parseEsError } from './es_error_parser'; diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/global_flyout/global_flyout.tsx b/src/plugins/es_ui_shared/__packages_do_not_import__/global_flyout/global_flyout.tsx index 4dd9cfcaff16bf..2b3e6ab48992d6 100644 --- a/src/plugins/es_ui_shared/__packages_do_not_import__/global_flyout/global_flyout.tsx +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/global_flyout/global_flyout.tsx @@ -54,7 +54,7 @@ export const GlobalFlyoutProvider: React.FC = ({ children }) => { const [showFlyout, setShowFlyout] = useState(false); const [activeContent, setActiveContent] = useState | undefined>(undefined); - const { id, Component, props, flyoutProps } = activeContent ?? {}; + const { id, Component, props, flyoutProps, cleanUpFunc } = activeContent ?? {}; const addContent: Context['addContent'] = useCallback((content) => { setActiveContent((prev) => { @@ -77,11 +77,19 @@ export const GlobalFlyoutProvider: React.FC = ({ children }) => { const removeContent: Context['removeContent'] = useCallback( (contentId: string) => { + // Note: when we will actually deal with multi content then + // there will be more logic here! :) if (contentId === id) { + setActiveContent(undefined); + + if (cleanUpFunc) { + cleanUpFunc(); + } + closeFlyout(); } }, - [id, closeFlyout] + [id, closeFlyout, cleanUpFunc] ); const mergedFlyoutProps = useMemo(() => { @@ -130,14 +138,6 @@ export const useGlobalFlyout = () => { const contents = useRef | undefined>(undefined); const { removeContent, addContent: addContentToContext } = ctx; - useEffect(() => { - isMounted.current = true; - - return () => { - isMounted.current = false; - }; - }, []); - const getContents = useCallback(() => { if (contents.current === undefined) { contents.current = new Set(); @@ -153,6 +153,14 @@ export const useGlobalFlyout = () => { [getContents, addContentToContext] ); + useEffect(() => { + isMounted.current = true; + + return () => { + isMounted.current = false; + }; + }, []); + useEffect(() => { return () => { if (!isMounted.current) { diff --git a/src/plugins/es_ui_shared/server/errors/index.ts b/src/plugins/es_ui_shared/server/errors/index.ts index 532e02774ff502..3533e96aaea3ae 100644 --- a/src/plugins/es_ui_shared/server/errors/index.ts +++ b/src/plugins/es_ui_shared/server/errors/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { isEsError, handleEsError } from '../../__packages_do_not_import__/errors'; +export { isEsError, handleEsError, parseEsError } from '../../__packages_do_not_import__/errors'; diff --git a/src/plugins/es_ui_shared/server/index.ts b/src/plugins/es_ui_shared/server/index.ts index b2c9c85d956bac..2801d0569aa3f4 100644 --- a/src/plugins/es_ui_shared/server/index.ts +++ b/src/plugins/es_ui_shared/server/index.ts @@ -17,7 +17,7 @@ * under the License. */ -export { isEsError, handleEsError } from './errors'; +export { isEsError, handleEsError, parseEsError } from './errors'; /** dummy plugin*/ export function plugin() { diff --git a/x-pack/plugins/index_management/public/application/app_context.tsx b/x-pack/plugins/index_management/public/application/app_context.tsx index c9337767365fa2..a9cdb668ca35e8 100644 --- a/x-pack/plugins/index_management/public/application/app_context.tsx +++ b/x-pack/plugins/index_management/public/application/app_context.tsx @@ -37,6 +37,7 @@ export interface AppDependencies { setBreadcrumbs: ManagementAppMountParams['setBreadcrumbs']; uiSettings: CoreSetup['uiSettings']; urlGenerators: SharePluginStart['urlGenerators']; + docLinks: CoreStart['docLinks']; } export const AppContextProvider = ({ diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/index.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/index.ts index 4e03adcbcbb441..10b5805a7ad2fa 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/index.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/index.ts @@ -9,7 +9,7 @@ import { setup as componentTemplateDetailsSetup } from './component_template_det export { nextTick, getRandomString, findTestSubject } from '@kbn/test/jest'; -export { setupEnvironment, appDependencies } from './setup_environment'; +export { setupEnvironment, componentTemplatesDependencies } from './setup_environment'; export const pageHelpers = { componentTemplateList: { setup: componentTemplatesListSetup }, diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx index ac748e1b7dc2c9..38832e6beb5f5a 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx @@ -15,6 +15,7 @@ import { } from '../../../../../../../../../../src/core/public/mocks'; import { GlobalFlyout } from '../../../../../../../../../../src/plugins/es_ui_shared/public'; +import { AppContextProvider } from '../../../../../app_context'; import { MappingsEditorProvider } from '../../../../mappings_editor'; import { ComponentTemplatesProvider } from '../../../component_templates_context'; @@ -24,7 +25,12 @@ import { API_BASE_PATH } from './constants'; const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); const { GlobalFlyoutProvider } = GlobalFlyout; -export const appDependencies = { +// We provide the minimum deps required to make the tests pass +const appDependencies = { + docLinks: {} as any, +} as any; + +export const componentTemplatesDependencies = { httpClient: (mockHttpClient as unknown) as HttpSetup, apiBasePath: API_BASE_PATH, trackMetric: () => {}, @@ -44,11 +50,14 @@ export const setupEnvironment = () => { }; export const WithAppDependencies = (Comp: any) => (props: any) => ( - - - - - - - + + + + + + + + + / + ); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/index.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/index.ts index ebd2cd93925688..a0cafbb6d42172 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/index.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { appDependencies as componentTemplatesMockDependencies } from './client_integration/helpers'; +export { componentTemplatesDependencies as componentTemplatesMockDependencies } from './client_integration/helpers'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx index 9302e080028cc9..14252fc34c4e51 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx @@ -3,57 +3,16 @@ * 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 { act } from 'react-dom/test-utils'; import { ReactWrapper } from 'enzyme'; +import { registerTestBed, TestBed, findTestSubject } from '@kbn/test/jest'; -import { registerTestBed, TestBed } from '@kbn/test/jest'; -import { GlobalFlyout } from '../../../../../../../../../../src/plugins/es_ui_shared/public'; +// This import needs to come first as it sets the jest.mock calls +import { WithAppDependencies } from './setup_environment'; import { getChildFieldsName } from '../../../lib'; +import { RuntimeField } from '../../../shared_imports'; import { MappingsEditor } from '../../../mappings_editor'; -import { MappingsEditorProvider } from '../../../mappings_editor_context'; - -jest.mock('@elastic/eui', () => { - const original = jest.requireActual('@elastic/eui'); - - return { - ...original, - // Mocking EuiComboBox, as it utilizes "react-virtualized" for rendering search suggestions, - // which does not produce a valid component wrapper - EuiComboBox: (props: any) => ( - { - props.onChange([syntheticEvent['0']]); - }} - /> - ), - // Mocking EuiCodeEditor, which uses React Ace under the hood - EuiCodeEditor: (props: any) => ( - { - props.onChange(e.jsonContent); - }} - /> - ), - // Mocking EuiSuperSelect to be able to easily change its value - // with a `myWrapper.simulate('change', { target: { value: 'someValue' } })` - EuiSuperSelect: (props: any) => ( - { - props.onChange(e.target.value); - }} - /> - ), - }; -}); - -const { GlobalFlyoutProvider } = GlobalFlyout; export interface DomFields { [key: string]: { @@ -64,8 +23,9 @@ export interface DomFields { } const createActions = (testBed: TestBed) => { - const { find, form, component } = testBed; + const { find, exists, form, component } = testBed; + // --- Mapped fields --- const getFieldInfo = (testSubjectField: string): { name: string; type: string } => { const name = find(`${testSubjectField}-fieldName` as TestSubjects).text(); const type = find(`${testSubjectField}-datatype` as TestSubjects).props()['data-type-value']; @@ -206,8 +166,102 @@ const createActions = (testBed: TestBed) => { component.update(); }; - const selectTab = async (tab: 'fields' | 'templates' | 'advanced') => { - const index = ['fields', 'templates', 'advanced'].indexOf(tab); + // --- Runtime fields --- + const openRuntimeFieldEditor = () => { + find('createRuntimeFieldButton').simulate('click'); + component.update(); + }; + + const updateRuntimeFieldForm = async (field: RuntimeField) => { + const valueToLabelMap = { + keyword: 'Keyword', + date: 'Date', + ip: 'IP', + long: 'Long', + double: 'Double', + boolean: 'Boolean', + }; + + if (!exists('runtimeFieldEditor')) { + throw new Error(`Can't update runtime field form as the editor is not opened.`); + } + + await act(async () => { + form.setInputValue('runtimeFieldEditor.nameField.input', field.name); + form.setInputValue('runtimeFieldEditor.scriptField', field.script.source); + find('typeField').simulate('change', [ + { + label: valueToLabelMap[field.type], + value: field.type, + }, + ]); + }); + }; + + const getRuntimeFieldsList = () => { + const fields = find('runtimeFieldsListItem').map((wrapper) => wrapper); + return fields.map((field) => { + return { + reactWrapper: field, + name: findTestSubject(field, 'fieldName').text(), + type: findTestSubject(field, 'fieldType').text(), + }; + }); + }; + + /** + * Open the editor, fill the form and close the editor + * @param field the field to add + */ + const addRuntimeField = async (field: RuntimeField) => { + openRuntimeFieldEditor(); + + await updateRuntimeFieldForm(field); + + await act(async () => { + find('runtimeFieldEditor.saveFieldButton').simulate('click'); + }); + component.update(); + }; + + const deleteRuntimeField = async (name: string) => { + const runtimeField = getRuntimeFieldsList().find((field) => field.name === name); + + if (!runtimeField) { + throw new Error(`Runtime field "${name}" to delete not found.`); + } + + await act(async () => { + findTestSubject(runtimeField.reactWrapper, 'removeFieldButton').simulate('click'); + }); + component.update(); + + // Modal is opened, confirm deletion + const modal = find('runtimeFieldDeleteConfirmModal'); + + act(() => { + findTestSubject(modal, 'confirmModalConfirmButton').simulate('click'); + }); + + component.update(); + }; + + const startEditRuntimeField = async (name: string) => { + const runtimeField = getRuntimeFieldsList().find((field) => field.name === name); + + if (!runtimeField) { + throw new Error(`Runtime field "${name}" to edit not found.`); + } + + await act(async () => { + findTestSubject(runtimeField.reactWrapper, 'editFieldButton').simulate('click'); + }); + component.update(); + }; + + // --- Other --- + const selectTab = async (tab: 'fields' | 'runtimeFields' | 'templates' | 'advanced') => { + const index = ['fields', 'runtimeFields', 'templates', 'advanced'].indexOf(tab); const tabElement = find('formTab').at(index); if (tabElement.length === 0) { @@ -268,19 +322,17 @@ const createActions = (testBed: TestBed) => { getToggleValue, getCheckboxValue, toggleFormRow, + openRuntimeFieldEditor, + getRuntimeFieldsList, + updateRuntimeFieldForm, + addRuntimeField, + deleteRuntimeField, + startEditRuntimeField, }; }; export const setup = (props: any = { onUpdate() {} }): MappingsEditorTestBed => { - const ComponentToTest = (propsOverride: { [key: string]: any }) => ( - - - - - - ); - - const setupTestBed = registerTestBed(ComponentToTest, { + const setupTestBed = registerTestBed(WithAppDependencies(MappingsEditor), { memoryRouter: { wrapComponent: false, }, @@ -312,10 +364,12 @@ export const getMappingsEditorDataFactory = (onChangeHandler: jest.MockedFunctio const [arg] = mockCalls[mockCalls.length - 1]; const { isValid, validate, getData } = arg; - let isMappingsValid = isValid; + let isMappingsValid: boolean = isValid; if (isMappingsValid === undefined) { - isMappingsValid = await act(validate); + await act(async () => { + isMappingsValid = await validate(); + }); component.update(); } diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/setup_environment.tsx new file mode 100644 index 00000000000000..f5fab4263e9b1e --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/setup_environment.tsx @@ -0,0 +1,95 @@ +/* + * 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 { GlobalFlyout } from '../../../../../../../../../../src/plugins/es_ui_shared/public'; +import { uiSettingsServiceMock } from '../../../../../../../../../../src/core/public/mocks'; +import { MappingsEditorProvider } from '../../../mappings_editor_context'; +import { createKibanaReactContext } from '../../../shared_imports'; + +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); + + return { + ...original, + // Mocking EuiComboBox, as it utilizes "react-virtualized" for rendering search suggestions, + // which does not produce a valid component wrapper + EuiComboBox: (props: any) => ( + { + props.onChange([syntheticEvent['0']]); + }} + /> + ), + // Mocking EuiCodeEditor, which uses React Ace under the hood + EuiCodeEditor: (props: any) => ( + { + props.onChange(e.jsonContent); + }} + /> + ), + // Mocking EuiSuperSelect to be able to easily change its value + // with a `myWrapper.simulate('change', { target: { value: 'someValue' } })` + EuiSuperSelect: (props: any) => ( + { + props.onChange(e.target.value); + }} + /> + ), + }; +}); + +jest.mock('../../../../../../../../../../src/plugins/kibana_react/public', () => { + const original = jest.requireActual( + '../../../../../../../../../../src/plugins/kibana_react/public' + ); + + const CodeEditorMock = (props: any) => ( + ) => { + props.onChange(e.target.value); + }} + /> + ); + + return { + ...original, + CodeEditor: CodeEditorMock, + }; +}); + +const { GlobalFlyoutProvider } = GlobalFlyout; + +const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ + uiSettings: uiSettingsServiceMock.createSetupContract(), +}); + +const defaultProps = { + docLinks: { + DOC_LINK_VERSION: 'master', + ELASTIC_WEBSITE_URL: 'https://jest.elastic.co', + }, +}; + +export const WithAppDependencies = (Comp: any) => (props: any) => ( + + + + + + + +); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mapped_fields.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mapped_fields.test.tsx index 8e5a3a314c6f6a..d6dcc317e67efb 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mapped_fields.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mapped_fields.test.tsx @@ -25,7 +25,7 @@ describe('Mappings editor: mapped fields', () => { describe('', () => { let testBed: MappingsEditorTestBed; - const defaultMappings = { + let defaultMappings = { properties: { myField: { type: 'text', @@ -72,6 +72,50 @@ describe('Mappings editor: mapped fields', () => { expect(domTreeMetadata).toEqual(defaultMappings.properties); }); + test('should indicate when a field is shadowed by a runtime field', async () => { + defaultMappings = { + properties: { + // myField is shadowed by runtime field with same name + myField: { + type: 'text', + fields: { + // Same name but is not root so not shadowed + myField: { + type: 'text', + }, + }, + }, + myObject: { + type: 'object', + properties: { + // Object properties are also non root fields so not shadowed + myField: { + type: 'object', + }, + }, + }, + }, + runtime: { + myField: { + type: 'boolean', + script: { + source: 'emit("hello")', + }, + }, + }, + } as any; + + const { actions, find } = setup({ + value: defaultMappings, + onChange: onChangeHandler, + }); + + await actions.expandAllFieldsAndReturnMetadata(); + + expect(find('fieldsListItem').length).toBe(4); // 2 for text and 2 for object + expect(find('fieldsListItem.isShadowedIndicator').length).toBe(1); // only root level text field + }); + test('should allow to be controlled by parent component and update on prop change', async () => { testBed = setup({ value: defaultMappings, diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mappings_editor.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mappings_editor.test.tsx index f5fcff9f962543..ead4fef5506e51 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mappings_editor.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mappings_editor.test.tsx @@ -129,6 +129,18 @@ describe('Mappings editor: core', () => { testBed.component.update(); }); + test('should have 4 tabs (fields, runtime, template, advanced settings)', () => { + const { find } = testBed; + const tabs = find('formTab').map((wrapper) => wrapper.text()); + + expect(tabs).toEqual([ + 'Mapped fields', + 'Runtime fields', + 'Dynamic templates', + 'Advanced options', + ]); + }); + test('should keep the changes when switching tabs', async () => { const { actions: { addField, selectTab, updateJsonEditor, getJsonEditorValue, getToggleValue }, @@ -196,7 +208,6 @@ describe('Mappings editor: core', () => { isNumericDetectionVisible = exists('advancedConfiguration.numericDetection'); expect(isNumericDetectionVisible).toBe(false); - // await act(() => promise); // ---------------------------------------------------------------------------- // Go back to dynamic templates tab and make sure our changes are still there // ---------------------------------------------------------------------------- diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/runtime_fields.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/runtime_fields.test.tsx new file mode 100644 index 00000000000000..dc7859c24fb9e2 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/runtime_fields.test.tsx @@ -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 { act } from 'react-dom/test-utils'; + +import { componentHelpers, MappingsEditorTestBed } from './helpers'; + +const { setup, getMappingsEditorDataFactory } = componentHelpers.mappingsEditor; + +describe('Mappings editor: runtime fields', () => { + /** + * Variable to store the mappings data forwarded to the consumer component + */ + let data: any; + let onChangeHandler: jest.Mock = jest.fn(); + let getMappingsEditorData = getMappingsEditorDataFactory(onChangeHandler); + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + onChangeHandler = jest.fn(); + getMappingsEditorData = getMappingsEditorDataFactory(onChangeHandler); + }); + + describe('', () => { + let testBed: MappingsEditorTestBed; + + describe('when there are no runtime fields', () => { + const defaultMappings = {}; + + beforeEach(async () => { + testBed = setup({ + value: defaultMappings, + onChange: onChangeHandler, + }); + + await testBed.actions.selectTab('runtimeFields'); + }); + + test('should display an empty prompt', () => { + const { exists, find } = testBed; + + expect(exists('emptyList')).toBe(true); + expect(find('emptyList').text()).toContain('Start by creating a runtime field'); + }); + + test('should have a button to create a field and a link that points to the docs', () => { + const { exists, find, actions } = testBed; + + expect(exists('emptyList.learnMoreLink')).toBe(true); + expect(exists('emptyList.createRuntimeFieldButton')).toBe(true); + expect(find('createRuntimeFieldButton').text()).toBe('Create runtime field'); + + expect(exists('runtimeFieldEditor')).toBe(false); + actions.openRuntimeFieldEditor(); + expect(exists('runtimeFieldEditor')).toBe(true); + }); + }); + + describe('when there are runtime fields', () => { + const defaultMappings = { + runtime: { + day_of_week: { + type: 'date', + script: { + source: 'emit("hello Kibana")', + }, + }, + }, + }; + + beforeEach(async () => { + testBed = setup({ + value: defaultMappings, + onChange: onChangeHandler, + }); + + await testBed.actions.selectTab('runtimeFields'); + }); + + test('should list the fields', async () => { + const { find, actions } = testBed; + + const fields = actions.getRuntimeFieldsList(); + expect(fields.length).toBe(1); + + const [field] = fields; + expect(field.name).toBe('day_of_week'); + expect(field.type).toBe('Date'); + + await actions.startEditRuntimeField('day_of_week'); + expect(find('runtimeFieldEditor.scriptField').props().value).toBe('emit("hello Kibana")'); + }); + + test('should have a button to create fields', () => { + const { actions, exists } = testBed; + + expect(exists('createRuntimeFieldButton')).toBe(true); + + actions.openRuntimeFieldEditor(); + expect(exists('runtimeFieldEditor')).toBe(true); + }); + + test('should close the runtime editor when switching tab', async () => { + const { exists, actions } = testBed; + expect(exists('runtimeFieldEditor')).toBe(false); // closed + + actions.openRuntimeFieldEditor(); + expect(exists('runtimeFieldEditor')).toBe(true); // opened + + // Navigate away + await testBed.actions.selectTab('templates'); + expect(exists('runtimeFieldEditor')).toBe(false); // closed + + // Back to runtime fields + await testBed.actions.selectTab('runtimeFields'); + expect(exists('runtimeFieldEditor')).toBe(false); // still closed + }); + }); + + describe('Create / edit / delete runtime fields', () => { + const defaultMappings = {}; + + beforeEach(async () => { + testBed = setup({ + value: defaultMappings, + onChange: onChangeHandler, + }); + + await testBed.actions.selectTab('runtimeFields'); + }); + + test('should add the runtime field to the list and remove the empty prompt', async () => { + const { exists, actions, component } = testBed; + + await actions.addRuntimeField({ + name: 'myField', + script: { source: 'emit("hello")' }, + type: 'boolean', + }); + + // Make sure editor is closed and the field is in the list + expect(exists('runtimeFieldEditor')).toBe(false); + expect(exists('emptyList')).toBe(false); + + const fields = actions.getRuntimeFieldsList(); + expect(fields.length).toBe(1); + + const [field] = fields; + expect(field.name).toBe('myField'); + expect(field.type).toBe('Boolean'); + + // Make sure the field has been added to forwarded data + ({ data } = await getMappingsEditorData(component)); + + expect(data).toEqual({ + runtime: { + myField: { + type: 'boolean', + script: { + source: 'emit("hello")', + }, + }, + }, + }); + }); + + test('should remove the runtime field from the list', async () => { + const { actions, component } = testBed; + + await actions.addRuntimeField({ + name: 'myField', + script: { source: 'emit("hello")' }, + type: 'boolean', + }); + + let fields = actions.getRuntimeFieldsList(); + expect(fields.length).toBe(1); + ({ data } = await getMappingsEditorData(component)); + expect(data).toBeDefined(); + expect(data.runtime).toBeDefined(); + + await actions.deleteRuntimeField('myField'); + + fields = actions.getRuntimeFieldsList(); + expect(fields.length).toBe(0); + + ({ data } = await getMappingsEditorData(component)); + + expect(data).toBeUndefined(); + }); + + test('should edit the runtime field', async () => { + const { find, component, actions } = testBed; + + await actions.addRuntimeField({ + name: 'myField', + script: { source: 'emit("hello")' }, + type: 'boolean', + }); + + let fields = actions.getRuntimeFieldsList(); + expect(fields.length).toBe(1); + + await actions.startEditRuntimeField('myField'); + await actions.updateRuntimeFieldForm({ + name: 'updatedName', + script: { source: 'new script' }, + type: 'date', + }); + + await act(async () => { + find('runtimeFieldEditor.saveFieldButton').simulate('click'); + }); + component.update(); + + fields = actions.getRuntimeFieldsList(); + const [field] = fields; + + expect(field.name).toBe('updatedName'); + expect(field.type).toBe('Date'); + + ({ data } = await getMappingsEditorData(component)); + + expect(data).toEqual({ + runtime: { + updatedName: { + type: 'date', + script: { + source: 'new script', + }, + }, + }, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/document_fields_header.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/document_fields_header.tsx index 56c01510376be4..84c4bf491cef57 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/document_fields_header.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/document_fields_header.tsx @@ -25,7 +25,7 @@ export const DocumentFieldsHeader = React.memo(({ searchValue, onSearchChange }: defaultMessage="Define the fields for your indexed documents. {docsLink}" values={{ docsLink: ( - + {i18n.translate('xpack.idxMgmt.mappingsEditor.documentFieldsDocumentationLink', { defaultMessage: 'Learn more.', })} diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzer_parameter.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzer_parameter.tsx index 1457c4583aa0e8..c613ddf282f0ad 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzer_parameter.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzer_parameter.tsx @@ -16,7 +16,7 @@ import { SelectOption, SuperSelectOption, } from '../../../types'; -import { useIndexSettings } from '../../../index_settings_context'; +import { useConfig } from '../../../config_context'; import { AnalyzerParameterSelects } from './analyzer_parameter_selects'; interface Props { @@ -71,7 +71,9 @@ export const AnalyzerParameter = ({ allowsIndexDefaultOption = true, 'data-test-subj': dataTestSubj, }: Props) => { - const { value: indexSettings } = useIndexSettings(); + const { + value: { indexSettings }, + } = useConfig(); const customAnalyzers = getCustomAnalyzers(indexSettings); const analyzerOptions = allowsIndexDefaultOption ? ANALYZER_OPTIONS diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/index.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/index.ts index c47ea4a884111f..b3bf0719489562 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/index.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/index.ts @@ -73,10 +73,6 @@ export * from './meta_parameter'; export * from './ignore_above_parameter'; -export { RuntimeTypeParameter } from './runtime_type_parameter'; - -export { PainlessScriptParameter } from './painless_script_parameter'; - export const PARAMETER_SERIALIZERS = [relationsSerializer, dynamicSerializer]; export const PARAMETER_DESERIALIZERS = [relationsDeserializer, dynamicDeserializer]; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/painless_script_parameter.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/painless_script_parameter.tsx deleted file mode 100644 index 9042e7f6ee328a..00000000000000 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/painless_script_parameter.tsx +++ /dev/null @@ -1,80 +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 from 'react'; -import { i18n } from '@kbn/i18n'; -import { PainlessLang } from '@kbn/monaco'; -import { EuiFormRow, EuiDescribedFormGroup } from '@elastic/eui'; - -import { CodeEditor, UseField } from '../../../shared_imports'; -import { getFieldConfig } from '../../../lib'; -import { EditFieldFormRow } from '../fields/edit_field'; - -interface Props { - stack?: boolean; -} - -export const PainlessScriptParameter = ({ stack }: Props) => { - return ( - path="script.source" config={getFieldConfig('script')}> - {(scriptField) => { - const error = scriptField.getErrorsMessages(); - const isInvalid = error ? Boolean(error.length) : false; - - const field = ( - - - - ); - - const fieldTitle = i18n.translate('xpack.idxMgmt.mappingsEditor.painlessScript.title', { - defaultMessage: 'Emitted value', - }); - - const fieldDescription = i18n.translate( - 'xpack.idxMgmt.mappingsEditor.painlessScript.description', - { - defaultMessage: 'Use emit() to define the value of this runtime field.', - } - ); - - if (stack) { - return ( - - {field} - - ); - } - - return ( - {fieldTitle}} - description={fieldDescription} - fullWidth={true} - > - {field} - - ); - }} - - ); -}; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/runtime_type_parameter.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/runtime_type_parameter.tsx deleted file mode 100644 index 95a6c5364ac4d3..00000000000000 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/runtime_type_parameter.tsx +++ /dev/null @@ -1,105 +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 from 'react'; -import { i18n } from '@kbn/i18n'; -import { - EuiFormRow, - EuiComboBox, - EuiComboBoxOptionOption, - EuiDescribedFormGroup, - EuiSpacer, -} from '@elastic/eui'; - -import { UseField, RUNTIME_FIELD_OPTIONS } from '../../../shared_imports'; -import { DataType } from '../../../types'; -import { getFieldConfig } from '../../../lib'; -import { TYPE_DEFINITION } from '../../../constants'; -import { EditFieldFormRow, FieldDescriptionSection } from '../fields/edit_field'; - -interface Props { - stack?: boolean; -} - -export const RuntimeTypeParameter = ({ stack }: Props) => { - return ( - - path="runtime_type" - config={getFieldConfig('runtime_type')} - > - {(runtimeTypeField) => { - const { label, value, setValue } = runtimeTypeField; - const typeDefinition = - TYPE_DEFINITION[(value as EuiComboBoxOptionOption[])[0]!.value as DataType]; - - const field = ( - <> - - { - if (newValue.length === 0) { - // Don't allow clearing the type. One must always be selected - return; - } - setValue(newValue); - }} - isClearable={false} - fullWidth - /> - - - - - {/* Field description */} - {typeDefinition && ( - - {typeDefinition.description?.() as JSX.Element} - - )} - - ); - - const fieldTitle = i18n.translate('xpack.idxMgmt.mappingsEditor.runtimeType.title', { - defaultMessage: 'Emitted type', - }); - - const fieldDescription = i18n.translate( - 'xpack.idxMgmt.mappingsEditor.runtimeType.description', - { - defaultMessage: 'Select the type of value emitted by the runtime field.', - } - ); - - if (stack) { - return ( - - {field} - - ); - } - - return ( - {fieldTitle}} - description={fieldDescription} - fullWidth={true} - > - {field} - - ); - }} - - ); -}; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/required_parameters_forms/index.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/required_parameters_forms/index.ts index 5c04b2fbb336c5..ccd1312ed48962 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/required_parameters_forms/index.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/required_parameters_forms/index.ts @@ -11,7 +11,6 @@ import { AliasTypeRequiredParameters } from './alias_type'; import { TokenCountTypeRequiredParameters } from './token_count_type'; import { ScaledFloatTypeRequiredParameters } from './scaled_float_type'; import { DenseVectorRequiredParameters } from './dense_vector_type'; -import { RuntimeTypeRequiredParameters } from './runtime_type'; export interface ComponentProps { allFields: NormalizedFields['byId']; @@ -22,7 +21,6 @@ const typeToParametersFormMap: { [key in DataType]?: ComponentType } = { token_count: TokenCountTypeRequiredParameters, scaled_float: ScaledFloatTypeRequiredParameters, dense_vector: DenseVectorRequiredParameters, - runtime: RuntimeTypeRequiredParameters, }; export const getRequiredParametersFormForType = ( diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/required_parameters_forms/runtime_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/required_parameters_forms/runtime_type.tsx deleted file mode 100644 index 54907295f8a157..00000000000000 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/required_parameters_forms/runtime_type.tsx +++ /dev/null @@ -1,18 +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 from 'react'; - -import { RuntimeTypeParameter, PainlessScriptParameter } from '../../../field_parameters'; - -export const RuntimeTypeRequiredParameters = () => { - return ( - <> - - - - ); -}; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/index.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/index.ts index d135d1b81419cf..0f9308aa434489 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/index.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/index.ts @@ -31,7 +31,6 @@ import { JoinType } from './join_type'; import { HistogramType } from './histogram_type'; import { ConstantKeywordType } from './constant_keyword_type'; import { RankFeatureType } from './rank_feature_type'; -import { RuntimeType } from './runtime_type'; import { WildcardType } from './wildcard_type'; import { PointType } from './point_type'; import { VersionType } from './version_type'; @@ -62,7 +61,6 @@ const typeToParametersFormMap: { [key in DataType]?: ComponentType } = { histogram: HistogramType, constant_keyword: ConstantKeywordType, rank_feature: RankFeatureType, - runtime: RuntimeType, wildcard: WildcardType, point: PointType, version: VersionType, diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/runtime_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/runtime_type.tsx deleted file mode 100644 index dcf5a74e0e304d..00000000000000 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/runtime_type.tsx +++ /dev/null @@ -1,19 +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 from 'react'; - -import { RuntimeTypeParameter, PainlessScriptParameter } from '../../field_parameters'; -import { BasicParametersSection } from '../edit_field'; - -export const RuntimeType = () => { - return ( - - - - - ); -}; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/fields_list_item.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/fields_list_item.tsx index 1939f09fa6762b..22898a7b2b92e4 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/fields_list_item.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/fields_list_item.tsx @@ -23,6 +23,27 @@ import { FieldsList } from './fields_list'; import { CreateField } from './create_field'; import { DeleteFieldProvider } from './delete_field_provider'; +const i18nTexts = { + addMultiFieldButtonLabel: i18n.translate( + 'xpack.idxMgmt.mappingsEditor.addMultiFieldTooltipLabel', + { + defaultMessage: 'Add a multi-field to index the same field in different ways.', + } + ), + addPropertyButtonLabel: i18n.translate('xpack.idxMgmt.mappingsEditor.addPropertyButtonLabel', { + defaultMessage: 'Add property', + }), + editButtonLabel: i18n.translate('xpack.idxMgmt.mappingsEditor.editFieldButtonLabel', { + defaultMessage: 'Edit', + }), + deleteButtonLabel: i18n.translate('xpack.idxMgmt.mappingsEditor.removeFieldButtonLabel', { + defaultMessage: 'Remove', + }), + fieldIsShadowedLabel: i18n.translate('xpack.idxMgmt.mappingsEditor.fieldIsShadowedLabel', { + defaultMessage: 'Field shadowed by a runtime field with the same name.', + }), +}; + interface Props { field: NormalizedField; allFields: NormalizedFields['byId']; @@ -31,6 +52,7 @@ interface Props { isHighlighted: boolean; isDimmed: boolean; isLastItem: boolean; + isShadowed?: boolean; childFieldsArray: NormalizedField[]; maxNestedDepth: number; addField(): void; @@ -48,6 +70,7 @@ function FieldListItemComponent( isCreateFieldFormVisible, areActionButtonsVisible, isLastItem, + isShadowed = false, childFieldsArray, maxNestedDepth, addField, @@ -106,30 +129,12 @@ function FieldListItemComponent( return null; } - const addMultiFieldButtonLabel = i18n.translate( - 'xpack.idxMgmt.mappingsEditor.addMultiFieldTooltipLabel', - { - defaultMessage: 'Add a multi-field to index the same field in different ways.', - } - ); - - const addPropertyButtonLabel = i18n.translate( - 'xpack.idxMgmt.mappingsEditor.addPropertyButtonLabel', - { - defaultMessage: 'Add property', - } - ); - - const editButtonLabel = i18n.translate('xpack.idxMgmt.mappingsEditor.editFieldButtonLabel', { - defaultMessage: 'Edit', - }); - - const deleteButtonLabel = i18n.translate( - 'xpack.idxMgmt.mappingsEditor.removeFieldButtonLabel', - { - defaultMessage: 'Remove', - } - ); + const { + addMultiFieldButtonLabel, + addPropertyButtonLabel, + editButtonLabel, + deleteButtonLabel, + } = i18nTexts; return ( @@ -288,6 +293,18 @@ function FieldListItemComponent( + {isShadowed && ( + + + + {i18n.translate('xpack.idxMgmt.mappingsEditor.shadowedBadgeLabel', { + defaultMessage: 'Shadowed', + })} + + + + )} + {renderActionButtons()} diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/fields_list_item_container.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/fields_list_item_container.tsx index 7d9ad3bc6aaec4..02d915ee349b06 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/fields_list_item_container.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/fields_list_item_container.tsx @@ -20,10 +20,12 @@ export const FieldsListItemContainer = ({ fieldId, treeDepth, isLastItem }: Prop const listElement = useRef(null); const { documentFields: { status, fieldToAddFieldTo, fieldToEdit }, - fields: { byId, maxNestedDepth }, + fields: { byId, maxNestedDepth, rootLevelFields }, + runtimeFields, } = useMappingsState(); const getField = useCallback((id: string) => byId[id], [byId]); + const runtimeFieldNames = Object.values(runtimeFields).map((field) => field.source.name); const field: NormalizedField = getField(fieldId); const { childFields } = field; @@ -35,6 +37,10 @@ export const FieldsListItemContainer = ({ fieldId, treeDepth, isLastItem }: Prop () => (childFields !== undefined ? childFields.map(getField) : []), [childFields, getField] ); + // Indicate if the field is shadowed by a runtime field with the same name + // Currently this can only occur for **root level** fields. + const isShadowed = + rootLevelFields.includes(fieldId) && runtimeFieldNames.includes(field.source.name); const addField = useCallback(() => { dispatch({ @@ -62,6 +68,7 @@ export const FieldsListItemContainer = ({ fieldId, treeDepth, isLastItem }: Prop treeDepth={treeDepth} isHighlighted={isHighlighted} isDimmed={isDimmed} + isShadowed={isShadowed} isCreateFieldFormVisible={isCreateFieldFormVisible} areActionButtonsVisible={areActionButtonsVisible} isLastItem={isLastItem} diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/index.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/index.ts index 2958ecd75910f3..2a19ccb3f5d1cf 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/index.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/index.ts @@ -8,6 +8,8 @@ export * from './configuration_form'; export * from './document_fields'; +export * from './runtime_fields'; + export * from './templates_form'; export * from './multiple_mappings_warning'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/delete_field_provider.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/delete_field_provider.tsx new file mode 100644 index 00000000000000..17daf7d671c5da --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/delete_field_provider.tsx @@ -0,0 +1,89 @@ +/* + * 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 } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; + +import { useDispatch } from '../../mappings_state_context'; +import { NormalizedRuntimeField } from '../../types'; + +type DeleteFieldFunc = (property: NormalizedRuntimeField) => void; + +interface Props { + children: (deleteProperty: DeleteFieldFunc) => React.ReactNode; +} + +interface State { + isModalOpen: boolean; + field?: NormalizedRuntimeField; +} + +export const DeleteRuntimeFieldProvider = ({ children }: Props) => { + const [state, setState] = useState({ isModalOpen: false }); + const dispatch = useDispatch(); + + const confirmButtonText = i18n.translate( + 'xpack.idxMgmt.mappingsEditor.deleteRuntimeField.confirmationModal.removeButtonLabel', + { + defaultMessage: 'Remove', + } + ); + + let modalTitle: string | undefined; + + if (state.field) { + const { source } = state.field; + + modalTitle = i18n.translate( + 'xpack.idxMgmt.mappingsEditor.deleteRuntimeField.confirmationModal.title', + { + defaultMessage: "Remove runtime field '{fieldName}'?", + values: { + fieldName: source.name, + }, + } + ); + } + + const deleteField: DeleteFieldFunc = (field) => { + setState({ isModalOpen: true, field }); + }; + + const closeModal = () => { + setState({ isModalOpen: false }); + }; + + const confirmDelete = () => { + dispatch({ type: 'runtimeField.remove', value: state.field!.id }); + closeModal(); + }; + + return ( + <> + {children(deleteField)} + + {state.isModalOpen && ( + + + + )} + + ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/empty_prompt.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/empty_prompt.tsx new file mode 100644 index 00000000000000..7fb2b9d7df9678 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/empty_prompt.tsx @@ -0,0 +1,64 @@ +/* + * 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, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiEmptyPrompt, EuiLink, EuiButton } from '@elastic/eui'; + +interface Props { + createField: () => void; + runtimeFieldsDocsUri: string; +} + +export const EmptyPrompt: FunctionComponent = ({ createField, runtimeFieldsDocsUri }) => { + return ( + + {i18n.translate('xpack.idxMgmt.mappingsEditor.runtimeFields.emptyPromptTitle', { + defaultMessage: 'Start by creating a runtime field', + })} + + } + body={ +

+ +
+ + {i18n.translate( + 'xpack.idxMgmt.mappingsEditor.runtimeFields.emptyPromptDocumentionLink', + { + defaultMessage: 'Learn more.', + } + )} + +

+ } + actions={ + createField()} + iconType="plusInCircle" + data-test-subj="createRuntimeFieldButton" + fill + > + {i18n.translate('xpack.idxMgmt.mappingsEditor.runtimeFields.emptyPromptButtonLabel', { + defaultMessage: 'Create runtime field', + })} + + } + /> + ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/index.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/index.ts new file mode 100644 index 00000000000000..e5928ebb07ddcf --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_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 { RuntimeFieldsList } from './runtime_fields_list'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/runtime_fields_list.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/runtime_fields_list.tsx new file mode 100644 index 00000000000000..dce5ad1657d386 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/runtime_fields_list.tsx @@ -0,0 +1,151 @@ +/* + * 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, { useCallback, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiSpacer, EuiButtonEmpty, EuiText, EuiLink } from '@elastic/eui'; + +import { useMappingsState, useDispatch } from '../../mappings_state_context'; +import { + documentationService, + GlobalFlyout, + RuntimeField, + RuntimeFieldEditorFlyoutContent, + RuntimeFieldEditorFlyoutContentProps, +} from '../../shared_imports'; +import { useConfig } from '../../config_context'; +import { EmptyPrompt } from './empty_prompt'; +import { RuntimeFieldsListItemContainer } from './runtimefields_list_item_container'; + +const { useGlobalFlyout } = GlobalFlyout; + +export const RuntimeFieldsList = () => { + const runtimeFieldsDocsUri = documentationService.getRuntimeFields(); + const { + runtimeFields, + runtimeFieldsList: { status, fieldToEdit }, + fields, + } = useMappingsState(); + + const dispatch = useDispatch(); + + const { + addContent: addContentToGlobalFlyout, + removeContent: removeContentFromGlobalFlyout, + } = useGlobalFlyout(); + + const { + value: { docLinks }, + } = useConfig(); + + const createField = useCallback(() => { + dispatch({ type: 'runtimeFieldsList.createField' }); + }, [dispatch]); + + const exitEdit = useCallback(() => { + dispatch({ type: 'runtimeFieldsList.closeRuntimeFieldEditor' }); + }, [dispatch]); + + const saveRuntimeField = useCallback( + (field: RuntimeField) => { + if (fieldToEdit) { + dispatch({ + type: 'runtimeField.edit', + value: { + id: fieldToEdit, + source: field, + }, + }); + } else { + dispatch({ type: 'runtimeField.add', value: field }); + } + }, + [dispatch, fieldToEdit] + ); + + useEffect(() => { + if (status === 'creatingField' || status === 'editingField') { + addContentToGlobalFlyout({ + id: 'runtimeFieldEditor', + Component: RuntimeFieldEditorFlyoutContent, + props: { + onSave: saveRuntimeField, + onCancel: exitEdit, + defaultValue: fieldToEdit ? runtimeFields[fieldToEdit]?.source : undefined, + docLinks: docLinks!, + ctx: { + namesNotAllowed: Object.values(runtimeFields).map((field) => field.source.name), + existingConcreteFields: Object.values(fields.byId).map((field) => field.source.name), + }, + }, + flyoutProps: { + 'data-test-subj': 'runtimeFieldEditor', + 'aria-labelledby': 'runtimeFieldEditorEditTitle', + maxWidth: 720, + onClose: exitEdit, + }, + cleanUpFunc: exitEdit, + }); + } else if (status === 'idle') { + removeContentFromGlobalFlyout('runtimeFieldEditor'); + } + }, [ + status, + fieldToEdit, + runtimeFields, + fields, + docLinks, + addContentToGlobalFlyout, + removeContentFromGlobalFlyout, + saveRuntimeField, + exitEdit, + ]); + + const fieldsToArray = Object.entries(runtimeFields); + const isEmpty = fieldsToArray.length === 0; + const isCreateFieldDisabled = status !== 'idle'; + + return isEmpty ? ( + + ) : ( + <> + + + {i18n.translate('xpack.idxMgmt.mappingsEditor.runtimeFieldsDocumentationLink', { + defaultMessage: 'Learn more.', + })} +
+ ), + }} + /> + + +
    + {fieldsToArray.map(([fieldId]) => ( + + ))} +
+ + + + + {i18n.translate('xpack.idxMgmt.mappingsEditor.addRuntimeFieldButtonLabel', { + defaultMessage: 'Add field', + })} + + + ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/runtimefields_list_item.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/runtimefields_list_item.tsx new file mode 100644 index 00000000000000..754004ae0c6225 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/runtimefields_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 from 'react'; +import classNames from 'classnames'; +import { EuiFlexGroup, EuiFlexItem, EuiBadge, EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { NormalizedRuntimeField } from '../../types'; +import { getTypeLabelFromField } from '../../lib'; + +import { DeleteRuntimeFieldProvider } from './delete_field_provider'; + +interface Props { + field: NormalizedRuntimeField; + areActionButtonsVisible: boolean; + isHighlighted: boolean; + isDimmed: boolean; + editField(): void; +} + +function RuntimeFieldsListItemComponent( + { field, areActionButtonsVisible, isHighlighted, isDimmed, editField }: Props, + ref: React.Ref +) { + const { source } = field; + + const renderActionButtons = () => { + if (!areActionButtonsVisible) { + return null; + } + + const editButtonLabel = i18n.translate( + 'xpack.idxMgmt.mappingsEditor.editRuntimeFieldButtonLabel', + { + defaultMessage: 'Edit', + } + ); + + const deleteButtonLabel = i18n.translate( + 'xpack.idxMgmt.mappingsEditor.removeRuntimeFieldButtonLabel', + { + defaultMessage: 'Remove', + } + ); + + return ( + + + + + + + + + + {(deleteField) => ( + + deleteField(field)} + data-test-subj="removeFieldButton" + aria-label={deleteButtonLabel} + /> + + )} + + + + ); + }; + + return ( +
  • +
    +
    + + + {source.name} + + + + + {getTypeLabelFromField(source)} + + + + {renderActionButtons()} + +
    +
    +
  • + ); +} + +export const RuntimeFieldsListItem = React.memo( + RuntimeFieldsListItemComponent +) as typeof RuntimeFieldsListItemComponent; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/runtimefields_list_item_container.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/runtimefields_list_item_container.tsx new file mode 100644 index 00000000000000..90008193fa056c --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/runtimefields_list_item_container.tsx @@ -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 React, { useCallback } from 'react'; + +import { useMappingsState, useDispatch } from '../../mappings_state_context'; +import { NormalizedRuntimeField } from '../../types'; +import { RuntimeFieldsListItem } from './runtimefields_list_item'; + +interface Props { + fieldId: string; +} + +export const RuntimeFieldsListItemContainer = ({ fieldId }: Props) => { + const dispatch = useDispatch(); + const { + runtimeFieldsList: { status, fieldToEdit }, + runtimeFields, + } = useMappingsState(); + + const getField = useCallback((id: string) => runtimeFields[id], [runtimeFields]); + + const field: NormalizedRuntimeField = getField(fieldId); + const isHighlighted = fieldToEdit === fieldId; + const isDimmed = status === 'editingField' && fieldToEdit !== fieldId; + const areActionButtonsVisible = status === 'idle'; + + const editField = useCallback(() => { + dispatch({ + type: 'runtimeFieldsList.editField', + value: fieldId, + }); + }, [fieldId, dispatch]); + + return ( + + ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/config_context.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/config_context.tsx new file mode 100644 index 00000000000000..84b42508f904a2 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/config_context.tsx @@ -0,0 +1,45 @@ +/* + * 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, { createContext, useContext, useState } from 'react'; + +import { DocLinksStart } from './shared_imports'; +import { IndexSettings } from './types'; + +interface ContextState { + indexSettings: IndexSettings; + docLinks?: DocLinksStart; +} + +interface Context { + value: ContextState; + update: (value: ContextState) => void; +} + +const ConfigContext = createContext(undefined); + +interface Props { + children: React.ReactNode; +} + +export const ConfigProvider = ({ children }: Props) => { + const [state, setState] = useState({ + indexSettings: {}, + }); + + return ( + + {children} + + ); +}; + +export const useConfig = () => { + const ctx = useContext(ConfigContext); + if (ctx === undefined) { + throw new Error('useConfig must be used within a '); + } + return ctx; +}; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/data_types_definition.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/data_types_definition.tsx index 07ca0a69afefb3..66be208fbb66be 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/data_types_definition.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/data_types_definition.tsx @@ -13,25 +13,6 @@ import { documentationService } from '../../../services/documentation'; import { MainType, SubType, DataType, DataTypeDefinition } from '../types'; export const TYPE_DEFINITION: { [key in DataType]: DataTypeDefinition } = { - runtime: { - value: 'runtime', - isBeta: true, - label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.runtimeFieldDescription', { - defaultMessage: 'Runtime', - }), - // TODO: Add this once the page exists. - // documentation: { - // main: '/runtime_field.html', - // }, - description: () => ( -

    - -

    - ), - }, text: { value: 'text', label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.textDescription', { @@ -944,7 +925,6 @@ export const MAIN_TYPES: MainType[] = [ 'range', 'rank_feature', 'rank_features', - 'runtime', 'search_as_you_type', 'shape', 'text', diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/field_options.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/field_options.tsx index 46292b7b2d3575..d16bf68b80e5d8 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/field_options.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/field_options.tsx @@ -18,7 +18,6 @@ export const TYPE_NOT_ALLOWED_MULTIFIELD: DataType[] = [ 'object', 'nested', 'alias', - 'runtime', ]; export const FIELD_TYPES_OPTIONS = Object.entries(MAIN_DATA_TYPE_DEFINITION).map( diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/parameters_definition.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/parameters_definition.tsx index 64f84ee2611a0d..281b14a25fcb6c 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/parameters_definition.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/parameters_definition.tsx @@ -16,8 +16,6 @@ import { ValidationFuncArg, fieldFormatters, FieldConfig, - RUNTIME_FIELD_OPTIONS, - RuntimeType, } from '../shared_imports'; import { AliasOption, @@ -187,52 +185,6 @@ export const PARAMETERS_DEFINITION: { [key in ParameterName]: ParameterDefinitio }, schema: t.string, }, - runtime_type: { - fieldConfig: { - type: FIELD_TYPES.COMBO_BOX, - label: i18n.translate('xpack.idxMgmt.mappingsEditor.parameters.runtimeTypeLabel', { - defaultMessage: 'Type', - }), - defaultValue: 'keyword', - deserializer: (fieldType: RuntimeType | undefined) => { - if (typeof fieldType === 'string' && Boolean(fieldType)) { - const label = - RUNTIME_FIELD_OPTIONS.find(({ value }) => value === fieldType)?.label ?? fieldType; - return [ - { - label, - value: fieldType, - }, - ]; - } - return []; - }, - serializer: (value: ComboBoxOption[]) => (value.length === 0 ? '' : value[0].value), - }, - schema: t.string, - }, - script: { - fieldConfig: { - defaultValue: '', - type: FIELD_TYPES.TEXT, - label: i18n.translate('xpack.idxMgmt.mappingsEditor.parameters.painlessScriptLabel', { - defaultMessage: 'Script', - }), - validations: [ - { - validator: emptyField( - i18n.translate( - 'xpack.idxMgmt.mappingsEditor.parameters.validations.scriptIsRequiredErrorMessage', - { - defaultMessage: 'Script must emit() a value.', - } - ) - ), - }, - ], - }, - schema: t.string, - }, store: { fieldConfig: { type: FIELD_TYPES.CHECKBOX, diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/index_settings_context.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/index_settings_context.tsx deleted file mode 100644 index bd84c3a905ec8d..00000000000000 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/index_settings_context.tsx +++ /dev/null @@ -1,34 +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, { createContext, useContext, useState } from 'react'; - -import { IndexSettings } from './types'; - -const IndexSettingsContext = createContext< - { value: IndexSettings; update: (value: IndexSettings) => void } | undefined ->(undefined); - -interface Props { - children: React.ReactNode; -} - -export const IndexSettingsProvider = ({ children }: Props) => { - const [state, setState] = useState({}); - - return ( - - {children} - - ); -}; - -export const useIndexSettings = () => { - const ctx = useContext(IndexSettingsContext); - if (ctx === undefined) { - throw new Error('useIndexSettings must be used within a '); - } - return ctx; -}; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/extract_mappings_definition.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/extract_mappings_definition.ts index 1fd8329ae4b40c..c32c0d43632191 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/extract_mappings_definition.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/extract_mappings_definition.ts @@ -15,16 +15,22 @@ const isMappingDefinition = (obj: GenericObject): boolean => { return false; } - const { properties, dynamic_templates: dynamicTemplates, ...mappingsConfiguration } = obj; + const { + properties, + dynamic_templates: dynamicTemplates, + runtime, + ...mappingsConfiguration + } = obj; const { errors } = validateMappingsConfiguration(mappingsConfiguration); const isConfigurationValid = errors.length === 0; const isPropertiesValid = properties === undefined || isPlainObject(properties); const isDynamicTemplatesValid = dynamicTemplates === undefined || Array.isArray(dynamicTemplates); + const isRuntimeValid = runtime === undefined || isPlainObject(runtime); - // If the configuration, the properties and the dynamic templates are valid + // If the configuration, the properties, the dynamic templates and runtime are valid // we can assume that the mapping is declared at root level (no types) - return isConfigurationValid && isPropertiesValid && isDynamicTemplatesValid; + return isConfigurationValid && isPropertiesValid && isDynamicTemplatesValid && isRuntimeValid; }; /** diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/index.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/index.ts index 0a59cafdcef47c..2a0b39c4e2c9c5 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/index.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/index.ts @@ -24,6 +24,8 @@ export { shouldDeleteChildFieldsAfterTypeChange, canUseMappingsEditor, stripUndefinedValues, + normalizeRuntimeFields, + deNormalizeRuntimeFields, } from './utils'; export * from './serializers'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/mappings_validator.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/mappings_validator.ts index f0d90be9472f66..4d1ae627bc9102 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/mappings_validator.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/mappings_validator.ts @@ -303,4 +303,5 @@ export const VALID_MAPPINGS_PARAMETERS = [ ...mappingsConfigurationSchemaKeys, 'dynamic_templates', 'properties', + 'runtime', ]; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.test.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.test.ts index e1988c071314ee..41ec4887a7abd4 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.test.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.test.ts @@ -58,10 +58,9 @@ describe('utils', () => { }); describe('getTypeLabelFromField()', () => { - test('returns an unprocessed label for non-runtime fields', () => { + test('returns label for fields', () => { expect( getTypeLabelFromField({ - name: 'testField', type: 'keyword', }) ).toBe('Keyword'); @@ -76,26 +75,5 @@ describe('utils', () => { }) ).toBe('Other: hyperdrive'); }); - - test("returns a label prepended with 'Runtime' for runtime fields", () => { - expect( - getTypeLabelFromField({ - name: 'testField', - type: 'runtime', - runtime_type: 'keyword', - }) - ).toBe('Runtime Keyword'); - }); - - test("returns a label prepended with 'Runtime Other' for unrecognized runtime fields", () => { - expect( - getTypeLabelFromField({ - name: 'testField', - type: 'runtime', - // @ts-ignore - runtime_type: 'hyperdrive', - }) - ).toBe('Runtime Other: hyperdrive'); - }); }); }); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts index fd7aa416385052..283ca83c54bb0c 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts @@ -18,6 +18,8 @@ import { ParameterName, ComboBoxOption, GenericObject, + RuntimeFields, + NormalizedRuntimeFields, } from '../types'; import { @@ -77,15 +79,10 @@ const getTypeLabel = (type?: DataType): string => { : `${TYPE_DEFINITION.other.label}: ${type}`; }; -export const getTypeLabelFromField = (field: Field) => { - const { type, runtime_type: runtimeType } = field; +export const getTypeLabelFromField = (field: { type: DataType }) => { + const { type } = field; const typeLabel = getTypeLabel(type); - if (type === 'runtime') { - const runtimeTypeLabel = getTypeLabel(runtimeType); - return `${typeLabel} ${runtimeTypeLabel}`; - } - return typeLabel; }; @@ -566,3 +563,29 @@ export const stripUndefinedValues = (obj: GenericObject, recu ? { ...acc, [key]: stripUndefinedValues(value, recursive) } : { ...acc, [key]: value }; }, {} as T); + +export const normalizeRuntimeFields = (fields: RuntimeFields = {}): NormalizedRuntimeFields => { + return Object.entries(fields).reduce((acc, [name, field]) => { + const id = getUniqueId(); + return { + ...acc, + [id]: { + id, + source: { + name, + ...field, + }, + }, + }; + }, {} as NormalizedRuntimeFields); +}; + +export const deNormalizeRuntimeFields = (fields: NormalizedRuntimeFields): RuntimeFields => { + return Object.values(fields).reduce((acc, { source }) => { + const { name, ...rest } = source; + return { + ...acc, + [name]: rest, + }; + }, {} as RuntimeFields); +}; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor.tsx index 3902337f28ad23..3af9f24f48ed2c 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor.tsx @@ -9,9 +9,10 @@ import { i18n } from '@kbn/i18n'; import { EuiSpacer, EuiTabs, EuiTab } from '@elastic/eui'; import { - ConfigurationForm, DocumentFields, + RuntimeFieldsList, TemplatesForm, + ConfigurationForm, MultipleMappingsWarning, } from './components'; import { @@ -21,19 +22,22 @@ import { Mappings, MappingsConfiguration, MappingsTemplates, + RuntimeFields, } from './types'; import { extractMappingsDefinition } from './lib'; import { useMappingsState } from './mappings_state_context'; import { useMappingsStateListener } from './use_state_listener'; -import { useIndexSettings } from './index_settings_context'; +import { useConfig } from './config_context'; +import { DocLinksStart } from './shared_imports'; -type TabName = 'fields' | 'advanced' | 'templates'; +type TabName = 'fields' | 'runtimeFields' | 'advanced' | 'templates'; interface MappingsEditorParsedMetadata { parsedDefaultValue?: { configuration: MappingsConfiguration; fields: { [key: string]: Field }; templates: MappingsTemplates; + runtime: RuntimeFields; }; multipleMappingsDeclared: boolean; } @@ -42,9 +46,10 @@ interface Props { onChange: OnUpdateHandler; value?: { [key: string]: any }; indexSettings?: IndexSettings; + docLinks: DocLinksStart; } -export const MappingsEditor = React.memo(({ onChange, value, indexSettings }: Props) => { +export const MappingsEditor = React.memo(({ onChange, value, docLinks, indexSettings }: Props) => { const { parsedDefaultValue, multipleMappingsDeclared, @@ -60,11 +65,12 @@ export const MappingsEditor = React.memo(({ onChange, value, indexSettings }: Pr _meta, _routing, dynamic, + properties, + runtime, /* eslint-disable @typescript-eslint/naming-convention */ numeric_detection, date_detection, dynamic_date_formats, - properties, dynamic_templates, /* eslint-enable @typescript-eslint/naming-convention */ } = mappingsDefinition; @@ -83,6 +89,7 @@ export const MappingsEditor = React.memo(({ onChange, value, indexSettings }: Pr templates: { dynamic_templates, }, + runtime, }; return { parsedDefaultValue: parsed, multipleMappingsDeclared: false }; @@ -95,12 +102,7 @@ export const MappingsEditor = React.memo(({ onChange, value, indexSettings }: Pr */ useMappingsStateListener({ onChange, value: parsedDefaultValue }); - // Update the Index settings context so it is available in the Global flyout - const { update: updateIndexSettings } = useIndexSettings(); - if (indexSettings !== undefined) { - updateIndexSettings(indexSettings); - } - + const { update: updateConfig } = useConfig(); const state = useMappingsState(); const [selectedTab, selectTab] = useState('fields'); @@ -115,6 +117,14 @@ export const MappingsEditor = React.memo(({ onChange, value, indexSettings }: Pr } }, [multipleMappingsDeclared, onChange, value]); + useEffect(() => { + // Update the the config context so it is available globally (e.g in our Global flyout) + updateConfig({ + docLinks, + indexSettings: indexSettings ?? {}, + }); + }, [updateConfig, docLinks, indexSettings]); + const changeTab = async (tab: TabName) => { if (selectedTab === 'advanced') { // When we navigate away we need to submit the form to validate if there are any errors. @@ -139,6 +149,7 @@ export const MappingsEditor = React.memo(({ onChange, value, indexSettings }: Pr const tabToContentMap = { fields: , + runtimeFields: , templates: , advanced: , }; @@ -159,6 +170,15 @@ export const MappingsEditor = React.memo(({ onChange, value, indexSettings }: Pr defaultMessage: 'Mapped fields', })} + changeTab('runtimeFields')} + isSelected={selectedTab === 'runtimeFields'} + data-test-subj="formTab" + > + {i18n.translate('xpack.idxMgmt.mappingsEditor.runtimeFieldsTabLabel', { + defaultMessage: 'Runtime fields', + })} + changeTab('templates')} isSelected={selectedTab === 'templates'} diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor_context.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor_context.tsx index 8e30d07c2262f2..f4d827b631dd15 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor_context.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor_context.tsx @@ -6,12 +6,12 @@ import React from 'react'; import { StateProvider } from './mappings_state_context'; -import { IndexSettingsProvider } from './index_settings_context'; +import { ConfigProvider } from './config_context'; export const MappingsEditorProvider: React.FC = ({ children }) => { return ( - {children} + {children} ); }; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state_context.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state_context.tsx index 4912b0963bc121..57c326b1211414 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state_context.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state_context.tsx @@ -41,6 +41,10 @@ export const StateProvider: React.FC = ({ children }) => { status: 'idle', editor: 'default', }, + runtimeFields: {}, + runtimeFieldsList: { + status: 'idle', + }, fieldsJsonEditor: { format: () => ({}), isValid: true, diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts index 47e9d5200ea084..b76541479f68ca 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts @@ -195,6 +195,10 @@ export const reducer = (state: State, action: Action): State => { fieldToAddFieldTo: undefined, fieldToEdit: undefined, }, + runtimeFields: action.value.runtimeFields, + runtimeFieldsList: { + status: 'idle', + }, search: { term: '', result: [], @@ -482,6 +486,80 @@ export const reducer = (state: State, action: Action): State => { }, }; } + case 'runtimeFieldsList.createField': { + return { + ...state, + runtimeFieldsList: { + ...state.runtimeFieldsList, + status: 'creatingField', + }, + }; + } + case 'runtimeFieldsList.editField': { + return { + ...state, + runtimeFieldsList: { + ...state.runtimeFieldsList, + status: 'editingField', + fieldToEdit: action.value, + }, + }; + } + case 'runtimeField.add': { + const id = getUniqueId(); + const normalizedField = { + id, + source: action.value, + }; + + return { + ...state, + runtimeFields: { + ...state.runtimeFields, + [id]: normalizedField, + }, + runtimeFieldsList: { + ...state.runtimeFieldsList, + status: 'idle', + }, + }; + } + case 'runtimeField.edit': { + const fieldToEdit = state.runtimeFieldsList.fieldToEdit!; + + return { + ...state, + runtimeFields: { + ...state.runtimeFields, + [fieldToEdit]: action.value, + }, + runtimeFieldsList: { + ...state.runtimeFieldsList, + status: 'idle', + }, + }; + } + case 'runtimeField.remove': { + const field = state.runtimeFields[action.value]; + const { id } = field; + + const updatedFields = { ...state.runtimeFields }; + delete updatedFields[id]; + + return { + ...state, + runtimeFields: updatedFields, + }; + } + case 'runtimeFieldsList.closeRuntimeFieldEditor': + return { + ...state, + runtimeFieldsList: { + ...state.runtimeFieldsList, + status: 'idle', + fieldToEdit: undefined, + }, + }; case 'fieldsJsonEditor.update': { const nextState = { ...state, diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/shared_imports.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/shared_imports.ts index 68b40e876f6552..36f7fecbcff21b 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/shared_imports.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/shared_imports.ts @@ -52,6 +52,14 @@ export { GlobalFlyout, } from '../../../../../../../src/plugins/es_ui_shared/public'; -export { CodeEditor } from '../../../../../../../src/plugins/kibana_react/public'; +export { documentationService } from '../../services/documentation'; -export { RUNTIME_FIELD_OPTIONS, RuntimeType } from '../../../../../runtime_fields/public'; +export { + RuntimeField, + RuntimeFieldEditorFlyoutContent, + RuntimeFieldEditorFlyoutContentProps, +} from '../../../../../runtime_fields/public'; + +export { createKibanaReactContext } from '../../../../../../../src/plugins/kibana_react/public'; + +export { DocLinksStart } from '../../../../../../../src/core/public'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts index b143eedd4f9d4f..b5c61594e5cb0b 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts @@ -7,7 +7,7 @@ import { ReactNode } from 'react'; import { GenericObject } from './mappings_editor'; -import { FieldConfig } from '../shared_imports'; +import { FieldConfig, RuntimeField } from '../shared_imports'; import { PARAMETERS_DEFINITION } from '../constants'; export interface DataTypeDefinition { @@ -36,7 +36,6 @@ export interface ParameterDefinition { } export type MainType = - | 'runtime' | 'text' | 'keyword' | 'numeric' @@ -154,8 +153,6 @@ export type ParameterName = | 'depth_limit' | 'relations' | 'max_shingle_size' - | 'runtime_type' - | 'script' | 'value' | 'meta'; @@ -173,7 +170,6 @@ export interface Fields { interface FieldBasic { name: string; type: DataType; - runtime_type?: DataType; subType?: SubType; properties?: { [key: string]: Omit }; fields?: { [key: string]: Omit }; @@ -223,3 +219,16 @@ export interface AliasOption { id: string; label: string; } + +export interface RuntimeFields { + [name: string]: Omit; +} + +export interface NormalizedRuntimeField { + id: string; + source: RuntimeField; +} + +export interface NormalizedRuntimeFields { + [id: string]: NormalizedRuntimeField; +} diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/types/state.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/state.ts index 34df70374aa88d..7371e348fd51cf 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/types/state.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/state.ts @@ -4,8 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { FormHook, OnFormUpdateArg } from '../shared_imports'; -import { Field, NormalizedFields } from './document_fields'; +import { FormHook, OnFormUpdateArg, RuntimeField } from '../shared_imports'; +import { + Field, + NormalizedFields, + NormalizedRuntimeField, + NormalizedRuntimeFields, +} from './document_fields'; import { FieldsEditor, SearchResult } from './mappings_editor'; export type Mappings = MappingsTemplates & @@ -58,6 +63,11 @@ export interface DocumentFieldsState { fieldToAddFieldTo?: string; } +interface RuntimeFieldsListState { + status: DocumentFieldsStatus; + fieldToEdit?: string; +} + export interface ConfigurationFormState extends OnFormUpdateArg { defaultValue: MappingsConfiguration; submitForm?: FormHook['submit']; @@ -72,7 +82,9 @@ export interface State { isValid: boolean | undefined; configuration: ConfigurationFormState; documentFields: DocumentFieldsState; + runtimeFieldsList: RuntimeFieldsListState; fields: NormalizedFields; + runtimeFields: NormalizedRuntimeFields; fieldForm?: OnFormUpdateArg; fieldsJsonEditor: { format(): MappingsFields; @@ -100,6 +112,12 @@ export type Action = | { type: 'documentField.editField'; value: string } | { type: 'documentField.changeStatus'; value: DocumentFieldsStatus } | { type: 'documentField.changeEditor'; value: FieldsEditor } + | { type: 'runtimeFieldsList.createField' } + | { type: 'runtimeFieldsList.editField'; value: string } + | { type: 'runtimeFieldsList.closeRuntimeFieldEditor' } + | { type: 'runtimeField.add'; value: RuntimeField } + | { type: 'runtimeField.remove'; value: string } + | { type: 'runtimeField.edit'; value: NormalizedRuntimeField } | { type: 'fieldsJsonEditor.update'; value: { json: { [key: string]: any }; isValid: boolean } } | { type: 'search:update'; value: string } | { type: 'validity:update'; value: boolean }; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/use_state_listener.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/use_state_listener.tsx index 8d039475f9cf8f..79dec9cedaf7a3 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/use_state_listener.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/use_state_listener.tsx @@ -11,8 +11,15 @@ import { MappingsConfiguration, MappingsTemplates, OnUpdateHandler, + RuntimeFields, } from './types'; -import { normalize, deNormalize, stripUndefinedValues } from './lib'; +import { + normalize, + deNormalize, + stripUndefinedValues, + normalizeRuntimeFields, + deNormalizeRuntimeFields, +} from './lib'; import { useMappingsState, useDispatch } from './mappings_state_context'; interface Args { @@ -21,6 +28,7 @@ interface Args { templates: MappingsTemplates; configuration: MappingsConfiguration; fields: { [key: string]: Field }; + runtime: RuntimeFields; }; } @@ -28,7 +36,13 @@ export const useMappingsStateListener = ({ onChange, value }: Args) => { const state = useMappingsState(); const dispatch = useDispatch(); - const parsedFieldsDefaultValue = useMemo(() => normalize(value?.fields), [value?.fields]); + const { fields: mappedFields, runtime: runtimeFields } = value ?? {}; + + const parsedFieldsDefaultValue = useMemo(() => normalize(mappedFields), [mappedFields]); + const parsedRuntimeFieldsDefaultValue = useMemo(() => normalizeRuntimeFields(runtimeFields), [ + runtimeFields, + ]); + useEffect(() => { // If we are creating a new field, but haven't entered any name // it is valid and we can byPass its form validation (that requires a "name" to be defined) @@ -50,6 +64,9 @@ export const useMappingsStateListener = ({ onChange, value }: Args) => { ? state.fieldsJsonEditor.format() : deNormalize(state.fields); + // Get the runtime fields + const runtime = deNormalizeRuntimeFields(state.runtimeFields); + const configurationData = state.configuration.data.format(); const templatesData = state.templates.data.format(); @@ -60,10 +77,16 @@ export const useMappingsStateListener = ({ onChange, value }: Args) => { }), }; + // Mapped fields if (fields && Object.keys(fields).length > 0) { output.properties = fields; } + // Runtime fields + if (runtime && Object.keys(runtime).length > 0) { + output.runtime = runtime; + } + return Object.keys(output).length > 0 ? (output as Mappings) : undefined; }, validate: async () => { @@ -118,7 +141,8 @@ export const useMappingsStateListener = ({ onChange, value }: Args) => { status: parsedFieldsDefaultValue.rootLevelFields.length === 0 ? 'creatingField' : 'idle', editor: 'default', }, + runtimeFields: parsedRuntimeFieldsDefaultValue, }, }); - }, [value, parsedFieldsDefaultValue, dispatch]); + }, [value, parsedFieldsDefaultValue, dispatch, parsedRuntimeFieldsDefaultValue]); }; diff --git a/x-pack/plugins/index_management/public/application/components/section_error.tsx b/x-pack/plugins/index_management/public/application/components/section_error.tsx index f807ef45559f1b..86acb7bf7419ac 100644 --- a/x-pack/plugins/index_management/public/application/components/section_error.tsx +++ b/x-pack/plugins/index_management/public/application/components/section_error.tsx @@ -11,6 +11,9 @@ export interface Error { cause?: string[]; message?: string; statusText?: string; + attributes?: { + cause: string[]; + }; } interface Props { @@ -20,11 +23,14 @@ interface Props { export const SectionError: React.FunctionComponent = ({ title, error, ...rest }) => { const { - cause, // wrapEsError() on the server adds a "cause" array + cause: causeRoot, // wrapEsError() on the server adds a "cause" array message, statusText, + attributes: { cause: causeAttributes } = {}, } = error; + const cause = causeAttributes ?? causeRoot; + return (
    {message || statusText}
    diff --git a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings.tsx b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings.tsx index bbf7a04080a28a..aeb4eb793cde85 100644 --- a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings.tsx +++ b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings.tsx @@ -16,6 +16,7 @@ import { } from '@elastic/eui'; import { Forms } from '../../../../../shared_imports'; +import { useAppContext } from '../../../../app_context'; import { MappingsEditor, OnUpdateHandler, @@ -33,6 +34,7 @@ interface Props { export const StepMappings: React.FunctionComponent = React.memo( ({ defaultValue = {}, onChange, indexSettings, esDocsBase }) => { const [mappings, setMappings] = useState(defaultValue); + const { docLinks } = useAppContext(); const onMappingsEditorUpdate = useCallback( ({ isValid, getData, validate }) => { @@ -107,6 +109,7 @@ export const StepMappings: React.FunctionComponent = React.memo( value={mappings} onChange={onMappingsEditorUpdate} indexSettings={indexSettings} + docLinks={docLinks} /> diff --git a/x-pack/plugins/index_management/public/application/mount_management_section.ts b/x-pack/plugins/index_management/public/application/mount_management_section.ts index 13e25f6d29a14a..f3084630934c4f 100644 --- a/x-pack/plugins/index_management/public/application/mount_management_section.ts +++ b/x-pack/plugins/index_management/public/application/mount_management_section.ts @@ -64,6 +64,7 @@ export async function mountManagementSection( setBreadcrumbs, uiSettings, urlGenerators, + docLinks, }; const unmountAppCallback = renderApp(element, { core, dependencies: appDependencies }); diff --git a/x-pack/plugins/index_management/public/application/services/documentation.ts b/x-pack/plugins/index_management/public/application/services/documentation.ts index c52b958d94ae18..e0aac742499be0 100644 --- a/x-pack/plugins/index_management/public/application/services/documentation.ts +++ b/x-pack/plugins/index_management/public/application/services/documentation.ts @@ -211,6 +211,10 @@ class DocumentationService { return `${this.esDocsBase}/enabled.html`; } + public getRuntimeFields() { + return `${this.esDocsBase}/runtime.html`; + } + public getWellKnownTextLink() { return 'http://docs.opengeospatial.org/is/12-063r5/12-063r5.html'; } diff --git a/x-pack/plugins/index_management/server/plugin.ts b/x-pack/plugins/index_management/server/plugin.ts index 3d70140fa60b74..99facacacfe4cd 100644 --- a/x-pack/plugins/index_management/server/plugin.ts +++ b/x-pack/plugins/index_management/server/plugin.ts @@ -24,7 +24,7 @@ import { PLUGIN } from '../common'; import { Dependencies } from './types'; import { ApiRoutes } from './routes'; import { License, IndexDataEnricher } from './services'; -import { isEsError, handleEsError } from './shared_imports'; +import { isEsError, handleEsError, parseEsError } from './shared_imports'; import { elasticsearchJsPlugin } from './client/elasticsearch'; export interface DataManagementContext { @@ -110,6 +110,7 @@ export class IndexMgmtServerPlugin implements Plugin { indexDataEnricher: mockedIndexDataEnricher, lib: { isEsError: jest.fn(), + parseEsError: jest.fn(), handleEsError: jest.fn(), }, }); @@ -124,6 +125,7 @@ describe('GET privileges', () => { indexDataEnricher: mockedIndexDataEnricher, lib: { isEsError: jest.fn(), + parseEsError: jest.fn(), handleEsError: jest.fn(), }, }); diff --git a/x-pack/plugins/index_management/server/routes/api/templates/register_create_route.ts b/x-pack/plugins/index_management/server/routes/api/templates/register_create_route.ts index 4b735c941be709..46004c64d158dc 100644 --- a/x-pack/plugins/index_management/server/routes/api/templates/register_create_route.ts +++ b/x-pack/plugins/index_management/server/routes/api/templates/register_create_route.ts @@ -51,9 +51,13 @@ export function registerCreateRoute({ router, license, lib }: RouteDependencies) return res.ok({ body: response }); } catch (e) { if (lib.isEsError(e)) { + const error = lib.parseEsError(e.response); return res.customError({ statusCode: e.statusCode, - body: e, + body: { + message: error.message, + attributes: error, + }, }); } // Case: default diff --git a/x-pack/plugins/index_management/server/routes/api/templates/register_simulate_route.ts b/x-pack/plugins/index_management/server/routes/api/templates/register_simulate_route.ts index 9d078e135fd52e..322f15914b7351 100644 --- a/x-pack/plugins/index_management/server/routes/api/templates/register_simulate_route.ts +++ b/x-pack/plugins/index_management/server/routes/api/templates/register_simulate_route.ts @@ -29,9 +29,13 @@ export function registerSimulateRoute({ router, license, lib }: RouteDependencie return res.ok({ body: templatePreview }); } catch (e) { if (lib.isEsError(e)) { + const error = lib.parseEsError(e.response); return res.customError({ statusCode: e.statusCode, - body: e, + body: { + message: error.message, + attributes: error, + }, }); } // Case: default diff --git a/x-pack/plugins/index_management/server/routes/api/templates/register_update_route.ts b/x-pack/plugins/index_management/server/routes/api/templates/register_update_route.ts index 3055321d6b5942..9ad751023db919 100644 --- a/x-pack/plugins/index_management/server/routes/api/templates/register_update_route.ts +++ b/x-pack/plugins/index_management/server/routes/api/templates/register_update_route.ts @@ -44,9 +44,13 @@ export function registerUpdateRoute({ router, license, lib }: RouteDependencies) return res.ok({ body: response }); } catch (e) { if (lib.isEsError(e)) { + const error = lib.parseEsError(e.response); return res.customError({ statusCode: e.statusCode, - body: e, + body: { + message: error.message, + attributes: error, + }, }); } // Case: default diff --git a/x-pack/plugins/index_management/server/shared_imports.ts b/x-pack/plugins/index_management/server/shared_imports.ts index 0606f474897b55..f7b513a8a240c6 100644 --- a/x-pack/plugins/index_management/server/shared_imports.ts +++ b/x-pack/plugins/index_management/server/shared_imports.ts @@ -4,4 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -export { isEsError, handleEsError } from '../../../../src/plugins/es_ui_shared/server'; +export { + isEsError, + parseEsError, + handleEsError, +} from '../../../../src/plugins/es_ui_shared/server'; diff --git a/x-pack/plugins/index_management/server/types.ts b/x-pack/plugins/index_management/server/types.ts index 177dedeb87bb40..16a6b43af85123 100644 --- a/x-pack/plugins/index_management/server/types.ts +++ b/x-pack/plugins/index_management/server/types.ts @@ -8,7 +8,7 @@ import { PluginSetupContract as FeaturesPluginSetup } from '../../features/serve import { LicensingPluginSetup } from '../../licensing/server'; import { SecurityPluginSetup } from '../../security/server'; import { License, IndexDataEnricher } from './services'; -import { isEsError, handleEsError } from './shared_imports'; +import { isEsError, parseEsError, handleEsError } from './shared_imports'; export interface Dependencies { security: SecurityPluginSetup; @@ -25,6 +25,7 @@ export interface RouteDependencies { indexDataEnricher: IndexDataEnricher; lib: { isEsError: typeof isEsError; + parseEsError: typeof parseEsError; handleEsError: typeof handleEsError; }; } diff --git a/x-pack/plugins/runtime_fields/README.md b/x-pack/plugins/runtime_fields/README.md index d4664a3a07c617..e682d77f7a8843 100644 --- a/x-pack/plugins/runtime_fields/README.md +++ b/x-pack/plugins/runtime_fields/README.md @@ -35,7 +35,7 @@ const MyComponent = () => { const saveRuntimeField = (field: RuntimeField) => { // Do something with the field - console.log(field); // { name: 'myField', type: 'boolean', script: "return 'hello'" } + // See interface returned in @returns section below }; const openRuntimeFieldsEditor = async() => { @@ -45,6 +45,7 @@ const MyComponent = () => { closeRuntimeFieldEditor.current = openEditor({ onSave: saveRuntimeField, /* defaultValue: optional field to edit */ + /* ctx: Context -- see section below */ }); }; @@ -61,7 +62,40 @@ const MyComponent = () => { } ``` -#### Alternative +#### `@returns` + +You get back a `RuntimeField` object with the following interface + +```ts +interface RuntimeField { + name: string; + type: RuntimeType; // 'long' | 'boolean' ... + script: { + source: string; + } +} +``` + +#### Context object + +You can provide a context object to the runtime field editor. It has the following interface + +```ts +interface Context { + /** An array of field name not allowed. You would probably provide an array of existing runtime fields + * to prevent the user creating a field with the same name. + */ + namesNotAllowed?: string[]; + /** + * An array of existing concrete fields. If the user gives a name to the runtime + * field that matches one of the concrete fields, a callout will be displayed + * to indicate that this runtime field will shadow the concrete field. + */ + existingConcreteFields?: string[]; +} +``` + +#### Other type of integration The runtime field editor is also exported as static React component that you can import into your components. The editor is exported in 2 flavours: @@ -96,6 +130,7 @@ const MyComponent = () => { onCancel={() => setIsFlyoutVisible(false)} docLinks={docLinksStart} defaultValue={/*optional runtime field to edit*/} + ctx={/*optional context object -- see section above*/} /> )} @@ -138,6 +173,7 @@ const MyComponent = () => { onCancel={() => flyoutEditor.current?.close()} docLinks={docLinksStart} defaultValue={defaultRuntimeField} + ctx={/*optional context object -- see section above*/} /> ) @@ -182,6 +218,7 @@ const MyComponent = () => { onChange={setRuntimeFieldFormState} docLinks={docLinksStart} defaultValue={/*optional runtime field to edit*/} + ctx={/*optional context object -- see section above*/} /> diff --git a/x-pack/plugins/runtime_fields/public/components/index.ts b/x-pack/plugins/runtime_fields/public/components/index.ts index 86ac968d39f218..bccce5d591b517 100644 --- a/x-pack/plugins/runtime_fields/public/components/index.ts +++ b/x-pack/plugins/runtime_fields/public/components/index.ts @@ -8,4 +8,7 @@ export { RuntimeFieldForm, FormState as RuntimeFieldFormState } from './runtime_ export { RuntimeFieldEditor } from './runtime_field_editor'; -export { RuntimeFieldEditorFlyoutContent } from './runtime_field_editor_flyout_content'; +export { + RuntimeFieldEditorFlyoutContent, + RuntimeFieldEditorFlyoutContentProps, +} from './runtime_field_editor_flyout_content'; diff --git a/x-pack/plugins/runtime_fields/public/components/runtime_field_editor/runtime_field_editor.test.tsx b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor/runtime_field_editor.test.tsx index c56bc16c304ad8..a8f90810a12125 100644 --- a/x-pack/plugins/runtime_fields/public/components/runtime_field_editor/runtime_field_editor.test.tsx +++ b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor/runtime_field_editor.test.tsx @@ -31,6 +31,14 @@ describe('Runtime field editor', () => { const lastOnChangeCall = (): FormState[] => onChange.mock.calls[onChange.mock.calls.length - 1]; + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + beforeEach(() => { onChange = jest.fn(); }); @@ -46,7 +54,7 @@ describe('Runtime field editor', () => { const defaultValue: RuntimeField = { name: 'foo', type: 'date', - script: 'test=123', + script: { source: 'test=123' }, }; testBed = setup({ onChange, defaultValue, docLinks }); @@ -68,4 +76,75 @@ describe('Runtime field editor', () => { expect(lastState.isValid).toBe(true); expect(lastState.isSubmitted).toBe(true); }); + + test('should accept a list of existing concrete fields and display a callout when shadowing one of the fields', async () => { + const existingConcreteFields = ['myConcreteField']; + + testBed = setup({ onChange, docLinks, ctx: { existingConcreteFields } }); + + const { form, component, exists } = testBed; + + expect(exists('shadowingFieldCallout')).toBe(false); + + await act(async () => { + form.setInputValue('nameField.input', existingConcreteFields[0]); + }); + component.update(); + + expect(exists('shadowingFieldCallout')).toBe(true); + }); + + describe('validation', () => { + test('should accept an optional list of existing runtime fields and prevent creating duplicates', async () => { + const existingRuntimeFieldNames = ['myRuntimeField']; + + testBed = setup({ onChange, docLinks, ctx: { namesNotAllowed: existingRuntimeFieldNames } }); + + const { form, component } = testBed; + + await act(async () => { + form.setInputValue('nameField.input', existingRuntimeFieldNames[0]); + form.setInputValue('scriptField', 'echo("hello")'); + }); + + act(() => { + jest.advanceTimersByTime(1000); // Make sure our debounced error message is in the DOM + }); + + await act(async () => { + await lastOnChangeCall()[0].submit(); + }); + + component.update(); + + expect(lastOnChangeCall()[0].isValid).toBe(false); + expect(form.getErrorsMessages()).toEqual(['There is already a field with this name.']); + }); + + test('should not count the default value as a duplicate', async () => { + const existingRuntimeFieldNames = ['myRuntimeField']; + + const defaultValue: RuntimeField = { + name: 'myRuntimeField', + type: 'boolean', + script: { source: 'emit("hello"' }, + }; + + testBed = setup({ + defaultValue, + onChange, + docLinks, + ctx: { namesNotAllowed: existingRuntimeFieldNames }, + }); + + const { form } = testBed; + + await act(async () => { + await lastOnChangeCall()[0].submit(); + }); + + expect(lastOnChangeCall()[0].isValid).toBe(true); + expect(form.getErrorsMessages()).toEqual([]); + }); + }); }); diff --git a/x-pack/plugins/runtime_fields/public/components/runtime_field_editor/runtime_field_editor.tsx b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor/runtime_field_editor.tsx index 07935be171fd28..2472ccbda062fc 100644 --- a/x-pack/plugins/runtime_fields/public/components/runtime_field_editor/runtime_field_editor.tsx +++ b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor/runtime_field_editor.tsx @@ -15,10 +15,13 @@ export interface Props { docLinks: DocLinksStart; defaultValue?: RuntimeField; onChange?: FormProps['onChange']; + ctx?: FormProps['ctx']; } -export const RuntimeFieldEditor = ({ defaultValue, onChange, docLinks }: Props) => { +export const RuntimeFieldEditor = ({ defaultValue, onChange, docLinks, ctx }: Props) => { const links = getLinks(docLinks); - return ; + return ( + + ); }; diff --git a/x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/index.ts b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/index.ts index 32234bfcc5600b..ad6151b53546a3 100644 --- a/x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/index.ts +++ b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/index.ts @@ -4,4 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -export { RuntimeFieldEditorFlyoutContent } from './runtime_field_editor_flyout_content'; +export { + RuntimeFieldEditorFlyoutContent, + Props as RuntimeFieldEditorFlyoutContentProps, +} from './runtime_field_editor_flyout_content'; diff --git a/x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/runtime_field_editor_flyout_content.test.tsx b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/runtime_field_editor_flyout_content.test.tsx index 8e47472295f45f..972471d2e81902 100644 --- a/x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/runtime_field_editor_flyout_content.test.tsx +++ b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/runtime_field_editor_flyout_content.test.tsx @@ -39,7 +39,7 @@ describe('Runtime field editor flyout', () => { const field: RuntimeField = { name: 'foo', type: 'date', - script: 'test=123', + script: { source: 'test=123' }, }; const { find } = setup({ ...defaultProps, defaultValue: field }); @@ -47,14 +47,14 @@ describe('Runtime field editor flyout', () => { expect(find('flyoutTitle').text()).toBe(`Edit ${field.name} field`); expect(find('nameField.input').props().value).toBe(field.name); expect(find('typeField').props().value).toBe(field.type); - expect(find('scriptField').props().value).toBe(field.script); + expect(find('scriptField').props().value).toBe(field.script.source); }); test('should accept an onSave prop', async () => { const field: RuntimeField = { name: 'foo', type: 'date', - script: 'test=123', + script: { source: 'test=123' }, }; const onSave: jest.Mock = jest.fn(); @@ -93,10 +93,7 @@ describe('Runtime field editor flyout', () => { expect(onSave).toHaveBeenCalledTimes(0); expect(find('saveFieldButton').props().disabled).toBe(true); - expect(form.getErrorsMessages()).toEqual([ - 'Give a name to the field.', - 'Script must emit() a value.', - ]); + expect(form.getErrorsMessages()).toEqual(['Give a name to the field.']); expect(exists('formError')).toBe(true); expect(find('formError').text()).toBe('Fix errors in form before continuing.'); }); @@ -120,7 +117,7 @@ describe('Runtime field editor flyout', () => { expect(fieldReturned).toEqual({ name: 'someName', type: 'keyword', // default to keyword - script: 'script=123', + script: { source: 'script=123' }, }); // Change the type and make sure it is forwarded @@ -139,7 +136,7 @@ describe('Runtime field editor flyout', () => { expect(fieldReturned).toEqual({ name: 'someName', type: 'other_type', - script: 'script=123', + script: { source: 'script=123' }, }); }); }); diff --git a/x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/runtime_field_editor_flyout_content.tsx b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/runtime_field_editor_flyout_content.tsx index c7454cff0eb151..190cfb0deebcfd 100644 --- a/x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/runtime_field_editor_flyout_content.tsx +++ b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/runtime_field_editor_flyout_content.tsx @@ -21,7 +21,10 @@ import { DocLinksStart } from 'src/core/public'; import { RuntimeField } from '../../types'; import { FormState } from '../runtime_field_form'; -import { RuntimeFieldEditor } from '../runtime_field_editor'; +import { + RuntimeFieldEditor, + Props as RuntimeFieldEditorProps, +} from '../runtime_field_editor/runtime_field_editor'; const geti18nTexts = (field?: RuntimeField) => { return { @@ -64,6 +67,10 @@ export interface Props { * An optional runtime field to edit */ defaultValue?: RuntimeField; + /** + * Optional context object + */ + ctx?: RuntimeFieldEditorProps['ctx']; } export const RuntimeFieldEditorFlyoutContent = ({ @@ -71,6 +78,7 @@ export const RuntimeFieldEditorFlyoutContent = ({ onCancel, docLinks, defaultValue: field, + ctx, }: Props) => { const i18nTexts = geti18nTexts(field); @@ -95,12 +103,17 @@ export const RuntimeFieldEditorFlyoutContent = ({ <> -

    {i18nTexts.flyoutTitle}

    +

    {i18nTexts.flyoutTitle}

    - + diff --git a/x-pack/plugins/runtime_fields/public/components/runtime_field_form/runtime_field_form.test.tsx b/x-pack/plugins/runtime_fields/public/components/runtime_field_form/runtime_field_form.test.tsx index 1829514856eed2..9714ff43288dd4 100644 --- a/x-pack/plugins/runtime_fields/public/components/runtime_field_form/runtime_field_form.test.tsx +++ b/x-pack/plugins/runtime_fields/public/components/runtime_field_form/runtime_field_form.test.tsx @@ -18,7 +18,7 @@ const setup = (props?: Props) => })(props) as TestBed; const links = { - painlessSyntax: 'https://jestTest.elastic.co/to-be-defined.html', + runtimePainless: 'https://jestTest.elastic.co/to-be-defined.html', }; describe('Runtime field form', () => { @@ -45,28 +45,28 @@ describe('Runtime field form', () => { const { exists, find } = testBed; expect(exists('painlessSyntaxLearnMoreLink')).toBe(true); - expect(find('painlessSyntaxLearnMoreLink').props().href).toBe(links.painlessSyntax); + expect(find('painlessSyntaxLearnMoreLink').props().href).toBe(links.runtimePainless); }); test('should accept a "defaultValue" prop', () => { const defaultValue: RuntimeField = { name: 'foo', type: 'date', - script: 'test=123', + script: { source: 'test=123' }, }; testBed = setup({ defaultValue, links }); const { find } = testBed; expect(find('nameField.input').props().value).toBe(defaultValue.name); expect(find('typeField').props().value).toBe(defaultValue.type); - expect(find('scriptField').props().value).toBe(defaultValue.script); + expect(find('scriptField').props().value).toBe(defaultValue.script.source); }); test('should accept an "onChange" prop to forward the form state', async () => { const defaultValue: RuntimeField = { name: 'foo', type: 'date', - script: 'test=123', + script: { source: 'test=123' }, }; testBed = setup({ onChange, defaultValue, links }); diff --git a/x-pack/plugins/runtime_fields/public/components/runtime_field_form/runtime_field_form.tsx b/x-pack/plugins/runtime_fields/public/components/runtime_field_form/runtime_field_form.tsx index 6068302f5b269b..2ed6df537a6fe1 100644 --- a/x-pack/plugins/runtime_fields/public/components/runtime_field_form/runtime_field_form.tsx +++ b/x-pack/plugins/runtime_fields/public/components/runtime_field_form/runtime_field_form.tsx @@ -14,9 +14,20 @@ import { EuiComboBox, EuiComboBoxOptionOption, EuiLink, + EuiCallOut, } from '@elastic/eui'; -import { useForm, Form, FormHook, UseField, TextField, CodeEditor } from '../../shared_imports'; +import { + useForm, + useFormData, + Form, + FormHook, + UseField, + TextField, + CodeEditor, + ValidationFunc, + FieldConfig, +} from '../../shared_imports'; import { RuntimeField } from '../../types'; import { RUNTIME_FIELD_OPTIONS } from '../../constants'; import { schema } from './schema'; @@ -29,15 +40,82 @@ export interface FormState { export interface Props { links: { - painlessSyntax: string; + runtimePainless: string; }; defaultValue?: RuntimeField; onChange?: (state: FormState) => void; + /** + * Optional context object + */ + ctx?: { + /** An array of field name not allowed */ + namesNotAllowed?: string[]; + /** + * An array of existing concrete fields. If the user gives a name to the runtime + * field that matches one of the concrete fields, a callout will be displayed + * to indicate that this runtime field will shadow the concrete field. + */ + existingConcreteFields?: string[]; + }; } -const RuntimeFieldFormComp = ({ defaultValue, onChange, links }: Props) => { +const createNameNotAllowedValidator = ( + namesNotAllowed: string[] +): ValidationFunc<{}, string, string> => ({ value }) => { + if (namesNotAllowed.includes(value)) { + return { + message: i18n.translate( + 'xpack.runtimeFields.runtimeFieldsEditor.existRuntimeFieldNamesValidationErrorMessage', + { + defaultMessage: 'There is already a field with this name.', + } + ), + }; + } +}; + +/** + * Dynamically retrieve the config for the "name" field, adding + * a validator to avoid duplicated runtime fields to be created. + * + * @param namesNotAllowed Array of names not allowed for the field "name" + * @param defaultValue Initial value of the form + */ +const getNameFieldConfig = ( + namesNotAllowed?: string[], + defaultValue?: Props['defaultValue'] +): FieldConfig => { + const nameFieldConfig = schema.name as FieldConfig; + + if (!namesNotAllowed) { + return nameFieldConfig; + } + + // Add validation to not allow duplicates + return { + ...nameFieldConfig!, + validations: [ + ...(nameFieldConfig.validations ?? []), + { + validator: createNameNotAllowedValidator( + namesNotAllowed.filter((name) => name !== defaultValue?.name) + ), + }, + ], + }; +}; + +const RuntimeFieldFormComp = ({ + defaultValue, + onChange, + links, + ctx: { namesNotAllowed, existingConcreteFields = [] } = {}, +}: Props) => { const { form } = useForm({ defaultValue, schema }); const { submit, isValid: isFormValid, isSubmitted } = form; + const [{ name }] = useFormData({ form, watch: 'name' }); + + const nameFieldConfig = getNameFieldConfig(namesNotAllowed, defaultValue); useEffect(() => { if (onChange) { @@ -50,7 +128,19 @@ const RuntimeFieldFormComp = ({ defaultValue, onChange, links }: Props) => { {/* Name */} - + + path="name" + config={nameFieldConfig} + component={TextField} + data-test-subj="nameField" + componentProps={{ + euiFieldProps: { + 'aria-label': i18n.translate('xpack.runtimeFields.form.nameAriaLabel', { + defaultMessage: 'Name field', + }), + }, + }} + /> {/* Return type */} @@ -82,6 +172,9 @@ const RuntimeFieldFormComp = ({ defaultValue, onChange, links }: Props) => { }} isClearable={false} data-test-subj="typeField" + aria-label={i18n.translate('xpack.runtimeFields.form.typeSelectAriaLabel', { + defaultMessage: 'Type select', + })} fullWidth /> @@ -92,10 +185,32 @@ const RuntimeFieldFormComp = ({ defaultValue, onChange, links }: Props) => { + {existingConcreteFields.includes(name) && ( + <> + + +
    + {i18n.translate('xpack.runtimeFields.form.fieldShadowingCalloutDescription', { + defaultMessage: + 'This field shares the name of a mapped field. Values for this field will be returned in search results.', + })} +
    +
    + + )} + {/* Script */} - path="script"> + path="script.source"> {({ value, setValue, label, isValid, getErrorsMessages }) => { return ( { { automaticLayout: true, }} data-test-subj="scriptField" + aria-label={i18n.translate('xpack.runtimeFields.form.scriptEditorAriaLabel', { + defaultMessage: 'Script editor', + })} /> ); diff --git a/x-pack/plugins/runtime_fields/public/components/runtime_field_form/schema.ts b/x-pack/plugins/runtime_fields/public/components/runtime_field_form/schema.ts index abb7cf812200f6..9db23ef5291a07 100644 --- a/x-pack/plugins/runtime_fields/public/components/runtime_field_form/schema.ts +++ b/x-pack/plugins/runtime_fields/public/components/runtime_field_form/schema.ts @@ -42,17 +42,10 @@ export const schema: FormSchema = { serializer: (value: Array>) => value[0].value!, }, script: { - label: i18n.translate('xpack.runtimeFields.form.defineFieldLabel', { - defaultMessage: 'Define field', - }), - validations: [ - { - validator: emptyField( - i18n.translate('xpack.runtimeFields.form.validations.scriptIsRequiredErrorMessage', { - defaultMessage: 'Script must emit() a value.', - }) - ), - }, - ], + source: { + label: i18n.translate('xpack.runtimeFields.form.defineFieldLabel', { + defaultMessage: 'Define field (optional)', + }), + }, }, }; diff --git a/x-pack/plugins/runtime_fields/public/index.ts b/x-pack/plugins/runtime_fields/public/index.ts index 0eab32c0b3d97b..3f5b8002da132c 100644 --- a/x-pack/plugins/runtime_fields/public/index.ts +++ b/x-pack/plugins/runtime_fields/public/index.ts @@ -7,6 +7,7 @@ import { RuntimeFieldsPlugin } from './plugin'; export { RuntimeFieldEditorFlyoutContent, + RuntimeFieldEditorFlyoutContentProps, RuntimeFieldEditor, RuntimeFieldFormState, } from './components'; diff --git a/x-pack/plugins/runtime_fields/public/lib/documentation.ts b/x-pack/plugins/runtime_fields/public/lib/documentation.ts index 87eab8b7ed997a..4f7eb10aa7c770 100644 --- a/x-pack/plugins/runtime_fields/public/lib/documentation.ts +++ b/x-pack/plugins/runtime_fields/public/lib/documentation.ts @@ -8,9 +8,11 @@ import { DocLinksStart } from 'src/core/public'; export const getLinks = (docLinks: DocLinksStart) => { const { DOC_LINK_VERSION, ELASTIC_WEBSITE_URL } = docLinks; const docsBase = `${ELASTIC_WEBSITE_URL}guide/en`; + const esDocsBase = `${docsBase}/elasticsearch/reference/${DOC_LINK_VERSION}`; const painlessDocsBase = `${docsBase}/elasticsearch/painless/${DOC_LINK_VERSION}`; return { + runtimePainless: `${esDocsBase}/runtime.html#runtime-mapping-fields`, painlessSyntax: `${painlessDocsBase}/painless-lang-spec.html`, }; }; diff --git a/x-pack/plugins/runtime_fields/public/load_editor.tsx b/x-pack/plugins/runtime_fields/public/load_editor.tsx index da2819411732bc..bf13e79caad0f3 100644 --- a/x-pack/plugins/runtime_fields/public/load_editor.tsx +++ b/x-pack/plugins/runtime_fields/public/load_editor.tsx @@ -8,10 +8,12 @@ import { CoreSetup, OverlayRef } from 'src/core/public'; import { toMountPoint, createKibanaReactContext } from './shared_imports'; import { LoadEditorResponse, RuntimeField } from './types'; +import { RuntimeFieldEditorFlyoutContentProps } from './components'; export interface OpenRuntimeFieldEditorProps { onSave(field: RuntimeField): void; - defaultValue?: RuntimeField; + defaultValue?: RuntimeFieldEditorFlyoutContentProps['defaultValue']; + ctx?: RuntimeFieldEditorFlyoutContentProps['ctx']; } export const getRuntimeFieldEditorLoader = ( @@ -24,10 +26,12 @@ export const getRuntimeFieldEditorLoader = ( let overlayRef: OverlayRef | null = null; - const openEditor = ({ onSave, defaultValue }: OpenRuntimeFieldEditorProps) => { + const openEditor = ({ onSave, defaultValue, ctx }: OpenRuntimeFieldEditorProps) => { const closeEditor = () => { - overlayRef?.close(); - overlayRef = null; + if (overlayRef) { + overlayRef.close(); + overlayRef = null; + } }; const onSaveField = (field: RuntimeField) => { @@ -43,6 +47,7 @@ export const getRuntimeFieldEditorLoader = ( onCancel={() => overlayRef?.close()} docLinks={docLinks} defaultValue={defaultValue} + ctx={ctx} /> ) diff --git a/x-pack/plugins/runtime_fields/public/shared_imports.ts b/x-pack/plugins/runtime_fields/public/shared_imports.ts index 200a68ab71031d..44ada67dc00147 100644 --- a/x-pack/plugins/runtime_fields/public/shared_imports.ts +++ b/x-pack/plugins/runtime_fields/public/shared_imports.ts @@ -6,10 +6,13 @@ export { useForm, + useFormData, Form, FormSchema, UseField, FormHook, + ValidationFunc, + FieldConfig, } from '../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; export { fieldValidators } from '../../../../src/plugins/es_ui_shared/static/forms/helpers'; diff --git a/x-pack/plugins/runtime_fields/public/types.ts b/x-pack/plugins/runtime_fields/public/types.ts index 4172061540af8e..b1bbb06d796551 100644 --- a/x-pack/plugins/runtime_fields/public/types.ts +++ b/x-pack/plugins/runtime_fields/public/types.ts @@ -31,7 +31,9 @@ export type RuntimeType = typeof RUNTIME_FIELD_TYPES[number]; export interface RuntimeField { name: string; type: RuntimeType; - script: string; + script: { + source: string; + }; } export interface ComboBoxOption { diff --git a/x-pack/test/api_integration/apis/management/index_management/templates.js b/x-pack/test/api_integration/apis/management/index_management/templates.js index 8d491e6a135ea0..dd5dac5626041b 100644 --- a/x-pack/test/api_integration/apis/management/index_management/templates.js +++ b/x-pack/test/api_integration/apis/management/index_management/templates.js @@ -191,6 +191,26 @@ export default function ({ getService }) { '[request body.indexPatterns]: expected value of type [array] ' ); }); + + it('should parse the ES error and return the cause', async () => { + const templateName = `template-${getRandomString()}`; + const payload = getTemplatePayload(templateName, [getRandomString()]); + const runtime = { + myRuntimeField: { + type: 'boolean', + script: { + source: 'emit("hello with error', // error in script + }, + }, + }; + payload.template.mappings = { ...payload.template.mappings, runtime }; + const { body } = await createTemplate(payload).expect(400); + + expect(body.attributes).an('object'); + expect(body.attributes.message).contain('template after composition is invalid'); + // one of the item of the cause array should point to our script + expect(body.attributes.cause.join(',')).contain('"hello with error'); + }); }); describe('update', () => { @@ -248,6 +268,32 @@ export default function ({ getService }) { catTemplateResponse.find(({ name: templateName }) => templateName === name).version ).to.equal(updatedVersion.toString()); }); + + it('should parse the ES error and return the cause', async () => { + const templateName = `template-${getRandomString()}`; + const payload = getTemplatePayload(templateName, [getRandomString()]); + const runtime = { + myRuntimeField: { + type: 'keyword', + script: { + source: 'emit("hello")', + }, + }, + }; + + // Add runtime field + payload.template.mappings = { ...payload.template.mappings, runtime }; + + await createTemplate(payload).expect(200); + + // Update template with an error in the runtime field script + payload.template.mappings.runtime.myRuntimeField.script = 'emit("hello with error'; + const { body } = await updateTemplate(payload, templateName).expect(400); + + expect(body.attributes).an('object'); + // one of the item of the cause array should point to our script + expect(body.attributes.cause.join(',')).contain('"hello with error'); + }); }); describe('delete', () => {