[Mappings editor] Accommodate legacy index templates (#55388) (#55687)

This commit is contained in:
Sébastien Loix 2020-01-24 10:25:55 +05:30 committed by GitHub
parent 7c575de914
commit 0b85d28b2b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 468 additions and 65 deletions

View file

@ -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 },
};

View file

@ -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,
});

View file

@ -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('<MappingsEditor />', () => {
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);
});
});
});

View file

@ -48,7 +48,7 @@ export const DocumentFields = React.memo(() => {
const searchTerm = search.term.trim();
return (
<>
<div data-test-subj="documentFields">
<DocumentFieldsHeader searchValue={search.term} onSearchChange={onSearchChange} />
<EuiSpacer size="m" />
{searchTerm !== '' ? (
@ -57,6 +57,6 @@ export const DocumentFields = React.memo(() => {
editor
)}
{renderEditField()}
</>
</div>
);
});

View file

@ -9,3 +9,5 @@ export * from './configuration_form';
export * from './document_fields';
export * from './templates_form';
export * from './multiple_mappings_warning';

View file

@ -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 = () => (
<EuiCallOut
title={i18n.translate('xpack.idxMgmt.mappingsEditor.mappingTypesDetectedCallOutTitle', {
defaultMessage: 'Mapping types detected',
})}
iconType="alert"
color="warning"
data-test-subj="mappingTypesDetectedCallout"
>
<p>
<FormattedMessage
id="xpack.idxMgmt.mappingsEditor.mappingTypesDetectedCallOutDescription"
defaultMessage="The mappings for this template uses types, which have been removed. {docsLink}"
values={{
docsLink: (
<EuiLink href={documentationService.getAlternativeToMappingTypesLink()} target="_blank">
{i18n.translate(
'xpack.idxMgmt.mappingsEditor.mappingTypesDetectedCallOutDocumentationLink',
{
defaultMessage: 'Consider these alternatives to mapping types.',
}
)}
</EuiLink>
),
}}
/>
</p>
</EuiCallOut>
);

View file

@ -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);
});
});

View file

@ -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;
};

View file

@ -13,3 +13,5 @@ export * from './validators';
export * from './mappings_validator';
export * from './search_fields';
export * from './extract_mappings_definition';

View file

@ -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([]);
});
});

View file

@ -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: {} };
}

View file

@ -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<TabName>('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 (
<IndexSettingsProvider indexSettings={indexSettings}>
<MappingsState onUpdate={onUpdate} defaultValue={parsedDefaultValue}>
{({ state }) => {
const tabToContentMap = {
fields: <DocumentFields />,
templates: <TemplatesForm defaultValue={state.templates.defaultValue} />,
advanced: <ConfigurationForm defaultValue={state.configuration.defaultValue} />,
};
<div data-test-subj="mappingsEditor">
{multipleMappingsDeclared ? (
<MultipleMappingsWarning />
) : (
<IndexSettingsProvider indexSettings={indexSettings}>
<MappingsState onUpdate={onUpdate} defaultValue={parsedDefaultValue!}>
{({ state }) => {
const tabToContentMap = {
fields: <DocumentFields />,
templates: <TemplatesForm defaultValue={state.templates.defaultValue} />,
advanced: <ConfigurationForm defaultValue={state.configuration.defaultValue} />,
};
return (
<div className="mappingsEditor">
<EuiTabs>
<EuiTab
onClick={() => changeTab('fields', state)}
isSelected={selectedTab === 'fields'}
>
{i18n.translate('xpack.idxMgmt.mappingsEditor.fieldsTabLabel', {
defaultMessage: 'Mapped fields',
})}
</EuiTab>
<EuiTab
onClick={() => changeTab('templates', state)}
isSelected={selectedTab === 'templates'}
>
{i18n.translate('xpack.idxMgmt.mappingsEditor.templatesTabLabel', {
defaultMessage: 'Dynamic templates',
})}
</EuiTab>
<EuiTab
onClick={() => changeTab('advanced', state)}
isSelected={selectedTab === 'advanced'}
>
{i18n.translate('xpack.idxMgmt.mappingsEditor.advancedTabLabel', {
defaultMessage: 'Advanced options',
})}
</EuiTab>
</EuiTabs>
return (
<div className="mappingsEditor">
<EuiTabs>
<EuiTab
onClick={() => changeTab('fields', state)}
isSelected={selectedTab === 'fields'}
>
{i18n.translate('xpack.idxMgmt.mappingsEditor.fieldsTabLabel', {
defaultMessage: 'Mapped fields',
})}
</EuiTab>
<EuiTab
onClick={() => changeTab('templates', state)}
isSelected={selectedTab === 'templates'}
>
{i18n.translate('xpack.idxMgmt.mappingsEditor.templatesTabLabel', {
defaultMessage: 'Dynamic templates',
})}
</EuiTab>
<EuiTab
onClick={() => changeTab('advanced', state)}
isSelected={selectedTab === 'advanced'}
>
{i18n.translate('xpack.idxMgmt.mappingsEditor.advancedTabLabel', {
defaultMessage: 'Advanced options',
})}
</EuiTab>
</EuiTabs>
<EuiSpacer size="l" />
<EuiSpacer size="l" />
{tabToContentMap[selectedTab]}
</div>
);
}}
</MappingsState>
</IndexSettingsProvider>
{tabToContentMap[selectedTab]}
</div>
);
}}
</MappingsState>
</IndexSettingsProvider>
)}
</div>
);
});

View file

@ -287,3 +287,7 @@ export interface SearchMetadata {
*/
stringMatch: string | null;
}
export interface GenericObject {
[key: string]: any;
}

View file

@ -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`;
}