diff --git a/src/plugins/data/common/index.ts b/src/plugins/data/common/index.ts index a97b8025426f2..7970f8373a42e 100644 --- a/src/plugins/data/common/index.ts +++ b/src/plugins/data/common/index.ts @@ -103,6 +103,7 @@ export type { FieldFormatMap, RuntimeType, RuntimeField, + RuntimeFieldSpec, IIndexPattern, DataViewAttributes, IndexPatternAttributes, diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 02480aded9655..aae8d204a9f2e 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -81,6 +81,7 @@ export type { IndexPatternLoadExpressionFunctionDefinition, GetFieldsOptions, AggregationRestrictions, + RuntimeType, DataViewListItem, } from '../common'; export { diff --git a/src/plugins/data_view_field_editor/__jest__/client_integration/field_editor.test.tsx b/src/plugins/data_view_field_editor/__jest__/client_integration/field_editor.test.tsx index 55b9876ac54ad..f13811b72dcd1 100644 --- a/src/plugins/data_view_field_editor/__jest__/client_integration/field_editor.test.tsx +++ b/src/plugins/data_view_field_editor/__jest__/client_integration/field_editor.test.tsx @@ -99,7 +99,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")' }, }; @@ -113,7 +113,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(); @@ -129,7 +129,10 @@ describe('', () => { onChange, }, { - namesNotAllowed: existingFields, + namesNotAllowed: { + fields: existingFields, + runtimeComposites: [], + }, existingConcreteFields: [], fieldTypeToProcess: 'runtime', } @@ -166,7 +169,10 @@ describe('', () => { onChange, }, { - namesNotAllowed: existingRuntimeFieldNames, + namesNotAllowed: { + fields: existingRuntimeFieldNames, + runtimeComposites: [], + }, existingConcreteFields: [], fieldTypeToProcess: 'runtime', } diff --git a/src/plugins/data_view_field_editor/__jest__/client_integration/field_editor_flyout_content.test.ts b/src/plugins/data_view_field_editor/__jest__/client_integration/field_editor_flyout_content.test.ts index 1730593dbda20..c83a786d7e7a7 100644 --- a/src/plugins/data_view_field_editor/__jest__/client_integration/field_editor_flyout_content.test.ts +++ b/src/plugins/data_view_field_editor/__jest__/client_integration/field_editor_flyout_content.test.ts @@ -40,7 +40,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")', }, @@ -57,7 +57,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(); @@ -72,7 +72,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 () => { @@ -133,6 +133,7 @@ describe('', () => { name: 'someName', type: 'keyword', // default to keyword script: { source: 'echo("hello")' }, + format: null, }); // Change the type and make sure it is forwarded @@ -149,6 +150,7 @@ describe('', () => { name: 'someName', type: 'date', script: { source: 'echo("hello")' }, + format: null, }); }); @@ -186,6 +188,7 @@ describe('', () => { name: 'someName', type: 'keyword', script: { source: 'echo("hello")' }, + format: null, }); }); }); diff --git a/src/plugins/data_view_field_editor/__jest__/client_integration/field_editor_flyout_preview.test.ts b/src/plugins/data_view_field_editor/__jest__/client_integration/field_editor_flyout_preview.test.ts index c3175d7097a36..7c273286b4a5a 100644 --- a/src/plugins/data_view_field_editor/__jest__/client_integration/field_editor_flyout_preview.test.ts +++ b/src/plugins/data_view_field_editor/__jest__/client_integration/field_editor_flyout_preview.test.ts @@ -220,7 +220,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")', }, @@ -241,7 +241,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/data_view_field_editor/__jest__/client_integration/helpers/setup_environment.tsx b/src/plugins/data_view_field_editor/__jest__/client_integration/helpers/setup_environment.tsx index 722b1b1ec2cea..bdef57ea84e91 100644 --- a/src/plugins/data_view_field_editor/__jest__/client_integration/helpers/setup_environment.tsx +++ b/src/plugins/data_view_field_editor/__jest__/client_integration/helpers/setup_environment.tsx @@ -127,7 +127,7 @@ export const WithFieldEditorDependencies = uiSettings: uiSettingsServiceMock.createStartContract(), fieldTypeToProcess: 'runtime', existingConcreteFields: [], - namesNotAllowed: [], + namesNotAllowed: { fields: [], runtimeComposites: [] }, links: { runtimePainless: 'https://elastic.co', }, diff --git a/src/plugins/data_view_field_editor/public/components/field_editor/composite_editor.tsx b/src/plugins/data_view_field_editor/public/components/field_editor/composite_editor.tsx new file mode 100644 index 0000000000000..fc66d4a9c3bc4 --- /dev/null +++ b/src/plugins/data_view_field_editor/public/components/field_editor/composite_editor.tsx @@ -0,0 +1,11 @@ +/* + * 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 React from 'react'; + +export const CompositeEditor = () => <>hi; diff --git a/src/plugins/data_view_field_editor/public/components/field_editor/constants.ts b/src/plugins/data_view_field_editor/public/components/field_editor/constants.ts index e262d3ecbfe45..30f3c20131ae7 100644 --- a/src/plugins/data_view_field_editor/public/components/field_editor/constants.ts +++ b/src/plugins/data_view_field_editor/public/components/field_editor/constants.ts @@ -38,4 +38,8 @@ export const RUNTIME_FIELD_OPTIONS: Array> label: 'Geo point', value: 'geo_point', }, + { + label: 'Composite', + value: 'composite', + }, ]; diff --git a/src/plugins/data_view_field_editor/public/components/field_editor/field_detail.tsx b/src/plugins/data_view_field_editor/public/components/field_editor/field_detail.tsx new file mode 100644 index 0000000000000..b9db87b65e3cd --- /dev/null +++ b/src/plugins/data_view_field_editor/public/components/field_editor/field_detail.tsx @@ -0,0 +1,117 @@ +/* + * 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 React from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiCode } from '@elastic/eui'; +import { AdvancedParametersSection } from './advanced_parameters_section'; +import { FormRow } from './form_row'; +import { PopularityField, FormatField, ScriptField, CustomLabelField } from './form_fields'; +import { useFieldEditorContext } from '../field_editor_context'; + +const geti18nTexts = (): { + [key: string]: { title: string; description: JSX.Element | string }; +} => ({ + customLabel: { + title: i18n.translate('indexPatternFieldEditor.editor.form.customLabelTitle', { + defaultMessage: 'Set custom label', + }), + description: i18n.translate('indexPatternFieldEditor.editor.form.customLabelDescription', { + defaultMessage: `Create a label to display in place of the field name in Discover, Maps, and Visualize. Useful for shortening a long field name. Queries and filters use the original field name.`, + }), + }, + value: { + title: i18n.translate('indexPatternFieldEditor.editor.form.valueTitle', { + defaultMessage: 'Set value', + }), + description: ( + {'_source'}, + }} + /> + ), + }, + + format: { + title: i18n.translate('indexPatternFieldEditor.editor.form.formatTitle', { + defaultMessage: 'Set format', + }), + description: i18n.translate('indexPatternFieldEditor.editor.form.formatDescription', { + defaultMessage: `Set your preferred format for displaying the value. Changing the format can affect the value and prevent highlighting in Discover.`, + }), + }, + + popularity: { + title: i18n.translate('indexPatternFieldEditor.editor.form.popularityTitle', { + defaultMessage: 'Set popularity', + }), + description: i18n.translate('indexPatternFieldEditor.editor.form.popularityDescription', { + defaultMessage: `Adjust the popularity to make the field appear higher or lower in the fields list. By default, Discover orders fields from most selected to least selected.`, + }), + }, +}); + +export const FieldDetail = ({}) => { + const { links, existingConcreteFields, fieldTypeToProcess } = useFieldEditorContext(); + const i18nTexts = geti18nTexts(); + return ( + <> + {/* Set custom label */} + + + + + {/* Set value */} + {fieldTypeToProcess === 'runtime' && ( + + + + )} + + {/* Set custom format */} + + + + + {/* Advanced settings */} + + + + + + + ); +}; diff --git a/src/plugins/data_view_field_editor/public/components/field_editor/field_editor.tsx b/src/plugins/data_view_field_editor/public/components/field_editor/field_editor.tsx index 0185e1ba1c3f9..c06ee0ee46288 100644 --- a/src/plugins/data_view_field_editor/public/components/field_editor/field_editor.tsx +++ b/src/plugins/data_view_field_editor/public/components/field_editor/field_editor.tsx @@ -8,14 +8,12 @@ import React, { useEffect } from 'react'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; import { get } from 'lodash'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiComboBoxOptionOption, - EuiCode, EuiCallOut, } from '@elastic/eui'; @@ -36,15 +34,9 @@ import { useFieldPreviewContext } from '../preview'; import { RUNTIME_FIELD_OPTIONS } from './constants'; import { schema } from './form_schema'; import { getNameFieldConfig } from './lib'; -import { - TypeField, - CustomLabelField, - ScriptField, - FormatField, - PopularityField, -} from './form_fields'; -import { FormRow } from './form_row'; -import { AdvancedParametersSection } from './advanced_parameters_section'; +import { TypeField } from './form_fields'; +import { FieldDetail } from './field_detail'; +import { CompositeEditor } from './composite_editor'; export interface FieldEditorFormState { isValid: boolean | undefined; @@ -72,49 +64,6 @@ export interface Props { onFormModifiedChange?: (isModified: boolean) => void; } -const geti18nTexts = (): { - [key: string]: { title: string; description: JSX.Element | string }; -} => ({ - customLabel: { - title: i18n.translate('indexPatternFieldEditor.editor.form.customLabelTitle', { - defaultMessage: 'Set custom label', - }), - description: i18n.translate('indexPatternFieldEditor.editor.form.customLabelDescription', { - defaultMessage: `Create a label to display in place of the field name in Discover, Maps, and Visualize. Useful for shortening a long field name. Queries and filters use the original field name.`, - }), - }, - value: { - title: i18n.translate('indexPatternFieldEditor.editor.form.valueTitle', { - defaultMessage: 'Set value', - }), - description: ( - {'_source'}, - }} - /> - ), - }, - format: { - title: i18n.translate('indexPatternFieldEditor.editor.form.formatTitle', { - defaultMessage: 'Set format', - }), - description: i18n.translate('indexPatternFieldEditor.editor.form.formatDescription', { - defaultMessage: `Set your preferred format for displaying the value. Changing the format can affect the value and prevent highlighting in Discover.`, - }), - }, - popularity: { - title: i18n.translate('indexPatternFieldEditor.editor.form.popularityTitle', { - defaultMessage: 'Set popularity', - }), - description: i18n.translate('indexPatternFieldEditor.editor.form.popularityDescription', { - defaultMessage: `Adjust the popularity to make the field appear higher or lower in the fields list. By default, Discover orders fields from most selected to least selected.`, - }), - }, -}); - const changeWarning = i18n.translate('indexPatternFieldEditor.editor.form.changeWarning', { defaultMessage: 'Changing name or type can break searches and visualizations that rely on this field.', @@ -129,9 +78,12 @@ const formDeserializer = (field: Field): FieldFormInternal => { 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, @@ -142,16 +94,18 @@ 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, }; }; const FieldEditorComponent = ({ field, onChange, onFormModifiedChange }: Props) => { - const { links, namesNotAllowed, existingConcreteFields, fieldTypeToProcess } = - useFieldEditorContext(); + const { namesNotAllowed, fieldTypeToProcess } = useFieldEditorContext(); const { params: { update: updatePreviewParams }, } = useFieldPreviewContext(); @@ -164,7 +118,6 @@ const FieldEditorComponent = ({ field, onChange, onFormModifiedChange }: Props) const { submit, isValid: isFormValid, isSubmitted, getFields, isSubmitting } = form; const nameFieldConfig = getNameFieldConfig(namesNotAllowed, field); - const i18nTexts = geti18nTexts(); const [formData] = useFormData({ form }); const isFormModified = useFormIsModified({ @@ -213,6 +166,8 @@ const FieldEditorComponent = ({ field, onChange, onFormModifiedChange }: Props) } }, [isFormModified, onFormModifiedChange]); + console.log('render', updatedType && updatedType[0].value); + return (
)} - - - {/* Set custom label */} - - - - {/* Set value */} - {fieldTypeToProcess === 'runtime' && ( - - - - )} - - {/* Set custom format */} - - - + - {/* Advanced settings */} - - - - - + {updatedType && updatedType[0].value !== 'composite' ? : } ); }; diff --git a/src/plugins/data_view_field_editor/public/components/field_editor/form_fields/format_field.tsx b/src/plugins/data_view_field_editor/public/components/field_editor/form_fields/format_field.tsx index c584a59eda8d1..f5880a2227439 100644 --- a/src/plugins/data_view_field_editor/public/components/field_editor/form_fields/format_field.tsx +++ b/src/plugins/data_view_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 { dataView, 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/data_view_field_editor/public/components/field_editor/lib.ts b/src/plugins/data_view_field_editor/public/components/field_editor/lib.ts index 5b2e66c66fe39..9f2b9df86da2e 100644 --- a/src/plugins/data_view_field_editor/public/components/field_editor/lib.ts +++ b/src/plugins/data_view_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[]): ValidationFunc<{}, string, 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/data_view_field_editor/public/components/field_editor_context.tsx b/src/plugins/data_view_field_editor/public/components/field_editor_context.tsx index 4bee00f3c4b2a..5dd56d6512b9c 100644 --- a/src/plugins/data_view_field_editor/public/components/field_editor_context.tsx +++ b/src/plugins/data_view_field_editor/public/components/field_editor_context.tsx @@ -8,8 +8,7 @@ import React, { createContext, useContext, FunctionComponent, useMemo } from 'react'; import { NotificationsStart, CoreStart } from 'src/core/public'; -import { FieldFormatsStart } from '../shared_imports'; -import type { DataView, DataPublicPluginStart } from '../shared_imports'; +import type { DataView, DataPublicPluginStart, FieldFormatsStart } from '../shared_imports'; import { ApiService } from '../lib/api'; import type { InternalFieldType, PluginStart } from '../types'; @@ -32,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/data_view_field_editor/public/components/field_editor_flyout_content.tsx b/src/plugins/data_view_field_editor/public/components/field_editor_flyout_content.tsx index 5d14f3a7be4cb..4453ae68602e4 100644 --- a/src/plugins/data_view_field_editor/public/components/field_editor_flyout_content.tsx +++ b/src/plugins/data_view_field_editor/public/components/field_editor_flyout_content.tsx @@ -20,6 +20,7 @@ import { import type { Field } from '../types'; 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'; @@ -70,6 +71,7 @@ const FieldEditorFlyoutContentComponent = ({ const { dataView } = useFieldEditorContext(); const { panel: { isVisible: isPanelVisible }, + fieldsInScript, } = useFieldPreviewContext(); const [formState, setFormState] = useState({ @@ -97,8 +99,44 @@ 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 { isValid, data: updatedField } = await submit(); if (!isMounted.current) { // User has closed the flyout meanwhile submitting the form @@ -106,8 +144,8 @@ const FieldEditorFlyoutContentComponent = ({ } if (isValid) { - const nameChange = field?.name !== data.name; - const typeChange = field?.type !== data.type; + const nameChange = field?.name !== updatedField.name; + const typeChange = field?.type !== updatedField.type; if (isEditingExistingField && (nameChange || typeChange)) { setModalVisibility({ @@ -115,10 +153,10 @@ const FieldEditorFlyoutContentComponent = ({ confirmChangeNameOrType: true, }); } else { - onSave(data); + onSave(addSubfieldsToField(updatedField)); } } - }, [onSave, submit, field, isEditingExistingField]); + }, [onSave, submit, field, isEditingExistingField, addSubfieldsToField]); const onClickCancel = useCallback(() => { const canClose = canCloseValidator(); @@ -134,8 +172,8 @@ const FieldEditorFlyoutContentComponent = ({ { - const { data } = await submit(); - onSave(data); + const { data: updatedField } = await submit(); + onSave(addSubfieldsToField(updatedField)); }} onCancel={() => { setModalVisibility(defaultModalVisibility); @@ -182,6 +220,8 @@ const FieldEditorFlyoutContentComponent = ({ }; }, []); + console.log('renderfield editor'); + return ( <> void; + onSave: (field: DataViewField[]) => void; /** Handler for the "cancel" footer button */ onCancel: () => void; onMounted?: FieldEditorFlyoutContentProps['onMounted']; @@ -43,7 +42,7 @@ export interface Props { /** The Kibana field type of the field to create or edit (default: "runtime") */ fieldTypeToProcess: InternalFieldType; /** Optional field to edit */ - field?: DataViewField; + field?: Field; /** Services */ dataViews: DataViewsPublicPluginStart; notifications: NotificationsStart; @@ -80,12 +79,20 @@ export const FieldEditorFlyoutContentContainer = ({ fieldFormats, uiSettings, }: Props) => { - const fieldToEdit = deserializeField(dataView, field); const [isSaving, setIsSaving] = useState(false); const { fields } = dataView; - const namesNotAllowed = useMemo(() => fields.map((fld) => fld.name), [fields]); + const namesNotAllowed = useMemo(() => { + const fieldNames = dataView.fields.map((fld) => fld.name); + const runtimeCompositeNames = Object.entries(dataView.getAllRuntimeFields()) + .filter(([, _runtimeField]) => _runtimeField.type === 'composite') + .map(([_runtimeFieldName]) => _runtimeFieldName); + return { + fields: fieldNames, + runtimeComposites: runtimeCompositeNames, + }; + }, [dataView]); const existingConcreteFields = useMemo(() => { const existing: Array<{ name: string; type: string }> = []; @@ -114,59 +121,78 @@ export const FieldEditorFlyoutContentContainer = ({ [apiService, search, notifications] ); - const saveField = useCallback( - async (updatedField: Field) => { - setIsSaving(true); + const updateRuntimeField = useCallback( + (updatedField: Field): DataViewField[] => { + 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" + dataView.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) { - dataView.removeRuntimeField(field.name); - } - - dataView.addRuntimeField(updatedField.name, { - type: updatedField.type as RuntimeType, - script, - }); + return dataView.addRuntimeField(updatedField.name, updatedField); + }, + [field, dataView] + ); + + const updateConcreteField = useCallback( + (updatedField: Field): DataViewField[] => { + const editedField = dataView.getFieldByName(updatedField.name); + + if (!editedField) { + throw new Error( + `Unable to find field named '${updatedField.name}' on index pattern '${dataView.title}'` + ); + } + + // Update custom label, popularity and format + dataView.setFieldCustomLabel(updatedField.name, updatedField.customLabel); + + editedField.count = updatedField.popularity || 0; + if (updatedField.format) { + dataView.setFieldFormat(updatedField.name, updatedField.format!); } else { - try { - usageCollection.reportUiCounter(pluginName, METRIC_TYPE.COUNT, 'save_concrete'); - // eslint-disable-next-line no-empty - } catch {} + dataView.deleteFieldFormat(updatedField.name); } - const editedField = dataView.getFieldByName(updatedField.name); + return [editedField]; + }, + [dataView] + ); + const saveField = useCallback( + async (updatedField: Field) => { try { - if (!editedField) { - throw new Error( - `Unable to find field named '${updatedField.name}' on index pattern '${dataView.title}'` - ); - } - - dataView.setFieldCustomLabel(updatedField.name, updatedField.customLabel); - editedField.count = updatedField.popularity || 0; - if (updatedField.format) { - dataView.setFieldFormat(updatedField.name, updatedField.format); - } else { - dataView.deleteFieldFormat(updatedField.name); - } - - await dataViews.updateSavedObject(dataView).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: DataViewField[] = + fieldTypeToProcess === 'runtime' + ? updateRuntimeField(updatedField) + : updateConcreteField(updatedField as Field); + + await dataViews.updateSavedObject(dataView); + + 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', @@ -175,7 +201,16 @@ export const FieldEditorFlyoutContentContainer = ({ setIsSaving(false); } }, - [onSave, dataView, dataViews, notifications, fieldTypeToProcess, field?.name, usageCollection] + [ + onSave, + dataView, + dataViews, + notifications, + fieldTypeToProcess, + updateConcreteField, + updateRuntimeField, + usageCollection, + ] ); return ( @@ -195,7 +230,7 @@ export const FieldEditorFlyoutContentContainer = ({ onSave={saveField} onCancel={onCancel} onMounted={onMounted} - field={fieldToEdit} + field={field} isSavingField={isSaving} /> diff --git a/src/plugins/data_view_field_editor/public/components/field_format_editor/field_format_editor.tsx b/src/plugins/data_view_field_editor/public/components/field_format_editor/field_format_editor.tsx index e921d0beafce1..533e05ac7cd99 100644 --- a/src/plugins/data_view_field_editor/public/components/field_format_editor/field_format_editor.tsx +++ b/src/plugins/data_view_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 { KBN_FIELD_TYPES, ES_FIELD_TYPES } from 'src/plugins/data/public'; @@ -17,9 +16,9 @@ import { CoreStart } from 'src/core/public'; import { castEsToKbnFieldTypeName } from '@kbn/field-types'; import { FieldFormatsStart } from 'src/plugins/field_formats/public'; import { DataView } from 'src/plugins/data_views/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[]; @@ -27,9 +26,9 @@ export interface FormatSelectEditorProps { fieldFormatEditors: FormatEditorServiceStart['fieldFormatEditors']; fieldFormats: FieldFormatsStart; 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/data_view_field_editor/public/components/field_format_editor/format_editor.tsx b/src/plugins/data_view_field_editor/public/components/field_format_editor/format_editor.tsx index c6f5fc9899ac7..3ea9dcf3dbd5a 100644 --- a/src/plugins/data_view_field_editor/public/components/field_format_editor/format_editor.tsx +++ b/src/plugins/data_view_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/data_view_field_editor/public/components/field_format_editor/index.ts b/src/plugins/data_view_field_editor/public/components/field_format_editor/index.ts index 0c23c8de616cf..2ae6b3149dc5f 100644 --- a/src/plugins/data_view_field_editor/public/components/field_format_editor/index.ts +++ b/src/plugins/data_view_field_editor/public/components/field_format_editor/index.ts @@ -8,4 +8,6 @@ export type { FormatSelectEditorProps } from './field_format_editor'; export { FormatSelectEditor } from './field_format_editor'; +export type { FormatEditorState } from './format_editor'; +export type { Sample } from './types'; export * from './editors'; diff --git a/src/plugins/data_view_field_editor/public/components/preview/field_preview.tsx b/src/plugins/data_view_field_editor/public/components/preview/field_preview.tsx index 05339e6473b6b..f331e62bd7016 100644 --- a/src/plugins/data_view_field_editor/public/components/preview/field_preview.tsx +++ b/src/plugins/data_view_field_editor/public/components/preview/field_preview.tsx @@ -27,6 +27,7 @@ export const FieldPreview = () => { params: { value: { name, script, format }, }, + isLoadingPreview, fields, error, documents: { fetchDocError }, @@ -36,15 +37,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 - Boolean(error) || 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 doRenderListOfFields = fetchDocError === null; const showWarningPreviewNotAvailable = isPreviewAvailable === false && fetchDocError === null; @@ -58,13 +59,13 @@ export const FieldPreview = () => { return null; } - const [field] = fields; - return (
    -
  • - -
  • + {fields.map((field, i) => ( +
  • + +
  • + ))}
); }; diff --git a/src/plugins/data_view_field_editor/public/components/preview/field_preview_context.tsx b/src/plugins/data_view_field_editor/public/components/preview/field_preview_context.tsx index 9f2ca0ef53e1b..f91ceb5bf0ade 100644 --- a/src/plugins/data_view_field_editor/public/components/preview/field_preview_context.tsx +++ b/src/plugins/data_view_field_editor/public/components/preview/field_preview_context.tsx @@ -109,6 +109,8 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { isValid: boolean; message: string | null; }>({ isValidating: false, isValid: true, message: null }); + /** Array of fields detected in the script (returned by the _execute API) */ + const [fieldsInScript, setFieldsInScript] = useState([]); const { documents, currentIdx } = clusterData; const currentDocument: EsDocument | undefined = documents[currentIdx]; @@ -314,6 +316,55 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { [dataView, search] ); + const updateSingleFieldPreview = useCallback( + (fieldName: string, values: unknown[]) => { + console.log('SINGLE FIELD PREVIEW'); + const [value] = values; + const formattedValue = valueFormatter(value); + + setPreviewResponse({ + fields: [{ key: fieldName, value, formattedValue }], + error: null, + }); + }, + [valueFormatter] + ); + + const updateCompositeFieldPreview = useCallback( + (compositeName: string | null, compositeValues: Record) => { + console.log('COMPOSITE FIELD VALUE'); + 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 () => { if (scriptEditorValidation.isValidating) { return; @@ -332,6 +383,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { const currentApiCall = ++previewCount.current; + console.log('getFieldPreview'); const response = await getFieldPreview({ index: dataView.title, document: document!, @@ -368,13 +420,11 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { error: { code: 'PAINLESS_SCRIPT_ERROR', error: parseEsError(error) }, }); } else { - const [value] = values; - const formattedValue = valueFormatter(value); - - setPreviewResponse({ - fields: [{ key: name!, value, formattedValue }], - error: null, - }); + if (!Array.isArray(values)) { + updateCompositeFieldPreview(name, values); + return; + } + updateSingleFieldPreview(name!, values); } } @@ -387,10 +437,11 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { currentDocId, getFieldPreview, notifications.toasts, - valueFormatter, allParamsDefined, scriptEditorValidation, hasSomeParamsChanged, + updateSingleFieldPreview, + updateCompositeFieldPreview, dataView.title, ]); @@ -468,6 +519,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { validation: { setScriptEditorValidation, }, + fieldsInScript, }), [ previewResponse, @@ -490,6 +542,7 @@ export const FieldPreviewProvider: FunctionComponent = ({ children }) => { from, reset, pinnedFields, + fieldsInScript, ] ); @@ -536,24 +589,62 @@ 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 = 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 + if (updatedFields.length === 0 && name !== null) { + updatedFields = [{ key: name, value: undefined, formattedValue: undefined }]; + } + + return { + ...prev, + fields: updatedFields, + }; + }); + }, [name, type]); - const formattedValue = valueFormatter(nextValue); + /** + * 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/data_view_field_editor/public/components/preview/types.ts b/src/plugins/data_view_field_editor/public/components/preview/types.ts index d7c0a5867efd6..aac063c8f54ed 100644 --- a/src/plugins/data_view_field_editor/public/components/preview/types.ts +++ b/src/plugins/data_view_field_editor/public/components/preview/types.ts @@ -7,8 +7,8 @@ */ import React from 'react'; -import type { RuntimeType, RuntimeField } from '../../shared_imports'; -import type { FieldFormatConfig, RuntimeFieldPainlessError } from '../../types'; +import type { RuntimeType, RuntimeField, SerializedFieldFormat } from '../../shared_imports'; +import type { RuntimeFieldPainlessError } from '../../types'; export type From = 'cluster' | 'custom'; @@ -54,7 +54,7 @@ export interface Params { index: string | null; type: RuntimeType | null; script: Required['script'] | null; - format: FieldFormatConfig | null; + format: SerializedFieldFormat | null; document: { [key: string]: unknown } | null; } @@ -108,6 +108,8 @@ export interface Context { React.SetStateAction<{ isValid: boolean; isValidating: boolean; message: string | null }> >; }; + /** List of fields detected in the Painless script */ + fieldsInScript: string[]; } export type PainlessExecuteContext = diff --git a/src/plugins/data_view_field_editor/public/index.ts b/src/plugins/data_view_field_editor/public/index.ts index 55af27fdb29eb..284c9feccff9b 100644 --- a/src/plugins/data_view_field_editor/public/index.ts +++ b/src/plugins/data_view_field_editor/public/index.ts @@ -25,7 +25,14 @@ export type { PluginStart as IndexPatternFieldEditorStart, } from './types'; export { DefaultFormatEditor } from './components/field_format_editor/editors/default/default'; -export type { FieldFormatEditorFactory, FieldFormatEditor, FormatEditorProps } from './components'; +export type { + FieldFormatEditorFactory, + FieldFormatEditor, + DeleteFieldProviderProps, + FormatEditorProps, + FormatEditorState, + Sample, +} from './components'; export function plugin() { return new IndexPatternFieldEditorPlugin(); diff --git a/src/plugins/data_view_field_editor/public/lib/index.ts b/src/plugins/data_view_field_editor/public/lib/index.ts index c7627a63da9ff..febe9a02c7749 100644 --- a/src/plugins/data_view_field_editor/public/lib/index.ts +++ b/src/plugins/data_view_field_editor/public/lib/index.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -export { deserializeField, painlessErrorToMonacoMarker } from './serialization'; +export { painlessErrorToMonacoMarker } from './serialization'; export { getLinks } from './documentation'; diff --git a/src/plugins/data_view_field_editor/public/lib/serialization.ts b/src/plugins/data_view_field_editor/public/lib/serialization.ts index 833fe331203c2..752d7af6d7612 100644 --- a/src/plugins/data_view_field_editor/public/lib/serialization.ts +++ b/src/plugins/data_view_field_editor/public/lib/serialization.ts @@ -6,23 +6,7 @@ * Side Public License, v 1. */ import { monaco } from '@kbn/monaco'; -import { DataViewField, DataView } from '../shared_imports'; -import type { Field, RuntimeFieldPainlessError } from '../types'; - -export const deserializeField = (dataView: DataView, field?: DataViewField): 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: dataView.getFormatterForFieldNoDefault(field.name)?.toJSON(), - }; -}; +import type { RuntimeFieldPainlessError } from '../types'; export const painlessErrorToMonacoMarker = ( { reason }: RuntimeFieldPainlessError, diff --git a/src/plugins/data_view_field_editor/public/open_delete_modal.tsx b/src/plugins/data_view_field_editor/public/open_delete_modal.tsx index f44367d16d08d..c5bc6e77a4f6e 100644 --- a/src/plugins/data_view_field_editor/public/open_delete_modal.tsx +++ b/src/plugins/data_view_field_editor/public/open_delete_modal.tsx @@ -38,6 +38,18 @@ interface Dependencies { export const getFieldDeleteModalOpener = ({ core, dataViews, usageCollection }: Dependencies) => (options: OpenFieldDeleteModalOptions): CloseEditor => { + if (typeof options.fieldName === 'string') { + const fieldToDelete = options.ctx.dataView.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/data_view_field_editor/public/open_editor.tsx b/src/plugins/data_view_field_editor/public/open_editor.tsx index d3a91739dc94a..bb4bbd975fb75 100644 --- a/src/plugins/data_view_field_editor/public/open_editor.tsx +++ b/src/plugins/data_view_field_editor/public/open_editor.tsx @@ -17,12 +17,12 @@ import { DataPublicPluginStart, DataView, UsageCollectionStart, + RuntimeType, DataViewsPublicPluginStart, FieldFormatsStart, - 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'; @@ -31,7 +31,7 @@ export interface OpenFieldEditorOptions { ctx: { dataView: DataView; }; - onSave?: (field: DataViewField) => void; + onSave?: (field: DataViewField[]) => void; fieldName?: string; } @@ -85,7 +85,7 @@ export const getFieldEditorOpener = } }; - const onSaveField = (updatedField: DataViewField) => { + const onSaveField = (updatedField: DataViewField[]) => { closeEditor(); if (onSave) { @@ -93,9 +93,9 @@ export const getFieldEditorOpener = } }; - const field = fieldName ? dataView.getFieldByName(fieldName) : undefined; + const dataViewField = fieldName ? dataView.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 }, @@ -106,14 +106,41 @@ export const getFieldEditorOpener = const isNewRuntimeField = !fieldName; const isExistingRuntimeField = - field && - field.runtimeField && - !field.isMapped && + dataViewField && + dataViewField.runtimeField && + !dataViewField.isMapped && // treat composite field instances as mapped fields for field editing purposes - field.runtimeField.type !== ('composite' as RuntimeType); + dataViewField.runtimeField.type !== ('composite' as RuntimeType); 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, + ...dataView.getRuntimeField(compositeName)!, + }; + } else if (isExistingRuntimeField) { + // Runtime field + field = { + name: fieldName!, + ...dataView.getRuntimeField(fieldName!)!, + }; + } else { + // Concrete field + field = { + name: fieldName!, + type: (dataViewField?.esTypes ? dataViewField.esTypes[0] : 'keyword') as RuntimeType, + customLabel: dataViewField.customLabel, + popularity: dataViewField.count, + format: dataView.getFormatterForFieldNoDefault(fieldName!)?.toJSON(), + }; + } + } overlayRef = overlays.openFlyout( toMountPoint( diff --git a/src/plugins/data_view_field_editor/public/shared_imports.ts b/src/plugins/data_view_field_editor/public/shared_imports.ts index c8332a2afe76d..ba29fc642447e 100644 --- a/src/plugins/data_view_field_editor/public/shared_imports.ts +++ b/src/plugins/data_view_field_editor/public/shared_imports.ts @@ -13,12 +13,17 @@ export type { FieldFormatsStart } from '../../field_formats/public'; export type { UsageCollectionStart } from '../../usage_collection/public'; -export type { RuntimeType, RuntimeField } from '../../data/common'; +export type { + RuntimeType, + RuntimeField, + RuntimeFieldSpec, + RuntimeFieldSubField, +} from '../../data_views/common'; export { KBN_FIELD_TYPES, ES_FIELD_TYPES } from '../../data/common'; export { createKibanaReactContext, toMountPoint, CodeEditor } from '../../kibana_react/public'; -export { FieldFormat } from '../../field_formats/common'; +export type { FieldFormat, SerializedFieldFormat } from '../../field_formats/common'; export type { FormSchema, diff --git a/src/plugins/data_view_field_editor/public/types.ts b/src/plugins/data_view_field_editor/public/types.ts index 25f97e6737bf2..be623c6cc8776 100644 --- a/src/plugins/data_view_field_editor/public/types.ts +++ b/src/plugins/data_view_field_editor/public/types.ts @@ -12,7 +12,6 @@ import { DataPublicPluginStart, DataViewsPublicPluginStart, RuntimeField, - RuntimeType, UsageCollectionStart, FieldFormatsStart, } from './shared_imports'; @@ -47,25 +46,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/data_view_field_editor/server/routes/field_preview.ts b/src/plugins/data_view_field_editor/server/routes/field_preview.ts index 022cd92a4bb1e..14b865b61eaa0 100644 --- a/src/plugins/data_view_field_editor/server/routes/field_preview.ts +++ b/src/plugins/data_view_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' }), documentId: schema.string(), diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/create_field_button/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/create_field_button/index.tsx index 6a758d8deb9b0..b697d859f7ee4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/create_field_button/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/create_field_button/index.tsx @@ -16,7 +16,6 @@ import * as i18n from './translations'; const StyledButton = styled(EuiButton)` margin-left: ${({ theme }) => theme.eui.paddingSizes.m}; `; - export interface UseCreateFieldButtonProps { hasFieldEditPermission: boolean; loading: boolean; diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.tsx index b38b6d03749c9..beb49d3cb0913 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.tsx @@ -73,38 +73,41 @@ export const useFieldBrowserOptions: UseFieldBrowserOptions = ({ const closeFieldEditor = dataViewFieldEditor.openEditor({ ctx: { dataView }, fieldName, - onSave: async (savedField: DataViewField) => { + onSave: async (savedFields: DataViewField[]) => { // Fetch the updated list of fields // Using cleanCache since the number of fields might have not changed, but we need to update the state anyway await indexFieldsSearch({ dataViewId: selectedDataViewId, cleanCache: true }); - if (fieldName && fieldName !== savedField.name) { - // Remove old field from event table when renaming a field + for (const savedField of savedFields) { + if (fieldName && fieldName !== savedField.name) { + // Remove old field from event table when renaming a field + dispatch( + removeColumn({ + columnId: fieldName, + id: timelineId, + }) + ); + } + + // Add the saved column field to the table in any case dispatch( - removeColumn({ - columnId: fieldName, + upsertColumn({ + column: { + columnHeaderType: defaultColumnHeaderType, + id: savedField.name, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, + }, id: timelineId, + index: 0, }) ); } - - // Add the saved column field to the table in any case - dispatch( - upsertColumn({ - column: { - columnHeaderType: defaultColumnHeaderType, - id: savedField.name, - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, - }, - id: timelineId, - index: 0, - }) - ); if (editorActionsRef) { editorActionsRef.current = null; } }, }); + if (editorActionsRef) { editorActionsRef.current = { closeEditor: () => { diff --git a/x-pack/plugins/transform/common/api_schemas/common.ts b/x-pack/plugins/transform/common/api_schemas/common.ts index 285f3879681c7..52f1485107c76 100644 --- a/x-pack/plugins/transform/common/api_schemas/common.ts +++ b/x-pack/plugins/transform/common/api_schemas/common.ts @@ -6,9 +6,7 @@ */ import { schema, TypeOf } from '@kbn/config-schema'; -import { i18n } from '@kbn/i18n'; import { TRANSFORM_STATE } from '../constants'; -import { isRuntimeField } from '../shared_imports'; export const transformIdsSchema = schema.arrayOf( schema.object({ @@ -58,17 +56,27 @@ export interface CommonResponseStatusSchema { } export const runtimeMappingsSchema = schema.maybe( - schema.object( - {}, - { - unknowns: 'allow', - validate: (v: object) => { - if (Object.values(v).some((o) => !isRuntimeField(o))) { - return i18n.translate('xpack.transform.invalidRuntimeFieldMessage', { - defaultMessage: 'Invalid runtime field', - }); - } - }, - } + schema.recordOf( + schema.string(), + schema.object({ + type: schema.oneOf([ + schema.literal('keyword'), + schema.literal('long'), + schema.literal('double'), + schema.literal('date'), + schema.literal('ip'), + schema.literal('boolean'), + schema.literal('geo_point'), + schema.literal('composite'), + ]), + script: schema.maybe( + schema.oneOf([ + schema.string(), + schema.object({ + source: schema.string(), + }), + ]) + ), + }) ) ); 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 ); diff --git a/x-pack/plugins/transform/server/routes/api/transforms.ts b/x-pack/plugins/transform/server/routes/api/transforms.ts index 2f82b9a70389b..4d6337d89e1c4 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms.ts @@ -638,7 +638,11 @@ const previewTransformHandler: RequestHandler< > = async (ctx, req, res) => { try { const reqBody = req.body; + const body = await ctx.core.elasticsearch.client.asCurrentUser.transform.previewTransform({ + // todo examine this + // @ts-expect-error The ES client does not yet include the "composite" runtime type + // Once "composite" is added to the MappingRuntimeFieldType, those comments can be safely removed. body: reqBody, }); if (isLatestTransform(reqBody)) { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 9c09921142f0b..5cea09472cab2 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -27098,7 +27098,6 @@ "xpack.transform.home.breadcrumbTitle": "変換", "xpack.transform.indexPreview.copyClipboardTooltip": "インデックスプレビューの開発コンソールステートメントをクリップボードにコピーします。", "xpack.transform.indexPreview.copyRuntimeFieldsClipboardTooltip": "ランタイムフィールドの開発コンソールステートメントをクリップボードにコピーします。", - "xpack.transform.invalidRuntimeFieldMessage": "無効なランタイムフィールド", "xpack.transform.latestPreview.latestPreviewIncompleteConfigCalloutBody": "1 つ以上の一意キーと並べ替えフィールドを選択してください。", "xpack.transform.licenseCheckErrorMessage": "ライセンス確認失敗", "xpack.transform.list.emptyPromptButtonText": "初めての変換を作成してみましょう。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 2252df4d2d2ab..3e7a1636e1906 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -27129,7 +27129,6 @@ "xpack.transform.home.breadcrumbTitle": "转换", "xpack.transform.indexPreview.copyClipboardTooltip": "将索引预览的开发控制台语句复制到剪贴板。", "xpack.transform.indexPreview.copyRuntimeFieldsClipboardTooltip": "将运行时字段的开发控制台语句复制到剪贴板。", - "xpack.transform.invalidRuntimeFieldMessage": "运行时字段无效", "xpack.transform.latestPreview.latestPreviewIncompleteConfigCalloutBody": "请选择至少一个唯一键和排序字段。", "xpack.transform.licenseCheckErrorMessage": "许可证检查失败", "xpack.transform.list.emptyPromptButtonText": "创建您的首个转换",