= {
+ dynamicTemplates: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.templates.dynamicTemplatesEditorLabel', {
+ defaultMessage: 'Dynamic templates data',
+ }),
+ validations: [
+ {
+ validator: isJsonField(
+ i18n.translate('xpack.idxMgmt.mappingsEditor.templates.dynamicTemplatesEditorJsonError', {
+ defaultMessage: 'The dynamic templates JSON is not valid.',
+ })
+ ),
+ },
+ ],
+ },
+};
diff --git a/x-pack/legacy/plugins/index_management/static/ui/index.ts b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/components/tree/index.ts
similarity index 89%
rename from x-pack/legacy/plugins/index_management/static/ui/index.ts
rename to x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/components/tree/index.ts
index 73bbde465146c..201488a01de94 100644
--- a/x-pack/legacy/plugins/index_management/static/ui/index.ts
+++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/components/tree/index.ts
@@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
-export * from './components';
+export * from './tree';
diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/components/tree/tree.tsx b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/components/tree/tree.tsx
new file mode 100644
index 0000000000000..ee963cfaee7f5
--- /dev/null
+++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/components/tree/tree.tsx
@@ -0,0 +1,27 @@
+/*
+ * 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 { TreeItem as TreeItemComponent } from './tree_item';
+
+export interface TreeItem {
+ label: string | JSX.Element;
+ children?: TreeItem[];
+}
+
+interface Props {
+ tree: TreeItem[];
+}
+
+export const Tree = ({ tree }: Props) => {
+ return (
+
+ {tree.map((treeItem, i) => (
+
+ ))}
+
+ );
+};
diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/components/tree/tree_item.tsx b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/components/tree/tree_item.tsx
new file mode 100644
index 0000000000000..2194bf1267dfa
--- /dev/null
+++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/components/tree/tree_item.tsx
@@ -0,0 +1,23 @@
+/*
+ * 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 { TreeItem as TreeItemType } from './tree';
+import { Tree } from './tree';
+
+interface Props {
+ treeItem: TreeItemType;
+}
+
+export const TreeItem = ({ treeItem }: Props) => {
+ return (
+
+ {treeItem.label}
+ {treeItem.children && }
+
+ );
+};
diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/constants/data_types_definition.tsx b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/constants/data_types_definition.tsx
new file mode 100644
index 0000000000000..f904281181c48
--- /dev/null
+++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/constants/data_types_definition.tsx
@@ -0,0 +1,853 @@
+/*
+ * 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 { EuiLink, EuiCode } from '@elastic/eui';
+
+import { documentationService } from '../../../services/documentation';
+import { MainType, SubType, DataType, DataTypeDefinition } from '../types';
+
+export const TYPE_DEFINITION: { [key in DataType]: DataTypeDefinition } = {
+ text: {
+ value: 'text',
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.textDescription', {
+ defaultMessage: 'Text',
+ }),
+ documentation: {
+ main: '/text.html',
+ },
+ description: () => (
+
+
+ {i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.dataType.textLongDescription.keywordTypeLink',
+ {
+ defaultMessage: 'keyword data type',
+ }
+ )}
+
+ ),
+ }}
+ />
+
+ ),
+ },
+ keyword: {
+ value: 'keyword',
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.keywordDescription', {
+ defaultMessage: 'Keyword',
+ }),
+ documentation: {
+ main: '/keyword.html',
+ },
+ description: () => (
+
+
+ {i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.dataType.keywordLongDescription.textTypeLink',
+ {
+ defaultMessage: 'text data type',
+ }
+ )}
+
+ ),
+ }}
+ />
+
+ ),
+ },
+ numeric: {
+ value: 'numeric',
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.numericDescription', {
+ defaultMessage: 'Numeric',
+ }),
+ documentation: {
+ main: '/number.html',
+ },
+ subTypes: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.numericSubtypeDescription', {
+ defaultMessage: 'Numeric type',
+ }),
+ types: ['byte', 'double', 'float', 'half_float', 'integer', 'long', 'scaled_float', 'short'],
+ },
+ },
+ byte: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.byteDescription', {
+ defaultMessage: 'Byte',
+ }),
+ value: 'byte',
+ description: () => (
+
+ -128,
+ maxValue: 127,
+ }}
+ />
+
+ ),
+ },
+ double: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.doubleDescription', {
+ defaultMessage: 'Double',
+ }),
+ value: 'double',
+ description: () => (
+
+
+
+ ),
+ },
+ integer: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.integerDescription', {
+ defaultMessage: 'Integer',
+ }),
+ value: 'integer',
+ description: () => (
+
+
+ -231
+
+ ),
+ maxValue: (
+
+ 231-1
+
+ ),
+ }}
+ />
+
+ ),
+ },
+ long: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.longDescription', {
+ defaultMessage: 'Long',
+ }),
+ value: 'long',
+ description: () => (
+
+
+ -263
+
+ ),
+ maxValue: (
+
+ 263-1
+
+ ),
+ }}
+ />
+
+ ),
+ },
+ float: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.floatDescription', {
+ defaultMessage: 'Float',
+ }),
+ value: 'float',
+ description: () => (
+
+
+
+ ),
+ },
+ half_float: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.halfFloatDescription', {
+ defaultMessage: 'Half float',
+ }),
+ value: 'half_float',
+ description: () => (
+
+
+
+ ),
+ },
+ scaled_float: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.scaledFloatDescription', {
+ defaultMessage: 'Scaled float',
+ }),
+ value: 'scaled_float',
+ description: () => (
+
+ long,
+ doubleType: double,
+ }}
+ />
+
+ ),
+ },
+ short: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.shortDescription', {
+ defaultMessage: 'Short',
+ }),
+ value: 'short',
+ description: () => (
+
+ -32,768,
+ maxValue: 32,767,
+ }}
+ />
+
+ ),
+ },
+ date: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.dateDescription', {
+ defaultMessage: 'Date',
+ }),
+ value: 'date',
+ documentation: {
+ main: '/date.html',
+ },
+ description: () => (
+
+
+
+ ),
+ },
+ date_nanos: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.dateNanosDescription', {
+ defaultMessage: 'Date nanoseconds',
+ }),
+ value: 'date_nanos',
+ documentation: {
+ main: '/date_nanos.html',
+ },
+ description: () => (
+
+
+ {i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.dataType.dateNanosLongDescription.dateTypeLink',
+ {
+ defaultMessage: 'date data type',
+ }
+ )}
+
+ ),
+ }}
+ />
+
+ ),
+ },
+ binary: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.binaryDescription', {
+ defaultMessage: 'Binary',
+ }),
+ value: 'binary',
+ documentation: {
+ main: '/binary.html',
+ },
+ description: () => (
+
+
+
+ ),
+ },
+ ip: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.ipDescription', {
+ defaultMessage: 'IP',
+ }),
+ value: 'ip',
+ documentation: {
+ main: '/ip.html',
+ },
+ description: () => (
+
+
+ {i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.dataType.ipLongDescription.ipRangeTypeLink',
+ {
+ defaultMessage: 'IP range data type',
+ }
+ )}
+
+ ),
+ }}
+ />
+
+ ),
+ },
+ boolean: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.booleanDescription', {
+ defaultMessage: 'Boolean',
+ }),
+ value: 'boolean',
+ documentation: {
+ main: '/boolean.html',
+ },
+ description: () => (
+
+ true,
+ false: false,
+ }}
+ />
+
+ ),
+ },
+ range: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.rangeDescription', {
+ defaultMessage: 'Range',
+ }),
+ value: 'range',
+ documentation: {
+ main: '/range.html',
+ },
+ subTypes: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.rangeSubtypeDescription', {
+ defaultMessage: 'Range type',
+ }),
+ types: [
+ 'date_range',
+ 'double_range',
+ 'float_range',
+ 'integer_range',
+ 'ip_range',
+ 'long_range',
+ ],
+ },
+ },
+ object: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.objectDescription', {
+ defaultMessage: 'Object',
+ }),
+ value: 'object',
+ documentation: {
+ main: '/object.html',
+ },
+ description: () => (
+
+
+ {i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.dataType.objectLongDescription.nestedTypeLink',
+ {
+ defaultMessage: 'nested data type',
+ }
+ )}
+
+ ),
+ }}
+ />
+
+ ),
+ },
+ nested: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.nestedDescription', {
+ defaultMessage: 'Nested',
+ }),
+ value: 'nested',
+ documentation: {
+ main: '/nested.html',
+ },
+ description: () => (
+
+
+ {i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.dataType.nestedLongDescription.objectTypeLink',
+ {
+ defaultMessage: 'objects',
+ }
+ )}
+
+ ),
+ }}
+ />
+
+ ),
+ },
+ rank_feature: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.rankFeatureDescription', {
+ defaultMessage: 'Rank feature',
+ }),
+ value: 'rank_feature',
+ documentation: {
+ main: '/rank-feature.html',
+ },
+ description: () => (
+
+
+ {i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.dataType.rankFeatureLongDescription.queryLink',
+ {
+ defaultMessage: 'rank_feature queries',
+ }
+ )}
+
+ ),
+ }}
+ />
+
+ ),
+ },
+ rank_features: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.rankFeaturesDescription', {
+ defaultMessage: 'Rank features',
+ }),
+ value: 'rank_features',
+ documentation: {
+ main: '/rank-features.html',
+ },
+ description: () => (
+
+
+ {i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.dataType.rankFeaturesLongDescription.queryLink',
+ {
+ defaultMessage: 'rank_feature queries',
+ }
+ )}
+
+ ),
+ }}
+ />
+
+ ),
+ },
+ dense_vector: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.denseVectorDescription', {
+ defaultMessage: 'Dense vector',
+ }),
+ value: 'dense_vector',
+ documentation: {
+ main: '/dense-vector.html',
+ },
+ description: () => (
+
+
+
+ ),
+ },
+ date_range: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.dateRangeDescription', {
+ defaultMessage: 'Date range',
+ }),
+ value: 'date_range',
+ description: () => (
+
+
+
+ ),
+ },
+ double_range: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.doubleRangeDescription', {
+ defaultMessage: 'Double range',
+ }),
+ value: 'double_range',
+ description: () => (
+
+
+
+ ),
+ },
+ float_range: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.floatRangeDescription', {
+ defaultMessage: 'Float range',
+ }),
+ value: 'float_range',
+ description: () => (
+
+
+
+ ),
+ },
+ integer_range: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.integerRangeDescription', {
+ defaultMessage: 'Integer range',
+ }),
+ value: 'integer_range',
+ description: () => (
+
+
+
+ ),
+ },
+ long_range: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.longRangeDescription', {
+ defaultMessage: 'Long range',
+ }),
+ value: 'long_range',
+ description: () => (
+
+
+
+ ),
+ },
+ ip_range: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.ipRangeDescription', {
+ defaultMessage: 'IP range',
+ }),
+ value: 'ip_range',
+ description: () => (
+
+
+
+ ),
+ },
+ geo_point: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.geoPointDescription', {
+ defaultMessage: 'Geo-point',
+ }),
+ value: 'geo_point',
+ documentation: {
+ main: '/geo-point.html',
+ },
+ description: () => (
+
+
+
+ ),
+ },
+ geo_shape: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.geoShapeDescription', {
+ defaultMessage: 'Geo-shape',
+ }),
+ value: 'geo_shape',
+ documentation: {
+ main: '/geo-shape.html',
+ learnMore: '/geo-shape.html#geoshape-indexing-approach',
+ },
+ description: () => (
+
+
+ {i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.geoShapeType.fieldDescription.learnMoreLink',
+ {
+ defaultMessage: 'Learn more.',
+ }
+ )}
+
+ ),
+ }}
+ />
+
+ ),
+ },
+ completion: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.completionSuggesterDescription', {
+ defaultMessage: 'Completion suggester',
+ }),
+ value: 'completion',
+ documentation: {
+ main: '/search-suggesters.html#completion-suggester',
+ },
+ description: () => (
+
+
+
+ ),
+ },
+ token_count: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.tokenCountDescription', {
+ defaultMessage: 'Token count',
+ }),
+ value: 'token_count',
+ documentation: {
+ main: '/token-count.html',
+ },
+ description: () => (
+
+
+
+ ),
+ },
+ percolator: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.percolatorDescription', {
+ defaultMessage: 'Percolator',
+ }),
+ value: 'percolator',
+ documentation: {
+ main: '/percolator.html',
+ },
+ description: () => (
+
+
+ {i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.dataType.percolatorLongDescription.learnMoreLink',
+ {
+ defaultMessage: 'percolator queries',
+ }
+ )}
+
+ ),
+ }}
+ />
+
+ ),
+ },
+ join: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.joinDescription', {
+ defaultMessage: 'Join',
+ }),
+ value: 'join',
+ documentation: {
+ main: '/parent-join.html',
+ },
+ description: () => (
+
+
+
+ ),
+ },
+ alias: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.aliasDescription', {
+ defaultMessage: 'Alias',
+ }),
+ value: 'alias',
+ documentation: {
+ main: '/alias.html',
+ },
+ description: () => (
+
+
+
+ ),
+ },
+ search_as_you_type: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.searchAsYouTypeDescription', {
+ defaultMessage: 'Search-as-you-type',
+ }),
+ value: 'search_as_you_type',
+ documentation: {
+ main: '/search-as-you-type.html',
+ },
+ description: () => (
+
+
+
+ ),
+ },
+ flattened: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.flattenedDescription', {
+ defaultMessage: 'Flattened',
+ }),
+ value: 'flattened',
+ documentation: {
+ main: '/flattened.html',
+ },
+ description: () => (
+
+
+
+ ),
+ },
+ shape: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.shapeDescription', {
+ defaultMessage: 'Shape',
+ }),
+ value: 'shape',
+ documentation: {
+ main: '/shape.html',
+ },
+ description: () => (
+
+
+
+ ),
+ },
+};
+
+export const MAIN_TYPES: MainType[] = [
+ 'alias',
+ 'binary',
+ 'boolean',
+ 'completion',
+ 'date',
+ 'date_nanos',
+ 'dense_vector',
+ 'flattened',
+ 'geo_point',
+ 'geo_shape',
+ 'ip',
+ 'join',
+ 'keyword',
+ 'nested',
+ 'numeric',
+ 'object',
+ 'percolator',
+ 'range',
+ 'rank_feature',
+ 'rank_features',
+ 'search_as_you_type',
+ 'shape',
+ 'text',
+ 'token_count',
+];
+
+export const MAIN_DATA_TYPE_DEFINITION: {
+ [key in MainType]: DataTypeDefinition;
+} = MAIN_TYPES.reduce(
+ (acc, type) => ({
+ ...acc,
+ [type]: TYPE_DEFINITION[type],
+ }),
+ {} as { [key in MainType]: DataTypeDefinition }
+);
+
+/**
+ * Return a map of subType -> mainType
+ *
+ * @example
+ *
+ * {
+ * long: 'numeric',
+ * integer: 'numeric',
+ * short: 'numeric',
+ * }
+ */
+export const SUB_TYPE_MAP_TO_MAIN = Object.entries(MAIN_DATA_TYPE_DEFINITION).reduce(
+ (acc, [type, definition]) => {
+ if ({}.hasOwnProperty.call(definition, 'subTypes')) {
+ definition.subTypes!.types.forEach(subType => {
+ acc[subType] = type;
+ });
+ }
+ return acc;
+ },
+ {} as Record
+);
+
+// Single source of truth of all the possible data types.
+export const ALL_DATA_TYPES = [
+ ...Object.keys(MAIN_DATA_TYPE_DEFINITION),
+ ...Object.keys(SUB_TYPE_MAP_TO_MAIN),
+];
diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/constants/default_values.ts b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/constants/default_values.ts
new file mode 100644
index 0000000000000..96623b855dd3a
--- /dev/null
+++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/constants/default_values.ts
@@ -0,0 +1,14 @@
+/*
+ * 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.
+ */
+
+/**
+ * When we want to set a parameter value to the index "default" in a Select option
+ * we will use this constant to define it. We will then strip this placeholder value
+ * and let Elasticsearch handle it.
+ */
+export const INDEX_DEFAULT = 'index_default';
+
+export const STANDARD = 'standard';
diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/constants/field_options.tsx b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/constants/field_options.tsx
new file mode 100644
index 0000000000000..710e637de8b08
--- /dev/null
+++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/constants/field_options.tsx
@@ -0,0 +1,255 @@
+/*
+ * 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 { EuiText } from '@elastic/eui';
+
+import { DataType, ParameterName, SelectOption, SuperSelectOption, ComboBoxOption } from '../types';
+import { FIELD_OPTIONS_TEXTS, LANGUAGE_OPTIONS_TEXT, FieldOption } from './field_options_i18n';
+import { INDEX_DEFAULT, STANDARD } from './default_values';
+import { MAIN_DATA_TYPE_DEFINITION } from './data_types_definition';
+
+export const TYPE_ONLY_ALLOWED_AT_ROOT_LEVEL: DataType[] = ['join'];
+
+export const TYPE_NOT_ALLOWED_MULTIFIELD: DataType[] = [
+ ...TYPE_ONLY_ALLOWED_AT_ROOT_LEVEL,
+ 'object',
+ 'nested',
+ 'alias',
+];
+
+export const FIELD_TYPES_OPTIONS = Object.entries(MAIN_DATA_TYPE_DEFINITION).map(
+ ([dataType, { label }]) => ({
+ value: dataType,
+ label,
+ })
+) as ComboBoxOption[];
+
+interface SuperSelectOptionConfig {
+ inputDisplay: string;
+ dropdownDisplay: JSX.Element;
+}
+
+export const getSuperSelectOption = (
+ title: string,
+ description: string
+): SuperSelectOptionConfig => ({
+ inputDisplay: title,
+ dropdownDisplay: (
+ <>
+ {title}
+
+ {description}
+
+ >
+ ),
+});
+
+const getOptionTexts = (option: FieldOption): SuperSelectOptionConfig =>
+ getSuperSelectOption(FIELD_OPTIONS_TEXTS[option].title, FIELD_OPTIONS_TEXTS[option].description);
+
+type ParametersOptions = ParameterName | 'languageAnalyzer';
+
+export const PARAMETERS_OPTIONS: {
+ [key in ParametersOptions]?: SelectOption[] | SuperSelectOption[];
+} = {
+ index_options: [
+ {
+ value: 'docs',
+ ...getOptionTexts('indexOptions.docs'),
+ },
+ {
+ value: 'freqs',
+ ...getOptionTexts('indexOptions.freqs'),
+ },
+ {
+ value: 'positions',
+ ...getOptionTexts('indexOptions.positions'),
+ },
+ {
+ value: 'offsets',
+ ...getOptionTexts('indexOptions.offsets'),
+ },
+ ] as SuperSelectOption[],
+ index_options_flattened: [
+ {
+ value: 'docs',
+ ...getOptionTexts('indexOptions.docs'),
+ },
+ {
+ value: 'freqs',
+ ...getOptionTexts('indexOptions.freqs'),
+ },
+ ] as SuperSelectOption[],
+ index_options_keyword: [
+ {
+ value: 'docs',
+ ...getOptionTexts('indexOptions.docs'),
+ },
+ {
+ value: 'freqs',
+ ...getOptionTexts('indexOptions.freqs'),
+ },
+ ] as SuperSelectOption[],
+ analyzer: [
+ {
+ value: INDEX_DEFAULT,
+ ...getOptionTexts('analyzer.indexDefault'),
+ },
+ {
+ value: STANDARD,
+ ...getOptionTexts('analyzer.standard'),
+ },
+ {
+ value: 'simple',
+ ...getOptionTexts('analyzer.simple'),
+ },
+ {
+ value: 'whitespace',
+ ...getOptionTexts('analyzer.whitespace'),
+ },
+ {
+ value: 'stop',
+ ...getOptionTexts('analyzer.stop'),
+ },
+ {
+ value: 'keyword',
+ ...getOptionTexts('analyzer.keyword'),
+ },
+ {
+ value: 'pattern',
+ ...getOptionTexts('analyzer.pattern'),
+ },
+ {
+ value: 'fingerprint',
+ ...getOptionTexts('analyzer.fingerprint'),
+ },
+ {
+ value: 'language',
+ ...getOptionTexts('analyzer.language'),
+ },
+ ] as SuperSelectOption[],
+ languageAnalyzer: Object.entries(LANGUAGE_OPTIONS_TEXT).map(([value, text]) => ({
+ value,
+ text,
+ })),
+ similarity: [
+ {
+ value: 'BM25',
+ ...getOptionTexts('similarity.bm25'),
+ },
+ {
+ value: 'boolean',
+ ...getOptionTexts('similarity.boolean'),
+ },
+ ] as SuperSelectOption[],
+ term_vector: [
+ {
+ value: 'no',
+ ...getOptionTexts('termVector.no'),
+ },
+ {
+ value: 'yes',
+ ...getOptionTexts('termVector.yes'),
+ },
+ {
+ value: 'with_positions',
+ ...getOptionTexts('termVector.withPositions'),
+ },
+ {
+ value: 'with_offsets',
+ ...getOptionTexts('termVector.withOffsets'),
+ },
+ {
+ value: 'with_positions_offsets',
+ ...getOptionTexts('termVector.withPositionsOffsets'),
+ },
+ {
+ value: 'with_positions_payloads',
+ ...getOptionTexts('termVector.withPositionsPayloads'),
+ },
+ {
+ value: 'with_positions_offsets_payloads',
+ ...getOptionTexts('termVector.withPositionsOffsetsPayloads'),
+ },
+ ] as SuperSelectOption[],
+ orientation: [
+ {
+ value: 'ccw',
+ ...getOptionTexts('orientation.counterclockwise'),
+ },
+ {
+ value: 'cw',
+ ...getOptionTexts('orientation.clockwise'),
+ },
+ ] as SuperSelectOption[],
+};
+
+const DATE_FORMATS = [
+ { label: 'epoch_millis' },
+ { label: 'epoch_second' },
+ { label: 'date_optional_time', strict: true },
+ { label: 'basic_date' },
+ { label: 'basic_date_time' },
+ { label: 'basic_date_time_no_millis' },
+ { label: 'basic_ordinal_date' },
+ { label: 'basic_ordinal_date_time' },
+ { label: 'basic_ordinal_date_time_no_millis' },
+ { label: 'basic_time' },
+ { label: 'basic_time_no_millis' },
+ { label: 'basic_t_time' },
+ { label: 'basic_t_time_no_millis' },
+ { label: 'basic_week_date', strict: true },
+ { label: 'basic_week_date_time', strict: true },
+ {
+ label: 'basic_week_date_time_no_millis',
+ strict: true,
+ },
+ { label: 'date', strict: true },
+ { label: 'date_hour', strict: true },
+ { label: 'date_hour_minute', strict: true },
+ { label: 'date_hour_minute_second', strict: true },
+ {
+ label: 'date_hour_minute_second_fraction',
+ strict: true,
+ },
+ {
+ label: 'date_hour_minute_second_millis',
+ strict: true,
+ },
+ { label: 'date_time', strict: true },
+ { label: 'date_time_no_millis', strict: true },
+ { label: 'hour', strict: true },
+ { label: 'hour_minute ', strict: true },
+ { label: 'hour_minute_second', strict: true },
+ { label: 'hour_minute_second_fraction', strict: true },
+ { label: 'hour_minute_second_millis', strict: true },
+ { label: 'ordinal_date', strict: true },
+ { label: 'ordinal_date_time', strict: true },
+ { label: 'ordinal_date_time_no_millis', strict: true },
+ { label: 'time', strict: true },
+ { label: 'time_no_millis', strict: true },
+ { label: 't_time', strict: true },
+ { label: 't_time_no_millis', strict: true },
+ { label: 'week_date', strict: true },
+ { label: 'week_date_time', strict: true },
+ { label: 'week_date_time_no_millis', strict: true },
+ { label: 'weekyear', strict: true },
+ { label: 'weekyear_week', strict: true },
+ { label: 'weekyear_week_day', strict: true },
+ { label: 'year', strict: true },
+ { label: 'year_month', strict: true },
+ { label: 'year_month_day', strict: true },
+];
+
+const STRICT_DATE_FORMAT_OPTIONS = DATE_FORMATS.filter(format => format.strict).map(
+ ({ label }) => ({
+ label: `strict_${label}`,
+ })
+);
+
+const DATE_FORMAT_OPTIONS = DATE_FORMATS.map(({ label }) => ({ label }));
+
+export const ALL_DATE_FORMAT_OPTIONS = [...DATE_FORMAT_OPTIONS, ...STRICT_DATE_FORMAT_OPTIONS];
diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/constants/field_options_i18n.ts b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/constants/field_options_i18n.ts
new file mode 100644
index 0000000000000..15079d520f2ad
--- /dev/null
+++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/constants/field_options_i18n.ts
@@ -0,0 +1,495 @@
+/*
+ * 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 { i18n } from '@kbn/i18n';
+
+interface Optioni18n {
+ title: string;
+ description: string;
+}
+
+type IndexOptions =
+ | 'indexOptions.docs'
+ | 'indexOptions.freqs'
+ | 'indexOptions.positions'
+ | 'indexOptions.offsets';
+
+type AnalyzerOptions =
+ | 'analyzer.indexDefault'
+ | 'analyzer.standard'
+ | 'analyzer.simple'
+ | 'analyzer.whitespace'
+ | 'analyzer.stop'
+ | 'analyzer.keyword'
+ | 'analyzer.pattern'
+ | 'analyzer.fingerprint'
+ | 'analyzer.language';
+
+type SimilarityOptions = 'similarity.bm25' | 'similarity.boolean';
+
+type TermVectorOptions =
+ | 'termVector.no'
+ | 'termVector.yes'
+ | 'termVector.withPositions'
+ | 'termVector.withOffsets'
+ | 'termVector.withPositionsOffsets'
+ | 'termVector.withPositionsPayloads'
+ | 'termVector.withPositionsOffsetsPayloads';
+
+type OrientationOptions = 'orientation.counterclockwise' | 'orientation.clockwise';
+
+type LanguageAnalyzerOption =
+ | 'arabic'
+ | 'armenian'
+ | 'basque'
+ | 'bengali'
+ | 'brazilian'
+ | 'bulgarian'
+ | 'catalan'
+ | 'cjk'
+ | 'czech'
+ | 'danish'
+ | 'dutch'
+ | 'english'
+ | 'finnish'
+ | 'french'
+ | 'galician'
+ | 'german'
+ | 'greek'
+ | 'hindi'
+ | 'hungarian'
+ | 'indonesian'
+ | 'irish'
+ | 'italian'
+ | 'latvian'
+ | 'lithuanian'
+ | 'norwegian'
+ | 'persian'
+ | 'portuguese'
+ | 'romanian'
+ | 'russian'
+ | 'sorani'
+ | 'spanish'
+ | 'swedish'
+ | 'turkish'
+ | 'thai';
+
+export type FieldOption =
+ | IndexOptions
+ | AnalyzerOptions
+ | SimilarityOptions
+ | TermVectorOptions
+ | OrientationOptions;
+
+export const FIELD_OPTIONS_TEXTS: { [key in FieldOption]: Optioni18n } = {
+ 'indexOptions.docs': {
+ title: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.indexOptions.docNumberTitle', {
+ defaultMessage: 'Doc number',
+ }),
+ description: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.indexOptions.docNumberDescription',
+ {
+ defaultMessage:
+ 'Index the doc number only. Used to verify the existence of a term in a field.',
+ }
+ ),
+ },
+ 'indexOptions.freqs': {
+ title: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.indexOptions.termFrequencyTitle',
+ {
+ defaultMessage: 'Term frequencies',
+ }
+ ),
+ description: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.indexOptions.termFrequencyDescription',
+ {
+ defaultMessage:
+ 'Index the doc number and term frequencies. Repeated terms score higher than single terms.',
+ }
+ ),
+ },
+ 'indexOptions.positions': {
+ title: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.indexOptions.positionsTitle', {
+ defaultMessage: 'Positions',
+ }),
+ description: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.indexOptions.positionsDescription',
+ {
+ defaultMessage:
+ 'Index the doc number, term frequencies, positions, and start and end character offsets. Offsets map the term back to the original string.',
+ }
+ ),
+ },
+ 'indexOptions.offsets': {
+ title: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.indexOptions.offsetsTitle', {
+ defaultMessage: 'Offsets',
+ }),
+ description: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.indexOptions.offsetsDescription',
+ {
+ defaultMessage:
+ 'Doc number, term frequencies, positions, and start and end character offsets (which map the term back to the original string) are indexed.',
+ }
+ ),
+ },
+ 'analyzer.indexDefault': {
+ title: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.analyzer.indexDefaultTitle', {
+ defaultMessage: 'Index default',
+ }),
+ description: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.analyzer.indexDefaultDescription',
+ {
+ defaultMessage: 'Use the analyzer defined for the index.',
+ }
+ ),
+ },
+ 'analyzer.standard': {
+ title: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.analyzer.standardTitle', {
+ defaultMessage: 'Standard',
+ }),
+ description: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.analyzer.standardDescription',
+ {
+ defaultMessage:
+ 'The standard analyzer divides text into terms on word boundaries, as defined by the Unicode Text Segmentation algorithm.',
+ }
+ ),
+ },
+ 'analyzer.simple': {
+ title: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.analyzer.simpleTitle', {
+ defaultMessage: 'Simple',
+ }),
+ description: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.analyzer.simpleDescription',
+ {
+ defaultMessage:
+ 'The simple analyzer divides text into terms whenever it encounters a character which is not a letter. ',
+ }
+ ),
+ },
+ 'analyzer.whitespace': {
+ title: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.analyzer.whitespaceTitle', {
+ defaultMessage: 'Whitespace',
+ }),
+ description: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.analyzer.whitespaceDescription',
+ {
+ defaultMessage:
+ 'The whitespace analyzer divides text into terms whenever it encounters any whitespace character.',
+ }
+ ),
+ },
+ 'analyzer.stop': {
+ title: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.analyzer.stopTitle', {
+ defaultMessage: 'Stop',
+ }),
+ description: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.analyzer.stopDescription',
+ {
+ defaultMessage:
+ 'The stop analyzer is like the simple analyzer, but also supports removal of stop words.',
+ }
+ ),
+ },
+ 'analyzer.keyword': {
+ title: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.analyzer.keywordTitle', {
+ defaultMessage: 'Keyword',
+ }),
+ description: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.analyzer.keywordDescription',
+ {
+ defaultMessage:
+ 'The keyword analyzer is a “noop” analyzer that accepts whatever text it is given and outputs the exact same text as a single term.',
+ }
+ ),
+ },
+ 'analyzer.pattern': {
+ title: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.analyzer.patternTitle', {
+ defaultMessage: 'Pattern',
+ }),
+ description: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.analyzer.patternDescription',
+ {
+ defaultMessage:
+ 'The pattern analyzer uses a regular expression to split the text into terms. It supports lower-casing and stop words.',
+ }
+ ),
+ },
+ 'analyzer.fingerprint': {
+ title: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.analyzer.fingerprintTitle', {
+ defaultMessage: 'Fingerprint',
+ }),
+ description: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.analyzer.fingerprintDescription',
+ {
+ defaultMessage:
+ 'The fingerprint analyzer is a specialist analyzer which creates a fingerprint which can be used for duplicate detection.',
+ }
+ ),
+ },
+ 'analyzer.language': {
+ title: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.analyzer.languageTitle', {
+ defaultMessage: 'Language',
+ }),
+ description: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.analyzer.languageDescription',
+ {
+ defaultMessage:
+ 'Elasticsearch provides many language-specific analyzers like english or french.',
+ }
+ ),
+ },
+ 'similarity.bm25': {
+ title: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.similarity.bm25Title', {
+ defaultMessage: 'Okapi BM25',
+ }),
+ description: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.similarity.bm25Description',
+ {
+ defaultMessage: 'The default algorithm used in Elasticsearch and Lucene.',
+ }
+ ),
+ },
+ 'similarity.boolean': {
+ title: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.similarity.booleanTitle', {
+ defaultMessage: 'Boolean',
+ }),
+ description: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.similarity.booleanDescription',
+ {
+ defaultMessage:
+ 'A boolean similarity to use when full text-ranking is not needed. The score is based on whether the query terms match.',
+ }
+ ),
+ },
+ 'termVector.no': {
+ title: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.termVector.noTitle', {
+ defaultMessage: 'No',
+ }),
+ description: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.termVector.noDescription',
+ {
+ defaultMessage: 'No term vectors are stored.',
+ }
+ ),
+ },
+ 'termVector.yes': {
+ title: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.termVector.yesTitle', {
+ defaultMessage: 'Yes',
+ }),
+ description: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.termVector.yesDescription',
+ {
+ defaultMessage: 'Just the terms in the field are stored.',
+ }
+ ),
+ },
+ 'termVector.withPositions': {
+ title: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.termVector.withPositionsTitle', {
+ defaultMessage: 'With positions',
+ }),
+ description: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.termVector.withPositionsDescription',
+ {
+ defaultMessage: 'Terms and positions are stored.',
+ }
+ ),
+ },
+ 'termVector.withOffsets': {
+ title: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.termVector.withOffsetsTitle', {
+ defaultMessage: 'With offsets',
+ }),
+ description: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.termVector.withOffsetsDescription',
+ {
+ defaultMessage: 'Terms and character offsets are stored.',
+ }
+ ),
+ },
+ 'termVector.withPositionsOffsets': {
+ title: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.termVector.withPositionsOffsetsTitle',
+ {
+ defaultMessage: 'With positions and offsets',
+ }
+ ),
+ description: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.termVector.withPositionsOffsetsDescription',
+ {
+ defaultMessage: 'Terms, positions, and character offsets are stored.',
+ }
+ ),
+ },
+ 'termVector.withPositionsPayloads': {
+ title: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.termVector.withPositionsPayloadsTitle',
+ {
+ defaultMessage: 'With positions and payloads',
+ }
+ ),
+ description: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.termVector.withPositionsPayloadsDescription',
+ {
+ defaultMessage: 'Terms, positions, and payloads are stored.',
+ }
+ ),
+ },
+ 'termVector.withPositionsOffsetsPayloads': {
+ title: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.termVector.withPositionsOffsetsPayloadsTitle',
+ {
+ defaultMessage: 'With positions, offsets, and payloads',
+ }
+ ),
+ description: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.termVector.withPositionsOffsetsPayloadsDescription',
+ {
+ defaultMessage: 'Terms, positions, offsets and payloads are stored.',
+ }
+ ),
+ },
+ 'orientation.counterclockwise': {
+ title: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.orientation.counterclockwiseTitle',
+ {
+ defaultMessage: 'Counterclockwise',
+ }
+ ),
+ description: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.orientation.counterclockwiseDescription',
+ {
+ defaultMessage:
+ 'Defines outer polygon vertices in counterclockwise order and interior shape vertices in clockwise order. This is the Open Geospatial Consortium (OGC) and GeoJSON standard.',
+ }
+ ),
+ },
+ 'orientation.clockwise': {
+ title: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.orientation.clockwiseTitle', {
+ defaultMessage: 'Clockwise',
+ }),
+ description: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.orientation.clockwiseDescription',
+ {
+ defaultMessage:
+ 'Defines outer polygon vertices in clockwise order and interior shape vertices in counterclockwise order.',
+ }
+ ),
+ },
+};
+
+export const LANGUAGE_OPTIONS_TEXT: { [key in LanguageAnalyzerOption]: string } = {
+ arabic: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.arabic', {
+ defaultMessage: 'Arabic',
+ }),
+ armenian: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.armenian', {
+ defaultMessage: 'Armenian',
+ }),
+ basque: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.basque', {
+ defaultMessage: 'Basque',
+ }),
+ bengali: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.bengali', {
+ defaultMessage: 'Bengali',
+ }),
+ brazilian: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.brazilian', {
+ defaultMessage: 'Brazilian',
+ }),
+ bulgarian: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.bulgarian', {
+ defaultMessage: 'Bulgarian',
+ }),
+ catalan: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.catalan', {
+ defaultMessage: 'Catalan',
+ }),
+ cjk: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.cjk', {
+ defaultMessage: 'Cjk',
+ }),
+ czech: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.czech', {
+ defaultMessage: 'Czech',
+ }),
+ danish: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.danish', {
+ defaultMessage: 'Danish',
+ }),
+ dutch: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.dutch', {
+ defaultMessage: 'Dutch',
+ }),
+ english: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.english', {
+ defaultMessage: 'English',
+ }),
+ finnish: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.finnish', {
+ defaultMessage: 'Finnish',
+ }),
+ french: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.french', {
+ defaultMessage: 'French',
+ }),
+ galician: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.galician', {
+ defaultMessage: 'Galician',
+ }),
+ german: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.german', {
+ defaultMessage: 'German',
+ }),
+ greek: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.greek', {
+ defaultMessage: 'Greek',
+ }),
+ hindi: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.hindi', {
+ defaultMessage: 'Hindi',
+ }),
+ hungarian: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.hungarian', {
+ defaultMessage: 'Hungarian',
+ }),
+ indonesian: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.indonesian',
+ {
+ defaultMessage: 'Indonesian',
+ }
+ ),
+ irish: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.irish', {
+ defaultMessage: 'Irish',
+ }),
+ italian: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.italian', {
+ defaultMessage: 'Italian',
+ }),
+ latvian: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.latvian', {
+ defaultMessage: 'Latvian',
+ }),
+ lithuanian: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.lithuanian',
+ {
+ defaultMessage: 'Lithuanian',
+ }
+ ),
+ norwegian: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.norwegian', {
+ defaultMessage: 'Norwegian',
+ }),
+ persian: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.persian', {
+ defaultMessage: 'Persian',
+ }),
+ portuguese: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.portuguese',
+ {
+ defaultMessage: 'Portuguese',
+ }
+ ),
+ romanian: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.romanian', {
+ defaultMessage: 'Romanian',
+ }),
+ russian: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.russian', {
+ defaultMessage: 'Russian',
+ }),
+ sorani: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.sorani', {
+ defaultMessage: 'Sorani',
+ }),
+ spanish: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.spanish', {
+ defaultMessage: 'Spanish',
+ }),
+ swedish: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.swedish', {
+ defaultMessage: 'Swedish',
+ }),
+ thai: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.thai', {
+ defaultMessage: 'Thai',
+ }),
+ turkish: i18n.translate('xpack.idxMgmt.mappingsEditor.formSelect.languageAnalyzer.turkish', {
+ defaultMessage: 'Turkish',
+ }),
+};
diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/constants/index.ts b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/constants/index.ts
new file mode 100644
index 0000000000000..8addf3d9c4284
--- /dev/null
+++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/constants/index.ts
@@ -0,0 +1,15 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export * from './default_values';
+
+export * from './field_options';
+
+export * from './data_types_definition';
+
+export * from './parameters_definition';
+
+export * from './mappings_editor';
diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/constants/mappings_editor.ts b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/constants/mappings_editor.ts
new file mode 100644
index 0000000000000..1678e09512019
--- /dev/null
+++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/constants/mappings_editor.ts
@@ -0,0 +1,21 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/**
+ * The max nested depth allowed for child fields.
+ * Above this thresold, the user has to use the JSON editor.
+ */
+export const MAX_DEPTH_DEFAULT_EDITOR = 4;
+
+/**
+ * 16px is the default $euiSize Sass variable.
+ * @link https://elastic.github.io/eui/#/guidelines/sass
+ */
+export const EUI_SIZE = 16;
+
+export const CHILD_FIELD_INDENT_SIZE = EUI_SIZE * 1.5;
+
+export const LEFT_PADDING_SIZE_FIELD_ITEM_WRAPPER = EUI_SIZE * 0.25;
diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/constants/parameters_definition.tsx b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/constants/parameters_definition.tsx
new file mode 100644
index 0000000000000..39da6dcf336b5
--- /dev/null
+++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/constants/parameters_definition.tsx
@@ -0,0 +1,899 @@
+/*
+ * 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 { FormattedMessage } from '@kbn/i18n/react';
+import { i18n } from '@kbn/i18n';
+import Joi from 'joi';
+
+import { EuiLink, EuiCode } from '@elastic/eui';
+import {
+ FIELD_TYPES,
+ fieldValidators,
+ ValidationFunc,
+ ValidationFuncArg,
+ fieldFormatters,
+ FieldConfig,
+} from '../shared_imports';
+import { AliasOption, DataType, ComboBoxOption } from '../types';
+import { documentationService } from '../../../services/documentation';
+import { INDEX_DEFAULT } from './default_values';
+import { TYPE_DEFINITION } from './data_types_definition';
+
+const { toInt } = fieldFormatters;
+const { emptyField, containsCharsField, numberGreaterThanField } = fieldValidators;
+
+const commonErrorMessages = {
+ smallerThanZero: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.parameters.validations.smallerZeroErrorMessage',
+ {
+ defaultMessage: 'The value must be greater or equal to 0.',
+ }
+ ),
+ spacesNotAllowed: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.parameters.validations.spacesNotAllowedErrorMessage',
+ {
+ defaultMessage: 'Spaces are not allowed.',
+ }
+ ),
+ analyzerIsRequired: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.parameters.validations.analyzerIsRequiredErrorMessage',
+ {
+ defaultMessage: 'Specify the custom analyzer name or choose a built-in analyzer.',
+ }
+ ),
+};
+
+const nullValueLabel = i18n.translate('xpack.idxMgmt.mappingsEditor.nullValueFieldLabel', {
+ defaultMessage: 'Null value',
+});
+
+const nullValueValidateEmptyField = emptyField(
+ i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.parameters.validations.nullValueIsRequiredErrorMessage',
+ {
+ defaultMessage: 'Null value is required.',
+ }
+ )
+);
+
+const mapIndexToValue = ['true', true, 'false', false];
+
+const indexOptionsConfig = {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.indexOptionsLabel', {
+ defaultMessage: 'Index options',
+ }),
+ helpText: () => (
+
+ {i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.configuration.indexOptionsdDocumentationLink',
+ {
+ defaultMessage: 'Learn more.',
+ }
+ )}
+
+ ),
+ }}
+ />
+ ),
+ type: FIELD_TYPES.SUPER_SELECT,
+};
+
+const fielddataFrequencyFilterParam = {
+ fieldConfig: { defaultValue: {} }, // Needed for "FieldParams" type
+ props: {
+ min_segment_size: {
+ fieldConfig: {
+ type: FIELD_TYPES.NUMBER,
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.minSegmentSizeFieldLabel', {
+ defaultMessage: 'Minimum segment size',
+ }),
+ defaultValue: 50,
+ formatters: [toInt],
+ },
+ },
+ },
+ schema: Joi.object().keys({
+ min: Joi.number(),
+ max: Joi.number(),
+ min_segment_size: Joi.number(),
+ }),
+};
+
+const analyzerValidations = [
+ {
+ validator: emptyField(commonErrorMessages.analyzerIsRequired),
+ },
+ {
+ validator: containsCharsField({
+ chars: ' ',
+ message: commonErrorMessages.spacesNotAllowed,
+ }),
+ },
+];
+
+/**
+ * Single source of truth for the parameters a user can change on _any_ field type.
+ * It is also the single source of truth for the parameters default values.
+ *
+ * As a consequence, if a parameter is *not* declared here, we won't be able to declare it in the Json editor.
+ */
+export const PARAMETERS_DEFINITION = {
+ name: {
+ fieldConfig: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.nameFieldLabel', {
+ defaultMessage: 'Field name',
+ }),
+ defaultValue: '',
+ validations: [
+ {
+ validator: emptyField(
+ i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.parameters.validations.nameIsRequiredErrorMessage',
+ {
+ defaultMessage: 'Give a name to the field.',
+ }
+ )
+ ),
+ },
+ ],
+ },
+ },
+ type: {
+ fieldConfig: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.typeFieldLabel', {
+ defaultMessage: 'Field type',
+ }),
+ defaultValue: 'text',
+ deserializer: (fieldType: DataType | undefined) => {
+ if (typeof fieldType === 'string' && Boolean(fieldType)) {
+ return [
+ {
+ label: TYPE_DEFINITION[fieldType] ? TYPE_DEFINITION[fieldType].label : fieldType,
+ value: fieldType,
+ },
+ ];
+ }
+ return [];
+ },
+ serializer: (fieldType: ComboBoxOption[] | undefined) =>
+ fieldType && fieldType.length ? fieldType[0].value : fieldType,
+ validations: [
+ {
+ validator: emptyField(
+ i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.parameters.validations.typeIsRequiredErrorMessage',
+ {
+ defaultMessage: 'Specify a field type.',
+ }
+ )
+ ),
+ },
+ ],
+ },
+ schema: Joi.string(),
+ },
+ store: {
+ fieldConfig: {
+ type: FIELD_TYPES.CHECKBOX,
+ defaultValue: false,
+ },
+ schema: Joi.boolean().strict(),
+ },
+ index: {
+ fieldConfig: {
+ type: FIELD_TYPES.CHECKBOX,
+ defaultValue: true,
+ },
+ schema: Joi.boolean().strict(),
+ },
+ doc_values: {
+ fieldConfig: {
+ defaultValue: true,
+ },
+ schema: Joi.boolean().strict(),
+ },
+ doc_values_binary: {
+ fieldConfig: {
+ defaultValue: false,
+ },
+ schema: Joi.boolean().strict(),
+ },
+ fielddata: {
+ fieldConfig: {
+ type: FIELD_TYPES.CHECKBOX,
+ defaultValue: false,
+ },
+ schema: Joi.boolean().strict(),
+ },
+ fielddata_frequency_filter: fielddataFrequencyFilterParam,
+ fielddata_frequency_filter_percentage: {
+ ...fielddataFrequencyFilterParam,
+ props: {
+ min: {
+ fieldConfig: {
+ defaultValue: 0.01,
+ serializer: value => (value === '' ? '' : toInt(value) / 100),
+ deserializer: value => Math.round(value * 100),
+ } as FieldConfig,
+ },
+ max: {
+ fieldConfig: {
+ defaultValue: 1,
+ serializer: value => (value === '' ? '' : toInt(value) / 100),
+ deserializer: value => Math.round(value * 100),
+ } as FieldConfig,
+ },
+ },
+ },
+ fielddata_frequency_filter_absolute: {
+ ...fielddataFrequencyFilterParam,
+ props: {
+ min: {
+ fieldConfig: {
+ defaultValue: 2,
+ validations: [
+ {
+ validator: numberGreaterThanField({
+ than: 1,
+ message: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.parameters.validations.fieldDataFrequency.numberGreaterThanOneErrorMessage',
+ {
+ defaultMessage: 'Value must be greater than one.',
+ }
+ ),
+ }),
+ },
+ ],
+ formatters: [toInt],
+ } as FieldConfig,
+ },
+ max: {
+ fieldConfig: {
+ defaultValue: 5,
+ validations: [
+ {
+ validator: numberGreaterThanField({
+ than: 1,
+ message: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.parameters.validations.fieldDataFrequency.numberGreaterThanOneErrorMessage',
+ {
+ defaultMessage: 'Value must be greater than one.',
+ }
+ ),
+ }),
+ },
+ ],
+ formatters: [toInt],
+ } as FieldConfig,
+ },
+ },
+ },
+ coerce: {
+ fieldConfig: {
+ defaultValue: true,
+ },
+ schema: Joi.boolean().strict(),
+ },
+ coerce_shape: {
+ fieldConfig: {
+ defaultValue: false,
+ },
+ schema: Joi.boolean().strict(),
+ },
+ ignore_malformed: {
+ fieldConfig: {
+ defaultValue: false,
+ },
+ schema: Joi.boolean().strict(),
+ },
+ null_value: {
+ fieldConfig: {
+ defaultValue: '',
+ type: FIELD_TYPES.TEXT,
+ label: nullValueLabel,
+ },
+ schema: Joi.string(),
+ },
+ null_value_ip: {
+ fieldConfig: {
+ defaultValue: '',
+ type: FIELD_TYPES.TEXT,
+ label: nullValueLabel,
+ helpText: i18n.translate('xpack.idxMgmt.mappingsEditor.parameters.nullValueIpHelpText', {
+ defaultMessage: 'Accepts an IP address.',
+ }),
+ },
+ },
+ null_value_numeric: {
+ fieldConfig: {
+ defaultValue: '', // Needed for FieldParams typing
+ label: nullValueLabel,
+ formatters: [toInt],
+ validations: [
+ {
+ validator: nullValueValidateEmptyField,
+ },
+ ],
+ },
+ schema: Joi.number(),
+ },
+ null_value_boolean: {
+ fieldConfig: {
+ defaultValue: false,
+ label: nullValueLabel,
+ deserializer: (value: string | boolean) => mapIndexToValue.indexOf(value),
+ serializer: (value: number) => mapIndexToValue[value],
+ },
+ schema: Joi.any().valid([true, false, 'true', 'false']),
+ },
+ null_value_geo_point: {
+ fieldConfig: {
+ defaultValue: '', // Needed for FieldParams typing
+ label: nullValueLabel,
+ helpText: () => (
+
+ {i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.parameters.wellKnownTextDocumentationLink',
+ {
+ defaultMessage: 'Well-Known Text',
+ }
+ )}
+
+ ),
+ }}
+ />
+ ),
+ validations: [
+ {
+ validator: nullValueValidateEmptyField,
+ },
+ ],
+ deserializer: (value: any) => {
+ if (value === '') {
+ return value;
+ }
+ return JSON.stringify(value);
+ },
+ serializer: (value: string) => {
+ try {
+ return JSON.parse(value);
+ } catch (error) {
+ // swallow error and return non-parsed value;
+ return value;
+ }
+ },
+ },
+ schema: Joi.any(),
+ },
+ copy_to: {
+ fieldConfig: {
+ defaultValue: '',
+ type: FIELD_TYPES.TEXT,
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.parameters.copyToLabel', {
+ defaultMessage: 'Group field name',
+ }),
+ validations: [
+ {
+ validator: emptyField(
+ i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.parameters.validations.copyToIsRequiredErrorMessage',
+ {
+ defaultMessage: 'Group field name is required.',
+ }
+ )
+ ),
+ },
+ ],
+ },
+ schema: Joi.string(),
+ },
+ max_input_length: {
+ fieldConfig: {
+ defaultValue: 50,
+ type: FIELD_TYPES.NUMBER,
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.parameters.maxInputLengthLabel', {
+ defaultMessage: 'Max input length',
+ }),
+ formatters: [toInt],
+ validations: [
+ {
+ validator: emptyField(
+ i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.parameters.validations.maxInputLengthFieldRequiredErrorMessage',
+ {
+ defaultMessage: 'Specify a max input length.',
+ }
+ )
+ ),
+ },
+ ],
+ },
+ schema: Joi.number(),
+ },
+ locale: {
+ fieldConfig: {
+ defaultValue: 'ROOT',
+ type: FIELD_TYPES.TEXT,
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.parameters.localeLabel', {
+ defaultMessage: 'Locale',
+ }),
+ helpText: () => (
+ en-US,
+ hyphen: -,
+ underscore: _,
+ }}
+ />
+ ),
+ validations: [
+ {
+ validator: emptyField(
+ i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.parameters.validations.localeFieldRequiredErrorMessage',
+ {
+ defaultMessage: 'Specify a locale.',
+ }
+ )
+ ),
+ },
+ ],
+ },
+ schema: Joi.string(),
+ },
+ orientation: {
+ fieldConfig: {
+ defaultValue: 'ccw',
+ type: FIELD_TYPES.SUPER_SELECT,
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.parameters.orientationLabel', {
+ defaultMessage: 'Orientation',
+ }),
+ },
+ schema: Joi.string(),
+ },
+ boost: {
+ fieldConfig: {
+ defaultValue: 1.0,
+ type: FIELD_TYPES.NUMBER,
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.parameters.boostLabel', {
+ defaultMessage: 'Boost level',
+ }),
+ formatters: [toInt],
+ validations: [
+ {
+ validator: ({ value }: ValidationFuncArg) => {
+ if (value < 0) {
+ return { message: commonErrorMessages.smallerThanZero };
+ }
+ },
+ },
+ ],
+ } as FieldConfig,
+ schema: Joi.number(),
+ },
+ scaling_factor: {
+ title: i18n.translate('xpack.idxMgmt.mappingsEditor.parameters.scalingFactorFieldTitle', {
+ defaultMessage: 'Scaling factor',
+ }),
+ description: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.parameters.scalingFactorFieldDescription',
+ {
+ defaultMessage:
+ 'Values will be multiplied by this factor at index time and rounded to the closest long value. High factor values improve accuracy, but also increase space requirements.',
+ }
+ ),
+ fieldConfig: {
+ defaultValue: '',
+ type: FIELD_TYPES.NUMBER,
+ deserializer: (value: string | number) => +value,
+ formatters: [toInt],
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.parameters.scalingFactorLabel', {
+ defaultMessage: 'Scaling factor',
+ }),
+ validations: [
+ {
+ validator: emptyField(
+ i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.parameters.validations.scalingFactorIsRequiredErrorMessage',
+ {
+ defaultMessage: 'A scaling factor is required.',
+ }
+ )
+ ),
+ },
+ {
+ validator: ({ value }: ValidationFuncArg) => {
+ if (value <= 0) {
+ return {
+ message: i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.parameters.validations.greaterThanZeroErrorMessage',
+ {
+ defaultMessage: 'The scaling factor must be greater than 0.',
+ }
+ ),
+ };
+ }
+ },
+ },
+ ],
+ helpText: i18n.translate('xpack.idxMgmt.mappingsEditor.parameters.scalingFactorHelpText', {
+ defaultMessage: 'Value must be greater than 0.',
+ }),
+ } as FieldConfig,
+ schema: Joi.number(),
+ },
+ dynamic: {
+ fieldConfig: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dynamicFieldLabel', {
+ defaultMessage: 'Dynamic',
+ }),
+ type: FIELD_TYPES.CHECKBOX,
+ defaultValue: true,
+ },
+ schema: Joi.boolean().strict(),
+ },
+ enabled: {
+ fieldConfig: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.enabledFieldLabel', {
+ defaultMessage: 'Enabled',
+ }),
+ type: FIELD_TYPES.CHECKBOX,
+ defaultValue: true,
+ },
+ schema: Joi.boolean().strict(),
+ },
+ format: {
+ fieldConfig: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.formatFieldLabel', {
+ defaultMessage: 'Format',
+ }),
+ defaultValue: 'strict_date_optional_time||epoch_millis',
+ serializer: (format: ComboBoxOption[]): string | undefined =>
+ format.length ? format.map(({ label }) => label).join('||') : undefined,
+ deserializer: (formats: string): ComboBoxOption[] | undefined =>
+ formats.split('||').map(format => ({ label: format })),
+ helpText: (
+ yyyy/MM/dd,
+ }}
+ />
+ ),
+ },
+ schema: Joi.string(),
+ },
+ analyzer: {
+ fieldConfig: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.analyzerFieldLabel', {
+ defaultMessage: 'Analyzer',
+ }),
+ defaultValue: INDEX_DEFAULT,
+ validations: analyzerValidations,
+ },
+ schema: Joi.string(),
+ },
+ search_analyzer: {
+ fieldConfig: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.searchAnalyzerFieldLabel', {
+ defaultMessage: 'Search analyzer',
+ }),
+ defaultValue: INDEX_DEFAULT,
+ validations: analyzerValidations,
+ },
+ schema: Joi.string(),
+ },
+ search_quote_analyzer: {
+ fieldConfig: {
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.searchQuoteAnalyzerFieldLabel', {
+ defaultMessage: 'Search quote analyzer',
+ }),
+ defaultValue: INDEX_DEFAULT,
+ validations: analyzerValidations,
+ },
+ schema: Joi.string(),
+ },
+ normalizer: {
+ fieldConfig: {
+ label: 'Normalizer',
+ defaultValue: '',
+ type: FIELD_TYPES.TEXT,
+ validations: [
+ {
+ validator: emptyField(
+ i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.parameters.validations.normalizerIsRequiredErrorMessage',
+ {
+ defaultMessage: 'Normalizer name is required.',
+ }
+ )
+ ),
+ },
+ {
+ validator: containsCharsField({
+ chars: ' ',
+ message: commonErrorMessages.spacesNotAllowed,
+ }),
+ },
+ ],
+ helpText: i18n.translate('xpack.idxMgmt.mappingsEditor.parameters.normalizerHelpText', {
+ defaultMessage: `The name of a normalizer defined in the index's settings.`,
+ }),
+ },
+ schema: Joi.string(),
+ },
+ index_options: {
+ fieldConfig: {
+ ...indexOptionsConfig,
+ defaultValue: 'positions',
+ },
+ schema: Joi.string(),
+ },
+ index_options_keyword: {
+ fieldConfig: {
+ ...indexOptionsConfig,
+ defaultValue: 'docs',
+ },
+ schema: Joi.string(),
+ },
+ index_options_flattened: {
+ fieldConfig: {
+ ...indexOptionsConfig,
+ defaultValue: 'docs',
+ },
+ schema: Joi.string(),
+ },
+ eager_global_ordinals: {
+ fieldConfig: {
+ defaultValue: false,
+ },
+ schema: Joi.boolean().strict(),
+ },
+ index_phrases: {
+ fieldConfig: {
+ defaultValue: false,
+ },
+ schema: Joi.boolean().strict(),
+ },
+ preserve_separators: {
+ fieldConfig: {
+ defaultValue: true,
+ },
+ schema: Joi.boolean().strict(),
+ },
+ preserve_position_increments: {
+ fieldConfig: {
+ defaultValue: true,
+ },
+ schema: Joi.boolean().strict(),
+ },
+ ignore_z_value: {
+ fieldConfig: {
+ defaultValue: true,
+ },
+ schema: Joi.boolean().strict(),
+ },
+ points_only: {
+ fieldConfig: {
+ defaultValue: false,
+ },
+ schema: Joi.boolean().strict(),
+ },
+ norms: {
+ fieldConfig: {
+ defaultValue: true,
+ },
+ schema: Joi.boolean().strict(),
+ },
+ norms_keyword: {
+ fieldConfig: {
+ defaultValue: false,
+ },
+ schema: Joi.boolean().strict(),
+ },
+ term_vector: {
+ fieldConfig: {
+ type: FIELD_TYPES.SUPER_SELECT,
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.parameters.termVectorLabel', {
+ defaultMessage: 'Set term vector',
+ }),
+ defaultValue: 'no',
+ },
+ schema: Joi.string(),
+ },
+ path: {
+ fieldConfig: {
+ type: FIELD_TYPES.COMBO_BOX,
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.parameters.pathLabel', {
+ defaultMessage: 'Field path',
+ }),
+ helpText: i18n.translate('xpack.idxMgmt.mappingsEditor.parameters.pathHelpText', {
+ defaultMessage: 'The absolute path from the root to the target field.',
+ }),
+ validations: [
+ {
+ validator: emptyField(
+ i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.parameters.validations.pathIsRequiredErrorMessage',
+ {
+ defaultMessage: 'Select a field to point the alias to.',
+ }
+ )
+ ),
+ },
+ ],
+ serializer: (value: AliasOption[]) => (value.length === 0 ? '' : value[0].id),
+ } as FieldConfig,
+ targetTypesNotAllowed: ['object', 'nested', 'alias'] as DataType[],
+ schema: Joi.string(),
+ },
+ position_increment_gap: {
+ fieldConfig: {
+ type: FIELD_TYPES.NUMBER,
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.parameters.positionIncrementGapLabel', {
+ defaultMessage: 'Position increment gap',
+ }),
+ defaultValue: 100,
+ formatters: [toInt],
+ validations: [
+ {
+ validator: emptyField(
+ i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.parameters.validations.positionIncrementGapIsRequiredErrorMessage',
+ {
+ defaultMessage: 'Set a position increment gap value',
+ }
+ )
+ ),
+ },
+ {
+ validator: (({ value }: ValidationFuncArg) => {
+ if (value < 0) {
+ return { message: commonErrorMessages.smallerThanZero };
+ }
+ }) as ValidationFunc,
+ },
+ ],
+ },
+ schema: Joi.number(),
+ },
+ index_prefixes: {
+ fieldConfig: { defaultValue: {} }, // Needed for FieldParams typing
+ props: {
+ min_chars: {
+ fieldConfig: {
+ type: FIELD_TYPES.NUMBER,
+ defaultValue: 2,
+ serializer: value => (value === '' ? '' : toInt(value)),
+ } as FieldConfig,
+ },
+ max_chars: {
+ fieldConfig: {
+ type: FIELD_TYPES.NUMBER,
+ defaultValue: 5,
+ serializer: value => (value === '' ? '' : toInt(value)),
+ } as FieldConfig,
+ },
+ },
+ schema: Joi.object().keys({
+ min_chars: Joi.number(),
+ max_chars: Joi.number(),
+ }),
+ },
+ similarity: {
+ fieldConfig: {
+ defaultValue: 'BM25',
+ type: FIELD_TYPES.SUPER_SELECT,
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.parameters.similarityLabel', {
+ defaultMessage: 'Similarity algorithm',
+ }),
+ },
+ schema: Joi.string(),
+ },
+ split_queries_on_whitespace: {
+ fieldConfig: {
+ defaultValue: false,
+ },
+ schema: Joi.boolean().strict(),
+ },
+ ignore_above: {
+ fieldConfig: {
+ // Protects against Lucene’s term byte-length limit of 32766. UTF-8 characters may occupy at
+ // most 4 bytes, so 32766 / 4 = 8191 characters.
+ defaultValue: 8191,
+ type: FIELD_TYPES.NUMBER,
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.ignoreAboveFieldLabel', {
+ defaultMessage: 'Character length limit',
+ }),
+ formatters: [toInt],
+ validations: [
+ {
+ validator: emptyField(
+ i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.parameters.validations.ignoreAboveIsRequiredErrorMessage',
+ {
+ defaultMessage: 'Character length limit is required.',
+ }
+ )
+ ),
+ },
+ {
+ validator: (({ value }: ValidationFuncArg) => {
+ if ((value as number) < 0) {
+ return { message: commonErrorMessages.smallerThanZero };
+ }
+ }) as ValidationFunc,
+ },
+ ],
+ },
+ schema: Joi.number(),
+ },
+ enable_position_increments: {
+ fieldConfig: {
+ defaultValue: true,
+ },
+ schema: Joi.boolean().strict(),
+ },
+ depth_limit: {
+ fieldConfig: {
+ defaultValue: 20,
+ type: FIELD_TYPES.NUMBER,
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.depthLimitFieldLabel', {
+ defaultMessage: 'Nested object depth limit',
+ }),
+ formatters: [toInt],
+ validations: [
+ {
+ validator: (({ value }: ValidationFuncArg) => {
+ if ((value as number) < 0) {
+ return { message: commonErrorMessages.smallerThanZero };
+ }
+ }) as ValidationFunc,
+ },
+ ],
+ },
+ schema: Joi.number(),
+ },
+ dims: {
+ fieldConfig: {
+ defaultValue: '',
+ type: FIELD_TYPES.NUMBER,
+ label: i18n.translate('xpack.idxMgmt.mappingsEditor.dimsFieldLabel', {
+ defaultMessage: 'Dimensions',
+ }),
+ helpText: i18n.translate('xpack.idxMgmt.mappingsEditor.parameters.dimsHelpTextDescription', {
+ defaultMessage: 'The number of dimensions in the vector.',
+ }),
+ formatters: [toInt],
+ validations: [
+ {
+ validator: emptyField(
+ i18n.translate(
+ 'xpack.idxMgmt.mappingsEditor.parameters.validations.dimsIsRequiredErrorMessage',
+ {
+ defaultMessage: 'Specify a dimension.',
+ }
+ )
+ ),
+ },
+ ],
+ },
+ schema: Joi.string(),
+ },
+};
diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/index.ts b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/index.ts
new file mode 100644
index 0000000000000..58db8af3f7c5c
--- /dev/null
+++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/index.ts
@@ -0,0 +1,13 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export * from './mappings_editor';
+
+// We export both the button & the load mappings provider
+// to give flexibility to the consumer
+export * from './components/load_mappings';
+
+export { OnUpdateHandler, Types } from './mappings_state';
diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/index_settings_context.tsx b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/index_settings_context.tsx
new file mode 100644
index 0000000000000..04e0980513b6a
--- /dev/null
+++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/index_settings_context.tsx
@@ -0,0 +1,24 @@
+/*
+ * 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 } from 'react';
+import { IndexSettings } from './types';
+
+const IndexSettingsContext = createContext(undefined);
+
+interface Props {
+ indexSettings: IndexSettings | undefined;
+ children: React.ReactNode;
+}
+
+export const IndexSettingsProvider = ({ indexSettings, children }: Props) => (
+ {children}
+);
+
+export const useIndexSettings = () => {
+ const ctx = useContext(IndexSettingsContext);
+
+ return ctx === undefined ? {} : ctx;
+};
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
new file mode 100644
index 0000000000000..1b1c5cc8dc8d4
--- /dev/null
+++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/index.ts
@@ -0,0 +1,15 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export * from './utils';
+
+export * from './serializers';
+
+export * from './validators';
+
+export * from './mappings_validator';
+
+export * from './search_fields';
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
new file mode 100644
index 0000000000000..e9af16af2afa0
--- /dev/null
+++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/mappings_validator.test.ts
@@ -0,0 +1,330 @@
+/*
+ * 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 { validateMappings, validateProperties, isObject } from './mappings_validator';
+
+describe('Mappings configuration validator', () => {
+ it('should convert non object to empty object', () => {
+ const tests = ['abc', 123, [], null, undefined];
+
+ tests.forEach(testValue => {
+ const { value, errors } = validateMappings(testValue as any);
+ expect(isObject(value)).toBe(true);
+ expect(errors).toBe(undefined);
+ });
+ });
+
+ it('should strip out unknown configuration', () => {
+ const mappings = {
+ dynamic: true,
+ date_detection: true,
+ numeric_detection: true,
+ dynamic_date_formats: ['abc'],
+ _source: {
+ enabled: true,
+ includes: ['abc'],
+ excludes: ['abc'],
+ },
+ properties: { title: { type: 'text' } },
+ unknown: 123,
+ };
+
+ const { value, errors } = validateMappings(mappings);
+
+ const { unknown, ...expected } = mappings;
+ expect(value).toEqual(expected);
+ expect(errors).toBe(undefined);
+ });
+
+ it('should strip out invalid configuration and returns the errors for each of them', () => {
+ const mappings = {
+ dynamic: true,
+ numeric_detection: 123, // wrong format
+ dynamic_date_formats: false, // wrong format
+ _source: {
+ enabled: true,
+ includes: 'abc',
+ excludes: ['abc'],
+ wrong: 123, // parameter not allowed
+ },
+ properties: 'abc',
+ };
+
+ const { value, errors } = validateMappings(mappings);
+
+ expect(value).toEqual({
+ dynamic: true,
+ properties: {},
+ });
+
+ expect(errors).not.toBe(undefined);
+ expect(errors!.length).toBe(3);
+ expect(errors!).toEqual([
+ { code: 'ERR_CONFIG', configName: 'numeric_detection' },
+ { code: 'ERR_CONFIG', configName: 'dynamic_date_formats' },
+ { code: 'ERR_CONFIG', configName: '_source' },
+ ]);
+ });
+});
+
+describe('Properties validator', () => {
+ it('should convert non object to empty object', () => {
+ const tests = ['abc', 123, [], null, undefined];
+
+ tests.forEach(testValue => {
+ const { value, errors } = validateProperties(testValue as any);
+ expect(isObject(value)).toBe(true);
+ expect(errors).toEqual([]);
+ });
+ });
+
+ it('should strip non object fields', () => {
+ const properties = {
+ prop1: { type: 'text' },
+ prop2: 'abc', // To be removed
+ prop3: 123, // To be removed
+ prop4: null, // To be removed
+ prop5: [], // To be removed
+ prop6: {
+ properties: {
+ prop1: { type: 'text' },
+ prop2: 'abc', // To be removed
+ },
+ },
+ };
+ const { value, errors } = validateProperties(properties as any);
+
+ expect(value).toEqual({
+ prop1: { type: 'text' },
+ prop6: {
+ type: 'object',
+ properties: {
+ prop1: { type: 'text' },
+ },
+ },
+ });
+
+ expect(errors).toEqual(
+ ['prop2', 'prop3', 'prop4', 'prop5', 'prop6.prop2'].map(fieldPath => ({
+ code: 'ERR_FIELD',
+ fieldPath,
+ }))
+ );
+ });
+
+ it(`should set the type to "object" when type is not provided`, () => {
+ const properties = {
+ prop1: { type: 'text' },
+ prop2: {},
+ prop3: {
+ type: 'object',
+ properties: {
+ prop1: {},
+ prop2: { type: 'keyword' },
+ },
+ },
+ };
+ const { value, errors } = validateProperties(properties as any);
+
+ expect(value).toEqual({
+ prop1: {
+ type: 'text',
+ },
+ prop2: {
+ type: 'object',
+ },
+ prop3: {
+ type: 'object',
+ properties: {
+ prop1: {
+ type: 'object',
+ },
+ prop2: {
+ type: 'keyword',
+ },
+ },
+ },
+ });
+ expect(errors).toEqual([]);
+ });
+
+ it('should strip field whose type is not a string or is unknown', () => {
+ const properties = {
+ prop1: { type: 123 },
+ prop2: { type: 'clearlyUnknown' },
+ };
+
+ const { value, errors } = validateProperties(properties as any);
+
+ expect(Object.keys(value)).toEqual([]);
+ expect(errors).toEqual([
+ {
+ code: 'ERR_FIELD',
+ fieldPath: 'prop1',
+ },
+ {
+ code: 'ERR_FIELD',
+ fieldPath: 'prop2',
+ },
+ ]);
+ });
+
+ it('should strip parameters that are unknown', () => {
+ const properties = {
+ prop1: { type: 'text', unknown: true, anotherUnknown: 123 },
+ prop2: { type: 'keyword', store: true, index: true, doc_values_binary: true },
+ prop3: {
+ type: 'object',
+ properties: {
+ hello: { type: 'keyword', unknown: true, anotherUnknown: 123 },
+ },
+ },
+ };
+
+ const { value, errors } = validateProperties(properties as any);
+
+ expect(value).toEqual({
+ prop1: { type: 'text' },
+ prop2: { type: 'keyword', store: true, index: true, doc_values_binary: true },
+ prop3: {
+ type: 'object',
+ properties: {
+ hello: { type: 'keyword' },
+ },
+ },
+ });
+
+ expect(errors).toEqual([
+ { code: 'ERR_PARAMETER', fieldPath: 'prop1', paramName: 'unknown' },
+ { code: 'ERR_PARAMETER', fieldPath: 'prop1', paramName: 'anotherUnknown' },
+ { code: 'ERR_PARAMETER', fieldPath: 'prop3.hello', paramName: 'unknown' },
+ { code: 'ERR_PARAMETER', fieldPath: 'prop3.hello', paramName: 'anotherUnknown' },
+ ]);
+ });
+
+ it(`should strip parameters whose value don't have the valid type.`, () => {
+ const properties = {
+ // All the parameters in "wrongField" have a wrong format defined
+ // and should be stripped out when running the validation
+ wrongField: {
+ type: 'text',
+ store: 'abc',
+ index: 'abc',
+ doc_values: { a: 123 },
+ doc_values_binary: null,
+ fielddata: [''],
+ fielddata_frequency_filter: [123, 456],
+ coerce: 1234,
+ coerce_shape: '',
+ ignore_malformed: 0,
+ null_value: {},
+ null_value_numeric: 'abc',
+ null_value_boolean: [],
+ copy_to: [],
+ max_input_length: true,
+ locale: 1,
+ orientation: [],
+ boost: { a: 123 },
+ scaling_factor: 'some_string',
+ dynamic: [true],
+ enabled: 'false',
+ format: null,
+ analyzer: 1,
+ search_analyzer: null,
+ search_quote_analyzer: {},
+ normalizer: [],
+ index_options: 1,
+ index_options_keyword: true,
+ index_options_flattened: [],
+ eager_global_ordinals: 123,
+ index_phrases: null,
+ preserve_separators: 'abc',
+ preserve_position_increments: [],
+ ignore_z_value: {},
+ points_only: [true],
+ norms: 'false',
+ norms_keyword: 'abc',
+ term_vector: ['no'],
+ path: [null],
+ position_increment_gap: 'abc',
+ index_prefixes: { min_chars: [], max_chars: 'abc' },
+ similarity: 1,
+ split_queries_on_whitespace: {},
+ ignore_above: 'abc',
+ enable_position_increments: [],
+ depth_limit: true,
+ dims: false,
+ },
+ // All the parameters in "goodField" have the correct format
+ // and should still be there after the validation ran.
+ goodField: {
+ type: 'text',
+ store: true,
+ index: true,
+ doc_values: true,
+ doc_values_binary: true,
+ fielddata: true,
+ fielddata_frequency_filter: { min: 1, max: 2, min_segment_size: 10 },
+ coerce: true,
+ coerce_shape: true,
+ ignore_malformed: true,
+ null_value: 'NULL',
+ null_value_numeric: 1,
+ null_value_boolean: 'true',
+ copy_to: 'abc',
+ max_input_length: 10,
+ locale: 'en',
+ orientation: 'ccw',
+ boost: 1.5,
+ scaling_factor: 2.5,
+ dynamic: true,
+ enabled: true,
+ format: 'strict_date_optional_time',
+ analyzer: 'standard',
+ search_analyzer: 'standard',
+ search_quote_analyzer: 'standard',
+ normalizer: 'standard',
+ index_options: 'positions',
+ index_options_keyword: 'docs',
+ index_options_flattened: 'docs',
+ eager_global_ordinals: true,
+ index_phrases: true,
+ preserve_separators: true,
+ preserve_position_increments: true,
+ ignore_z_value: true,
+ points_only: true,
+ norms: true,
+ norms_keyword: true,
+ term_vector: 'no',
+ path: 'abc',
+ position_increment_gap: 100,
+ index_prefixes: { min_chars: 2, max_chars: 5 },
+ similarity: 'BM25',
+ split_queries_on_whitespace: true,
+ ignore_above: 64,
+ enable_position_increments: true,
+ depth_limit: 20,
+ dims: 'abc',
+ },
+ };
+
+ const { value, errors } = validateProperties(properties as any);
+
+ expect(Object.keys(value)).toEqual(['wrongField', 'goodField']);
+
+ expect(value.wrongField).toEqual({ type: 'text' }); // All parameters have been stripped out but the "type".
+ expect(value.goodField).toEqual(properties.goodField); // All parameters are stil there.
+
+ const allWrongParameters = Object.keys(properties.wrongField).filter(v => v !== 'type');
+ expect(errors).toEqual(
+ allWrongParameters.map(paramName => ({
+ code: 'ERR_PARAMETER',
+ fieldPath: 'wrongField',
+ paramName,
+ }))
+ );
+ });
+});
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
new file mode 100644
index 0000000000000..cd7fc57d1dbc8
--- /dev/null
+++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/mappings_validator.ts
@@ -0,0 +1,267 @@
+/*
+ * 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 Joi from 'joi';
+import { ALL_DATA_TYPES, PARAMETERS_DEFINITION } from '../constants';
+import { FieldMeta } from '../types';
+import { getFieldMeta } from '../lib';
+
+const ALLOWED_FIELD_PROPERTIES = [
+ ...Object.keys(PARAMETERS_DEFINITION),
+ 'type',
+ 'properties',
+ 'fields',
+];
+
+const DEFAULT_FIELD_TYPE = 'object';
+
+export type MappingsValidationError =
+ | { code: 'ERR_CONFIG'; configName: string }
+ | { code: 'ERR_FIELD'; fieldPath: string }
+ | { code: 'ERR_PARAMETER'; paramName: string; fieldPath: string };
+
+export interface MappingsValidatorResponse {
+ /* The parsed mappings object without any error */
+ value: GenericObject;
+ errors?: MappingsValidationError[];
+}
+
+interface PropertiesValidatorResponse {
+ /* The parsed "properties" object without any error */
+ value: GenericObject;
+ errors: MappingsValidationError[];
+}
+
+interface FieldValidatorResponse {
+ /* The parsed field. If undefined means that it was invalid */
+ value?: GenericObject;
+ parametersRemoved: string[];
+}
+
+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;
+ }
+
+ if (!ALL_DATA_TYPES.includes(type)) {
+ return false;
+ }
+ return true;
+};
+
+const validateParameter = (parameter: string, value: any): boolean => {
+ if (parameter === 'type') {
+ return true;
+ }
+
+ if (parameter === 'name') {
+ return false;
+ }
+
+ if (parameter === 'properties' || parameter === 'fields') {
+ return isObject(value);
+ }
+
+ const parameterSchema = (PARAMETERS_DEFINITION as any)[parameter]!.schema;
+ if (parameterSchema) {
+ return Boolean(Joi.validate(value, parameterSchema).error) === false;
+ }
+
+ // Fallback, if no schema defined for the parameter (this should not happen in theory)
+ return true;
+};
+
+const stripUnknownOrInvalidParameter = (field: GenericObject): FieldValidatorResponse =>
+ Object.entries(field).reduce(
+ (acc, [key, value]) => {
+ if (!ALLOWED_FIELD_PROPERTIES.includes(key) || !validateParameter(key, value)) {
+ acc.parametersRemoved.push(key);
+ } else {
+ acc.value = acc.value ?? {};
+ acc.value[key] = value;
+ }
+ return acc;
+ },
+ { parametersRemoved: [] } as FieldValidatorResponse
+ );
+
+const parseField = (field: any): FieldValidatorResponse & { meta?: FieldMeta } => {
+ // Sanitize the input to make sure we are working with an object
+ if (!isObject(field)) {
+ return { parametersRemoved: [] };
+ }
+ // Make sure the field "type" is valid
+ if (!validateFieldType(field.type ?? DEFAULT_FIELD_TYPE)) {
+ return { parametersRemoved: [] };
+ }
+
+ // Filter out unknown or invalid "parameters"
+ const fieldWithType = { type: DEFAULT_FIELD_TYPE, ...field };
+ const parsedField = stripUnknownOrInvalidParameter(fieldWithType);
+ const meta = getFieldMeta(fieldWithType);
+
+ return { ...parsedField, meta };
+};
+
+const parseFields = (
+ properties: GenericObject,
+ path: string[] = []
+): PropertiesValidatorResponse => {
+ return Object.entries(properties).reduce(
+ (acc, [fieldName, unparsedField]) => {
+ const fieldPath = [...path, fieldName].join('.');
+ const { value: parsedField, parametersRemoved, meta } = parseField(unparsedField);
+
+ if (parsedField === undefined) {
+ // Field has been stripped out because it was invalid
+ acc.errors.push({ code: 'ERR_FIELD', fieldPath });
+ } else {
+ if (meta!.hasChildFields || meta!.hasMultiFields) {
+ // Recursively parse all the possible children ("properties" or "fields" for multi-fields)
+ const parsedChildren = parseFields(parsedField[meta!.childFieldsName!], [
+ ...path,
+ fieldName,
+ ]);
+ parsedField[meta!.childFieldsName!] = parsedChildren.value;
+
+ /**
+ * If the children parsed have any error we concatenate them in our accumulator.
+ */
+ if (parsedChildren.errors) {
+ acc.errors = [...acc.errors, ...parsedChildren.errors];
+ }
+ }
+
+ acc.value[fieldName] = parsedField;
+
+ if (Boolean(parametersRemoved.length)) {
+ acc.errors = [
+ ...acc.errors,
+ ...parametersRemoved.map(paramName => ({
+ code: 'ERR_PARAMETER' as 'ERR_PARAMETER',
+ fieldPath,
+ paramName,
+ })),
+ ];
+ }
+ }
+
+ return acc;
+ },
+ {
+ value: {},
+ errors: [],
+ } as PropertiesValidatorResponse
+ );
+};
+
+/**
+ * Utility function that reads a mappings "properties" object and validate its fields by
+ * - Removing unknown field types
+ * - Removing unknown field parameters or field parameters that don't have the correct format.
+ *
+ * This method does not mutate the original properties object. It returns an object with
+ * the parsed properties and an array of field paths that have been removed.
+ * This allows us to display a warning in the UI and let the user correct the fields that we
+ * are about to remove.
+ *
+ * NOTE: The Joi Schema that we defined for each parameter (in "parameters_definition".tsx)
+ * does not do an exhaustive validation of the parameter value.
+ * It's main purpose is to prevent the UI from blowing up.
+ *
+ * @param properties A mappings "properties" object
+ */
+export const validateProperties = (properties = {}): PropertiesValidatorResponse => {
+ // Sanitize the input to make sure we are working with an object
+ if (!isObject(properties)) {
+ return { value: {}, errors: [] };
+ }
+
+ return parseFields(properties);
+};
+
+/**
+ * Single source of truth to validate the *configuration* of the mappings.
+ * Whenever a user loads a JSON object it will be validate against this Joi schema.
+ */
+export const mappingsConfigurationSchema = Joi.object().keys({
+ dynamic: Joi.any().valid([true, false, 'strict']),
+ date_detection: Joi.boolean().strict(),
+ numeric_detection: Joi.boolean().strict(),
+ dynamic_date_formats: Joi.array().items(Joi.string()),
+ _source: Joi.object().keys({
+ enabled: Joi.boolean().strict(),
+ includes: Joi.array().items(Joi.string()),
+ excludes: Joi.array().items(Joi.string()),
+ }),
+ _meta: Joi.object(),
+ _routing: Joi.object().keys({
+ required: Joi.boolean().strict(),
+ }),
+});
+
+const validateMappingsConfiguration = (
+ mappingsConfiguration: any
+): { value: any; errors: MappingsValidationError[] } => {
+ // Array to keep track of invalid configuration parameters.
+ const configurationRemoved: string[] = [];
+
+ const { value: parsedConfiguration, error: configurationError } = Joi.validate(
+ mappingsConfiguration,
+ mappingsConfigurationSchema,
+ {
+ stripUnknown: true,
+ abortEarly: false,
+ }
+ );
+
+ if (configurationError) {
+ /**
+ * To keep the logic simple we will strip out the parameters that contain errors
+ */
+ configurationError.details.forEach(error => {
+ const configurationName = error.path[0];
+ configurationRemoved.push(configurationName);
+ delete parsedConfiguration[configurationName];
+ });
+ }
+
+ const errors: MappingsValidationError[] = configurationRemoved.map(configName => ({
+ code: 'ERR_CONFIG',
+ configName,
+ }));
+
+ return { value: parsedConfiguration, errors };
+};
+
+export const validateMappings = (mappings: any = {}): MappingsValidatorResponse => {
+ if (!isObject(mappings)) {
+ return { value: {} };
+ }
+
+ const { properties, dynamic_templates, ...mappingsConfiguration } = mappings;
+
+ const { value: parsedConfiguration, errors: configurationErrors } = validateMappingsConfiguration(
+ mappingsConfiguration
+ );
+ const { value: parsedProperties, errors: propertiesErrors } = validateProperties(properties);
+
+ const errors = [...configurationErrors, ...propertiesErrors];
+
+ return {
+ value: {
+ ...parsedConfiguration,
+ properties: parsedProperties,
+ dynamic_templates,
+ },
+ errors: errors.length ? errors : undefined,
+ };
+};
diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/search_fields.test.ts b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/search_fields.test.ts
new file mode 100644
index 0000000000000..048a9c2fe7569
--- /dev/null
+++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/search_fields.test.ts
@@ -0,0 +1,204 @@
+/*
+ * 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 { searchFields } from './search_fields';
+import { NormalizedField } from '../types';
+import { getUniqueId } from '../lib';
+
+const irrelevantProps = {
+ canHaveChildFields: false,
+ canHaveMultiFields: true,
+ childFieldsName: 'fields' as 'fields',
+ hasChildFields: false,
+ hasMultiFields: false,
+ isExpanded: false,
+ isMultiField: false,
+ nestedDepth: 1,
+};
+
+const getField = (
+ source: any,
+ path = ['some', 'field', 'path'],
+ id = getUniqueId()
+): NormalizedField => ({
+ id,
+ source: {
+ ...source,
+ name: path[path.length - 1],
+ },
+ path,
+ ...irrelevantProps,
+});
+
+describe('Search fields', () => {
+ test('should return empty array when no result found', () => {
+ const field = getField({ type: 'text' });
+ const allFields = {
+ [field.id]: field,
+ };
+ const searchTerm = 'keyword';
+
+ const result = searchFields(searchTerm, allFields);
+ expect(result).toEqual([]);
+ });
+
+ test('should return field if path contains search term', () => {
+ const field = getField({ type: 'text' }, ['someObject', 'property']);
+ const allFields = {
+ [field.id]: field,
+ };
+ const searchTerm = 'proper';
+
+ const result = searchFields(searchTerm, allFields);
+ expect(result.length).toBe(1);
+ expect(result[0].field).toEqual(field);
+ });
+
+ test('should return field if type matches part of search term', () => {
+ const field = getField({ type: 'keyword' });
+ const allFields = {
+ [field.id]: field,
+ };
+ const searchTerm = 'keywo';
+
+ const result = searchFields(searchTerm, allFields);
+ expect(result.length).toBe(1);
+ expect(result[0].field).toEqual(field);
+ });
+
+ test('should give higher score if the search term matches the "path" over the "type"', () => {
+ const field1 = getField({ type: 'keyword' }, ['field1']);
+ const field2 = getField({ type: 'text' }, ['field2', 'keywords']); // Higher score
+ const allFields = {
+ [field1.id]: field1, // field 1 comes first
+ [field2.id]: field2,
+ };
+ const searchTerm = 'keyword';
+
+ const result = searchFields(searchTerm, allFields);
+ expect(result.length).toBe(2);
+ expect(result[0].field.path).toEqual(field2.path);
+ expect(result[1].field.path).toEqual(field1.path); // field 1 is second
+ });
+
+ test('should extract the "type" in multi words search', () => {
+ const field1 = getField({ type: 'date' });
+ const field2 = getField({ type: 'keyword' }, ['myField', 'someProp']); // Should come in result as second as only the type matches
+ const field3 = getField({ type: 'text' }, ['myField', 'keyword']); // Path match scores higher than the field type
+
+ const allFields = {
+ [field1.id]: field1,
+ [field2.id]: field2,
+ [field3.id]: field3,
+ };
+ const searchTerm = 'myField keyword';
+
+ const result = searchFields(searchTerm, allFields);
+ expect(result.length).toBe(2);
+ expect(result[0].field.path).toEqual(field3.path);
+ expect(result[1].field.path).toEqual(field2.path);
+ });
+
+ test('should *NOT* extract the "type" in multi-words search if in the middle of 2 words', () => {
+ const field1 = getField({ type: 'date' });
+ const field2 = getField({ type: 'keyword' }, ['shouldNotMatch']);
+ const field3 = getField({ type: 'text' }, ['myField', 'keyword_more']); // Only valid result. Case incensitive.
+
+ const allFields = {
+ [field1.id]: field1,
+ [field2.id]: field2,
+ [field3.id]: field3,
+ };
+ const searchTerm = 'myField keyword more';
+
+ const result = searchFields(searchTerm, allFields);
+ expect(result.length).toBe(1);
+ expect(result[0].field.path).toEqual(field3.path);
+ });
+
+ test('should be case insensitive', () => {
+ const field1 = getField({ type: 'text' }, ['myFirstField']);
+ const field2 = getField({ type: 'text' }, ['myObject', 'firstProp']);
+
+ const allFields = {
+ [field1.id]: field1,
+ [field2.id]: field2,
+ };
+
+ const searchTerm = 'first';
+
+ const result = searchFields(searchTerm, allFields);
+ expect(result.length).toBe(2);
+ expect(result[0].field.path).toEqual(field2.path);
+ expect(result[1].field.path).toEqual(field1.path);
+ });
+
+ test('should refine search with multiple terms', () => {
+ const field1 = getField({ type: 'text' }, ['myObject']);
+ const field2 = getField({ type: 'keyword' }, ['myObject', 'someProp']);
+
+ const allFields = {
+ [field1.id]: field1,
+ [field2.id]: field2,
+ };
+
+ const searchTerm = 'myObject someProp';
+
+ const result = searchFields(searchTerm, allFields);
+ expect(result.length).toBe(1);
+ expect(result[0].field.path).toEqual(field2.path); // Field 2 first as it matches the type
+ });
+
+ test('should sort first match on field name before descendants', () => {
+ const field1 = getField({ type: 'text' }, ['server', 'space', 'myField']);
+ const field2 = getField({ type: 'text' }, ['myObject', 'server']);
+ const field3 = getField({ type: 'text' }, ['server']);
+
+ const allFields = {
+ [field1.id]: field1,
+ [field2.id]: field2,
+ [field3.id]: field3,
+ };
+
+ const searchTerm = 'serve';
+
+ const result = searchFields(searchTerm, allFields);
+ expect(result.length).toBe(3);
+ expect(result[0].field.path).toEqual(field3.path); // Should come first as it has the shortest path
+ expect(result[1].field.path).toEqual(field2.path); // Field 2 name _is_ the search term, comes first
+ expect(result[2].field.path).toEqual(field1.path);
+ });
+
+ test('should sort first field whose name fully matches the term', () => {
+ const field1 = getField({ type: 'text' }, ['aerospke', 'namespace']);
+ const field2 = getField({ type: 'text' }, ['agent', 'name']);
+
+ const allFields = {
+ [field1.id]: field1,
+ [field2.id]: field2,
+ };
+
+ const searchTerm = 'name';
+
+ const result = searchFields(searchTerm, allFields);
+ expect(result.length).toBe(2);
+ expect(result[0].field.path).toEqual(field2.path); // Field 2 name fully matches
+ expect(result[1].field.path).toEqual(field1.path);
+ });
+
+ test('should return empty result if searching for ">"', () => {
+ const field1 = getField({ type: 'text' }, ['aerospke', 'namespace']);
+
+ const allFields = {
+ [field1.id]: field1,
+ };
+
+ const searchTerm = '>';
+
+ const result = searchFields(searchTerm, allFields);
+ expect(result.length).toBe(0);
+ });
+});
diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/search_fields.tsx b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/search_fields.tsx
new file mode 100644
index 0000000000000..807bf233b0da0
--- /dev/null
+++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/search_fields.tsx
@@ -0,0 +1,257 @@
+/*
+ * 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 { NormalizedFields, NormalizedField, SearchResult, SearchMetadata } from '../types';
+import { ALL_DATA_TYPES } from '../constants';
+
+interface FieldWithMeta {
+ field: NormalizedField;
+ metadata: SearchMetadata;
+}
+
+interface SearchData {
+ term: string;
+ terms: string[];
+ searchRegexArray: RegExp[];
+ type?: string;
+}
+
+interface FieldData {
+ name: string;
+ path: string;
+ type: string;
+}
+
+/**
+ * Copied from https://stackoverflow.com/a/9310752
+ */
+const escapeRegExp = (text: string) => {
+ return text.replace(/[-\[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
+};
+
+const sortResult = (a: FieldWithMeta, b: FieldWithMeta) => {
+ if (a.metadata.score > b.metadata.score) {
+ return -1;
+ } else if (b.metadata.score > a.metadata.score) {
+ return 1;
+ }
+ if (a.metadata.stringMatch === null) {
+ return 1;
+ } else if (b.metadata.stringMatch === null) {
+ return -1;
+ }
+
+ // With a match and the same score,...
+
+ if (a.metadata.matchFieldName && b.metadata.matchFieldName) {
+ // The field with the shortest name comes first
+ // So searching "nam" would bring "name" before "namespace"
+ return a.field.source.name.length - b.field.source.name.length;
+ }
+
+ if (a.metadata.stringMatch.length === b.metadata.stringMatch.length) {
+ // The field with the shortest path (less tree "depth") comes first
+ return a.field.path.length - b.field.path.length;
+ }
+
+ // The longest match string wins.
+ return b.metadata.stringMatch.length - a.metadata.stringMatch.length;
+};
+
+const calculateScore = (metadata: Omit): number => {
+ let score = 0;
+
+ if (metadata.fullyMatchFieldName) {
+ score += 15;
+ }
+
+ if (metadata.matchFieldName) {
+ score += 5;
+ }
+
+ if (metadata.matchPath) {
+ score += 15;
+ }
+
+ if (metadata.matchStartOfPath) {
+ score += 5;
+ }
+
+ if (metadata.fullyMatchPath) {
+ score += 5;
+ }
+
+ if (metadata.matchType) {
+ score += 5;
+ }
+
+ if (metadata.fullyMatchType) {
+ score += 5;
+ }
+
+ return score;
+};
+
+const getJSXdisplayFromMeta = (
+ searchData: SearchData,
+ fieldData: FieldData,
+ metadata: Omit
+): JSX.Element => {
+ const { term } = searchData;
+ const { path } = fieldData;
+
+ let display: JSX.Element = {path};
+
+ if (metadata.fullyMatchPath) {
+ display = (
+
+ {path}
+
+ );
+ } else if (metadata.matchStartOfPath) {
+ const endString = path.substr(term.length, path.length);
+ display = (
+
+ {term}
+ {endString}
+
+ );
+ } else if (metadata.matchPath) {
+ const { stringMatch } = metadata;
+ const charIndex = path.lastIndexOf(stringMatch!);
+ const startString = path.substr(0, charIndex);
+ const endString = path.substr(charIndex + stringMatch!.length);
+ display = (
+
+ {startString}
+ {stringMatch}
+ {endString}
+
+ );
+ }
+
+ return display;
+};
+
+const getSearchMetadata = (searchData: SearchData, fieldData: FieldData): SearchMetadata => {
+ const { term, type, searchRegexArray } = searchData;
+ const typeToCompare = type ?? term;
+
+ const fullyMatchFieldName = term === fieldData.name;
+ const fullyMatchPath = term === fieldData.path;
+ const fieldNameRegMatch = searchRegexArray[0].exec(fieldData.name);
+ const matchFieldName = fullyMatchFieldName ? true : fieldNameRegMatch !== null;
+ const matchStartOfPath = fieldData.path.startsWith(term);
+ const matchType = fieldData.type.includes(typeToCompare);
+ const fullyMatchType = typeToCompare === fieldData.type;
+
+ let stringMatch: string | null = null;
+
+ if (fullyMatchPath) {
+ stringMatch = fieldData.path;
+ } else if (matchFieldName) {
+ stringMatch = fullyMatchFieldName ? fieldData.name : fieldNameRegMatch![0];
+ } else {
+ // Execute all the regEx and sort them with the one that has the most
+ // characters match first.
+ const arrayMatch = searchRegexArray
+ .map(regex => regex.exec(fieldData.path))
+ .filter(Boolean)
+ .sort((a, b) => b![0].length - a![0].length);
+
+ if (arrayMatch.length) {
+ stringMatch = arrayMatch[0]![0].toLowerCase();
+ }
+ }
+
+ const matchPath = stringMatch !== null;
+
+ const metadata = {
+ matchFieldName,
+ matchPath,
+ matchStartOfPath,
+ fullyMatchPath,
+ matchType,
+ fullyMatchFieldName,
+ fullyMatchType,
+ stringMatch,
+ };
+
+ const score = calculateScore(metadata);
+ const display = getJSXdisplayFromMeta(searchData, fieldData, metadata);
+
+ // console.log(fieldData.path, score, metadata);
+
+ return {
+ ...metadata,
+ display,
+ score,
+ };
+};
+
+const getRegexArrayFromSearchTerms = (searchTerms: string[]): RegExp[] => {
+ const fuzzyJoinChar = '([\\._-\\s]|(\\s>\\s))?';
+
+ return [new RegExp(searchTerms.join(fuzzyJoinChar), 'i')];
+};
+
+/**
+ * We will parsre the term to check if the _first_ or _last_ word matches a field "type"
+ *
+ * @param term The term introduced in the search box
+ */
+const parseSearchTerm = (term: string): SearchData => {
+ let type: string | undefined;
+ let parsedTerm = term.replace(/\s+/g, ' ').trim(); // Remove multiple spaces with 1 single space
+
+ const words = parsedTerm.split(' ').map(escapeRegExp);
+
+ // We don't take into account if the last word is a ">" char
+ if (words[words.length - 1] === '>') {
+ words.pop();
+ parsedTerm = words.join(' ');
+ }
+
+ const searchRegexArray = getRegexArrayFromSearchTerms(words);
+
+ const firstWordIsType = ALL_DATA_TYPES.includes(words[0]);
+ const lastWordIsType = ALL_DATA_TYPES.includes(words[words.length - 1]);
+
+ if (firstWordIsType) {
+ type = words[0];
+ } else if (lastWordIsType) {
+ type = words[words.length - 1];
+ }
+
+ return { term: parsedTerm, terms: words, type, searchRegexArray };
+};
+
+export const searchFields = (term: string, fields: NormalizedFields['byId']): SearchResult[] => {
+ const searchData = parseSearchTerm(term);
+
+ // An empty string means that we have searched for ">" and that is has been
+ // stripped out. So we exit early with an empty result.
+ if (searchData.term === '') {
+ return [];
+ }
+
+ return Object.values(fields)
+ .map(field => ({
+ field,
+ metadata: getSearchMetadata(searchData, {
+ name: field.source.name,
+ path: field.path.join(' > ').toLowerCase(),
+ type: field.source.type,
+ }),
+ }))
+ .filter(({ metadata }) => metadata.score > 0)
+ .sort(sortResult)
+ .map(({ field, metadata: { display } }) => ({
+ display,
+ field,
+ }));
+};
diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/serializers.ts b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/serializers.ts
new file mode 100644
index 0000000000000..f57f0bb9d87de
--- /dev/null
+++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/serializers.ts
@@ -0,0 +1,54 @@
+/*
+ * 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 { SerializerFunc } from '../shared_imports';
+import { Field, DataType, MainType, SubType } from '../types';
+import { INDEX_DEFAULT, MAIN_DATA_TYPE_DEFINITION } from '../constants';
+import { getMainTypeFromSubType } from './utils';
+
+const sanitizeField = (field: Field): Field =>
+ Object.entries(field)
+ // If a parameter value is "index_default", we remove it
+ .filter(({ 1: value }) => value !== INDEX_DEFAULT)
+ .reduce(
+ (acc, [param, value]) => ({
+ ...acc,
+ [param]: value,
+ }),
+ {} as any
+ );
+
+export const fieldSerializer: SerializerFunc = (field: Field) => {
+ // If a subType is present, use it as type for ES
+ if ({}.hasOwnProperty.call(field, 'subType')) {
+ field.type = field.subType as DataType;
+ delete field.subType;
+ }
+
+ // Delete temp fields
+ delete (field as any).useSameAnalyzerForSearch;
+
+ return sanitizeField(field);
+};
+
+export const fieldDeserializer: SerializerFunc = (field: Field): Field => {
+ if (!MAIN_DATA_TYPE_DEFINITION[field.type as MainType]) {
+ // IF the type if not one of the main one, it is then probably a "sub" type.
+ const type = getMainTypeFromSubType(field.type as SubType);
+ if (!type) {
+ throw new Error(
+ `Property type "${field.type}" not recognized and no subType was found for it.`
+ );
+ }
+ field.subType = field.type as SubType;
+ field.type = type;
+ }
+
+ (field as any).useSameAnalyzerForSearch =
+ {}.hasOwnProperty.call(field, 'search_analyzer') === false;
+
+ return field;
+};
diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/utils.test.ts b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/utils.test.ts
new file mode 100644
index 0000000000000..0431ea472643b
--- /dev/null
+++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/utils.test.ts
@@ -0,0 +1,65 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+jest.mock('../constants', () => ({ MAIN_DATA_TYPE_DEFINITION: {} }));
+
+import { isStateValid } from './utils';
+
+describe('utils', () => {
+ describe('isStateValid()', () => {
+ let components: any;
+ it('handles base case', () => {
+ components = {
+ fieldsJsonEditor: { isValid: undefined },
+ configuration: { isValid: undefined },
+ fieldForm: undefined,
+ };
+ expect(isStateValid(components)).toBe(undefined);
+ });
+
+ it('handles combinations of true, false and undefined', () => {
+ components = {
+ fieldsJsonEditor: { isValid: false },
+ configuration: { isValid: true },
+ fieldForm: undefined,
+ };
+
+ expect(isStateValid(components)).toBe(false);
+
+ components = {
+ fieldsJsonEditor: { isValid: false },
+ configuration: { isValid: undefined },
+ fieldForm: undefined,
+ };
+
+ expect(isStateValid(components)).toBe(undefined);
+
+ components = {
+ fieldsJsonEditor: { isValid: true },
+ configuration: { isValid: undefined },
+ fieldForm: undefined,
+ };
+
+ expect(isStateValid(components)).toBe(undefined);
+
+ components = {
+ fieldsJsonEditor: { isValid: true },
+ configuration: { isValid: false },
+ fieldForm: undefined,
+ };
+
+ expect(isStateValid(components)).toBe(false);
+
+ components = {
+ fieldsJsonEditor: { isValid: false },
+ configuration: { isValid: true },
+ fieldForm: { isValid: true },
+ };
+
+ expect(isStateValid(components)).toBe(false);
+ });
+ });
+});
diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/utils.ts b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/utils.ts
new file mode 100644
index 0000000000000..50e4023c8c742
--- /dev/null
+++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/utils.ts
@@ -0,0 +1,504 @@
+/*
+ * 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 uuid from 'uuid';
+
+import {
+ DataType,
+ Fields,
+ Field,
+ NormalizedFields,
+ NormalizedField,
+ FieldMeta,
+ MainType,
+ SubType,
+ ChildFieldName,
+ ParameterName,
+ ComboBoxOption,
+} from '../types';
+
+import {
+ SUB_TYPE_MAP_TO_MAIN,
+ MAX_DEPTH_DEFAULT_EDITOR,
+ PARAMETERS_DEFINITION,
+ TYPE_NOT_ALLOWED_MULTIFIELD,
+ TYPE_ONLY_ALLOWED_AT_ROOT_LEVEL,
+} from '../constants';
+
+import { State } from '../reducer';
+import { FieldConfig } from '../shared_imports';
+import { TreeItem } from '../components/tree';
+
+export const getUniqueId = () => {
+ return uuid.v4();
+};
+
+const getChildFieldsName = (dataType: DataType): ChildFieldName | undefined => {
+ if (dataType === 'text' || dataType === 'keyword') {
+ return 'fields';
+ } else if (dataType === 'object' || dataType === 'nested') {
+ return 'properties';
+ }
+ return undefined;
+};
+
+export const getFieldMeta = (field: Field, isMultiField?: boolean): FieldMeta => {
+ const childFieldsName = getChildFieldsName(field.type);
+
+ const canHaveChildFields = isMultiField ? false : childFieldsName === 'properties';
+ const hasChildFields = isMultiField
+ ? false
+ : canHaveChildFields &&
+ Boolean(field[childFieldsName!]) &&
+ Object.keys(field[childFieldsName!]!).length > 0;
+
+ const canHaveMultiFields = isMultiField ? false : childFieldsName === 'fields';
+ const hasMultiFields = isMultiField
+ ? false
+ : canHaveMultiFields &&
+ Boolean(field[childFieldsName!]) &&
+ Object.keys(field[childFieldsName!]!).length > 0;
+
+ return {
+ childFieldsName,
+ canHaveChildFields,
+ hasChildFields,
+ canHaveMultiFields,
+ hasMultiFields,
+ isExpanded: false,
+ };
+};
+
+export const getFieldConfig = (param: ParameterName, prop?: string): FieldConfig => {
+ if (prop !== undefined) {
+ if (
+ !(PARAMETERS_DEFINITION[param] as any).props ||
+ !(PARAMETERS_DEFINITION[param] as any).props[prop]
+ ) {
+ throw new Error(`No field config found for prop "${prop}" on param "${param}" `);
+ }
+ return (PARAMETERS_DEFINITION[param] as any).props[prop].fieldConfig || {};
+ }
+
+ return (PARAMETERS_DEFINITION[param] as any).fieldConfig || {};
+};
+
+/**
+ * For "alias" field types, we work internaly by "id" references. When we normalize the fields, we need to
+ * replace the actual "path" parameter with the field (internal) `id` the alias points to.
+ * This method takes care of doing just that.
+ *
+ * @param byId The fields map by id
+ */
+
+const replaceAliasPathByAliasId = (
+ byId: NormalizedFields['byId']
+): {
+ aliases: NormalizedFields['aliases'];
+ byId: NormalizedFields['byId'];
+} => {
+ const aliases: NormalizedFields['aliases'] = {};
+
+ Object.entries(byId).forEach(([id, field]) => {
+ if (field.source.type === 'alias') {
+ const aliasTargetField = Object.values(byId).find(
+ _field => _field.path.join('.') === field.source.path
+ );
+
+ if (aliasTargetField) {
+ // we set the path to the aliasTargetField "id"
+ field.source.path = aliasTargetField.id;
+
+ // We add the alias field to our "aliases" map
+ aliases[aliasTargetField.id] = aliases[aliasTargetField.id] || [];
+ aliases[aliasTargetField.id].push(id);
+ }
+ }
+ });
+
+ return { aliases, byId };
+};
+
+export const getMainTypeFromSubType = (subType: SubType): MainType =>
+ SUB_TYPE_MAP_TO_MAIN[subType] as MainType;
+
+/**
+ * In order to better work with the recursive pattern of the mappings `properties`, this method flatten the fields
+ * to a `byId` object where the key is the **path** to the field and the value is a `NormalizedField`.
+ * The `NormalizedField` contains the field data under `source` and meta information about the capability of the field.
+ *
+ * @example
+
+// original
+{
+ myObject: {
+ type: 'object',
+ properties: {
+ name: {
+ type: 'text'
+ }
+ }
+ }
+}
+
+// normalized
+{
+ rootLevelFields: ['_uniqueId123'],
+ byId: {
+ '_uniqueId123': {
+ source: { type: 'object' },
+ id: '_uniqueId123',
+ parentId: undefined,
+ hasChildFields: true,
+ childFieldsName: 'properties', // "object" type have their child fields under "properties"
+ canHaveChildFields: true,
+ childFields: ['_uniqueId456'],
+ },
+ '_uniqueId456': {
+ source: { type: 'text' },
+ id: '_uniqueId456',
+ parentId: '_uniqueId123',
+ hasChildFields: false,
+ childFieldsName: 'fields', // "text" type have their child fields under "fields"
+ canHaveChildFields: true,
+ childFields: undefined,
+ },
+ },
+}
+ *
+ * @param fieldsToNormalize The "properties" object from the mappings (or "fields" object for `text` and `keyword` types)
+ */
+export const normalize = (fieldsToNormalize: Fields): NormalizedFields => {
+ let maxNestedDepth = 0;
+
+ const normalizeFields = (
+ props: Fields,
+ to: NormalizedFields['byId'],
+ paths: string[],
+ arrayToKeepRef: string[],
+ nestedDepth: number,
+ isMultiField: boolean = false,
+ parentId?: string
+ ): Record =>
+ Object.entries(props)
+ .sort(([a], [b]) => (a > b ? 1 : a < b ? -1 : 0))
+ .reduce((acc, [propName, value]) => {
+ const id = getUniqueId();
+ arrayToKeepRef.push(id);
+ const field = { name: propName, ...value } as Field;
+
+ // In some cases for object, the "type" is not defined but the field
+ // has properties defined. The mappings editor requires a "type" to be defined
+ // so we add it here.
+ if (field.type === undefined && field.properties !== undefined) {
+ field.type = 'object';
+ }
+
+ const meta = getFieldMeta(field, isMultiField);
+ const { childFieldsName, hasChildFields, hasMultiFields } = meta;
+
+ if (hasChildFields || hasMultiFields) {
+ const nextDepth =
+ meta.canHaveChildFields || meta.canHaveMultiFields ? nestedDepth + 1 : nestedDepth;
+ meta.childFields = [];
+ maxNestedDepth = Math.max(maxNestedDepth, nextDepth);
+
+ normalizeFields(
+ field[childFieldsName!]!,
+ to,
+ [...paths, propName],
+ meta.childFields,
+ nextDepth,
+ meta.canHaveMultiFields,
+ id
+ );
+ }
+
+ const { properties, fields, ...rest } = field;
+
+ const normalizedField: NormalizedField = {
+ id,
+ parentId,
+ nestedDepth,
+ isMultiField,
+ path: paths.length ? [...paths, propName] : [propName],
+ source: rest,
+ ...meta,
+ };
+
+ acc[id] = normalizedField;
+
+ return acc;
+ }, to);
+
+ const rootLevelFields: string[] = [];
+ const { byId, aliases } = replaceAliasPathByAliasId(
+ normalizeFields(fieldsToNormalize, {}, [], rootLevelFields, 0)
+ );
+
+ return {
+ byId,
+ aliases,
+ rootLevelFields,
+ maxNestedDepth,
+ };
+};
+
+/**
+ * The alias "path" value internally point to a field "id" (not its path). When we deNormalize the fields,
+ * we need to replace the target field "id" by its actual "path", making sure to not mutate our state "fields" object.
+ *
+ * @param aliases The aliases map
+ * @param byId The fields map by id
+ */
+const replaceAliasIdByAliasPath = (
+ aliases: NormalizedFields['aliases'],
+ byId: NormalizedFields['byId']
+): NormalizedFields['byId'] => {
+ const updatedById = { ...byId };
+
+ Object.entries(aliases).forEach(([targetId, aliasesIds]) => {
+ const path = updatedById[targetId] ? updatedById[targetId].path.join('.') : '';
+
+ aliasesIds.forEach(id => {
+ const aliasField = updatedById[id];
+ if (!aliasField) {
+ return;
+ }
+ const fieldWithUpdatedPath: NormalizedField = {
+ ...aliasField,
+ source: { ...aliasField.source, path },
+ };
+
+ updatedById[id] = fieldWithUpdatedPath;
+ });
+ });
+
+ return updatedById;
+};
+
+export const deNormalize = ({ rootLevelFields, byId, aliases }: NormalizedFields): Fields => {
+ const serializedFieldsById = replaceAliasIdByAliasPath(aliases, byId);
+
+ const deNormalizePaths = (ids: string[], to: Fields = {}) => {
+ ids.forEach(id => {
+ const { source, childFields, childFieldsName } = serializedFieldsById[id];
+ const { name, ...normalizedField } = source;
+ const field: Omit = normalizedField;
+ to[name] = field;
+ if (childFields) {
+ field[childFieldsName!] = {};
+ return deNormalizePaths(childFields, field[childFieldsName!]);
+ }
+ });
+ return to;
+ };
+
+ return deNormalizePaths(rootLevelFields);
+};
+
+/**
+ * If we change the "name" of a field, we need to update its `path` and the
+ * one of **all** of its child properties or multi-fields.
+ *
+ * @param field The field who's name has changed
+ * @param byId The map of all the document fields
+ */
+export const updateFieldsPathAfterFieldNameChange = (
+ field: NormalizedField,
+ byId: NormalizedFields['byId']
+): { updatedFieldPath: string[]; updatedById: NormalizedFields['byId'] } => {
+ const updatedById = { ...byId };
+ const paths = field.parentId ? byId[field.parentId].path : [];
+
+ const updateFieldPath = (_field: NormalizedField, _paths: string[]): void => {
+ const { name } = _field.source;
+ const path = _paths.length === 0 ? [name] : [..._paths, name];
+
+ updatedById[_field.id] = {
+ ..._field,
+ path,
+ };
+
+ if (_field.hasChildFields || _field.hasMultiFields) {
+ _field
+ .childFields!.map(fieldId => byId[fieldId])
+ .forEach(childField => {
+ updateFieldPath(childField, [..._paths, name]);
+ });
+ }
+ };
+
+ updateFieldPath(field, paths);
+
+ return { updatedFieldPath: updatedById[field.id].path, updatedById };
+};
+
+/**
+ * Retrieve recursively all the children fields of a field
+ *
+ * @param field The field to return the children from
+ * @param byId Map of all the document fields
+ */
+export const getAllChildFields = (
+ field: NormalizedField,
+ byId: NormalizedFields['byId']
+): NormalizedField[] => {
+ const getChildFields = (_field: NormalizedField, to: NormalizedField[] = []) => {
+ if (_field.hasChildFields || _field.hasMultiFields) {
+ _field
+ .childFields!.map(fieldId => byId[fieldId])
+ .forEach(childField => {
+ to.push(childField);
+ getChildFields(childField, to);
+ });
+ }
+ return to;
+ };
+
+ return getChildFields(field);
+};
+
+/**
+ * If we delete an object with child fields or a text/keyword with multi-field,
+ * we need to know if any of its "child" fields has an `alias` that points to it.
+ * This method traverse the field descendant tree and returns all the aliases found
+ * on the field and its possible children.
+ */
+export const getAllDescendantAliases = (
+ field: NormalizedField,
+ fields: NormalizedFields,
+ aliasesIds: string[] = []
+): string[] => {
+ const hasAliases = fields.aliases[field.id] && Boolean(fields.aliases[field.id].length);
+
+ if (!hasAliases && !field.hasChildFields && !field.hasMultiFields) {
+ return aliasesIds;
+ }
+
+ if (hasAliases) {
+ fields.aliases[field.id].forEach(id => {
+ aliasesIds.push(id);
+ });
+ }
+
+ if (field.childFields) {
+ field.childFields.forEach(id => {
+ if (!fields.byId[id]) {
+ return;
+ }
+ getAllDescendantAliases(fields.byId[id], fields, aliasesIds);
+ });
+ }
+
+ return aliasesIds;
+};
+
+/**
+ * Helper to retrieve a map of all the ancestors of a field
+ *
+ * @param fieldId The field id
+ * @param byId A map of all the fields by Id
+ */
+export const getFieldAncestors = (
+ fieldId: string,
+ byId: NormalizedFields['byId']
+): { [key: string]: boolean } => {
+ const ancestors: { [key: string]: boolean } = {};
+ const currentField = byId[fieldId];
+ let parent: NormalizedField | undefined =
+ currentField.parentId === undefined ? undefined : byId[currentField.parentId];
+
+ while (parent) {
+ ancestors[parent.id] = true;
+ parent = parent.parentId === undefined ? undefined : byId[parent.parentId];
+ }
+
+ return ancestors;
+};
+
+export const filterTypesForMultiField = (
+ options: ComboBoxOption[]
+): ComboBoxOption[] =>
+ options.filter(
+ option => TYPE_NOT_ALLOWED_MULTIFIELD.includes(option.value as MainType) === false
+ );
+
+export const filterTypesForNonRootFields = (
+ options: ComboBoxOption[]
+): ComboBoxOption[] =>
+ options.filter(
+ option => TYPE_ONLY_ALLOWED_AT_ROOT_LEVEL.includes(option.value as MainType) === false
+ );
+
+/**
+ * Return the max nested depth of the document fields
+ *
+ * @param byId Map of all the document fields
+ */
+export const getMaxNestedDepth = (byId: NormalizedFields['byId']): number =>
+ Object.values(byId).reduce((maxDepth, field) => {
+ return Math.max(maxDepth, field.nestedDepth);
+ }, 0);
+
+/**
+ * Create a nested array of fields and its possible children
+ * to render a Tree view of them.
+ */
+export const buildFieldTreeFromIds = (
+ fieldsIds: string[],
+ byId: NormalizedFields['byId'],
+ render: (field: NormalizedField) => JSX.Element | string
+): TreeItem[] =>
+ fieldsIds.map(id => {
+ const field = byId[id];
+ const children = field.childFields
+ ? buildFieldTreeFromIds(field.childFields, byId, render)
+ : undefined;
+
+ return { label: render(field), children };
+ });
+
+/**
+ * When changing the type of a field, in most cases we want to delete all its child fields.
+ * There are some exceptions, when changing from "text" to "keyword" as both have the same "fields" property.
+ */
+export const shouldDeleteChildFieldsAfterTypeChange = (
+ oldType: DataType,
+ newType: DataType
+): boolean => {
+ if (oldType === 'text' && newType !== 'keyword') {
+ return true;
+ } else if (oldType === 'keyword' && newType !== 'text') {
+ return true;
+ } else if (oldType === 'object' && newType !== 'nested') {
+ return true;
+ } else if (oldType === 'nested' && newType !== 'object') {
+ return true;
+ }
+
+ return false;
+};
+
+export const canUseMappingsEditor = (maxNestedDepth: number) =>
+ maxNestedDepth < MAX_DEPTH_DEFAULT_EDITOR;
+
+const stateWithValidity: Array = ['configuration', 'fieldsJsonEditor', 'fieldForm'];
+
+export const isStateValid = (state: State): boolean | undefined =>
+ Object.entries(state)
+ .filter(([key]) => stateWithValidity.includes(key as keyof State))
+ .reduce((isValid, { 1: value }) => {
+ if (value === undefined) {
+ return isValid;
+ }
+
+ // If one section validity of the state is "undefined", the mappings validity is also "undefined"
+ if (isValid === undefined || value.isValid === undefined) {
+ return undefined;
+ }
+
+ return isValid && value.isValid;
+ }, true as undefined | boolean);
diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/validators.ts b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/validators.ts
new file mode 100644
index 0000000000000..279d4612f3df1
--- /dev/null
+++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/lib/validators.ts
@@ -0,0 +1,34 @@
+/*
+ * 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 { i18n } from '@kbn/i18n';
+
+import { ValidationFunc } from '../shared_imports';
+import { NormalizedFields } from '../types';
+
+export const validateUniqueName = (
+ { rootLevelFields, byId }: Pick,
+ initialName: string | undefined = '',
+ parentId?: string
+) => {
+ const validator: ValidationFunc = ({ value }) => {
+ const existingNames = parentId
+ ? Object.values(byId)
+ .filter(field => field.parentId === parentId)
+ .map(field => field.source.name)
+ : rootLevelFields.map(fieldId => byId[fieldId].source.name);
+
+ if (existingNames.filter(name => name !== initialName).includes(value as string)) {
+ return {
+ message: i18n.translate('xpack.idxMgmt.mappingsEditor.existNamesValidationErrorMessage', {
+ defaultMessage: 'There is already a field with this name.',
+ }),
+ };
+ }
+ };
+
+ return validator;
+};
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
new file mode 100644
index 0000000000000..d1fee4c0af745
--- /dev/null
+++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/mappings_editor.tsx
@@ -0,0 +1,129 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { useMemo, useState } from 'react';
+import { i18n } from '@kbn/i18n';
+import { EuiSpacer, EuiTabs, EuiTab } from '@elastic/eui';
+
+import { ConfigurationForm, DocumentFields, TemplatesForm } from './components';
+import { IndexSettings } from './types';
+import { State } from './reducer';
+import { MappingsState, Props as MappingsStateProps } from './mappings_state';
+import { IndexSettingsProvider } from './index_settings_context';
+
+interface Props {
+ onUpdate: MappingsStateProps['onUpdate'];
+ defaultValue?: { [key: string]: any };
+ indexSettings?: IndexSettings;
+}
+
+type TabName = 'fields' | 'advanced' | 'templates';
+
+export const MappingsEditor = React.memo(({ onUpdate, defaultValue, indexSettings }: Props) => {
+ const [selectedTab, selectTab] = useState('fields');
+
+ const parsedDefaultValue = useMemo(() => {
+ const {
+ _source = {},
+ _meta = {},
+ _routing,
+ dynamic,
+ numeric_detection,
+ date_detection,
+ dynamic_date_formats,
+ properties = {},
+ dynamic_templates,
+ } = defaultValue ?? {};
+
+ return {
+ configuration: {
+ _source,
+ _meta,
+ _routing,
+ dynamic,
+ numeric_detection,
+ date_detection,
+ dynamic_date_formats,
+ },
+ fields: properties,
+ templates: {
+ dynamic_templates,
+ },
+ };
+ }, [defaultValue]);
+
+ 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.
+ const { isValid: isConfigurationFormValid } = await state.configuration.submitForm!();
+
+ 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;
+ }
+ } else if (selectedTab === 'templates') {
+ const { isValid: isTemplatesFormValid } = await state.templates.form!.submit();
+
+ if (!isTemplatesFormValid) {
+ return;
+ }
+ }
+
+ selectTab(tab);
+ };
+
+ 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]}
+
+ );
+ }}
+
+
+ );
+});
diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/mappings_state.tsx b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/mappings_state.tsx
new file mode 100644
index 0000000000000..54cdea9ff8a42
--- /dev/null
+++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/mappings_state.tsx
@@ -0,0 +1,210 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { useReducer, useEffect, createContext, useContext, useMemo, useRef } from 'react';
+
+import {
+ reducer,
+ addFieldToState,
+ MappingsConfiguration,
+ MappingsFields,
+ MappingsTemplates,
+ State,
+ Dispatch,
+} from './reducer';
+import { Field } from './types';
+import { normalize, deNormalize } from './lib';
+
+type Mappings = MappingsTemplates &
+ MappingsConfiguration & {
+ properties: MappingsFields;
+ };
+
+export interface Types {
+ Mappings: Mappings;
+ MappingsConfiguration: MappingsConfiguration;
+ MappingsFields: MappingsFields;
+ MappingsTemplates: MappingsTemplates;
+}
+
+export interface OnUpdateHandlerArg {
+ isValid?: boolean;
+ getData: (isValid: boolean) => Mappings;
+ validate: () => Promise;
+}
+
+export type OnUpdateHandler = (arg: OnUpdateHandlerArg) => void;
+
+const StateContext = createContext(undefined);
+const DispatchContext = createContext(undefined);
+
+export interface Props {
+ children: (params: { state: State }) => React.ReactNode;
+ defaultValue: {
+ templates: MappingsTemplates;
+ configuration: MappingsConfiguration;
+ fields: { [key: string]: Field };
+ };
+ onUpdate: OnUpdateHandler;
+}
+
+export const MappingsState = React.memo(({ children, onUpdate, defaultValue }: Props) => {
+ const didMountRef = useRef(false);
+
+ const parsedFieldsDefaultValue = useMemo(() => normalize(defaultValue.fields), [
+ defaultValue.fields,
+ ]);
+
+ const initialState: State = {
+ isValid: undefined,
+ configuration: {
+ defaultValue: defaultValue.configuration,
+ data: {
+ raw: defaultValue.configuration,
+ format: () => defaultValue.configuration,
+ },
+ validate: () => Promise.resolve(true),
+ },
+ templates: {
+ defaultValue: defaultValue.templates,
+ data: {
+ raw: defaultValue.templates,
+ format: () => defaultValue.templates,
+ },
+ validate: () => Promise.resolve(true),
+ },
+ fields: parsedFieldsDefaultValue,
+ documentFields: {
+ status: 'idle',
+ editor: 'default',
+ },
+ fieldsJsonEditor: {
+ format: () => ({}),
+ isValid: true,
+ },
+ search: {
+ term: '',
+ result: [],
+ },
+ };
+
+ const [state, dispatch] = useReducer(reducer, initialState);
+
+ 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)
+ const isFieldFormVisible = state.fieldForm !== undefined;
+ const emptyNameValue =
+ isFieldFormVisible &&
+ state.fieldForm!.data.raw.name !== undefined &&
+ state.fieldForm!.data.raw.name.trim() === '';
+
+ const bypassFieldFormValidation =
+ state.documentFields.status === 'creatingField' && emptyNameValue;
+
+ onUpdate({
+ // Output a mappings object from the user's input.
+ getData: (isValid: boolean) => {
+ let nextState = state;
+
+ if (
+ state.documentFields.status === 'creatingField' &&
+ isValid &&
+ !bypassFieldFormValidation
+ ) {
+ // If the form field is valid and we are creating a new field that has some data
+ // we automatically add the field to our state.
+ const fieldFormData = state.fieldForm!.data.format() as Field;
+ if (Object.keys(fieldFormData).length !== 0) {
+ nextState = addFieldToState(fieldFormData, state);
+ dispatch({ type: 'field.add', value: fieldFormData });
+ }
+ }
+
+ // Pull the mappings properties from the current editor
+ const fields =
+ nextState.documentFields.editor === 'json'
+ ? nextState.fieldsJsonEditor.format()
+ : deNormalize(nextState.fields);
+
+ const configurationData = nextState.configuration.data.format();
+ const templatesData = nextState.templates.data.format();
+
+ return {
+ ...configurationData,
+ ...templatesData,
+ properties: fields,
+ };
+ },
+ validate: async () => {
+ const configurationFormValidator =
+ state.configuration.submitForm !== undefined
+ ? new Promise(async resolve => {
+ const { isValid } = await state.configuration.submitForm!();
+ resolve(isValid);
+ })
+ : Promise.resolve(true);
+
+ const templatesFormValidator =
+ state.templates.form !== undefined
+ ? (await state.templates.form!.submit()).isValid
+ : Promise.resolve(true);
+
+ const promisesToValidate = [configurationFormValidator, templatesFormValidator];
+
+ if (state.fieldForm !== undefined && !bypassFieldFormValidation) {
+ promisesToValidate.push(state.fieldForm.validate());
+ }
+
+ return Promise.all(promisesToValidate).then(
+ validationArray => validationArray.every(Boolean) && state.fieldsJsonEditor.isValid
+ );
+ },
+ isValid: state.isValid,
+ });
+ }, [state]);
+
+ useEffect(() => {
+ /**
+ * If the defaultValue has changed that probably means that we have loaded
+ * new data from JSON. We need to update our state with the new mappings.
+ */
+ if (didMountRef.current) {
+ dispatch({
+ type: 'editor.replaceMappings',
+ value: {
+ configuration: defaultValue.configuration,
+ templates: defaultValue.templates,
+ fields: parsedFieldsDefaultValue,
+ },
+ });
+ } else {
+ didMountRef.current = true;
+ }
+ }, [defaultValue]);
+
+ return (
+
+ {children({ state })}
+
+ );
+});
+
+export const useMappingsState = () => {
+ const ctx = useContext(StateContext);
+ if (ctx === undefined) {
+ throw new Error('useMappingsState must be used within a ');
+ }
+ return ctx;
+};
+
+export const useDispatch = () => {
+ const ctx = useContext(DispatchContext);
+ if (ctx === undefined) {
+ throw new Error('useDispatch must be used within a ');
+ }
+ return ctx;
+};
diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/reducer.ts b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/reducer.ts
new file mode 100644
index 0000000000000..e843f4e841631
--- /dev/null
+++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/reducer.ts
@@ -0,0 +1,596 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { OnFormUpdateArg, FormHook } from './shared_imports';
+import { Field, NormalizedFields, NormalizedField, FieldsEditor, SearchResult } from './types';
+import {
+ getFieldMeta,
+ getUniqueId,
+ shouldDeleteChildFieldsAfterTypeChange,
+ getAllChildFields,
+ getMaxNestedDepth,
+ isStateValid,
+ normalize,
+ updateFieldsPathAfterFieldNameChange,
+ searchFields,
+} from './lib';
+import { PARAMETERS_DEFINITION } from './constants';
+
+export interface MappingsConfiguration {
+ enabled?: boolean;
+ throwErrorsForUnmappedFields?: boolean;
+ date_detection: boolean;
+ numeric_detection: boolean;
+ dynamic_date_formats: string[];
+ _source: {
+ enabled?: boolean;
+ includes?: string[];
+ excludes?: string[];
+ };
+ _meta?: string;
+}
+
+export interface MappingsTemplates {
+ dynamic_templates: Template[];
+}
+
+interface Template {
+ [key: string]: any;
+}
+
+export interface MappingsFields {
+ [key: string]: any;
+}
+
+type DocumentFieldsStatus = 'idle' | 'editingField' | 'creatingField';
+
+interface DocumentFieldsState {
+ status: DocumentFieldsStatus;
+ editor: FieldsEditor;
+ fieldToEdit?: string;
+ fieldToAddFieldTo?: string;
+}
+
+interface ConfigurationFormState extends OnFormUpdateArg {
+ defaultValue: MappingsConfiguration;
+ submitForm?: FormHook['submit'];
+}
+
+export interface State {
+ isValid: boolean | undefined;
+ configuration: ConfigurationFormState;
+ documentFields: DocumentFieldsState;
+ fields: NormalizedFields;
+ fieldForm?: OnFormUpdateArg;
+ fieldsJsonEditor: {
+ format(): MappingsFields;
+ isValid: boolean;
+ };
+ search: {
+ term: string;
+ result: SearchResult[];
+ };
+ templates: {
+ defaultValue: {
+ dynamic_templates: MappingsTemplates['dynamic_templates'];
+ };
+ form?: FormHook;
+ } & OnFormUpdateArg;
+}
+
+export type Action =
+ | { type: 'editor.replaceMappings'; value: { [key: string]: any } }
+ | { type: 'configuration.update'; value: Partial }
+ | { type: 'configuration.save' }
+ | { type: 'templates.update'; value: Partial }
+ | { type: 'templates.save' }
+ | { type: 'fieldForm.update'; value: OnFormUpdateArg }
+ | { type: 'field.add'; value: Field }
+ | { type: 'field.remove'; value: string }
+ | { type: 'field.edit'; value: Field }
+ | { type: 'field.toggleExpand'; value: { fieldId: string; isExpanded?: boolean } }
+ | { type: 'documentField.createField'; value?: string }
+ | { type: 'documentField.editField'; value: string }
+ | { type: 'documentField.changeStatus'; value: DocumentFieldsStatus }
+ | { type: 'documentField.changeEditor'; value: FieldsEditor }
+ | { type: 'fieldsJsonEditor.update'; value: { json: { [key: string]: any }; isValid: boolean } }
+ | { type: 'search:update'; value: string };
+
+export type Dispatch = (action: Action) => void;
+
+export const addFieldToState = (field: Field, state: State): State => {
+ const updatedFields = { ...state.fields };
+ const id = getUniqueId();
+ const { fieldToAddFieldTo } = state.documentFields;
+ const addToRootLevel = fieldToAddFieldTo === undefined;
+ const parentField = addToRootLevel ? undefined : updatedFields.byId[fieldToAddFieldTo!];
+ const isMultiField = parentField ? parentField.canHaveMultiFields : false;
+
+ updatedFields.byId = { ...updatedFields.byId };
+ updatedFields.rootLevelFields = addToRootLevel
+ ? [...updatedFields.rootLevelFields, id]
+ : updatedFields.rootLevelFields;
+
+ const nestedDepth =
+ parentField && (parentField.canHaveChildFields || parentField.canHaveMultiFields)
+ ? parentField.nestedDepth + 1
+ : 0;
+
+ updatedFields.maxNestedDepth = Math.max(updatedFields.maxNestedDepth, nestedDepth);
+
+ const { name } = field;
+ const path = parentField ? [...parentField.path, name] : [name];
+
+ const newField: NormalizedField = {
+ id,
+ parentId: fieldToAddFieldTo,
+ isMultiField,
+ source: field,
+ path,
+ nestedDepth,
+ ...getFieldMeta(field, isMultiField),
+ };
+
+ updatedFields.byId[id] = newField;
+
+ if (parentField) {
+ const childFields = parentField.childFields || [];
+
+ // Update parent field with new children
+ updatedFields.byId[fieldToAddFieldTo!] = {
+ ...parentField,
+ childFields: [...childFields, id],
+ hasChildFields: parentField.canHaveChildFields,
+ hasMultiFields: parentField.canHaveMultiFields,
+ isExpanded: true,
+ };
+ }
+
+ if (newField.source.type === 'alias') {
+ updatedFields.aliases = updateAliasesReferences(newField, updatedFields);
+ }
+
+ return {
+ ...state,
+ isValid: isStateValid(state),
+ fields: updatedFields,
+ };
+};
+
+const updateAliasesReferences = (
+ field: NormalizedField,
+ { aliases }: NormalizedFields,
+ previousTargetPath?: string
+): NormalizedFields['aliases'] => {
+ const updatedAliases = { ...aliases };
+ /**
+ * If the field where the alias points to has changed, we need to remove the alias field id from the previous reference array.
+ */
+ if (previousTargetPath && updatedAliases[previousTargetPath]) {
+ updatedAliases[previousTargetPath] = updatedAliases[previousTargetPath].filter(
+ id => id !== field.id
+ );
+ }
+
+ const targetId = field.source.path!;
+
+ if (!updatedAliases[targetId]) {
+ updatedAliases[targetId] = [];
+ }
+
+ updatedAliases[targetId] = [...updatedAliases[targetId], field.id];
+
+ return updatedAliases;
+};
+
+/**
+ * Helper to remove a field from our map, in an immutable way.
+ * When we remove a field we also need to update its parent "childFields" array, or
+ * if there are no parent, we then need to update the "rootLevelFields" array.
+ *
+ * @param fieldId The field id that has been removed
+ * @param byId The fields map by Id
+ */
+const removeFieldFromMap = (fieldId: string, fields: NormalizedFields): NormalizedFields => {
+ let { rootLevelFields } = fields;
+
+ const updatedById = { ...fields.byId };
+ const { parentId } = updatedById[fieldId];
+
+ // Remove the field from the map
+ delete updatedById[fieldId];
+
+ if (parentId) {
+ const parentField = updatedById[parentId];
+
+ if (parentField) {
+ // If the parent exist, update its childFields Array
+ const childFields = parentField.childFields!.filter(childId => childId !== fieldId);
+
+ updatedById[parentId] = {
+ ...parentField,
+ childFields,
+ hasChildFields: parentField.canHaveChildFields && Boolean(childFields.length),
+ hasMultiFields: parentField.canHaveMultiFields && Boolean(childFields.length),
+ isExpanded:
+ !parentField.hasChildFields && !parentField.hasMultiFields
+ ? false
+ : parentField.isExpanded,
+ };
+ }
+ } else {
+ // If there are no parentId it means that we have deleted a top level field
+ // We need to update the root level fields Array
+ rootLevelFields = rootLevelFields.filter(childId => childId !== fieldId);
+ }
+
+ let updatedFields = {
+ ...fields,
+ rootLevelFields,
+ byId: updatedById,
+ };
+
+ if (updatedFields.aliases[fieldId]) {
+ // Recursively remove all the alias fields pointing to this field being removed.
+ updatedFields = updatedFields.aliases[fieldId].reduce(
+ (_updatedFields, aliasId) => removeFieldFromMap(aliasId, _updatedFields),
+ updatedFields
+ );
+ const upddatedAliases = { ...updatedFields.aliases };
+ delete upddatedAliases[fieldId];
+
+ return {
+ ...updatedFields,
+ aliases: upddatedAliases,
+ };
+ }
+
+ return updatedFields;
+};
+
+export const reducer = (state: State, action: Action): State => {
+ switch (action.type) {
+ case 'editor.replaceMappings': {
+ return {
+ ...state,
+ fieldForm: undefined,
+ fields: action.value.fields,
+ configuration: {
+ ...state.configuration,
+ defaultValue: action.value.configuration,
+ },
+ templates: {
+ ...state.templates,
+ defaultValue: action.value.templates,
+ },
+ documentFields: {
+ ...state.documentFields,
+ status: 'idle',
+ fieldToAddFieldTo: undefined,
+ fieldToEdit: undefined,
+ },
+ search: {
+ term: '',
+ result: [],
+ },
+ };
+ }
+ case 'configuration.update': {
+ const nextState = {
+ ...state,
+ configuration: { ...state.configuration, ...action.value },
+ };
+
+ const isValid = isStateValid(nextState);
+ nextState.isValid = isValid;
+ return nextState;
+ }
+ case 'configuration.save': {
+ const {
+ data: { raw, format },
+ } = state.configuration;
+ const configurationData = format();
+
+ return {
+ ...state,
+ configuration: {
+ isValid: true,
+ defaultValue: configurationData,
+ data: {
+ raw,
+ format: () => configurationData,
+ },
+ validate: async () => true,
+ },
+ };
+ }
+ case 'templates.update': {
+ const nextState = {
+ ...state,
+ templates: { ...state.templates, ...action.value },
+ };
+
+ const isValid = isStateValid(nextState);
+ nextState.isValid = isValid;
+
+ return nextState;
+ }
+ case 'templates.save': {
+ const {
+ data: { raw, format },
+ } = state.templates;
+ const templatesData = format();
+
+ return {
+ ...state,
+ templates: {
+ isValid: true,
+ defaultValue: templatesData,
+ data: {
+ raw,
+ format: () => templatesData,
+ },
+ validate: async () => true,
+ },
+ };
+ }
+ case 'fieldForm.update': {
+ const nextState = {
+ ...state,
+ fieldForm: action.value,
+ };
+
+ const isValid = isStateValid(nextState);
+ nextState.isValid = isValid;
+
+ return nextState;
+ }
+ case 'documentField.createField': {
+ return {
+ ...state,
+ documentFields: {
+ ...state.documentFields,
+ fieldToAddFieldTo: action.value,
+ status: 'creatingField',
+ },
+ };
+ }
+ case 'documentField.editField': {
+ return {
+ ...state,
+ documentFields: {
+ ...state.documentFields,
+ status: 'editingField',
+ fieldToEdit: action.value,
+ },
+ };
+ }
+ case 'documentField.changeStatus':
+ const isValid = action.value === 'idle' ? state.configuration.isValid : state.isValid;
+ return {
+ ...state,
+ isValid,
+ fieldForm: undefined,
+ documentFields: {
+ ...state.documentFields,
+ status: action.value,
+ fieldToAddFieldTo: undefined,
+ fieldToEdit: undefined,
+ },
+ };
+ case 'documentField.changeEditor': {
+ const switchingToDefault = action.value === 'default';
+ const fields = switchingToDefault ? normalize(state.fieldsJsonEditor.format()) : state.fields;
+ return {
+ ...state,
+ fields,
+ fieldForm: undefined,
+ documentFields: {
+ ...state.documentFields,
+ status: 'idle',
+ fieldToAddFieldTo: undefined,
+ fieldToEdit: undefined,
+ editor: action.value,
+ },
+ };
+ }
+ case 'field.add': {
+ return addFieldToState(action.value, state);
+ }
+ case 'field.remove': {
+ const field = state.fields.byId[action.value];
+ const { id, hasChildFields, hasMultiFields } = field;
+
+ // Remove the field
+ let updatedFields = removeFieldFromMap(id, state.fields);
+
+ if (hasChildFields || hasMultiFields) {
+ const allChildFields = getAllChildFields(field, state.fields.byId);
+
+ // Remove all of its children
+ allChildFields!.forEach(childField => {
+ updatedFields = removeFieldFromMap(childField.id, updatedFields);
+ });
+ }
+
+ // Handle Alias
+ if (field.source.type === 'alias' && field.source.path) {
+ /**
+ * If we delete an alias field, we need to remove its id from the reference Array
+ */
+ const targetId = field.source.path;
+ updatedFields.aliases = {
+ ...updatedFields.aliases,
+ [targetId]: updatedFields.aliases[targetId].filter(aliasId => aliasId !== id),
+ };
+ }
+
+ updatedFields.maxNestedDepth = getMaxNestedDepth(updatedFields.byId);
+
+ return {
+ ...state,
+ fields: updatedFields,
+ // If we have a search in progress, we reexecute the search to update our result array
+ search: Boolean(state.search.term)
+ ? {
+ ...state.search,
+ result: searchFields(state.search.term, updatedFields.byId),
+ }
+ : state.search,
+ };
+ }
+ case 'field.edit': {
+ let updatedFields = { ...state.fields };
+ const fieldToEdit = state.documentFields.fieldToEdit!;
+ const previousField = updatedFields.byId[fieldToEdit!];
+
+ let newField: NormalizedField = {
+ ...previousField,
+ source: action.value,
+ };
+
+ if (newField.source.type === 'alias') {
+ updatedFields.aliases = updateAliasesReferences(
+ newField,
+ updatedFields,
+ previousField.source.path
+ );
+ }
+
+ const nameHasChanged = newField.source.name !== previousField.source.name;
+ const typeHasChanged = newField.source.type !== previousField.source.type;
+
+ if (nameHasChanged) {
+ // If the name has changed, we need to update the `path` of the field and recursively
+ // the paths of all its "descendant" fields (child or multi-field)
+ const { updatedFieldPath, updatedById } = updateFieldsPathAfterFieldNameChange(
+ newField,
+ updatedFields.byId
+ );
+ newField.path = updatedFieldPath;
+ updatedFields.byId = updatedById;
+ }
+
+ updatedFields.byId[fieldToEdit] = newField;
+
+ if (typeHasChanged) {
+ // The field `type` has changed, we need to update its meta information
+ // and delete all its children fields.
+
+ const shouldDeleteChildFields = shouldDeleteChildFieldsAfterTypeChange(
+ previousField.source.type,
+ newField.source.type
+ );
+
+ if (previousField.source.type === 'alias' && previousField.source.path) {
+ // The field was previously an alias, now that it is not an alias anymore
+ // We need to remove its reference from our state.aliases map
+ updatedFields.aliases = {
+ ...updatedFields.aliases,
+ [previousField.source.path]: updatedFields.aliases[previousField.source.path].filter(
+ aliasId => aliasId !== fieldToEdit
+ ),
+ };
+ } else {
+ const nextTypeCanHaveAlias = !PARAMETERS_DEFINITION.path.targetTypesNotAllowed.includes(
+ newField.source.type
+ );
+
+ if (!nextTypeCanHaveAlias && updatedFields.aliases[fieldToEdit]) {
+ updatedFields.aliases[fieldToEdit].forEach(aliasId => {
+ updatedFields = removeFieldFromMap(aliasId, updatedFields);
+ });
+ delete updatedFields.aliases[fieldToEdit];
+ }
+ }
+
+ if (shouldDeleteChildFields && previousField.childFields) {
+ const allChildFields = getAllChildFields(previousField, updatedFields.byId);
+ allChildFields!.forEach(childField => {
+ updatedFields = removeFieldFromMap(childField.id, updatedFields);
+ });
+ }
+
+ newField = {
+ ...newField,
+ ...getFieldMeta(action.value, newField.isMultiField),
+ childFields: shouldDeleteChildFields ? undefined : previousField.childFields,
+ hasChildFields: shouldDeleteChildFields ? false : previousField.hasChildFields,
+ hasMultiFields: shouldDeleteChildFields ? false : previousField.hasMultiFields,
+ isExpanded: shouldDeleteChildFields ? false : previousField.isExpanded,
+ };
+
+ updatedFields.byId[fieldToEdit] = newField;
+ }
+
+ updatedFields.maxNestedDepth = getMaxNestedDepth(updatedFields.byId);
+
+ return {
+ ...state,
+ isValid: isStateValid(state),
+ fieldForm: undefined,
+ fields: updatedFields,
+ documentFields: {
+ ...state.documentFields,
+ fieldToEdit: undefined,
+ status: 'idle',
+ },
+ // If we have a search in progress, we reexecute the search to update our result array
+ search: Boolean(state.search.term)
+ ? {
+ ...state.search,
+ result: searchFields(state.search.term, updatedFields.byId),
+ }
+ : state.search,
+ };
+ }
+ case 'field.toggleExpand': {
+ const { fieldId, isExpanded } = action.value;
+ const previousField = state.fields.byId[fieldId];
+
+ const nextField: NormalizedField = {
+ ...previousField,
+ isExpanded: isExpanded === undefined ? !previousField.isExpanded : isExpanded,
+ };
+
+ return {
+ ...state,
+ fields: {
+ ...state.fields,
+ byId: {
+ ...state.fields.byId,
+ [fieldId]: nextField,
+ },
+ },
+ };
+ }
+ case 'fieldsJsonEditor.update': {
+ const nextState = {
+ ...state,
+ fieldsJsonEditor: {
+ format() {
+ return action.value.json;
+ },
+ isValid: action.value.isValid,
+ },
+ };
+
+ nextState.isValid = isStateValid(nextState);
+
+ return nextState;
+ }
+ case 'search:update': {
+ return {
+ ...state,
+ search: {
+ term: action.value,
+ result: searchFields(action.value, state.fields.byId),
+ },
+ };
+ }
+ default:
+ throw new Error(`Action "${action!.type}" not recognized.`);
+ }
+};
diff --git a/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/shared_imports.ts b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/shared_imports.ts
new file mode 100644
index 0000000000000..8ac1c2f8c35d1
--- /dev/null
+++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/shared_imports.ts
@@ -0,0 +1,49 @@
+/*
+ * 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 {
+ FIELD_TYPES,
+ FieldConfig,
+ FieldHook,
+ Form,
+ FormDataProvider,
+ FormHook,
+ FormSchema,
+ getUseField,
+ OnFormUpdateArg,
+ SerializerFunc,
+ UseField,
+ useForm,
+ useFormContext,
+ UseMultiFields,
+ VALIDATION_TYPES,
+ ValidationFunc,
+ ValidationFuncArg,
+} from '../../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib';
+
+export {
+ CheckBoxField,
+ Field,
+ FormRow,
+ NumericField,
+ RangeField,
+ SelectField,
+ SuperSelectField,
+ TextAreaField,
+ TextField,
+ ToggleField,
+ JsonEditorField,
+} from '../../../../../../../../src/plugins/es_ui_shared/static/forms/components';
+
+export {
+ fieldFormatters,
+ fieldValidators,
+} from '../../../../../../../../src/plugins/es_ui_shared/static/forms/helpers';
+
+export {
+ JsonEditor,
+ OnJsonEditorUpdateHandler,
+} from '../../../../../../../../src/plugins/es_ui_shared/public';
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
new file mode 100644
index 0000000000000..0fce3422344bc
--- /dev/null
+++ b/x-pack/legacy/plugins/index_management/public/app/components/mappings_editor/types.ts
@@ -0,0 +1,271 @@
+/*
+ * 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 { ReactNode, OptionHTMLAttributes } from 'react';
+
+import { FieldConfig } from './shared_imports';
+import { PARAMETERS_DEFINITION } from './constants';
+
+export interface DataTypeDefinition {
+ label: string;
+ value: DataType;
+ documentation?: {
+ main: string;
+ [key: string]: string;
+ };
+ subTypes?: { label: string; types: SubType[] };
+ description?: () => ReactNode;
+}
+
+export type MainType =
+ | 'text'
+ | 'keyword'
+ | 'numeric'
+ | 'binary'
+ | 'boolean'
+ | 'range'
+ | 'object'
+ | 'nested'
+ | 'alias'
+ | 'completion'
+ | 'dense_vector'
+ | 'flattened'
+ | 'ip'
+ | 'join'
+ | 'percolator'
+ | 'rank_feature'
+ | 'rank_features'
+ | 'shape'
+ | 'search_as_you_type'
+ | 'date'
+ | 'date_nanos'
+ | 'geo_point'
+ | 'geo_shape'
+ | 'token_count';
+
+export type SubType = NumericType | RangeType;
+
+export type DataType = MainType | SubType;
+
+export type NumericType =
+ | 'long'
+ | 'integer'
+ | 'short'
+ | 'byte'
+ | 'double'
+ | 'float'
+ | 'half_float'
+ | 'scaled_float';
+
+export type RangeType =
+ | 'integer_range'
+ | 'float_range'
+ | 'long_range'
+ | 'ip_range'
+ | 'double_range'
+ | 'date_range';
+
+export type ParameterName =
+ | 'name'
+ | 'type'
+ | 'store'
+ | 'index'
+ | 'fielddata'
+ | 'fielddata_frequency_filter'
+ | 'fielddata_frequency_filter_percentage'
+ | 'fielddata_frequency_filter_absolute'
+ | 'doc_values'
+ | 'doc_values_binary'
+ | 'coerce'
+ | 'coerce_shape'
+ | 'ignore_malformed'
+ | 'null_value'
+ | 'null_value_numeric'
+ | 'null_value_boolean'
+ | 'null_value_geo_point'
+ | 'null_value_ip'
+ | 'copy_to'
+ | 'dynamic'
+ | 'enabled'
+ | 'boost'
+ | 'locale'
+ | 'format'
+ | 'analyzer'
+ | 'search_analyzer'
+ | 'search_quote_analyzer'
+ | 'index_options'
+ | 'index_options_flattened'
+ | 'index_options_keyword'
+ | 'eager_global_ordinals'
+ | 'index_prefixes'
+ | 'index_phrases'
+ | 'norms'
+ | 'norms_keyword'
+ | 'term_vector'
+ | 'position_increment_gap'
+ | 'similarity'
+ | 'normalizer'
+ | 'ignore_above'
+ | 'split_queries_on_whitespace'
+ | 'scaling_factor'
+ | 'max_input_length'
+ | 'preserve_separators'
+ | 'preserve_position_increments'
+ | 'ignore_z_value'
+ | 'enable_position_increments'
+ | 'orientation'
+ | 'points_only'
+ | 'path'
+ | 'dims'
+ | 'depth_limit';
+
+export interface Parameter {
+ fieldConfig: FieldConfig;
+ paramName?: string;
+ docs?: string;
+ props?: { [key: string]: FieldConfig };
+}
+
+export interface Fields {
+ [key: string]: Omit;
+}
+
+interface FieldBasic {
+ name: string;
+ type: DataType;
+ subType?: SubType;
+ properties?: { [key: string]: Omit };
+ fields?: { [key: string]: Omit };
+}
+
+type FieldParams = {
+ [K in ParameterName]: typeof PARAMETERS_DEFINITION[K]['fieldConfig']['defaultValue'];
+};
+
+export type Field = FieldBasic & FieldParams;
+
+export interface FieldMeta {
+ childFieldsName: ChildFieldName | undefined;
+ canHaveChildFields: boolean;
+ canHaveMultiFields: boolean;
+ hasChildFields: boolean;
+ hasMultiFields: boolean;
+ childFields?: string[];
+ isExpanded: boolean;
+}
+
+export interface NormalizedFields {
+ byId: {
+ [id: string]: NormalizedField;
+ };
+ rootLevelFields: string[];
+ aliases: { [key: string]: string[] };
+ maxNestedDepth: number;
+}
+
+export interface NormalizedField extends FieldMeta {
+ id: string;
+ parentId?: string;
+ nestedDepth: number;
+ path: string[];
+ source: Omit;
+ isMultiField: boolean;
+}
+
+export type ChildFieldName = 'properties' | 'fields';
+
+export type FieldsEditor = 'default' | 'json';
+
+export type SelectOption = {
+ value: unknown;
+ text: T | ReactNode;
+} & OptionHTMLAttributes;
+
+export interface SuperSelectOption {
+ value: unknown;
+ inputDisplay?: ReactNode;
+ dropdownDisplay?: ReactNode;
+ disabled?: boolean;
+ 'data-test-subj'?: string;
+}
+
+export interface AliasOption {
+ id: string;
+ label: string;
+}
+
+export interface IndexSettingsInterface {
+ analysis?: {
+ analyzer: {
+ [key: string]: {
+ type: string;
+ tokenizer: string;
+ char_filter?: string[];
+ filter?: string[];
+ position_increment_gap?: number;
+ };
+ };
+ };
+}
+
+/**
+ * When we define the index settings we can skip
+ * the "index" property and directly add the "analysis".
+ * ES always returns the settings wrapped under "index".
+ */
+export type IndexSettings = IndexSettingsInterface | { index: IndexSettingsInterface };
+
+export interface ComboBoxOption {
+ label: string;
+ value?: unknown;
+}
+
+export interface SearchResult {
+ display: JSX.Element;
+ field: NormalizedField;
+}
+
+export interface SearchMetadata {
+ /**
+ * Whether or not the search term match some part of the field path.
+ */
+ matchPath: boolean;
+ /**
+ * If the search term matches the field type we will give it a higher score.
+ */
+ matchType: boolean;
+ /**
+ * If the last word of the search terms matches the field name
+ */
+ matchFieldName: boolean;
+ /**
+ * If the search term matches the beginning of the path we will give it a higher score
+ */
+ matchStartOfPath: boolean;
+ /**
+ * If the last word of the search terms fully matches the field name
+ */
+ fullyMatchFieldName: boolean;
+ /**
+ * If the search term exactly matches the field type
+ */
+ fullyMatchType: boolean;
+ /**
+ * If the search term matches the full field path
+ */
+ fullyMatchPath: boolean;
+ /**
+ * The score of the result that will allow us to sort the list
+ */
+ score: number;
+ /**
+ * The JSX with tag wrapping the matched string
+ */
+ display: JSX.Element;
+ /**
+ * The field path substring that matches the search
+ */
+ stringMatch: string | null;
+}
diff --git a/x-pack/legacy/plugins/index_management/public/app/components/template_form/steps/step_mappings.tsx b/x-pack/legacy/plugins/index_management/public/app/components/template_form/steps/step_mappings.tsx
index 97cbaa57afef2..d51d512429ea4 100644
--- a/x-pack/legacy/plugins/index_management/public/app/components/template_form/steps/step_mappings.tsx
+++ b/x-pack/legacy/plugins/index_management/public/app/components/template_form/steps/step_mappings.tsx
@@ -4,8 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import React from 'react';
-import { i18n } from '@kbn/i18n';
+import React, { useCallback, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiFlexGroup,
@@ -13,26 +12,34 @@ import {
EuiTitle,
EuiButtonEmpty,
EuiSpacer,
- EuiFormRow,
EuiText,
- EuiCodeEditor,
- EuiCode,
} from '@elastic/eui';
import { documentationService } from '../../../services/documentation';
import { StepProps } from '../types';
-import { useJsonStep } from './use_json_step';
+import { MappingsEditor, OnUpdateHandler, LoadMappingsFromJsonButton } from '../../mappings_editor';
export const StepMappings: React.FunctionComponent = ({
template,
setDataGetter,
onStepValidityChange,
}) => {
- const { content, setContent, error } = useJsonStep({
- prop: 'mappings',
- defaultValue: template.mappings,
- setDataGetter,
- onStepValidityChange,
- });
+ const [mappings, setMappings] = useState(template.mappings);
+
+ const onMappingsEditorUpdate = useCallback(
+ ({ isValid, getData, validate }) => {
+ onStepValidityChange(isValid);
+ setDataGetter(async () => {
+ const isMappingsValid = isValid === undefined ? await validate() : isValid;
+ const data = getData(isMappingsValid);
+ return Promise.resolve({ isValid: isMappingsValid, data: { mappings: data } });
+ });
+ },
+ [setDataGetter, onStepValidityChange]
+ );
+
+ const onJsonLoaded = (json: { [key: string]: any }): void => {
+ setMappings(json);
+ };
return (
@@ -60,79 +67,39 @@ export const StepMappings: React.FunctionComponent = ({
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
+
{/* Mappings code editor */}
-
- }
- helpText={
-
- {JSON.stringify({
- properties: {
- name: { type: 'text' },
- },
- })}
-
- ),
- }}
- />
- }
- isInvalid={Boolean(error)}
- error={error}
- fullWidth
- >
- {
- setContent(udpated);
- }}
- data-test-subj="mappingsEditor"
- />
-
+
+
+
);
};
diff --git a/x-pack/legacy/plugins/index_management/public/app/components/template_form/template_form.tsx b/x-pack/legacy/plugins/index_management/public/app/components/template_form/template_form.tsx
index 78bf7e8e212fd..6a76e1d203b70 100644
--- a/x-pack/legacy/plugins/index_management/public/app/components/template_form/template_form.tsx
+++ b/x-pack/legacy/plugins/index_management/public/app/components/template_form/template_form.tsx
@@ -33,7 +33,7 @@ interface Props {
}
interface ValidationState {
- [key: number]: { isValid: boolean };
+ [key: number]: { isValid: boolean | undefined };
}
const defaultValidation = { isValid: true };
@@ -74,7 +74,7 @@ export const TemplateForm: React.FunctionComponent = ({
stepsDataGetters.current[currentStep] = stepDataGetter;
};
- const onStepValidityChange = (isValid: boolean) => {
+ const onStepValidityChange = (isValid: boolean | undefined) => {
setValidation(prev => ({
...prev,
[currentStep]: {
@@ -169,6 +169,7 @@ export const TemplateForm: React.FunctionComponent = ({
= ({
iconType="arrowRight"
onClick={onNext}
iconSide="right"
- disabled={!isStepValid}
+ disabled={isStepValid === false}
data-test-subj="nextButton"
>
= {
),
}),
},
+ {
+ validator: lowerCaseStringField(
+ i18n.translate('xpack.idxMgmt.templateValidation.templateNameLowerCaseRequiredError', {
+ defaultMessage: 'The template name must be in lowercase.',
+ })
+ ),
+ },
],
},
indexPatterns: {
diff --git a/x-pack/legacy/plugins/index_management/public/app/components/template_form/template_steps.tsx b/x-pack/legacy/plugins/index_management/public/app/components/template_form/template_steps.tsx
index 5603bb4173773..f36742c43af16 100644
--- a/x-pack/legacy/plugins/index_management/public/app/components/template_form/template_steps.tsx
+++ b/x-pack/legacy/plugins/index_management/public/app/components/template_form/template_steps.tsx
@@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n';
interface Props {
currentStep: number;
updateCurrentStep: (step: number, maxCompletedStep: number) => void;
- isCurrentStepValid: boolean;
+ isCurrentStepValid: boolean | undefined;
}
const stepNamesMap: { [key: number]: string } = {
@@ -42,7 +42,7 @@ export const TemplateSteps: React.FunctionComponent = ({
title: stepNamesMap[step],
isComplete: currentStep > step,
isSelected: currentStep === step,
- disabled: step !== currentStep && !isCurrentStepValid,
+ disabled: step !== currentStep && isCurrentStepValid === false,
onClick: () => updateCurrentStep(step, step - 1),
};
});
diff --git a/x-pack/legacy/plugins/index_management/public/app/components/template_form/types.ts b/x-pack/legacy/plugins/index_management/public/app/components/template_form/types.ts
index 4bb939f11c3fe..9385f0c9f738b 100644
--- a/x-pack/legacy/plugins/index_management/public/app/components/template_form/types.ts
+++ b/x-pack/legacy/plugins/index_management/public/app/components/template_form/types.ts
@@ -10,7 +10,7 @@ export interface StepProps {
template: Partial;
setDataGetter: (dataGetter: DataGetterFunc) => void;
updateCurrentStep: (step: number) => void;
- onStepValidityChange: (isValid: boolean) => void;
+ onStepValidityChange: (isValid: boolean | undefined) => void;
isEditing?: boolean;
}
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 15096c2fabf21..036388452f876 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
@@ -5,6 +5,8 @@
*/
import { DocLinksStart } from '../../../../../../../src/core/public';
+import { DataType } from '../components/mappings_editor/types';
+import { TYPE_DEFINITION } from '../components/mappings_editor/constants';
class DocumentationService {
private esDocsBase: string = '';
@@ -26,6 +28,10 @@ class DocumentationService {
return `${this.esDocsBase}/mapping.html`;
}
+ public getRoutingLink() {
+ return `${this.esDocsBase}/mapping-routing-field.html`;
+ }
+
public getTemplatesDocumentationLink() {
return `${this.esDocsBase}/indices-templates.html`;
}
@@ -33,6 +39,151 @@ class DocumentationService {
public getIdxMgmtDocumentationLink() {
return `${this.kibanaDocsBase}/managing-indices.html`;
}
+
+ public getTypeDocLink = (type: DataType, uri = 'main'): string | undefined => {
+ const typeDefinition = TYPE_DEFINITION[type];
+
+ if (!typeDefinition || !typeDefinition.documentation || !typeDefinition.documentation[uri]) {
+ return undefined;
+ }
+ return `${this.esDocsBase}${typeDefinition.documentation[uri]}`;
+ };
+
+ public getMappingTypesLink() {
+ return `${this.esDocsBase}/mapping-types.html`;
+ }
+
+ public getDynamicMappingLink() {
+ return `${this.esDocsBase}/dynamic-field-mapping.html`;
+ }
+
+ public getPercolatorQueryLink() {
+ return `${this.esDocsBase}/query-dsl-percolate-query.html`;
+ }
+
+ public getRankFeatureQueryLink() {
+ return `${this.esDocsBase}/rank-feature.html`;
+ }
+
+ public getMetaFieldLink() {
+ return `${this.esDocsBase}/mapping-meta-field.html`;
+ }
+
+ public getDynamicTemplatesLink() {
+ return `${this.esDocsBase}/dynamic-templates.html`;
+ }
+
+ public getMappingSourceFieldLink() {
+ return `${this.esDocsBase}/mapping-source-field.html`;
+ }
+
+ public getDisablingMappingSourceFieldLink() {
+ return `${this.esDocsBase}/mapping-source-field.html#disable-source-field`;
+ }
+
+ public getNullValueLink() {
+ return `${this.esDocsBase}/null-value.html`;
+ }
+
+ public getTermVectorLink() {
+ return `${this.esDocsBase}/term-vector.html`;
+ }
+
+ public getStoreLink() {
+ return `${this.esDocsBase}/mapping-store.html`;
+ }
+
+ public getSimilarityLink() {
+ return `${this.esDocsBase}/similarity.html`;
+ }
+
+ public getNormsLink() {
+ return `${this.esDocsBase}/norms.html`;
+ }
+
+ public getIndexLink() {
+ return `${this.esDocsBase}/mapping-index.html`;
+ }
+
+ public getIgnoreMalformedLink() {
+ return `${this.esDocsBase}/ignore-malformed.html`;
+ }
+
+ public getFormatLink() {
+ return `${this.esDocsBase}/mapping-date-format.html`;
+ }
+
+ public getEagerGlobalOrdinalsLink() {
+ return `${this.esDocsBase}/eager-global-ordinals.html`;
+ }
+
+ public getDocValuesLink() {
+ return `${this.esDocsBase}/doc-values.html`;
+ }
+
+ public getCopyToLink() {
+ return `${this.esDocsBase}/copy-to.html`;
+ }
+
+ public getCoerceLink() {
+ return `${this.esDocsBase}/coerce.html`;
+ }
+
+ public getBoostLink() {
+ return `${this.esDocsBase}/mapping-boost.html`;
+ }
+
+ public getNormalizerLink() {
+ return `${this.esDocsBase}/normalizer.html`;
+ }
+
+ public getIgnoreAboveLink() {
+ return `${this.esDocsBase}/ignore-above.html`;
+ }
+
+ public getFielddataLink() {
+ return `${this.esDocsBase}/fielddata.html`;
+ }
+
+ public getFielddataFrequencyLink() {
+ return `${this.esDocsBase}/fielddata.html#field-data-filtering`;
+ }
+
+ public getEnablingFielddataLink() {
+ return `${this.esDocsBase}/fielddata.html#before-enabling-fielddata`;
+ }
+
+ public getIndexPhrasesLink() {
+ return `${this.esDocsBase}/index-phrases.html`;
+ }
+
+ public getIndexPrefixesLink() {
+ return `${this.esDocsBase}/index-prefixes.html`;
+ }
+
+ public getPositionIncrementGapLink() {
+ return `${this.esDocsBase}/position-increment-gap.html`;
+ }
+
+ public getAnalyzerLink() {
+ return `${this.esDocsBase}/analyzer.html`;
+ }
+
+ public getDateFormatLink() {
+ return `${this.esDocsBase}/mapping-date-format.html`;
+ }
+
+ public getIndexOptionsLink() {
+ return `${this.esDocsBase}/index-options.html`;
+ }
+
+ public getWellKnownTextLink() {
+ return 'http://docs.opengeospatial.org/is/12-063r5/12-063r5.html';
+ }
+
+ public getRootLocaleLink() {
+ return 'https://docs.oracle.com/javase/8/docs/api/java/util/Locale.html#ROOT';
+ }
}
export const documentationService = new DocumentationService();
diff --git a/x-pack/legacy/plugins/index_management/public/index.scss b/x-pack/legacy/plugins/index_management/public/index.scss
index 0b73e5748a9d3..7128e4a207ca1 100644
--- a/x-pack/legacy/plugins/index_management/public/index.scss
+++ b/x-pack/legacy/plugins/index_management/public/index.scss
@@ -10,6 +10,8 @@
// indChart__legend--small
// indChart__legend-isLoading
+@import './app/components/mappings_editor/index';
+
.indTable {
// The index table is a bespoke table and can't make use of EuiBasicTable's width settings
thead th.indTable__header--name {
diff --git a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor.tsx b/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor.tsx
deleted file mode 100644
index cfc3aba4314c1..0000000000000
--- a/x-pack/legacy/plugins/index_management/static/ui/components/mappings_editor.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import React, { useState, useEffect, Fragment } from 'react';
-import { EuiCodeEditor, EuiSpacer, EuiCallOut } from '@elastic/eui';
-
-interface Props {
- setGetDataHandler: (handler: () => { isValid: boolean; data: Mappings }) => void;
- FormattedMessage: typeof ReactIntl.FormattedMessage;
- defaultValue?: Mappings;
- areErrorsVisible?: boolean;
-}
-
-export interface Mappings {
- [key: string]: any;
-}
-
-export const MappingsEditor = ({
- setGetDataHandler,
- FormattedMessage,
- areErrorsVisible = true,
- defaultValue = {},
-}: Props) => {
- const [mappings, setMappings] = useState(JSON.stringify(defaultValue, null, 2));
- const [error, setError] = useState(null);
-
- const getFormData = () => {
- setError(null);
- try {
- const parsed: Mappings = JSON.parse(mappings);
- return {
- data: parsed,
- isValid: true,
- };
- } catch (e) {
- setError(e.message);
- return {
- isValid: false,
- data: {},
- };
- }
- };
-
- useEffect(() => {
- setGetDataHandler(getFormData);
- }, [mappings]);
-
- return (
-
-
- }
- onChange={(value: string) => {
- setMappings(value);
- }}
- data-test-subj="mappingsEditor"
- />
- {areErrorsVisible && error && (
-
-
-
- }
- color="danger"
- iconType="alert"
- >
- {error}
-
-
- )}
-
- );
-};
diff --git a/x-pack/package.json b/x-pack/package.json
index 61cb04de49386..31781d86c8c24 100644
--- a/x-pack/package.json
+++ b/x-pack/package.json
@@ -310,6 +310,7 @@
"react-shortcuts": "^2.0.0",
"react-sticky": "^6.0.3",
"react-syntax-highlighter": "^5.7.0",
+ "react-tiny-virtual-list": "^2.2.0",
"react-use": "^13.13.0",
"react-vis": "^1.8.1",
"react-visibility-sensor": "^5.1.1",
diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json
index f02515310f7f3..7796c33b5388b 100644
--- a/x-pack/plugins/translations/translations/ja-JP.json
+++ b/x-pack/plugins/translations/translations/ja-JP.json
@@ -6051,8 +6051,6 @@
"xpack.idxMgmt.indexTemplatesList.loadingIndexTemplatesDescription": "テンプレートを読み込み中…",
"xpack.idxMgmt.indexTemplatesList.loadingIndexTemplatesErrorMessage": "テンプレートの読み込み中にエラーが発生",
"xpack.idxMgmt.indexTemplatesTable.systemIndexTemplatesSwitchLabel": "システムテンプレートを含める",
- "xpack.idxMgmt.mappingsEditor.formatError": "JSON フォーマットエラー",
- "xpack.idxMgmt.mappingsEditor.mappingsEditorAriaLabel": "インデックスマッピングエディター",
"xpack.idxMgmt.noMatch.noIndicesDescription": "表示するインデックスがありません",
"xpack.idxMgmt.openIndicesAction.successfullyOpenedIndicesMessage": "[{indexNames}] が開かれました",
"xpack.idxMgmt.pageErrorForbidden.title": "インデックス管理を使用するパーミッションがありません",
@@ -6126,10 +6124,7 @@
"xpack.idxMgmt.templateForm.stepLogistics.versionDescription": "テンプレートを外部管理システムで識別するための番号です。",
"xpack.idxMgmt.templateForm.stepLogistics.versionTitle": "バージョン",
"xpack.idxMgmt.templateForm.stepMappings.docsButtonLabel": "マッピングドキュメント",
- "xpack.idxMgmt.templateForm.stepMappings.fieldMappingsAriaLabel": "マッピングエディター",
- "xpack.idxMgmt.templateForm.stepMappings.fieldMappingsLabel": "マッピング",
"xpack.idxMgmt.templateForm.stepMappings.mappingsDescription": "ドキュメントの保存とインデックス方法を定義します。",
- "xpack.idxMgmt.templateForm.stepMappings.mappingsEditorHelpText": "JSON フォーマットを使用: {code}",
"xpack.idxMgmt.templateForm.stepMappings.stepTitle": "マッピング (任意)",
"xpack.idxMgmt.templateForm.stepReview.requestTab.descriptionText": "このリクエストは次のインデックステンプレートを作成します。",
"xpack.idxMgmt.templateForm.stepReview.requestTabTitle": "リクエスト",
diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json
index 8d8296653263a..c89159568ada6 100644
--- a/x-pack/plugins/translations/translations/zh-CN.json
+++ b/x-pack/plugins/translations/translations/zh-CN.json
@@ -6050,8 +6050,6 @@
"xpack.idxMgmt.indexTemplatesList.loadingIndexTemplatesDescription": "正在加载模板……",
"xpack.idxMgmt.indexTemplatesList.loadingIndexTemplatesErrorMessage": "加载模板时出错",
"xpack.idxMgmt.indexTemplatesTable.systemIndexTemplatesSwitchLabel": "包括系统模板",
- "xpack.idxMgmt.mappingsEditor.formatError": "JSON 格式错误",
- "xpack.idxMgmt.mappingsEditor.mappingsEditorAriaLabel": "索引映射编辑器",
"xpack.idxMgmt.noMatch.noIndicesDescription": "没有要显示的索引",
"xpack.idxMgmt.openIndicesAction.successfullyOpenedIndicesMessage": "已成功打开:[{indexNames}]",
"xpack.idxMgmt.pageErrorForbidden.title": "您无权使用“索引管理”",
@@ -6125,10 +6123,7 @@
"xpack.idxMgmt.templateForm.stepLogistics.versionDescription": "在外部管理系统中标识该模板的编号。",
"xpack.idxMgmt.templateForm.stepLogistics.versionTitle": "版本",
"xpack.idxMgmt.templateForm.stepMappings.docsButtonLabel": "映射文档",
- "xpack.idxMgmt.templateForm.stepMappings.fieldMappingsAriaLabel": "映射编辑器",
- "xpack.idxMgmt.templateForm.stepMappings.fieldMappingsLabel": "映射",
"xpack.idxMgmt.templateForm.stepMappings.mappingsDescription": "定义如何存储和索引文档。",
- "xpack.idxMgmt.templateForm.stepMappings.mappingsEditorHelpText": "使用 JSON 格式:{code}",
"xpack.idxMgmt.templateForm.stepMappings.stepTitle": "映射(可选)",
"xpack.idxMgmt.templateForm.stepReview.requestTab.descriptionText": "此请求将创建以下索引模板。",
"xpack.idxMgmt.templateForm.stepReview.requestTabTitle": "请求",
diff --git a/x-pack/test_utils/testbed/index.ts b/x-pack/test_utils/testbed/index.ts
index e4db3f68fec99..70b055afd254d 100644
--- a/x-pack/test_utils/testbed/index.ts
+++ b/x-pack/test_utils/testbed/index.ts
@@ -5,4 +5,4 @@
*/
export { registerTestBed } from './testbed';
-export { TestBed, TestBedConfig, SetupFunc } from './types';
+export { TestBed, TestBedConfig, SetupFunc, UnwrapPromise } from './types';
diff --git a/x-pack/test_utils/testbed/types.ts b/x-pack/test_utils/testbed/types.ts
index b9ced88f3774c..c51e6a256f66f 100644
--- a/x-pack/test_utils/testbed/types.ts
+++ b/x-pack/test_utils/testbed/types.ts
@@ -133,3 +133,8 @@ export interface MemoryRouterConfig {
/** A callBack that will be called with the React Router instance once mounted */
onRouter?: (router: any) => void;
}
+
+/**
+ * Utility type: extracts returned type from a Promise.
+ */
+export type UnwrapPromise = T extends Promise ? P : T;
diff --git a/yarn.lock b/yarn.lock
index 1cc6c78e365ec..6d46e052aaca5 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -23693,6 +23693,13 @@ react-textarea-autosize@^7.1.0:
"@babel/runtime" "^7.1.2"
prop-types "^15.6.0"
+react-tiny-virtual-list@^2.2.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/react-tiny-virtual-list/-/react-tiny-virtual-list-2.2.0.tgz#eafb6fcf764e4ed41150ff9752cdaad8b35edf4a"
+ integrity sha512-MDiy2xyqfvkWrRiQNdHFdm36lfxmcLLKuYnUqcf9xIubML85cmYCgzBJrDsLNZ3uJQ5LEHH9BnxGKKSm8+C0Bw==
+ dependencies:
+ prop-types "^15.5.7"
+
react-transition-group@^2.2.1:
version "2.7.1"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.7.1.tgz#1fe6d54e811e8f9dfd329aa836b39d9cd16587cb"