diff --git a/api_docs/index_pattern_field_editor.json b/api_docs/index_pattern_field_editor.json index 25cb2cb1d6ea9..e35d8685c4059 100644 --- a/api_docs/index_pattern_field_editor.json +++ b/api_docs/index_pattern_field_editor.json @@ -26,7 +26,13 @@ "text": "FormatEditorProps" }, "

, ", - "FormatEditorState", + { + "pluginId": "indexPatternFieldEditor", + "scope": "public", + "docId": "kibIndexPatternFieldEditorPluginApi", + "section": "def-public.FormatEditorState", + "text": "FormatEditorState" + }, " & S, any>" ], "path": "src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/default/default.tsx", @@ -50,7 +56,13 @@ "label": "state", "description": [], "signature": [ - "FormatEditorState", + { + "pluginId": "indexPatternFieldEditor", + "scope": "public", + "docId": "kibIndexPatternFieldEditorPluginApi", + "section": "def-public.FormatEditorState", + "text": "FormatEditorState" + }, " & S" ], "path": "src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/default/default.tsx", @@ -73,9 +85,21 @@ "text": "FormatEditorProps" }, "<{}>, state: ", - "FormatEditorState", + { + "pluginId": "indexPatternFieldEditor", + "scope": "public", + "docId": "kibIndexPatternFieldEditorPluginApi", + "section": "def-public.FormatEditorState", + "text": "FormatEditorState" + }, ") => { error: string | undefined; samples: ", - "Sample", + { + "pluginId": "indexPatternFieldEditor", + "scope": "public", + "docId": "kibIndexPatternFieldEditorPluginApi", + "section": "def-public.Sample", + "text": "Sample" + }, "[]; }" ], "path": "src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/default/default.tsx", @@ -110,7 +134,13 @@ "label": "state", "description": [], "signature": [ - "FormatEditorState" + { + "pluginId": "indexPatternFieldEditor", + "scope": "public", + "docId": "kibIndexPatternFieldEditorPluginApi", + "section": "def-public.FormatEditorState", + "text": "FormatEditorState" + } ], "path": "src/plugins/index_pattern_field_editor/public/components/field_format_editor/editors/default/default.tsx", "deprecated": false, @@ -307,6 +337,53 @@ ], "initialIsOpen": false }, + { + "parentPluginId": "indexPatternFieldEditor", + "id": "def-public.FormatEditorState", + "type": "Interface", + "tags": [], + "label": "FormatEditorState", + "description": [], + "path": "src/plugins/index_pattern_field_editor/public/components/field_format_editor/format_editor.tsx", + "deprecated": false, + "children": [ + { + "parentPluginId": "indexPatternFieldEditor", + "id": "def-public.FormatEditorState.EditorComponent", + "type": "CompoundType", + "tags": [], + "label": "EditorComponent", + "description": [], + "signature": [ + "React.LazyExoticComponent<", + { + "pluginId": "indexPatternFieldEditor", + "scope": "public", + "docId": "kibIndexPatternFieldEditorPluginApi", + "section": "def-public.FieldFormatEditor", + "text": "FieldFormatEditor" + }, + "<{}>> | null" + ], + "path": "src/plugins/index_pattern_field_editor/public/components/field_format_editor/format_editor.tsx", + "deprecated": false + }, + { + "parentPluginId": "indexPatternFieldEditor", + "id": "def-public.FormatEditorState.fieldFormatId", + "type": "string", + "tags": [], + "label": "fieldFormatId", + "description": [], + "signature": [ + "string | undefined" + ], + "path": "src/plugins/index_pattern_field_editor/public/components/field_format_editor/format_editor.tsx", + "deprecated": false + } + ], + "initialIsOpen": false + }, { "parentPluginId": "indexPatternFieldEditor", "id": "def-public.OpenFieldDeleteModalOptions", @@ -431,7 +508,7 @@ "section": "def-common.IndexPatternField", "text": "IndexPatternField" }, - ") => void) | undefined" + "[]) => void) | undefined" ], "path": "src/plugins/index_pattern_field_editor/public/open_editor.tsx", "deprecated": false, @@ -439,7 +516,7 @@ { "parentPluginId": "indexPatternFieldEditor", "id": "def-public.OpenFieldEditorOptions.onSave.$1", - "type": "Object", + "type": "Array", "tags": [], "label": "field", "description": [], @@ -450,7 +527,8 @@ "docId": "kibDataIndexPatternsPluginApi", "section": "def-common.IndexPatternField", "text": "IndexPatternField" - } + }, + "[]" ], "path": "src/plugins/index_pattern_field_editor/public/open_editor.tsx", "deprecated": false, @@ -474,6 +552,134 @@ } ], "initialIsOpen": false + }, + { + "parentPluginId": "indexPatternFieldEditor", + "id": "def-public.Props", + "type": "Interface", + "tags": [], + "label": "Props", + "description": [], + "path": "src/plugins/index_pattern_field_editor/public/components/delete_field_provider.tsx", + "deprecated": false, + "children": [ + { + "parentPluginId": "indexPatternFieldEditor", + "id": "def-public.Props.children", + "type": "Function", + "tags": [], + "label": "children", + "description": [], + "signature": [ + "(deleteFieldHandler: DeleteFieldFunc) => React.ReactNode" + ], + "path": "src/plugins/index_pattern_field_editor/public/components/delete_field_provider.tsx", + "deprecated": false, + "children": [ + { + "parentPluginId": "indexPatternFieldEditor", + "id": "def-public.Props.children.$1", + "type": "Function", + "tags": [], + "label": "deleteFieldHandler", + "description": [], + "signature": [ + "DeleteFieldFunc" + ], + "path": "src/plugins/index_pattern_field_editor/public/components/delete_field_provider.tsx", + "deprecated": false, + "isRequired": true + } + ], + "returnComment": [] + }, + { + "parentPluginId": "indexPatternFieldEditor", + "id": "def-public.Props.indexPattern", + "type": "Object", + "tags": [], + "label": "indexPattern", + "description": [], + "signature": [ + { + "pluginId": "data", + "scope": "common", + "docId": "kibDataIndexPatternsPluginApi", + "section": "def-common.IndexPattern", + "text": "IndexPattern" + } + ], + "path": "src/plugins/index_pattern_field_editor/public/components/delete_field_provider.tsx", + "deprecated": false + }, + { + "parentPluginId": "indexPatternFieldEditor", + "id": "def-public.Props.onDelete", + "type": "Function", + "tags": [], + "label": "onDelete", + "description": [], + "signature": [ + "((fieldNames: string[]) => void) | undefined" + ], + "path": "src/plugins/index_pattern_field_editor/public/components/delete_field_provider.tsx", + "deprecated": false, + "children": [ + { + "parentPluginId": "indexPatternFieldEditor", + "id": "def-public.Props.onDelete.$1", + "type": "Array", + "tags": [], + "label": "fieldNames", + "description": [], + "signature": [ + "string[]" + ], + "path": "src/plugins/index_pattern_field_editor/public/components/delete_field_provider.tsx", + "deprecated": false, + "isRequired": true + } + ], + "returnComment": [] + } + ], + "initialIsOpen": false + }, + { + "parentPluginId": "indexPatternFieldEditor", + "id": "def-public.Sample", + "type": "Interface", + "tags": [], + "label": "Sample", + "description": [], + "path": "src/plugins/index_pattern_field_editor/public/components/field_format_editor/types.ts", + "deprecated": false, + "children": [ + { + "parentPluginId": "indexPatternFieldEditor", + "id": "def-public.Sample.input", + "type": "CompoundType", + "tags": [], + "label": "input", + "description": [], + "signature": [ + "string | number | React.ReactText[] | Record" + ], + "path": "src/plugins/index_pattern_field_editor/public/components/field_format_editor/types.ts", + "deprecated": false + }, + { + "parentPluginId": "indexPatternFieldEditor", + "id": "def-public.Sample.output", + "type": "string", + "tags": [], + "label": "output", + "description": [], + "path": "src/plugins/index_pattern_field_editor/public/components/field_format_editor/types.ts", + "deprecated": false + } + ], + "initialIsOpen": false } ], "enums": [], @@ -722,7 +928,13 @@ "description": [], "signature": [ "React.FunctionComponent<", - "Props", + { + "pluginId": "indexPatternFieldEditor", + "scope": "public", + "docId": "kibIndexPatternFieldEditorPluginApi", + "section": "def-public.Props", + "text": "Props" + }, ">" ], "path": "src/plugins/index_pattern_field_editor/public/types.ts", diff --git a/api_docs/index_pattern_field_editor.mdx b/api_docs/index_pattern_field_editor.mdx index 7a3cfd0e66bbe..bdf963a71433d 100644 --- a/api_docs/index_pattern_field_editor.mdx +++ b/api_docs/index_pattern_field_editor.mdx @@ -18,7 +18,7 @@ Contact [App Services](https://github.com/orgs/elastic/teams/kibana-app-services | Public API count | Any count | Items lacking comments | Missing exports | |-------------------|-----------|------------------------|-----------------| -| 42 | 2 | 39 | 3 | +| 54 | 2 | 51 | 0 | ## Client diff --git a/src/plugins/data/common/index_patterns/constants.ts b/src/plugins/data/common/index_patterns/constants.ts index 67e266dbd84a2..43e41832e1770 100644 --- a/src/plugins/data/common/index_patterns/constants.ts +++ b/src/plugins/data/common/index_patterns/constants.ts @@ -14,6 +14,7 @@ export const RUNTIME_FIELD_TYPES = [ 'ip', 'boolean', 'geo_point', + 'composite', ] as const; /** diff --git a/src/plugins/data/common/index_patterns/fields/field_list.ts b/src/plugins/data/common/index_patterns/fields/field_list.ts index 78cc390c8c13f..48e641ce68d50 100644 --- a/src/plugins/data/common/index_patterns/fields/field_list.ts +++ b/src/plugins/data/common/index_patterns/fields/field_list.ts @@ -15,7 +15,7 @@ import { DataView } from '../index_patterns'; type FieldMap = Map; export interface IIndexPatternFieldList extends Array { - add(field: FieldSpec): void; + add(field: FieldSpec): DataViewField; getAll(): DataViewField[]; getByName(name: DataViewField['name']): DataViewField | undefined; getByType(type: DataViewField['type']): DataViewField[]; @@ -55,11 +55,12 @@ export const fieldList = ( public readonly getByType = (type: DataViewField['type']) => [ ...(this.groups.get(type) || new Map()).values(), ]; - public readonly add = (field: FieldSpec) => { + public readonly add = (field: FieldSpec): DataViewField => { const newField = new DataViewField({ ...field, shortDotsEnable }); this.push(newField); this.setByName(newField); this.setByGroup(newField); + return newField; }; public readonly remove = (field: IFieldType) => { diff --git a/src/plugins/data/common/index_patterns/fields/index_pattern_field.ts b/src/plugins/data/common/index_patterns/fields/index_pattern_field.ts index fae0e14b95c05..1f0e8cf076ebc 100644 --- a/src/plugins/data/common/index_patterns/fields/index_pattern_field.ts +++ b/src/plugins/data/common/index_patterns/fields/index_pattern_field.ts @@ -9,7 +9,7 @@ /* eslint-disable max-classes-per-file */ import { KbnFieldType, getKbnFieldType, castEsToKbnFieldTypeName } from '@kbn/field-types'; -import type { RuntimeField } from '../types'; +import type { RuntimeFieldSpec, RuntimeType } from '../types'; import { KBN_FIELD_TYPES } from '../../kbn_field_types/types'; import type { IFieldType } from './types'; import { FieldSpec, DataView } from '../..'; @@ -43,7 +43,7 @@ export class DataViewField implements IFieldType { return this.spec.runtimeField; } - public set runtimeField(runtimeField: RuntimeField | undefined) { + public set runtimeField(runtimeField: RuntimeFieldSpec | undefined) { this.spec.runtimeField = runtimeField; } @@ -102,13 +102,29 @@ export class DataViewField implements IFieldType { } public get type() { - return this.runtimeField?.type - ? castEsToKbnFieldTypeName(this.runtimeField?.type) - : this.spec.type; + if (this.isRuntimeField) { + let type: RuntimeType = this.runtimeField?.type!; + if (this.isRuntimeCompositeSubField) { + const [, subFieldName] = this.name.split('.'); + // We return the subField type (with fallback mechanism to "composite" type) + type = this.runtimeField?.fields![subFieldName]?.type ?? this.runtimeField?.type!; + } + return castEsToKbnFieldTypeName(type); + } + + return this.spec.type; } public get esTypes() { - return this.runtimeField?.type ? [this.runtimeField?.type] : this.spec.esTypes; + if (this.isRuntimeField) { + if (this.isRuntimeCompositeSubField) { + const [, subFieldName] = this.name.split('.'); + // We return the subField type (with fallback mechanism to "composite" type) + return [this.runtimeField?.fields![subFieldName]?.type ?? this.runtimeField?.type!]; + } + return [this.runtimeField?.type!]; + } + return this.spec.esTypes; } public get scripted() { @@ -138,6 +154,10 @@ export class DataViewField implements IFieldType { return this.spec.isMapped; } + public get isRuntimeField() { + return !this.isMapped && this.runtimeField !== undefined; + } + // not writable, not serialized public get sortable() { return ( @@ -206,6 +226,13 @@ export class DataViewField implements IFieldType { isMapped: this.isMapped, }; } + + private get isRuntimeCompositeSubField(): boolean { + if (!this.isRuntimeField) { + return false; + } + return this.runtimeField!.type === 'composite'; + } } /** diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts index e08d1e62bae06..52a0833649206 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts @@ -11,7 +11,7 @@ import _, { each, reject } from 'lodash'; import { castEsToKbnFieldTypeName } from '@kbn/field-types'; import { FieldAttrs, FieldAttrSet, DataViewAttributes } from '../..'; -import type { RuntimeField } from '../types'; +import type { RuntimeField, RuntimeFieldSpec, RuntimeType, FieldConfiguration } from '../types'; import { DuplicateField } from '../../../../kibana_utils/common'; import { ES_FIELD_TYPES, KBN_FIELD_TYPES, IIndexPattern, IFieldType } from '../../../common'; @@ -79,7 +79,7 @@ export class DataView implements IIndexPattern { private shortDotsEnable: boolean = false; private fieldFormats: FieldFormatsStartCommon; private fieldAttrs: FieldAttrs; - private runtimeFieldMap: Record; + private runtimeFieldMap: Record; /** * prevents errors when index pattern exists before indices @@ -192,11 +192,13 @@ export class DataView implements IIndexPattern { }; }); + const runtimeFields = this.getRuntimeMappings(); + return { storedFields: ['*'], scriptFields, docvalueFields, - runtimeFields: this.runtimeFieldMap, + runtimeFields, }; } @@ -360,26 +362,32 @@ export class DataView implements IIndexPattern { /** * Add a runtime field - Appended to existing mapped field or a new field is - * created as appropriate + * created as appropriate. * @param name Field name * @param runtimeField Runtime field definition */ - addRuntimeField(name: string, runtimeField: RuntimeField) { - const existingField = this.getFieldByName(name); - if (existingField) { - existingField.runtimeField = runtimeField; - } else { - this.fields.add({ - name, - runtimeField, - type: castEsToKbnFieldTypeName(runtimeField.type), - aggregatable: true, - searchable: true, - count: 0, - readFromDocValues: false, - }); + addRuntimeField(name: string, runtimeField: RuntimeField): DataViewField[] { + const { type, script, customLabel, format, popularity } = runtimeField; + + if (type === 'composite') { + return this.addCompositeRuntimeField(name, runtimeField); } - this.runtimeFieldMap[name] = runtimeField; + + const runtimeFieldSpec: RuntimeFieldSpec = { + type, + script, + }; + + const dataViewFields = [ + this.updateOrAddRuntimeField(name, type, runtimeFieldSpec, { + customLabel, + format, + popularity, + }), + ]; + + this.runtimeFieldMap[name] = runtimeFieldSpec; + return dataViewFields; } /** @@ -395,7 +403,60 @@ export class DataView implements IIndexPattern { * @param name */ getRuntimeField(name: string): RuntimeField | null { - return this.runtimeFieldMap[name] ?? null; + if (!this.runtimeFieldMap[name]) { + return null; + } + + const { type, script, fields } = { ...this.runtimeFieldMap[name] }; + const runtimeField: RuntimeField = { + type, + script, + }; + + if (type === 'composite') { + const subFields = Object.entries(fields!).reduce( + (acc, [subFieldName, subField]) => { + const fieldFullName = `${name}.${subFieldName}`; + const dataViewField = this.getFieldByName(fieldFullName); + if (!dataViewField) { + // We should never enter here as all composite runtime subfield + // are converted to data view fields. + return acc; + } + return { + ...acc, + [subFieldName]: { + type: subField.type, + format: this.getFormatterForFieldNoDefault(fieldFullName)?.toJSON(), + customLabel: dataViewField.customLabel, + popularity: dataViewField.count, + }, + }; + }, + {} + ); + + runtimeField.fields = subFields; + } else { + const dataViewField = this.getFieldByName(name); + if (dataViewField) { + runtimeField.customLabel = dataViewField.customLabel; + runtimeField.popularity = dataViewField.count; + runtimeField.format = this.getFormatterForFieldNoDefault(name)?.toJSON(); + } + } + + return runtimeField; + } + + getAllRuntimeFields(): Record { + return Object.keys(this.runtimeFieldMap).reduce>( + (acc, fieldName) => ({ + ...acc, + [fieldName]: this.getRuntimeField(fieldName)!, + }), + {} + ); } /** @@ -420,6 +481,7 @@ export class DataView implements IIndexPattern { */ removeRuntimeField(name: string) { const existingField = this.getFieldByName(name); + if (existingField) { if (existingField.isMapped) { // mapped field, remove runtimeField def @@ -427,10 +489,30 @@ export class DataView implements IIndexPattern { } else { this.fields.remove(existingField); } + } else { + const runtimeFieldSpec = this.runtimeFieldMap[name]; + + if (runtimeFieldSpec?.type === 'composite') { + // If we remove a "composite" runtime field we loop through each of its + // subFields and remove them from the field list + Object.keys(runtimeFieldSpec.fields!).forEach((subFieldName) => { + const subField = this.getFieldByName(`${name}.${subFieldName}`); + if (subField) { + this.fields.remove(subField); + } + }); + } } delete this.runtimeFieldMap[name]; } + /** + * Return the "runtime_mappings" section of the ES search query + */ + getRuntimeMappings(): Record { + return _.cloneDeep(this.runtimeFieldMap); + } + /** * Get formatter for a given field name. Return undefined if none exists * @param field @@ -484,6 +566,92 @@ export class DataView implements IIndexPattern { public readonly deleteFieldFormat = (fieldName: string) => { delete this.fieldFormatMap[fieldName]; }; + + private addCompositeRuntimeField(name: string, runtimeField: RuntimeField): DataViewField[] { + const { type, script, fields } = runtimeField; + + // Make sure subFields are provided + if (fields === undefined || Object.keys(fields).length === 0) { + throw new Error(`Can't add composite runtime field [name = ${name}] without subfields.`); + } + + // Make sure no field with the same name already exist + if (this.getFieldByName(name) !== undefined) { + throw new Error( + `Can't create composite runtime field ["${name}"] as there is already a field with this name` + ); + } + + const runtimeFieldSpecFields: RuntimeFieldSpec['fields'] = Object.entries(fields).reduce< + RuntimeFieldSpec['fields'] + >((acc, [subFieldName, subField]) => { + return { + ...acc, + [subFieldName]: { + type: subField.type, + }, + }; + }, {}); + + const runtimeFieldSpec: RuntimeFieldSpec = { + type, + script, + fields: runtimeFieldSpecFields, + }; + + // We first remove the runtime composite field with the same name which will remove all of its subFields. + // This guarantees that we don't leave behind orphan data view fields + this.removeRuntimeField(name); + + // We don't add composite runtime fields to the field list as + // they are not fields but **holder** of fields. + // What we do add to the field list are all their subFields. + const dataViewFields = Object.entries(fields).map(([subFieldName, subField]) => + this.updateOrAddRuntimeField(`${name}.${subFieldName}`, subField.type, runtimeFieldSpec, { + customLabel: subField.customLabel, + format: subField.format, + popularity: subField.popularity, + }) + ); + + this.runtimeFieldMap[name] = runtimeFieldSpec; + return dataViewFields; + } + + private updateOrAddRuntimeField( + fieldName: string, + fieldType: RuntimeType, + runtimeFieldSpec: RuntimeFieldSpec, + config: FieldConfiguration + ): DataViewField { + // Create the field if it does not exist or update an existing one + let createdField: DataViewField | undefined; + const existingField = this.getFieldByName(fieldName); + + if (existingField) { + existingField.runtimeField = runtimeFieldSpec; + } else { + createdField = this.fields.add({ + name: fieldName, + runtimeField: runtimeFieldSpec, + type: castEsToKbnFieldTypeName(fieldType), + aggregatable: true, + searchable: true, + count: config.popularity ?? 0, + readFromDocValues: false, + }); + } + + // Apply configuration to the field + this.setFieldCustomLabel(fieldName, config.customLabel); + if (config.format) { + this.setFieldFormat(fieldName, config.format); + } else if (config.format === null) { + this.deleteFieldFormat(fieldName); + } + + return createdField ?? existingField!; + } } /** diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts index a72224d1c3fe8..3201c443ebe3e 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts @@ -14,7 +14,7 @@ import { castEsToKbnFieldTypeName } from '@kbn/field-types'; import { DATA_VIEW_SAVED_OBJECT_TYPE, SavedObjectsClientCommon } from '../..'; import { createDataViewCache } from '.'; -import type { RuntimeField } from '../types'; +import type { RuntimeField, RuntimeFieldSpec, RuntimeType } from '../types'; import { DataView } from './index_pattern'; import { createEnsureDefaultDataView, EnsureDefaultDataView } from './ensure_default_index_pattern'; import { @@ -456,20 +456,35 @@ export class DataViewsService { spec.fieldAttrs ); + const addRuntimeFieldToSpecFields = ( + name: string, + fieldType: RuntimeType, + runtimeField: RuntimeFieldSpec + ) => { + spec.fields![name] = { + name, + type: castEsToKbnFieldTypeName(fieldType), + runtimeField, + aggregatable: true, + searchable: true, + readFromDocValues: false, + customLabel: spec.fieldAttrs?.[name]?.customLabel, + count: spec.fieldAttrs?.[name]?.count, + }; + }; + // CREATE RUNTIME FIELDS - for (const [key, value] of Object.entries(runtimeFieldMap || {})) { + for (const [name, runtimeField] of Object.entries(runtimeFieldMap || {})) { // do not create runtime field if mapped field exists - if (!spec.fields[key]) { - spec.fields[key] = { - name: key, - type: castEsToKbnFieldTypeName(value.type), - runtimeField: value, - aggregatable: true, - searchable: true, - readFromDocValues: false, - customLabel: spec.fieldAttrs?.[key]?.customLabel, - count: spec.fieldAttrs?.[key]?.count, - }; + if (!spec.fields[name]) { + // For composite runtime field we add the subFields, **not** the composite + if (runtimeField.type === 'composite') { + Object.entries(runtimeField.fields!).forEach(([subFieldName, subField]) => { + addRuntimeFieldToSpecFields(`${name}.${subFieldName}`, subField.type, runtimeField); + }); + } else { + addRuntimeFieldToSpecFields(name, runtimeField.type, runtimeField); + } } } } catch (err) { diff --git a/src/plugins/data/common/index_patterns/types.ts b/src/plugins/data/common/index_patterns/types.ts index d1e822aea4e97..ce6853828c8f8 100644 --- a/src/plugins/data/common/index_patterns/types.ts +++ b/src/plugins/data/common/index_patterns/types.ts @@ -19,13 +19,49 @@ import { FieldFormat } from '../../../field_formats/common'; export type FieldFormatMap = Record; export type RuntimeType = typeof RUNTIME_FIELD_TYPES[number]; -export interface RuntimeField { + +export type RuntimeTypeExceptComposite = Exclude; + +export interface RuntimeFieldBase { type: RuntimeType; script?: { source: string; }; } +/** + * The RuntimeField that will be sent in the ES Query "runtime_mappings" object + */ +export interface RuntimeFieldSpec extends RuntimeFieldBase { + fields?: Record< + string, + { + // It is not recursive, we can't create a composite inside a composite. + type: RuntimeTypeExceptComposite; + } + >; +} + +export interface FieldConfiguration { + format?: SerializedFieldFormat | null; + customLabel?: string; + popularity?: number; +} + +/** + * This is the RuntimeField interface enhanced with Data view field + * configuration: field format definition, customLabel or popularity. + * + * @see {@link RuntimeField} + */ +export interface RuntimeField extends RuntimeFieldBase, FieldConfiguration { + fields?: Record; +} + +export interface RuntimeFieldSubField extends FieldConfiguration { + type: RuntimeTypeExceptComposite; +} + /** * @deprecated * IIndexPattern allows for an IndexPattern OR an index pattern saved object @@ -218,7 +254,7 @@ export interface FieldSpec extends DataViewFieldBase { readFromDocValues?: boolean; indexed?: boolean; customLabel?: string; - runtimeField?: RuntimeField; + runtimeField?: RuntimeFieldSpec; // not persisted shortDotsEnable?: boolean; isMapped?: boolean; @@ -256,7 +292,7 @@ export interface DataViewSpec { typeMeta?: TypeMeta; type?: string; fieldFormats?: Record; - runtimeFieldMap?: Record; + runtimeFieldMap?: Record; fieldAttrs?: FieldAttrs; allowNoIndex?: boolean; } diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 595a88b412e9f..80741cf3993e3 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -89,6 +89,7 @@ export { AggregationRestrictions, IndexPatternType, IndexPatternListItem, + RuntimeType, } from '../common'; export { DuplicateDataViewError } from '../common/index_patterns/errors'; diff --git a/src/plugins/data/server/index_patterns/routes/runtime_fields/create_runtime_field.ts b/src/plugins/data/server/index_patterns/routes/runtime_fields/create_runtime_field.ts index faf6d87b6d10b..9854e5f79c226 100644 --- a/src/plugins/data/server/index_patterns/routes/runtime_fields/create_runtime_field.ts +++ b/src/plugins/data/server/index_patterns/routes/runtime_fields/create_runtime_field.ts @@ -7,6 +7,7 @@ */ import { schema } from '@kbn/config-schema'; +import { RuntimeField } from '../../../../common'; import { handleErrors } from '../util/handle_errors'; import { runtimeFieldSpecSchema } from '../util/schemas'; import { IRouter, StartServicesAccessor } from '../../../../../../core/server'; @@ -53,19 +54,18 @@ export const registerCreateRuntimeFieldRoute = ( throw new Error(`Field [name = ${name}] already exists.`); } - indexPattern.addRuntimeField(name, runtimeField); - - const addedField = indexPattern.fields.getByName(name); - if (!addedField) throw new Error(`Could not create a field [name = ${name}].`); + const createdFields = indexPattern.addRuntimeField(name, runtimeField as RuntimeField); await indexPatternsService.updateSavedObject(indexPattern); - const savedField = indexPattern.fields.getByName(name); - if (!savedField) throw new Error(`Could not create a field [name = ${name}].`); - return res.ok({ body: { - field: savedField.toSpec(), + // New API for 7.16 & 8.x. Return an Array of DataViewFields created + fields: createdFields.map((f) => f.toSpec()), + // @deprecated + // To avoid creating a breaking change in 7.16 we continue to support + // the old "field" in the response + field: createdFields[0].toSpec(), index_pattern: indexPattern.toSpec(), }, }); diff --git a/src/plugins/data/server/index_patterns/routes/runtime_fields/delete_runtime_field.ts b/src/plugins/data/server/index_patterns/routes/runtime_fields/delete_runtime_field.ts index 58b8529d7cf5a..b2c75e14a3a8b 100644 --- a/src/plugins/data/server/index_patterns/routes/runtime_fields/delete_runtime_field.ts +++ b/src/plugins/data/server/index_patterns/routes/runtime_fields/delete_runtime_field.ts @@ -44,16 +44,12 @@ export const registerDeleteRuntimeFieldRoute = ( const name = req.params.name; const indexPattern = await indexPatternsService.get(id); - const field = indexPattern.fields.getByName(name); + const runtimeField = indexPattern.getRuntimeField(name); - if (!field) { + if (!runtimeField) { throw new ErrorIndexPatternFieldNotFound(id, name); } - if (!field.runtimeField) { - throw new Error('Only runtime fields can be deleted.'); - } - indexPattern.removeRuntimeField(name); await indexPatternsService.updateSavedObject(indexPattern); diff --git a/src/plugins/data/server/index_patterns/routes/runtime_fields/get_runtime_field.ts b/src/plugins/data/server/index_patterns/routes/runtime_fields/get_runtime_field.ts index 6bc2bf396c0b4..f5b2b29648313 100644 --- a/src/plugins/data/server/index_patterns/routes/runtime_fields/get_runtime_field.ts +++ b/src/plugins/data/server/index_patterns/routes/runtime_fields/get_runtime_field.ts @@ -7,6 +7,7 @@ */ import { schema } from '@kbn/config-schema'; +import { DataViewField } from '../../../../common'; import { ErrorIndexPatternFieldNotFound } from '../../error'; import { handleErrors } from '../util/handle_errors'; import { IRouter, StartServicesAccessor } from '../../../../../../core/server'; @@ -46,20 +47,36 @@ export const registerGetRuntimeFieldRoute = ( const indexPattern = await indexPatternsService.get(id); - const field = indexPattern.fields.getByName(name); + const runtimeField = indexPattern.getRuntimeField(name); - if (!field) { + if (!runtimeField) { throw new ErrorIndexPatternFieldNotFound(id, name); } - if (!field.runtimeField) { - throw new Error('Only runtime fields can be retrieved.'); + // Access the data view fields created for the runtime field + let dataViewFields: DataViewField[]; + + if (runtimeField.type === 'composite') { + // For "composite" runtime fields we need to look at the "fields" + dataViewFields = Object.keys(runtimeField.fields!) + .map((subFieldName) => { + const fullName = `${name}.${subFieldName}`; + return indexPattern.fields.getByName(fullName); + }) + .filter(Boolean) as DataViewField[]; + } else { + dataViewFields = [indexPattern.fields.getByName(name)].filter(Boolean) as DataViewField[]; } return res.ok({ body: { - field: field.toSpec(), - runtimeField: indexPattern.getRuntimeField(name), + // New API for 7.16 & 8.x. Return an Array of DataViewFields for the runtime field + fields: dataViewFields, + // @deprecated + // To avoid creating a breaking change in 7.16 we continue to support + // the old "field" in the response + field: dataViewFields[0].toSpec(), + runtimeField, }, }); }) diff --git a/src/plugins/data/server/index_patterns/routes/runtime_fields/put_runtime_field.ts b/src/plugins/data/server/index_patterns/routes/runtime_fields/put_runtime_field.ts index a5e92fa5a36ec..1397c25d12068 100644 --- a/src/plugins/data/server/index_patterns/routes/runtime_fields/put_runtime_field.ts +++ b/src/plugins/data/server/index_patterns/routes/runtime_fields/put_runtime_field.ts @@ -7,6 +7,7 @@ */ import { schema } from '@kbn/config-schema'; +import { RuntimeField } from 'src/plugins/data/common'; import { handleErrors } from '../util/handle_errors'; import { runtimeFieldSpecSchema } from '../util/schemas'; import { IRouter, StartServicesAccessor } from '../../../../../../core/server'; @@ -44,30 +45,31 @@ export const registerPutRuntimeFieldRoute = ( elasticsearchClient ); const id = req.params.id; - const { name, runtimeField } = req.body; + const { name, runtimeField } = req.body as { + name: string; + runtimeField: RuntimeField; + }; const indexPattern = await indexPatternsService.get(id); - const oldFieldObject = indexPattern.fields.getByName(name); + const oldRuntimeFieldObject = indexPattern.getRuntimeField(name); - if (oldFieldObject && !oldFieldObject.runtimeField) { - throw new Error('Only runtime fields can be updated'); - } - - if (oldFieldObject) { + if (oldRuntimeFieldObject) { indexPattern.removeRuntimeField(name); } - indexPattern.addRuntimeField(name, runtimeField); + const createdFields = indexPattern.addRuntimeField(name, runtimeField); await indexPatternsService.updateSavedObject(indexPattern); - const fieldObject = indexPattern.fields.getByName(name); - if (!fieldObject) throw new Error(`Could not create a field [name = ${name}].`); - return res.ok({ body: { - field: fieldObject.toSpec(), + // New API for 7.16 & 8.x. Return an Array of DataViewFields created + fields: createdFields.map((f) => f.toSpec()), + // @deprecated + // To avoid creating a breaking change in 7.16 we continue to support + // the old "field" in the response + field: createdFields[0].toSpec(), index_pattern: indexPattern.toSpec(), }, }); diff --git a/src/plugins/data/server/index_patterns/routes/runtime_fields/update_runtime_field.ts b/src/plugins/data/server/index_patterns/routes/runtime_fields/update_runtime_field.ts index 3f3aae46c4388..dd2a2fee91049 100644 --- a/src/plugins/data/server/index_patterns/routes/runtime_fields/update_runtime_field.ts +++ b/src/plugins/data/server/index_patterns/routes/runtime_fields/update_runtime_field.ts @@ -63,19 +63,21 @@ export const registerUpdateRuntimeFieldRoute = ( } indexPattern.removeRuntimeField(name); - indexPattern.addRuntimeField(name, { + const createdFields = indexPattern.addRuntimeField(name, { ...existingRuntimeField, ...runtimeField, }); await indexPatternsService.updateSavedObject(indexPattern); - const fieldObject = indexPattern.fields.getByName(name); - if (!fieldObject) throw new Error(`Could not create a field [name = ${name}].`); - return res.ok({ body: { - field: fieldObject.toSpec(), + // New API for 7.16 & 8.x. Return an Array of DataViewFields created + fields: createdFields.map((f) => f.toSpec()), + // @deprecated + // To avoid creating a breaking change in 7.16 we continue to support + // the old "field" in the response + field: createdFields[0].toSpec(), index_pattern: indexPattern.toSpec(), }, }); diff --git a/src/plugins/data/server/index_patterns/routes/update_index_pattern.ts b/src/plugins/data/server/index_patterns/routes/update_index_pattern.ts index 1c88550c154c5..8936d45ba4291 100644 --- a/src/plugins/data/server/index_patterns/routes/update_index_pattern.ts +++ b/src/plugins/data/server/index_patterns/routes/update_index_pattern.ts @@ -14,6 +14,7 @@ import { serializedFieldFormatSchema, } from './util/schemas'; import { IRouter, StartServicesAccessor } from '../../../../../core/server'; +import { RuntimeField } from '../../../common'; import type { DataPluginStart, DataPluginStartDependencies } from '../../plugin'; const indexPatternUpdateSchema = schema.object({ @@ -139,7 +140,7 @@ export const registerUpdateIndexPatternRoute = ( if (runtimeFieldMap !== undefined) { changeCount++; - indexPattern.replaceAllRuntimeFields(runtimeFieldMap); + indexPattern.replaceAllRuntimeFields(runtimeFieldMap as Record); } if (changeCount < 1) { diff --git a/src/plugins/data/server/index_patterns/routes/util/schemas.ts b/src/plugins/data/server/index_patterns/routes/util/schemas.ts index 79ee1ffa1ab97..f378832254047 100644 --- a/src/plugins/data/server/index_patterns/routes/util/schemas.ts +++ b/src/plugins/data/server/index_patterns/routes/util/schemas.ts @@ -72,5 +72,28 @@ export const runtimeFieldSpec = { source: schema.string(), }) ), + fields: schema.maybe( + schema.recordOf( + schema.string(), + schema.object({ + type: runtimeFieldSpecTypeSchema, + format: schema.maybe(serializedFieldFormatSchema), + customLabel: schema.maybe(schema.string()), + popularity: schema.maybe( + schema.number({ + min: 0, + }) + ), + }) + ) + ), + format: schema.maybe(serializedFieldFormatSchema), + customLabel: schema.maybe(schema.string()), + popularity: schema.maybe( + schema.number({ + min: 0, + }) + ), }; + export const runtimeFieldSpecSchema = schema.object(runtimeFieldSpec); diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.test.tsx b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.test.tsx index 4a4c42f69fc8e..73a0f6d997de0 100644 --- a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.test.tsx +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor.test.tsx @@ -98,7 +98,7 @@ describe('', () => { test('should accept a defaultValue and onChange prop to forward the form state', async () => { const field = { name: 'foo', - type: 'date', + type: 'date' as const, script: { source: 'emit("hello")' }, }; @@ -112,7 +112,7 @@ describe('', () => { expect(lastState.submit).toBeDefined(); const { data: formData } = await submitFormAndGetData(lastState); - expect(formData).toEqual(field); + expect(formData).toEqual({ ...field, format: null }); // Make sure that both isValid and isSubmitted state are now "true" lastState = getLastStateUpdate(); @@ -128,7 +128,10 @@ describe('', () => { onChange, }, { - namesNotAllowed: existingFields, + namesNotAllowed: { + fields: existingFields, + runtimeComposites: [], + }, existingConcreteFields: [], fieldTypeToProcess: 'runtime', } @@ -165,7 +168,10 @@ describe('', () => { onChange, }, { - namesNotAllowed: existingRuntimeFieldNames, + namesNotAllowed: { + fields: existingRuntimeFieldNames, + runtimeComposites: [], + }, existingConcreteFields: [], fieldTypeToProcess: 'runtime', } diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.test.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.test.ts index 9b00ff762fe8f..75ac0b75c444f 100644 --- a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.test.ts +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_content.test.ts @@ -33,7 +33,7 @@ describe('', () => { test('should allow a field to be provided', async () => { const field = { name: 'foo', - type: 'ip', + type: 'ip' as const, script: { source: 'emit("hello world")', }, @@ -50,7 +50,7 @@ describe('', () => { test('should accept an "onSave" prop', async () => { const field = { name: 'foo', - type: 'date', + type: 'date' as const, script: { source: 'test=123' }, }; const onSave: jest.Mock = jest.fn(); @@ -69,7 +69,7 @@ describe('', () => { expect(onSave).toHaveBeenCalled(); const fieldReturned = onSave.mock.calls[onSave.mock.calls.length - 1][0]; - expect(fieldReturned).toEqual(field); + expect(fieldReturned).toEqual({ ...field, format: null }); }); test('should accept an onCancel prop', async () => { @@ -135,6 +135,7 @@ describe('', () => { name: 'someName', type: 'keyword', // default to keyword script: { source: 'echo("hello")' }, + format: null, }); // Change the type and make sure it is forwarded @@ -150,6 +151,7 @@ describe('', () => { name: 'someName', type: 'other_type', script: { source: 'echo("hello")' }, + format: null, }); }); }); diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_preview.test.ts b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_preview.test.ts index 65089bc24317b..33e74aec1bb83 100644 --- a/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_preview.test.ts +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/field_editor_flyout_preview.test.ts @@ -280,7 +280,7 @@ describe('Field editor Preview panel', () => { test('should **not** display an empty prompt editing a document with a script', async () => { const field = { name: 'foo', - type: 'ip', + type: 'ip' as const, script: { source: 'emit("hello world")', }, @@ -303,7 +303,7 @@ describe('Field editor Preview panel', () => { test('should **not** display an empty prompt editing a document with format defined', async () => { const field = { name: 'foo', - type: 'ip', + type: 'ip' as const, format: { id: 'upper', params: {}, diff --git a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/setup_environment.tsx b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/setup_environment.tsx index d87b49d35c68e..cf9a8d5406ac2 100644 --- a/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/setup_environment.tsx +++ b/src/plugins/index_pattern_field_editor/__jest__/client_integration/helpers/setup_environment.tsx @@ -94,7 +94,7 @@ export const WithFieldEditorDependencies = { fieldType = [{ label: label ?? field.type, value: field.type as RuntimeType }]; } + const format = field.format === null ? undefined : field.format; + return { ...field, type: fieldType, + format, __meta__: { isCustomLabelVisible: field.customLabel !== undefined, isValueVisible: field.script !== undefined, @@ -143,9 +146,12 @@ const formDeserializer = (field: Field): FieldFormInternal => { }; const formSerializer = (field: FieldFormInternal): Field => { - const { __meta__, type, ...rest } = field; + const { __meta__, type, format, ...rest } = field; return { type: type[0].value!, + // By passing "null" we are explicitly telling DataView to remove the + // format if there is one defined for the field. + format: format === undefined ? null : format, ...rest, }; }; diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/form_fields/format_field.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor/form_fields/format_field.tsx index 2ff4a48477def..3feef82533455 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor/form_fields/format_field.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor/form_fields/format_field.tsx @@ -8,11 +8,16 @@ import React, { useState, useEffect, useRef } from 'react'; import { EuiCallOut, EuiSpacer } from '@elastic/eui'; -import { UseField, useFormData, ES_FIELD_TYPES, useFormContext } from '../../../shared_imports'; +import { + UseField, + useFormData, + ES_FIELD_TYPES, + useFormContext, + SerializedFieldFormat, +} from '../../../shared_imports'; import { useFieldEditorContext } from '../../field_editor_context'; import { FormatSelectEditor } from '../../field_format_editor'; import type { FieldFormInternal } from '../field_editor'; -import type { FieldFormatConfig } from '../../../types'; export const FormatField = () => { const { indexPattern, uiSettings, fieldFormats, fieldFormatEditors } = useFieldEditorContext(); @@ -44,7 +49,7 @@ export const FormatField = () => { }, [type, getFields]); return ( - path="format"> + path="format"> {({ setValue, errors, value }) => { return ( <> diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor/lib.ts b/src/plugins/index_pattern_field_editor/public/components/field_editor/lib.ts index ba44682ba65e0..44455d958a7cd 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor/lib.ts +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor/lib.ts @@ -9,14 +9,15 @@ import { i18n } from '@kbn/i18n'; import { ValidationFunc, FieldConfig } from '../../shared_imports'; -import { Field } from '../../types'; +import type { Field } from '../../types'; +import type { Context } from '../field_editor_context'; import { schema } from './form_schema'; import type { Props } from './field_editor'; const createNameNotAllowedValidator = ( - namesNotAllowed: string[] + namesNotAllowed: Context['namesNotAllowed'] ): ValidationFunc<{}, string, string> => ({ value }) => { - if (namesNotAllowed.includes(value)) { + if (namesNotAllowed.fields.includes(value)) { return { message: i18n.translate( 'indexPatternFieldEditor.editor.runtimeFieldsEditor.existRuntimeFieldNamesValidationErrorMessage', @@ -25,6 +26,15 @@ const createNameNotAllowedValidator = ( } ), }; + } else if (namesNotAllowed.runtimeComposites.includes(value)) { + return { + message: i18n.translate( + 'indexPatternFieldEditor.editor.runtimeFieldsEditor.existCompositeNamesValidationErrorMessage', + { + defaultMessage: 'A runtime composite with this name already exists.', + } + ), + }; } }; @@ -36,7 +46,7 @@ const createNameNotAllowedValidator = ( * @param field Initial value of the form */ export const getNameFieldConfig = ( - namesNotAllowed?: string[], + namesNotAllowed?: Context['namesNotAllowed'], field?: Props['field'] ): FieldConfig => { const nameFieldConfig = schema.name as FieldConfig; @@ -45,15 +55,18 @@ export const getNameFieldConfig = ( return nameFieldConfig; } + const filterOutCurrentFieldName = (name: string) => name !== field?.name; + // Add validation to not allow duplicates return { ...nameFieldConfig!, validations: [ ...(nameFieldConfig.validations ?? []), { - validator: createNameNotAllowedValidator( - namesNotAllowed.filter((name) => name !== field?.name) - ), + validator: createNameNotAllowedValidator({ + fields: namesNotAllowed.fields.filter(filterOutCurrentFieldName), + runtimeComposites: namesNotAllowed.runtimeComposites.filter(filterOutCurrentFieldName), + }), }, ], }; diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor_context.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor_context.tsx index 74bf2657ba3de..88f9352465133 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor_context.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor_context.tsx @@ -31,7 +31,10 @@ export interface Context { * e.g we probably don't want a user to give a name of an existing * runtime field (for that the user should edit the existing runtime field). */ - namesNotAllowed: string[]; + namesNotAllowed: { + fields: string[]; + runtimeComposites: string[]; + }; /** * An array of existing concrete fields. If the user gives a name to the runtime * field that matches one of the concrete fields, a callout will be displayed diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx index 19015aa9d0d10..65521836db7d5 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content.tsx @@ -7,6 +7,7 @@ */ import React, { useState, useCallback, useMemo, useEffect } from 'react'; +import { estypes } from '@elastic/elasticsearch'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -20,9 +21,10 @@ import { EuiText, } from '@elastic/eui'; -import type { Field, EsRuntimeField } from '../types'; +import type { Field } from '../types'; import { RuntimeFieldPainlessError } from '../lib'; import { euiFlyoutClassname } from '../constants'; +import type { RuntimeFieldSubField } from '../shared_imports'; import { FlyoutPanels } from './flyout_panels'; import { useFieldEditorContext } from './field_editor_context'; import { FieldEditor, FieldEditorFormState } from './field_editor/field_editor'; @@ -56,7 +58,9 @@ export interface Props { */ onCancel: () => void; /** Handler to validate the script */ - runtimeFieldValidator: (field: EsRuntimeField) => Promise; + runtimeFieldValidator: ( + field: estypes.MappingRuntimeField + ) => Promise; /** Optional field to process */ field?: Field; isSavingField: boolean; @@ -78,6 +82,7 @@ const FieldEditorFlyoutContentComponent = ({ const { indexPattern } = useFieldEditorContext(); const { panel: { isVisible: isPanelVisible }, + fieldsInScript, } = useFieldPreviewContext(); const [formState, setFormState] = useState({ @@ -119,18 +124,55 @@ const FieldEditorFlyoutContentComponent = ({ return !isFormModified; }, [isFormModified]); + const addSubfieldsToField = useCallback( + (_field: Field): Field => { + const { name, type, script, format } = _field; + + // This is a **temporary hack** to create the subfields. + // It will be replaced by a UI where the user will set the type and format + // of each subField. + if (type === 'composite' && fieldsInScript.length > 0) { + const fields = fieldsInScript.reduce>( + (acc, subFieldName) => { + const subField: RuntimeFieldSubField = { + type: 'keyword' as const, + format, + }; + + acc[subFieldName] = subField; + return acc; + }, + {} + ); + + const updatedField: Field = { + name, + type, + script, + fields, + }; + + return updatedField; + } + + return _field; + }, + [fieldsInScript] + ); + const onClickSave = useCallback(async () => { - const { isValid, data } = await submit(); - const nameChange = field?.name !== data.name; - const typeChange = field?.type !== data.type; + const { isValid, data: updatedField } = await submit(); + const nameChange = field?.name !== updatedField.name; + const typeChange = field?.type !== updatedField.type; if (isValid) { - if (data.script) { + if (updatedField.script) { setIsValidating(true); const error = await runtimeFieldValidator({ - type: data.type, - script: data.script, + // @ts-expect-error @elastic/elasticsearch does not support "composite" type yet + type: updatedField.type, + script: updatedField.script, }); setIsValidating(false); @@ -147,10 +189,10 @@ const FieldEditorFlyoutContentComponent = ({ confirmChangeNameOrType: true, }); } else { - onSave(data); + onSave(addSubfieldsToField(updatedField)); } } - }, [onSave, submit, runtimeFieldValidator, field, isEditingExistingField]); + }, [onSave, submit, runtimeFieldValidator, field, isEditingExistingField, addSubfieldsToField]); const onClickCancel = useCallback(() => { const canClose = canCloseValidator(); @@ -166,8 +208,8 @@ const FieldEditorFlyoutContentComponent = ({ { - const { data } = await submit(); - onSave(data); + const { data: updatedField } = await submit(); + onSave(addSubfieldsToField(updatedField)); }} onCancel={() => { setModalVisibility(defaultModalVisibility); diff --git a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content_container.tsx b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content_container.tsx index cf2b29bbc97e8..a3587ea2b79a8 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content_container.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_editor_flyout_content_container.tsx @@ -15,12 +15,11 @@ import { IndexPatternField, IndexPattern, DataPublicPluginStart, - RuntimeType, UsageCollectionStart, } from '../shared_imports'; import type { Field, PluginStart, InternalFieldType } from '../types'; import { pluginName } from '../constants'; -import { deserializeField, getRuntimeFieldValidator, getLinks, ApiService } from '../lib'; +import { getRuntimeFieldValidator, getLinks, ApiService } from '../lib'; import { FieldEditorFlyoutContent, Props as FieldEditorFlyoutContentProps, @@ -30,7 +29,7 @@ import { FieldPreviewProvider } from './preview'; export interface Props { /** Handler for the "save" footer button */ - onSave: (field: IndexPatternField) => void; + onSave: (field: IndexPatternField[]) => void; /** Handler for the "cancel" footer button */ onCancel: () => void; onMounted?: FieldEditorFlyoutContentProps['onMounted']; @@ -41,7 +40,7 @@ export interface Props { /** The Kibana field type of the field to create or edit (default: "runtime") */ fieldTypeToProcess: InternalFieldType; /** Optional field to edit */ - field?: IndexPatternField; + field?: Field; /** Services */ indexPatternService: DataPublicPluginStart['indexPatterns']; notifications: NotificationsStart; @@ -78,12 +77,20 @@ export const FieldEditorFlyoutContentContainer = ({ fieldFormats, uiSettings, }: Props) => { - const fieldToEdit = deserializeField(indexPattern, field); const [isSaving, setIsSaving] = useState(false); const { fields } = indexPattern; - const namesNotAllowed = useMemo(() => fields.map((fld) => fld.name), [fields]); + const namesNotAllowed = useMemo(() => { + const fieldNames = indexPattern.fields.map((fld) => fld.name); + const runtimeCompositeNames = Object.entries(indexPattern.getAllRuntimeFields()) + .filter(([, _runtimeField]) => _runtimeField.type === 'composite') + .map(([_runtimeFieldName]) => _runtimeFieldName); + return { + fields: fieldNames, + runtimeComposites: runtimeCompositeNames, + }; + }, [indexPattern]); const existingConcreteFields = useMemo(() => { const existing: Array<{ name: string; type: string }> = []; @@ -117,59 +124,78 @@ export const FieldEditorFlyoutContentContainer = ({ [apiService, search, notifications] ); - const saveField = useCallback( - async (updatedField: Field) => { - setIsSaving(true); + const updateRuntimeField = useCallback( + (updatedField: Field): IndexPatternField[] => { + const nameHasChanged = Boolean(field) && field!.name !== updatedField.name; + const typeHasChanged = Boolean(field) && field!.type !== updatedField.type; + const hasChangeToOrFromComposite = + typeHasChanged && (field!.type === 'composite' || updatedField.type === 'composite'); + + if (nameHasChanged || hasChangeToOrFromComposite) { + // Rename an existing runtime field or the type has changed from being a "composite" + // to any other type or from any other type to "composite" + indexPattern.removeRuntimeField(field!.name); + } - const { script } = updatedField; - - if (fieldTypeToProcess === 'runtime') { - try { - usageCollection.reportUiCounter(pluginName, METRIC_TYPE.COUNT, 'save_runtime'); - // eslint-disable-next-line no-empty - } catch {} - // rename an existing runtime field - if (field?.name && field.name !== updatedField.name) { - indexPattern.removeRuntimeField(field.name); - } - - indexPattern.addRuntimeField(updatedField.name, { - type: updatedField.type as RuntimeType, - script, - }); + return indexPattern.addRuntimeField(updatedField.name, updatedField); + }, + [field, indexPattern] + ); + + const updateConcreteField = useCallback( + (updatedField: Field): IndexPatternField[] => { + const editedField = indexPattern.getFieldByName(updatedField.name); + + if (!editedField) { + throw new Error( + `Unable to find field named '${updatedField.name}' on index pattern '${indexPattern.title}'` + ); + } + + // Update custom label, popularity and format + indexPattern.setFieldCustomLabel(updatedField.name, updatedField.customLabel); + + editedField.count = updatedField.popularity || 0; + if (updatedField.format) { + indexPattern.setFieldFormat(updatedField.name, updatedField.format!); } else { - try { - usageCollection.reportUiCounter(pluginName, METRIC_TYPE.COUNT, 'save_concrete'); - // eslint-disable-next-line no-empty - } catch {} + indexPattern.deleteFieldFormat(updatedField.name); } - const editedField = indexPattern.getFieldByName(updatedField.name); + return [editedField]; + }, + [indexPattern] + ); + const saveField = useCallback( + async (updatedField: Field) => { try { - if (!editedField) { - throw new Error( - `Unable to find field named '${updatedField.name}' on index pattern '${indexPattern.title}'` - ); - } - - indexPattern.setFieldCustomLabel(updatedField.name, updatedField.customLabel); - editedField.count = updatedField.popularity || 0; - if (updatedField.format) { - indexPattern.setFieldFormat(updatedField.name, updatedField.format); - } else { - indexPattern.deleteFieldFormat(updatedField.name); - } - - await indexPatternService.updateSavedObject(indexPattern).then(() => { - const message = i18n.translate('indexPatternFieldEditor.deleteField.savedHeader', { - defaultMessage: "Saved '{fieldName}'", - values: { fieldName: updatedField.name }, - }); - notifications.toasts.addSuccess(message); - setIsSaving(false); - onSave(editedField); + usageCollection.reportUiCounter( + pluginName, + METRIC_TYPE.COUNT, + fieldTypeToProcess === 'runtime' ? 'save_runtime' : 'save_concrete' + ); + // eslint-disable-next-line no-empty + } catch {} + + setIsSaving(true); + + try { + const editedFields: IndexPatternField[] = + fieldTypeToProcess === 'runtime' + ? updateRuntimeField(updatedField) + : updateConcreteField(updatedField as Field); + + await indexPatternService.updateSavedObject(indexPattern); + + const message = i18n.translate('indexPatternFieldEditor.deleteField.savedHeader', { + defaultMessage: "Saved '{fieldName}'", + values: { fieldName: updatedField.name }, }); + notifications.toasts.addSuccess(message); + + setIsSaving(false); + onSave(editedFields); } catch (e) { const title = i18n.translate('indexPatternFieldEditor.save.errorTitle', { defaultMessage: 'Failed to save field changes', @@ -184,7 +210,8 @@ export const FieldEditorFlyoutContentContainer = ({ indexPatternService, notifications, fieldTypeToProcess, - field?.name, + updateConcreteField, + updateRuntimeField, usageCollection, ] ); @@ -206,7 +233,7 @@ export const FieldEditorFlyoutContentContainer = ({ onSave={saveField} onCancel={onCancel} onMounted={onMounted} - field={fieldToEdit} + field={field} runtimeFieldValidator={validateRuntimeField} isSavingField={isSaving} /> diff --git a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/field_format_editor.tsx b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/field_format_editor.tsx index 1c0c7ecba3b2b..bbd9fe88f818f 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/field_format_editor.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/field_format_editor.tsx @@ -8,7 +8,6 @@ import React, { PureComponent } from 'react'; import { EuiCode, EuiFormRow, EuiSelect } from '@elastic/eui'; - import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { @@ -19,10 +18,11 @@ import { } from 'src/plugins/data/public'; import type { FieldFormatInstanceType } from 'src/plugins/field_formats/common'; import { CoreStart } from 'src/core/public'; + import { castEsToKbnFieldTypeName } from '../../../../data/public'; -import { FormatEditor } from './format_editor'; import { FormatEditorServiceStart } from '../../service'; -import { FieldFormatConfig } from '../../types'; +import { SerializedFieldFormat } from '../../shared_imports'; +import { FormatEditor } from './format_editor'; export interface FormatSelectEditorProps { esTypes: ES_FIELD_TYPES[]; @@ -30,9 +30,9 @@ export interface FormatSelectEditorProps { fieldFormatEditors: FormatEditorServiceStart['fieldFormatEditors']; fieldFormats: DataPublicPluginStart['fieldFormats']; uiSettings: CoreStart['uiSettings']; - onChange: (change?: FieldFormatConfig) => void; + onChange: (change?: SerializedFieldFormat) => void; onError: (error?: string) => void; - value?: FieldFormatConfig; + value?: SerializedFieldFormat; } interface FieldTypeFormat { diff --git a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/format_editor.tsx b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/format_editor.tsx index c6f5fc9899ac7..3ea9dcf3dbd5a 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/format_editor.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/format_editor.tsx @@ -22,7 +22,7 @@ export interface FormatEditorProps { onError: (error?: string) => void; } -interface FormatEditorState { +export interface FormatEditorState { EditorComponent: LazyExoticComponent | null; fieldFormatId?: string; } diff --git a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/index.ts b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/index.ts index 34619f53e9eed..d0f41c8c35fbe 100644 --- a/src/plugins/index_pattern_field_editor/public/components/field_format_editor/index.ts +++ b/src/plugins/index_pattern_field_editor/public/components/field_format_editor/index.ts @@ -7,4 +7,6 @@ */ export { FormatSelectEditor, FormatSelectEditorProps } from './field_format_editor'; +export type { FormatEditorState } from './format_editor'; +export type { Sample } from './types'; export * from './editors'; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx index 09bacf2a46096..7fc8dae1f025c 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview.tsx @@ -27,6 +27,7 @@ export const FieldPreview = () => { params: { value: { name, script, format }, }, + isLoadingPreview, fields, error, reset, @@ -34,15 +35,15 @@ export const FieldPreview = () => { // To show the preview we at least need a name to be defined, the script or the format // and an first response from the _execute API - const isEmptyPromptVisible = - name === null && script === null && format === null - ? true - : // If we have some result from the _execute API call don't show the empty prompt - error !== null || fields.length > 0 - ? false - : name === null && format === null - ? true - : false; + let isEmptyPromptVisible = false; + const noParamDefined = name === null && script === null && format === null; + const haveResultFromPreview = error !== null || fields.length > 0; + + if (noParamDefined) { + isEmptyPromptVisible = true; + } else if (!haveResultFromPreview && !isLoadingPreview && name === null && format === null) { + isEmptyPromptVisible = true; + } const onFieldListResize = useCallback(({ height }: { height: number }) => { setFieldListHeight(height); @@ -53,13 +54,13 @@ export const FieldPreview = () => { return null; } - const [field] = fields; - return (

); }; diff --git a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx index e1fc4b05883f4..78d92e9085f44 100644 --- a/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx +++ b/src/plugins/index_pattern_field_editor/public/components/preview/field_preview_context.tsx @@ -20,9 +20,9 @@ import useDebounce from 'react-use/lib/useDebounce'; import { i18n } from '@kbn/i18n'; import { get } from 'lodash'; -import type { FieldPreviewContext, FieldFormatConfig } from '../../types'; +import type { FieldPreviewContext } from '../../types'; import { parseEsError } from '../../lib/runtime_field_validation'; -import { RuntimeType, RuntimeField } from '../../shared_imports'; +import { RuntimeType, SerializedFieldFormat } from '../../shared_imports'; import { useFieldEditorContext } from '../field_editor_context'; type From = 'cluster' | 'custom'; @@ -46,8 +46,8 @@ interface Params { name: string | null; index: string | null; type: RuntimeType | null; - script: Required['script'] | null; - format: FieldFormatConfig | null; + script: { source: string } | null; + format: SerializedFieldFormat | null; document: EsDocument | null; } @@ -94,6 +94,8 @@ interface Context { value: { [key: string]: boolean }; set: React.Dispatch>; }; + /** List of fields detected in the Painless script */ + fieldsInScript: string[]; } const fieldPreviewContext = createContext(undefined); @@ -157,6 +159,8 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { const [from, setFrom] = useState('cluster'); /** Map of fields pinned to the top of the list */ const [pinnedFields, setPinnedFields] = useState<{ [key: string]: boolean }>({}); + /** Array of fields detected in the script (returned by the _execute API) */ + const [fieldsInScript, setFieldsInScript] = useState([]); const { documents, currentIdx } = clusterData; const currentDocument: EsDocument | undefined = useMemo(() => documents[currentIdx], [ @@ -317,6 +321,53 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { [indexPattern, search] ); + const updateSingleFieldPreview = useCallback( + (fieldName: string, values: unknown[]) => { + const [value] = values; + const formattedValue = valueFormatter(value); + + setPreviewResponse({ + fields: [{ key: fieldName, value, formattedValue }], + error: null, + }); + }, + [valueFormatter] + ); + + const updateCompositeFieldPreview = useCallback( + (compositeName: string | null, compositeValues: Record) => { + if (typeof compositeValues !== 'object') { + return; + } + + const updatedFieldsInScript: string[] = []; + + const fields = Object.entries(compositeValues).map(([key, values]) => { + // The Painless _execute API returns the composite field values under a map. + // Each of the key is prefixed with "composite_field." (e.g. "composite_field.field1: ['value']") + const { 1: fieldName } = key.split('composite_field.'); + updatedFieldsInScript.push(fieldName); + + const [value] = values; + const formattedValue = valueFormatter(value); + + return { + key: `${compositeName ?? ''}.${fieldName}`, + value, + formattedValue, + }; + }); + + setPreviewResponse({ + fields, + error: null, + }); + + setFieldsInScript(updatedFieldsInScript); + }, + [valueFormatter] + ); + const updatePreview = useCallback(async () => { setLastExecutePainlessReqParams({ type: params.type, @@ -371,13 +422,11 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { error: { code: 'PAINLESS_SCRIPT_ERROR', error: parseEsError(error, true) ?? fallBackError }, }); } else { - const [value] = values; - const formattedValue = valueFormatter(value); - - setPreviewResponse({ - fields: [{ key: params.name!, value, formattedValue }], - error: null, - }); + if (!Array.isArray(values)) { + updateCompositeFieldPreview(params.name, values); + return; + } + updateSingleFieldPreview(params.name!, values); } }, [ needToUpdatePreview, @@ -386,7 +435,8 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { currentDocId, getFieldPreview, notifications.toasts, - valueFormatter, + updateSingleFieldPreview, + updateCompositeFieldPreview, ]); const goToNextDoc = useCallback(() => { @@ -463,6 +513,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { value: pinnedFields, set: setPinnedFields, }, + fieldsInScript, }), [ previewResponse, @@ -482,6 +533,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { from, reset, pinnedFields, + fieldsInScript, ] ); @@ -543,24 +595,64 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { }, [currentDocument, updateParams]); /** - * Whenever the name or the format changes we immediately update the preview + * Whenever the name changes we immediately update the preview */ useEffect(() => { setPreviewResponse((prev) => { - const { - fields: { 0: field }, - } = prev; + const { fields } = prev; + + let updatedFields: Context['fields'] = fields.map((field) => { + let key: string = name ?? ''; + + if (type === 'composite') { + const { 1: fieldName } = field.key.split('.'); + key = `${name ?? ''}.${fieldName}`; + } + + return { + ...field, + key, + }; + }); - const nextValue = - script === null && Boolean(document) - ? get(document, name ?? '') // When there is no script we read the value from _source - : field?.value; + // If the user has entered a name but not yet any script we will display + // the field in the preview with just the name (and a "-" for the value) + if (updatedFields.length === 0 && name !== null) { + updatedFields = [ + { key: name, value: undefined, formattedValue: defaultValueFormatter(undefined) }, + ]; + } - const formattedValue = valueFormatter(nextValue); + return { + ...prev, + fields: updatedFields, + }; + }); + }, [name, type]); + + /** + * Whenever the format changes we immediately update the preview + */ + useEffect(() => { + setPreviewResponse((prev) => { + const { fields } = prev; return { ...prev, - fields: [{ ...field, key: name ?? '', value: nextValue, formattedValue }], + fields: fields.map((field) => { + const nextValue = + script === null && Boolean(document) + ? get(document, name ?? '') // When there is no script we try to read the value from _source + : field?.value; + + const formattedValue = valueFormatter(nextValue); + + return { + ...field, + value: nextValue, + formattedValue, + }; + }), }; }); }, [name, script, document, valueFormatter]); diff --git a/src/plugins/index_pattern_field_editor/public/index.ts b/src/plugins/index_pattern_field_editor/public/index.ts index 6546dabcb2c44..a31ae03d7df49 100644 --- a/src/plugins/index_pattern_field_editor/public/index.ts +++ b/src/plugins/index_pattern_field_editor/public/index.ts @@ -25,7 +25,13 @@ export type { PluginStart as IndexPatternFieldEditorStart, } from './types'; export { DefaultFormatEditor } from './components/field_format_editor/editors/default/default'; -export { FieldFormatEditorFactory, FieldFormatEditor, FormatEditorProps } from './components'; +export { FieldFormatEditorFactory, FieldFormatEditor } from './components'; +export type { + DeleteFieldProviderProps, + FormatEditorProps, + FormatEditorState, + Sample, +} from './components'; export function plugin() { return new IndexPatternFieldEditorPlugin(); diff --git a/src/plugins/index_pattern_field_editor/public/lib/index.ts b/src/plugins/index_pattern_field_editor/public/lib/index.ts index 336de9574c460..0b94434f67f0a 100644 --- a/src/plugins/index_pattern_field_editor/public/lib/index.ts +++ b/src/plugins/index_pattern_field_editor/public/lib/index.ts @@ -6,8 +6,6 @@ * Side Public License, v 1. */ -export { deserializeField } from './serialization'; - export { getLinks } from './documentation'; export { diff --git a/src/plugins/index_pattern_field_editor/public/lib/runtime_field_validation.test.ts b/src/plugins/index_pattern_field_editor/public/lib/runtime_field_validation.test.ts index b25d47b3d0d15..485e4960605cd 100644 --- a/src/plugins/index_pattern_field_editor/public/lib/runtime_field_validation.test.ts +++ b/src/plugins/index_pattern_field_editor/public/lib/runtime_field_validation.test.ts @@ -13,7 +13,7 @@ const dataStart = dataPluginMock.createStartContract(); const { search } = dataStart; const runtimeField = { - type: 'keyword', + type: 'keyword' as const, script: { source: 'emit("hello")', }, diff --git a/src/plugins/index_pattern_field_editor/public/lib/runtime_field_validation.ts b/src/plugins/index_pattern_field_editor/public/lib/runtime_field_validation.ts index 789c4f7fa71fc..244df1ee10881 100644 --- a/src/plugins/index_pattern_field_editor/public/lib/runtime_field_validation.ts +++ b/src/plugins/index_pattern_field_editor/public/lib/runtime_field_validation.ts @@ -6,9 +6,9 @@ * Side Public License, v 1. */ import { i18n } from '@kbn/i18n'; +import { estypes } from '@elastic/elasticsearch'; import { DataPublicPluginStart } from '../shared_imports'; -import type { EsRuntimeField } from '../types'; export interface RuntimeFieldPainlessError { message: string; @@ -95,7 +95,7 @@ export const parseEsError = ( export const getRuntimeFieldValidator = ( index: string, searchService: DataPublicPluginStart['search'] -) => async (runtimeField: EsRuntimeField) => { +) => async (runtimeField: estypes.MappingRuntimeField) => { return await searchService .search({ params: { diff --git a/src/plugins/index_pattern_field_editor/public/lib/serialization.ts b/src/plugins/index_pattern_field_editor/public/lib/serialization.ts deleted file mode 100644 index 8a0a47e07c9c9..0000000000000 --- a/src/plugins/index_pattern_field_editor/public/lib/serialization.ts +++ /dev/null @@ -1,28 +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 - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { IndexPatternField, IndexPattern } from '../shared_imports'; -import type { Field } from '../types'; - -export const deserializeField = ( - indexPattern: IndexPattern, - field?: IndexPatternField -): Field | undefined => { - if (field === undefined) { - return undefined; - } - - return { - name: field.name, - type: field?.esTypes ? field.esTypes[0] : 'keyword', - script: field.runtimeField ? field.runtimeField.script : undefined, - customLabel: field.customLabel, - popularity: field.count, - format: indexPattern.getFormatterForFieldNoDefault(field.name)?.toJSON(), - }; -}; diff --git a/src/plugins/index_pattern_field_editor/public/open_delete_modal.tsx b/src/plugins/index_pattern_field_editor/public/open_delete_modal.tsx index 72dbb76863353..06018c010b1c9 100644 --- a/src/plugins/index_pattern_field_editor/public/open_delete_modal.tsx +++ b/src/plugins/index_pattern_field_editor/public/open_delete_modal.tsx @@ -40,6 +40,18 @@ export const getFieldDeleteModalOpener = ({ indexPatternService, usageCollection, }: Dependencies) => (options: OpenFieldDeleteModalOptions): CloseEditor => { + if (typeof options.fieldName === 'string') { + const fieldToDelete = options.ctx.indexPattern.getFieldByName(options.fieldName); + const doesBelongToCompositeField = fieldToDelete?.runtimeField?.type === 'composite'; + + if (doesBelongToCompositeField) { + console.log( // eslint-disable-line + 'TODO: display a modal to indicate that this field needs to be deleted through its parent.' + ); + return () => undefined; + } + } + const { overlays, notifications } = core; let overlayRef: OverlayRef | null = null; diff --git a/src/plugins/index_pattern_field_editor/public/open_editor.tsx b/src/plugins/index_pattern_field_editor/public/open_editor.tsx index 946e666bf8205..01d89585ce24c 100644 --- a/src/plugins/index_pattern_field_editor/public/open_editor.tsx +++ b/src/plugins/index_pattern_field_editor/public/open_editor.tsx @@ -17,9 +17,10 @@ import { DataPublicPluginStart, IndexPattern, UsageCollectionStart, + RuntimeType, } from './shared_imports'; -import type { PluginStart, InternalFieldType, CloseEditor } from './types'; +import type { PluginStart, InternalFieldType, CloseEditor, Field } from './types'; import type { ApiService } from './lib/api'; import { euiFlyoutClassname } from './constants'; import { FieldEditorLoader } from './components/field_editor_loader'; @@ -28,7 +29,7 @@ export interface OpenFieldEditorOptions { ctx: { indexPattern: IndexPattern; }; - onSave?: (field: IndexPatternField) => void; + onSave?: (field: IndexPatternField[]) => void; fieldName?: string; } @@ -80,7 +81,7 @@ export const getFieldEditorOpener = ({ } }; - const onSaveField = (updatedField: IndexPatternField) => { + const onSaveField = (updatedField: IndexPatternField[]) => { closeEditor(); if (onSave) { @@ -88,9 +89,9 @@ export const getFieldEditorOpener = ({ } }; - const field = fieldName ? indexPattern.getFieldByName(fieldName) : undefined; + const dataViewField = fieldName ? indexPattern.getFieldByName(fieldName) : undefined; - if (fieldName && !field) { + if (fieldName && !dataViewField) { const err = i18n.translate('indexPatternFieldEditor.noSuchFieldName', { defaultMessage: "Field named '{fieldName}' not found on index pattern", values: { fieldName }, @@ -100,10 +101,39 @@ export const getFieldEditorOpener = ({ } const isNewRuntimeField = !fieldName; - const isExistingRuntimeField = field && field.runtimeField && !field.isMapped; + const isExistingRuntimeField = + dataViewField && dataViewField.runtimeField && !dataViewField.isMapped; const fieldTypeToProcess: InternalFieldType = isNewRuntimeField || isExistingRuntimeField ? 'runtime' : 'concrete'; + let field: Field | undefined; + if (dataViewField) { + if (isExistingRuntimeField && dataViewField.runtimeField!.type === 'composite') { + // We are editing a composite runtime **subField**. + // We need to access the parent composite. + const [compositeName] = fieldName!.split('.'); + field = { + name: compositeName, + ...indexPattern.getRuntimeField(compositeName)!, + }; + } else if (isExistingRuntimeField) { + // Runtime field + field = { + name: fieldName!, + ...indexPattern.getRuntimeField(fieldName!)!, + }; + } else { + // Concrete field + field = { + name: fieldName!, + type: (dataViewField?.esTypes ? dataViewField.esTypes[0] : 'keyword') as RuntimeType, + customLabel: dataViewField.customLabel, + popularity: dataViewField.count, + format: indexPattern.getFormatterForFieldNoDefault(fieldName!)?.toJSON(), + }; + } + } + overlayRef = overlays.openFlyout( toMountPoint( diff --git a/src/plugins/index_pattern_field_editor/public/shared_imports.ts b/src/plugins/index_pattern_field_editor/public/shared_imports.ts index 2827928d1c060..c1ad6275b7599 100644 --- a/src/plugins/index_pattern_field_editor/public/shared_imports.ts +++ b/src/plugins/index_pattern_field_editor/public/shared_imports.ts @@ -10,11 +10,18 @@ export { IndexPattern, IndexPatternField, DataPublicPluginStart } from '../../da export { UsageCollectionStart } from '../../usage_collection/public'; -export { RuntimeType, RuntimeField, KBN_FIELD_TYPES, ES_FIELD_TYPES } from '../../data/common'; +export { + RuntimeType, + RuntimeField, + RuntimeFieldSpec, + RuntimeFieldSubField, + KBN_FIELD_TYPES, + ES_FIELD_TYPES, +} from '../../data/common'; export { createKibanaReactContext, toMountPoint, CodeEditor } from '../../kibana_react/public'; -export { FieldFormat } from '../../field_formats/common'; +export { FieldFormat, SerializedFieldFormat } from '../../field_formats/common'; export { useForm, diff --git a/src/plugins/index_pattern_field_editor/public/types.ts b/src/plugins/index_pattern_field_editor/public/types.ts index f7efc9d82fc48..ad1b65bedf27f 100644 --- a/src/plugins/index_pattern_field_editor/public/types.ts +++ b/src/plugins/index_pattern_field_editor/public/types.ts @@ -8,12 +8,7 @@ import { FunctionComponent } from 'react'; -import { - DataPublicPluginStart, - RuntimeField, - RuntimeType, - UsageCollectionStart, -} from './shared_imports'; +import { DataPublicPluginStart, UsageCollectionStart, RuntimeField } from './shared_imports'; import { OpenFieldEditorOptions } from './open_editor'; import { OpenFieldDeleteModalOptions } from './open_delete_modal'; import { FormatEditorServiceSetup, FormatEditorServiceStart } from './service'; @@ -43,25 +38,8 @@ export interface StartPlugins { export type InternalFieldType = 'concrete' | 'runtime'; -export interface Field { +export interface Field extends RuntimeField { name: string; - type: RuntimeField['type'] | string; - script?: RuntimeField['script']; - customLabel?: string; - popularity?: number; - format?: FieldFormatConfig; -} - -export interface FieldFormatConfig { - id: string; - params?: { [key: string]: any }; -} - -export interface EsRuntimeField { - type: RuntimeType | string; - script?: { - source: string; - }; } export type CloseEditor = () => void; diff --git a/src/plugins/index_pattern_field_editor/server/routes/field_preview.ts b/src/plugins/index_pattern_field_editor/server/routes/field_preview.ts index 238701904e22c..ff66d711adf96 100644 --- a/src/plugins/index_pattern_field_editor/server/routes/field_preview.ts +++ b/src/plugins/index_pattern_field_editor/server/routes/field_preview.ts @@ -24,6 +24,7 @@ const bodySchema = schema.object({ schema.literal('ip_field'), schema.literal('keyword_field'), schema.literal('long_field'), + schema.literal('composite_field'), ]), document: schema.object({}, { unknowns: 'allow' }), }); diff --git a/src/plugins/vis_types/vega/public/data_model/search_api.ts b/src/plugins/vis_types/vega/public/data_model/search_api.ts index e00cf647930a8..a40bc33ee1299 100644 --- a/src/plugins/vis_types/vega/public/data_model/search_api.ts +++ b/src/plugins/vis_types/vega/public/data_model/search_api.ts @@ -32,6 +32,7 @@ export const extendSearchParamsWithRuntimeFields = async ( const indexPattern = (await indexPatterns.find(indexPatternString)).find( (index) => index.title === indexPatternString ); + // @ts-expect-error The MappingRuntimeFieldType from @elastic/elasticsearch does not expose the "composite" runtime type yet runtimeMappings = indexPattern?.getComputedFields().runtimeFields; } diff --git a/test/api_integration/apis/index_patterns/runtime_fields_crud/delete_runtime_field/errors.ts b/test/api_integration/apis/index_patterns/runtime_fields_crud/delete_runtime_field/errors.ts index b41a630889ff8..6f5ff46816bb5 100644 --- a/test/api_integration/apis/index_patterns/runtime_fields_crud/delete_runtime_field/errors.ts +++ b/test/api_integration/apis/index_patterns/runtime_fields_crud/delete_runtime_field/errors.ts @@ -56,16 +56,6 @@ export default function ({ getService }: FtrProviderContext) { expect(response1.status).to.be(404); }); - it('returns error when attempting to delete a field which is not a runtime field', async () => { - const response2 = await supertest.delete( - `/api/index_patterns/index_pattern/${indexPattern.id}/runtime_field/foo` - ); - - expect(response2.status).to.be(400); - expect(response2.body.statusCode).to.be(400); - expect(response2.body.message).to.be('Only runtime fields can be deleted.'); - }); - it('returns error when ID is too long', async () => { const id = `xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx`; const response = await supertest.delete( diff --git a/test/api_integration/apis/index_patterns/runtime_fields_crud/get_runtime_field/errors.ts b/test/api_integration/apis/index_patterns/runtime_fields_crud/get_runtime_field/errors.ts index 3608089e4641a..d17e62630539e 100644 --- a/test/api_integration/apis/index_patterns/runtime_fields_crud/get_runtime_field/errors.ts +++ b/test/api_integration/apis/index_patterns/runtime_fields_crud/get_runtime_field/errors.ts @@ -67,15 +67,5 @@ export default function ({ getService }: FtrProviderContext) { '[request params.id]: value has length [1759] but it must have a maximum length of [1000].' ); }); - - it('returns error when attempting to fetch a field which is not a runtime field', async () => { - const response2 = await supertest.get( - `/api/index_patterns/index_pattern/${indexPattern.id}/runtime_field/foo` - ); - - expect(response2.status).to.be(400); - expect(response2.body.statusCode).to.be(400); - expect(response2.body.message).to.be('Only runtime fields can be retrieved.'); - }); }); } diff --git a/test/api_integration/apis/index_patterns/runtime_fields_crud/put_runtime_field/errors.ts b/test/api_integration/apis/index_patterns/runtime_fields_crud/put_runtime_field/errors.ts index 9faca08238033..0741f1988c647 100644 --- a/test/api_integration/apis/index_patterns/runtime_fields_crud/put_runtime_field/errors.ts +++ b/test/api_integration/apis/index_patterns/runtime_fields_crud/put_runtime_field/errors.ts @@ -40,30 +40,5 @@ export default function ({ getService }: FtrProviderContext) { expect(response.status).to.be(404); }); - - it('returns error on non-runtime field update attempt', async () => { - const title = `basic_index`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - }, - }); - - const response2 = await supertest - .put(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/runtime_field`) - .send({ - name: 'bar', - runtimeField: { - type: 'long', - script: { - source: "emit(doc['field_name'].value)", - }, - }, - }); - - expect(response2.status).to.be(400); - expect(response2.body.message).to.be('Only runtime fields can be updated'); - }); }); } diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/data_loader/data_loader.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/data_loader/data_loader.ts index 1b92eaddd1343..7bce589f3baca 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/data_loader/data_loader.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/data_loader/data_loader.ts @@ -21,7 +21,6 @@ const MAX_EXAMPLES_DEFAULT: number = 10; export class DataLoader { private _indexPattern: IndexPattern; - private _runtimeMappings: estypes.MappingRuntimeFields; private _indexPatternTitle: IndexPatternTitle = ''; private _maxExamples: number = MAX_EXAMPLES_DEFAULT; private _toastNotifications: CoreSetup['notifications']['toasts']; @@ -31,8 +30,6 @@ export class DataLoader { toastNotifications: CoreSetup['notifications']['toasts'] ) { this._indexPattern = indexPattern; - this._runtimeMappings = this._indexPattern.getComputedFields() - .runtimeFields as estypes.MappingRuntimeFields; this._indexPatternTitle = indexPattern.title; this._toastNotifications = toastNotifications; } @@ -70,7 +67,7 @@ export class DataLoader { latest, aggregatableFields, nonAggregatableFields, - runtimeMappings: this._runtimeMappings, + runtimeMappings: this._indexPattern.getRuntimeMappings() as estypes.MappingRuntimeFields, }); return stats; @@ -94,7 +91,7 @@ export class DataLoader { interval, fields, maxExamples: this._maxExamples, - runtimeMappings: this._runtimeMappings, + runtimeMappings: this._indexPattern.getRuntimeMappings() as estypes.MappingRuntimeFields, }); return stats; diff --git a/x-pack/plugins/infra/common/log_sources/resolved_log_source_configuration.ts b/x-pack/plugins/infra/common/log_sources/resolved_log_source_configuration.ts index ee831d9a98eb9..e6c64aef53f91 100644 --- a/x-pack/plugins/infra/common/log_sources/resolved_log_source_configuration.ts +++ b/x-pack/plugins/infra/common/log_sources/resolved_log_source_configuration.ts @@ -109,6 +109,7 @@ const resolveRuntimeMappings = (indexPattern: IndexPattern): estypes.MappingRunt const runtimeMappingsFromIndexPattern = (Object.entries(runtimeFields) as ObjectEntries< typeof runtimeFields >).reduce( + // @ts-expect-error @elasticsearch/elasticsearch does not support yet "composite" type for runtime fields (accumulatedMappings, [runtimeFieldName, runtimeFieldSpec]) => ({ ...accumulatedMappings, [runtimeFieldName]: { @@ -117,7 +118,7 @@ const resolveRuntimeMappings = (indexPattern: IndexPattern): estypes.MappingRunt ? { script: { lang: 'painless', // required in the es types - source: runtimeFieldSpec.script.source, + source: (runtimeFieldSpec.script as estypes.InlineScript).source, }, } : {}), @@ -126,5 +127,6 @@ const resolveRuntimeMappings = (indexPattern: IndexPattern): estypes.MappingRunt {} ); + // @ts-expect-error @elasticsearch/elasticsearch does not support yet "composite" type for runtime fields return runtimeMappingsFromIndexPattern; }; diff --git a/x-pack/plugins/lens/server/routes/existing_fields.ts b/x-pack/plugins/lens/server/routes/existing_fields.ts index f35b0a7f23179..ad65b05ba66c2 100644 --- a/x-pack/plugins/lens/server/routes/existing_fields.ts +++ b/x-pack/plugins/lens/server/routes/existing_fields.ts @@ -115,6 +115,8 @@ async function fetchFieldExistence({ const indexPattern = await indexPatternsService.get(indexPatternId); const fields = buildFieldList(indexPattern, metaFields); + const runtimeMappings = indexPattern.getRuntimeMappings(); + const docs = await fetchIndexPatternStats({ fromDate, toDate, @@ -123,6 +125,8 @@ async function fetchFieldExistence({ index: indexPattern.title, timeFieldName: timeFieldName || indexPattern.timeFieldName, fields, + // @ts-expect-error The MappingRuntimeField from @elastic/elasticsearch does not expose the "composite" runtime type yet + runtimeMappings, }); return { @@ -157,6 +161,7 @@ async function fetchIndexPatternStats({ fromDate, toDate, fields, + runtimeMappings, }: { client: ElasticsearchClient; index: string; @@ -165,6 +170,7 @@ async function fetchIndexPatternStats({ fromDate?: string; toDate?: string; fields: Field[]; + runtimeMappings: estypes.MappingRuntimeFields; }) { const filter = timeFieldName && fromDate && toDate @@ -188,7 +194,7 @@ async function fetchIndexPatternStats({ }; const scriptedFields = fields.filter((f) => f.isScript); - const runtimeFields = fields.filter((f) => f.runtimeField); + const { body: result } = await client.search( { index, @@ -199,11 +205,7 @@ async function fetchIndexPatternStats({ sort: timeFieldName && fromDate && toDate ? [{ [timeFieldName]: 'desc' }] : [], fields: ['*'], _source: false, - runtime_mappings: runtimeFields.reduce((acc, field) => { - if (!field.runtimeField) return acc; - acc[field.name] = field.runtimeField; - return acc; - }, {} as Record), + runtime_mappings: runtimeMappings, script_fields: scriptedFields.reduce((acc, field) => { acc[field.name] = { script: { diff --git a/x-pack/plugins/lens/server/routes/field_stats.ts b/x-pack/plugins/lens/server/routes/field_stats.ts index 7103e395eabdc..161a22328d74e 100644 --- a/x-pack/plugins/lens/server/routes/field_stats.ts +++ b/x-pack/plugins/lens/server/routes/field_stats.ts @@ -83,6 +83,7 @@ export async function initFieldsRoute(setup: CoreSetup) { .filter((f) => f.runtimeField) .reduce((acc, f) => { if (!f.runtimeField) return acc; + // @ts-expect-error The MappingRuntimeField from @elastic/elasticsearch does not expose the "composite" runtime type yet acc[f.name] = f.runtimeField; return acc; }, {} as Record); diff --git a/x-pack/plugins/transform/common/api_schemas/common.ts b/x-pack/plugins/transform/common/api_schemas/common.ts index 5354598dc2475..fdefa3a8c4ad9 100644 --- a/x-pack/plugins/transform/common/api_schemas/common.ts +++ b/x-pack/plugins/transform/common/api_schemas/common.ts @@ -68,6 +68,7 @@ export const runtimeMappingsSchema = schema.maybe( schema.literal('ip'), schema.literal('boolean'), schema.literal('geo_point'), + schema.literal('composite'), ]), script: schema.maybe( schema.oneOf([ diff --git a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts index 55a304207a1c7..c189642ccb691 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts @@ -117,6 +117,7 @@ export const useIndexData = ( if (combinedRuntimeMappings !== undefined) { result = Object.keys(combinedRuntimeMappings).map((fieldName) => { const field = combinedRuntimeMappings[fieldName]; + // @ts-expect-error @elastic/elasticsearch does not support yet "composite" type for runtime fields const schema = getDataGridSchemaFromESFieldType(field.type); return { id: fieldName, schema }; }); diff --git a/x-pack/plugins/transform/server/routes/api/field_histograms.ts b/x-pack/plugins/transform/server/routes/api/field_histograms.ts index bfe2f47078569..7b947cf5b2fd9 100644 --- a/x-pack/plugins/transform/server/routes/api/field_histograms.ts +++ b/x-pack/plugins/transform/server/routes/api/field_histograms.ts @@ -41,6 +41,7 @@ export function registerFieldHistogramsRoutes({ router, license }: RouteDependen query, fields, samplerShardSize, + // @ts-expect-error @elasticsearch/elasticsearch does not support yet "composite" type for runtime fields runtimeMappings );