diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/__jest__/client_integration/helpers/index.ts b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/__jest__/client_integration/helpers/index.ts new file mode 100644 index 0000000000000..e3313bfba56fd --- /dev/null +++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/__jest__/client_integration/helpers/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { setup as mappingsEditorSetup } from './mappings_editor.helpers'; + +export { + nextTick, + getRandomString, + findTestSubject, + TestBed, +} from '../../../../../../../../../../test_utils'; + +export const componentHelpers = { + mappingsEditor: { setup: mappingsEditorSetup }, +}; diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.ts b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.ts new file mode 100644 index 0000000000000..b8b67a0f36bd2 --- /dev/null +++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.ts @@ -0,0 +1,16 @@ +/* + * 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 { registerTestBed } from '../../../../../../../../../../test_utils'; +import { MappingsEditor } from '../../../mappings_editor'; + +export const setup = (props: any) => + registerTestBed(MappingsEditor, { + memoryRouter: { + wrapComponent: false, + }, + defaultProps: props, + }); diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/__jest__/client_integration/mappings_editor.test.tsx b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/__jest__/client_integration/mappings_editor.test.tsx new file mode 100644 index 0000000000000..9e390e785c7d5 --- /dev/null +++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/__jest__/client_integration/mappings_editor.test.tsx @@ -0,0 +1,55 @@ +/* + * 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 { componentHelpers } from './helpers'; + +const { setup } = componentHelpers.mappingsEditor; +const mockOnUpdate = () => undefined; + +describe('', () => { + describe('multiple mappings detection', () => { + test('should show a warning when multiple mappings are detected', async () => { + const defaultValue = { + type1: { + properties: { + name1: { + type: 'keyword', + }, + }, + }, + type2: { + properties: { + name2: { + type: 'keyword', + }, + }, + }, + }; + const testBed = await setup({ onUpdate: mockOnUpdate, defaultValue })(); + const { exists } = testBed; + + expect(exists('mappingsEditor')).toBe(true); + expect(exists('mappingTypesDetectedCallout')).toBe(true); + expect(exists('documentFields')).toBe(false); + }); + + test('should not show a warning when mappings a single-type', async () => { + const defaultValue = { + properties: { + name1: { + type: 'keyword', + }, + }, + }; + const testBed = await setup({ onUpdate: mockOnUpdate, defaultValue })(); + const { exists } = testBed; + + expect(exists('mappingsEditor')).toBe(true); + expect(exists('mappingTypesDetectedCallout')).toBe(false); + expect(exists('documentFields')).toBe(true); + }); + }); +}); diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/components/document_fields/document_fields.tsx b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/components/document_fields/document_fields.tsx index 71b5966c3295d..378d669dee69c 100644 --- a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/components/document_fields/document_fields.tsx +++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/components/document_fields/document_fields.tsx @@ -48,7 +48,7 @@ export const DocumentFields = React.memo(() => { const searchTerm = search.term.trim(); return ( - <> +
{searchTerm !== '' ? ( @@ -57,6 +57,6 @@ export const DocumentFields = React.memo(() => { editor )} {renderEditField()} - +
); }); diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/components/index.ts b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/components/index.ts index d5ad51ba35839..2958ecd75910f 100644 --- a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/components/index.ts +++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/components/index.ts @@ -9,3 +9,5 @@ export * from './configuration_form'; export * from './document_fields'; export * from './templates_form'; + +export * from './multiple_mappings_warning'; diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/components/multiple_mappings_warning.tsx b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/components/multiple_mappings_warning.tsx new file mode 100644 index 0000000000000..507e4dac262ff --- /dev/null +++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/components/multiple_mappings_warning.tsx @@ -0,0 +1,42 @@ +/* + * 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 { FormattedMessage } from '@kbn/i18n/react'; +import { EuiCallOut, EuiLink } from '@elastic/eui'; + +import { documentationService } from '../../../services/documentation'; + +export const MultipleMappingsWarning = () => ( + +

+ + {i18n.translate( + 'xpack.idxMgmt.mappingsEditor.mappingTypesDetectedCallOutDocumentationLink', + { + defaultMessage: 'Consider these alternatives to mapping types.', + } + )} + + ), + }} + /> +

+
+); diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/extract_mappings_definition.test.ts b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/extract_mappings_definition.test.ts new file mode 100644 index 0000000000000..3e38ff5991a8c --- /dev/null +++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/extract_mappings_definition.test.ts @@ -0,0 +1,128 @@ +/* + * 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 { extractMappingsDefinition } from './extract_mappings_definition'; + +describe('extractMappingsDefinition', () => { + test('should detect that the mappings has multiple types and return null', () => { + const mappings = { + type1: { + properties: { + name1: { + type: 'keyword', + }, + }, + }, + type2: { + properties: { + name2: { + type: 'keyword', + }, + }, + }, + }; + + expect(extractMappingsDefinition(mappings)).toBe(null); + }); + + test('should detect that the mappings has multiple types even when one of the type has not defined any "properties"', () => { + const mappings = { + type1: { + _source: { + excludes: [], + includes: [], + enabled: true, + }, + _routing: { + required: false, + }, + }, + type2: { + properties: { + name2: { + type: 'keyword', + }, + }, + }, + }; + + expect(extractMappingsDefinition(mappings)).toBe(null); + }); + + test('should detect that one of the mapping type is invalid and filter it out', () => { + const mappings = { + type1: { + invalidSetting: { + excludes: [], + includes: [], + enabled: true, + }, + _routing: { + required: false, + }, + }, + type2: { + properties: { + name2: { + type: 'keyword', + }, + }, + }, + }; + + expect(extractMappingsDefinition(mappings)).toBe(mappings.type2); + }); + + test('should detect that the mappings has one type and return its mapping definition', () => { + const mappings = { + myType: { + _source: { + excludes: [], + includes: [], + enabled: true, + }, + _meta: {}, + _routing: { + required: false, + }, + dynamic: true, + properties: { + title: { + type: 'keyword', + }, + }, + }, + }; + + expect(extractMappingsDefinition(mappings)).toBe(mappings.myType); + }); + + test('should detect that the mappings has one type at root level', () => { + const mappings = { + _source: { + excludes: [], + includes: [], + enabled: true, + }, + _meta: {}, + _routing: { + required: false, + }, + dynamic: true, + numeric_detection: false, + date_detection: true, + dynamic_date_formats: ['strict_date_optional_time'], + dynamic_templates: [], + properties: { + title: { + type: 'keyword', + }, + }, + }; + + expect(extractMappingsDefinition(mappings)).toBe(mappings); + }); +}); diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/extract_mappings_definition.ts b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/extract_mappings_definition.ts new file mode 100644 index 0000000000000..eae3c5b15759c --- /dev/null +++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/extract_mappings_definition.ts @@ -0,0 +1,103 @@ +/* + * 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 { isPlainObject } from 'lodash'; + +import { GenericObject } from '../types'; +import { + validateMappingsConfiguration, + mappingsConfigurationSchemaKeys, +} from './mappings_validator'; + +const ALLOWED_PARAMETERS = [...mappingsConfigurationSchemaKeys, 'dynamic_templates', 'properties']; + +const isMappingDefinition = (obj: GenericObject): boolean => { + const areAllKeysValid = Object.keys(obj).every(key => ALLOWED_PARAMETERS.includes(key)); + + if (!areAllKeysValid) { + return false; + } + + const { properties, dynamic_templates: dynamicTemplates, ...mappingsConfiguration } = obj; + + const { errors } = validateMappingsConfiguration(mappingsConfiguration); + const isConfigurationValid = errors.length === 0; + const isPropertiesValid = properties === undefined || isPlainObject(properties); + const isDynamicTemplatesValid = dynamicTemplates === undefined || Array.isArray(dynamicTemplates); + + // If the configuration, the properties and the dynamic templates are valid + // we can assume that the mapping is declared at root level (no types) + return isConfigurationValid && isPropertiesValid && isDynamicTemplatesValid; +}; + +/** + * 5.x index templates can be created with multiple types. + * e.g. + ``` + const mappings = { + type1: { + properties: { + name1: { + type: 'keyword', + }, + }, + }, + type2: { + properties: { + name2: { + type: 'keyword', + }, + }, + }, + }; + ``` + * A mappings can also be declared under an explicit "_doc" property. + ``` + const mappings = { + _doc: { + _source: { + "enabled": false + }, + properties: { + name1: { + type: 'keyword', + }, + }, + }, + }; + ``` + * This helpers parse the mappings provided an removes any possible mapping "type" declared + * + * @param mappings The mappings object to validate + */ +export const extractMappingsDefinition = (mappings: GenericObject = {}): GenericObject | null => { + if (isMappingDefinition(mappings)) { + // No need to go any further + return mappings; + } + + // At this point there must be one or more type mappings + const typedMappings = Object.values(mappings).reduce((acc: GenericObject[], value) => { + if (isMappingDefinition(value)) { + acc.push(value as GenericObject); + } + return acc; + }, []); + + // If there are no typed mappings found this means that one of the type must did not pass + // the "isMappingDefinition()" validation. + // In theory this should never happen but let's make sure the UI does not try to load an invalid mapping + if (typedMappings.length === 0) { + return null; + } + + // If there's only one mapping type then we can consume it as if the type doesn't exist. + if (typedMappings.length === 1) { + return typedMappings[0]; + } + + // If there's more than one mapping type, then the mappings object isn't usable. + return null; +}; diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/index.ts b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/index.ts index 1b1c5cc8dc8d4..8cd1bbf0764ab 100644 --- a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/index.ts +++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/index.ts @@ -13,3 +13,5 @@ export * from './validators'; export * from './mappings_validator'; export * from './search_fields'; + +export * from './extract_mappings_definition'; diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/mappings_validator.test.ts b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/mappings_validator.test.ts index c70462a656e2f..f1e6efb06c649 100644 --- a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/mappings_validator.test.ts +++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/mappings_validator.test.ts @@ -3,8 +3,9 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { isPlainObject } from 'lodash'; -import { validateMappings, validateProperties, isObject } from './mappings_validator'; +import { validateMappings, validateProperties } from './mappings_validator'; describe('Mappings configuration validator', () => { it('should convert non object to empty object', () => { @@ -12,7 +13,7 @@ describe('Mappings configuration validator', () => { tests.forEach(testValue => { const { value, errors } = validateMappings(testValue as any); - expect(isObject(value)).toBe(true); + expect(isPlainObject(value)).toBe(true); expect(errors).toBe(undefined); }); }); @@ -76,7 +77,7 @@ describe('Properties validator', () => { tests.forEach(testValue => { const { value, errors } = validateProperties(testValue as any); - expect(isObject(value)).toBe(true); + expect(isPlainObject(value)).toBe(true); expect(errors).toEqual([]); }); }); diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/mappings_validator.ts b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/mappings_validator.ts index fff735da2e758..6ccbfeb50dcf4 100644 --- a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/mappings_validator.ts +++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/mappings_validator.ts @@ -3,15 +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 { pick } from 'lodash'; +import { pick, isPlainObject } from 'lodash'; import * as t from 'io-ts'; import { ordString } from 'fp-ts/lib/Ord'; import { toArray } from 'fp-ts/lib/Set'; import { isLeft, isRight } from 'fp-ts/lib/Either'; + import { errorReporter } from './error_reporter'; import { ALL_DATA_TYPES, PARAMETERS_DEFINITION } from '../constants'; import { FieldMeta } from '../types'; -import { getFieldMeta } from '../lib'; +import { getFieldMeta } from './utils'; const ALLOWED_FIELD_PROPERTIES = [ ...Object.keys(PARAMETERS_DEFINITION), @@ -49,8 +50,6 @@ interface GenericObject { [key: string]: any; } -export const isObject = (obj: any) => obj != null && obj.constructor.name === 'Object'; - const validateFieldType = (type: any): boolean => { if (typeof type !== 'string') { return false; @@ -72,7 +71,7 @@ const validateParameter = (parameter: string, value: any): boolean => { } if (parameter === 'properties' || parameter === 'fields') { - return isObject(value); + return isPlainObject(value); } const parameterSchema = (PARAMETERS_DEFINITION as any)[parameter]!.schema; @@ -100,7 +99,7 @@ const stripUnknownOrInvalidParameter = (field: GenericObject): FieldValidatorRes const parseField = (field: any): FieldValidatorResponse & { meta?: FieldMeta } => { // Sanitize the input to make sure we are working with an object - if (!isObject(field)) { + if (!isPlainObject(field)) { return { parametersRemoved: [] }; } // Make sure the field "type" is valid @@ -186,7 +185,7 @@ const parseFields = ( */ export const validateProperties = (properties = {}): PropertiesValidatorResponse => { // Sanitize the input to make sure we are working with an object - if (!isObject(properties)) { + if (!isPlainObject(properties)) { return { value: {}, errors: [] }; } @@ -213,9 +212,9 @@ export const mappingsConfigurationSchema = t.partial({ }), }); -const mappingsConfigurationSchemaKeys = Object.keys(mappingsConfigurationSchema.props); +export const mappingsConfigurationSchemaKeys = Object.keys(mappingsConfigurationSchema.props); -const validateMappingsConfiguration = ( +export const validateMappingsConfiguration = ( mappingsConfiguration: any ): { value: any; errors: MappingsValidationError[] } => { // Set to keep track of invalid configuration parameters. @@ -249,7 +248,7 @@ const validateMappingsConfiguration = ( }; export const validateMappings = (mappings: any = {}): MappingsValidatorResponse => { - if (!isObject(mappings)) { + if (!isPlainObject(mappings)) { return { value: {} }; } diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/mappings_editor.tsx b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/mappings_editor.tsx index e3fdf42d889e9..d79a023386e8d 100644 --- a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/mappings_editor.tsx +++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/mappings_editor.tsx @@ -4,14 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, useState } from 'react'; +import React, { useMemo, useState, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiSpacer, EuiTabs, EuiTab } from '@elastic/eui'; -import { ConfigurationForm, DocumentFields, TemplatesForm } from './components'; +import { + ConfigurationForm, + DocumentFields, + TemplatesForm, + MultipleMappingsWarning, +} from './components'; import { IndexSettings } from './types'; +import { extractMappingsDefinition } from './lib'; import { State } from './reducer'; -import { MappingsState, Props as MappingsStateProps } from './mappings_state'; +import { MappingsState, Props as MappingsStateProps, Types } from './mappings_state'; import { IndexSettingsProvider } from './index_settings_context'; interface Props { @@ -25,7 +31,13 @@ type TabName = 'fields' | 'advanced' | 'templates'; export const MappingsEditor = React.memo(({ onUpdate, defaultValue, indexSettings }: Props) => { const [selectedTab, selectTab] = useState('fields'); - const parsedDefaultValue = useMemo(() => { + const { parsedDefaultValue, multipleMappingsDeclared } = useMemo(() => { + const mappingsDefinition = extractMappingsDefinition(defaultValue); + + if (mappingsDefinition === null) { + return { multipleMappingsDeclared: true }; + } + const { _source = {}, _meta = {}, @@ -36,9 +48,9 @@ export const MappingsEditor = React.memo(({ onUpdate, defaultValue, indexSetting dynamic_date_formats, properties = {}, dynamic_templates, - } = defaultValue ?? {}; + } = mappingsDefinition; - return { + const parsed = { configuration: { _source, _meta, @@ -53,8 +65,21 @@ export const MappingsEditor = React.memo(({ onUpdate, defaultValue, indexSetting dynamic_templates, }, }; + + return { parsedDefaultValue: parsed, multipleMappingsDeclared: false }; }, [defaultValue]); + useEffect(() => { + if (multipleMappingsDeclared) { + // We set the data getter here as the user won't be able to make any changes + onUpdate({ + getData: () => defaultValue! as Types['Mappings'], + validate: () => Promise.resolve(true), + isValid: true, + }); + } + }, [multipleMappingsDeclared]); + const changeTab = async (tab: TabName, state: State) => { if (selectedTab === 'advanced') { // When we navigate away we need to submit the form to validate if there are any errors. @@ -63,7 +88,6 @@ export const MappingsEditor = React.memo(({ onUpdate, defaultValue, indexSetting if (!isConfigurationFormValid) { /** * Don't navigate away from the tab if there are errors in the form. - * For now there is no need to display a CallOut as the form can never be invalid. */ return; } @@ -79,51 +103,57 @@ export const MappingsEditor = React.memo(({ onUpdate, defaultValue, indexSetting }; return ( - - - {({ state }) => { - const tabToContentMap = { - fields: , - templates: , - advanced: , - }; - - return ( -
- - changeTab('fields', state)} - isSelected={selectedTab === 'fields'} - > - {i18n.translate('xpack.idxMgmt.mappingsEditor.fieldsTabLabel', { - defaultMessage: 'Mapped fields', - })} - - changeTab('templates', state)} - isSelected={selectedTab === 'templates'} - > - {i18n.translate('xpack.idxMgmt.mappingsEditor.templatesTabLabel', { - defaultMessage: 'Dynamic templates', - })} - - changeTab('advanced', state)} - isSelected={selectedTab === 'advanced'} - > - {i18n.translate('xpack.idxMgmt.mappingsEditor.advancedTabLabel', { - defaultMessage: 'Advanced options', - })} - - - - - - {tabToContentMap[selectedTab]} -
- ); - }} -
-
+
+ {multipleMappingsDeclared ? ( + + ) : ( + + + {({ state }) => { + const tabToContentMap = { + fields: , + templates: , + advanced: , + }; + + return ( +
+ + changeTab('fields', state)} + isSelected={selectedTab === 'fields'} + > + {i18n.translate('xpack.idxMgmt.mappingsEditor.fieldsTabLabel', { + defaultMessage: 'Mapped fields', + })} + + changeTab('templates', state)} + isSelected={selectedTab === 'templates'} + > + {i18n.translate('xpack.idxMgmt.mappingsEditor.templatesTabLabel', { + defaultMessage: 'Dynamic templates', + })} + + changeTab('advanced', state)} + isSelected={selectedTab === 'advanced'} + > + {i18n.translate('xpack.idxMgmt.mappingsEditor.advancedTabLabel', { + defaultMessage: 'Advanced options', + })} + + + + + + {tabToContentMap[selectedTab]} +
+ ); + }} +
+
+ )} +
); }); diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/types.ts b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/types.ts index 5d9c15caf9cd9..dbbffe5a0bd31 100644 --- a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/types.ts +++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/types.ts @@ -287,3 +287,7 @@ export interface SearchMetadata { */ stringMatch: string | null; } + +export interface GenericObject { + [key: string]: any; +} diff --git a/x-pack/legacy/plugins/index_management/public/app/services/documentation.ts b/x-pack/legacy/plugins/index_management/public/app/services/documentation.ts index f63dc622453fe..9bf0d983df161 100644 --- a/x-pack/legacy/plugins/index_management/public/app/services/documentation.ts +++ b/x-pack/legacy/plugins/index_management/public/app/services/documentation.ts @@ -181,6 +181,10 @@ class DocumentationService { return `${this.esDocsBase}/index-options.html`; } + public getAlternativeToMappingTypesLink() { + return `${this.esDocsBase}/removal-of-types.html#_alternatives_to_mapping_types`; + } + public getJoinMultiLevelsPerformanceLink() { return `${this.esDocsBase}/parent-join.html#_parent_join_and_performance`; }