diff --git a/src/platform/packages/shared/kbn-react-field/index.ts b/src/platform/packages/shared/kbn-react-field/index.ts index 178d49cae38f2..7949b8724c28f 100644 --- a/src/platform/packages/shared/kbn-react-field/index.ts +++ b/src/platform/packages/shared/kbn-react-field/index.ts @@ -11,3 +11,5 @@ export { FieldIcon } from './src/field_icon'; export type { FieldIconProps } from './src/field_icon'; export { FieldButton } from './src/field_button'; export type { FieldButtonProps, ButtonSize } from './src/field_button'; +export { FieldNameWithIcon } from './src/field_name_with_icon'; +export type { FieldNameWithIconProps } from './src/field_name_with_icon'; diff --git a/src/platform/packages/shared/kbn-react-field/src/field_name_with_icon/__snapshots__/field_name_with_icon.test.tsx.snap b/src/platform/packages/shared/kbn-react-field/src/field_name_with_icon/__snapshots__/field_name_with_icon.test.tsx.snap new file mode 100644 index 0000000000000..1d724b9a27ea7 --- /dev/null +++ b/src/platform/packages/shared/kbn-react-field/src/field_name_with_icon/__snapshots__/field_name_with_icon.test.tsx.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FieldNameWithIcon renders an icon when type is passed 1`] = ` + + + agent.name + +`; + +exports[`FieldNameWithIcon renders only the name when the type is not passed 1`] = `"agent.name"`; diff --git a/src/platform/packages/shared/kbn-react-field/src/field_name_with_icon/field_name_with_icon.test.tsx b/src/platform/packages/shared/kbn-react-field/src/field_name_with_icon/field_name_with_icon.test.tsx new file mode 100644 index 0000000000000..82de89c725625 --- /dev/null +++ b/src/platform/packages/shared/kbn-react-field/src/field_name_with_icon/field_name_with_icon.test.tsx @@ -0,0 +1,22 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { FieldNameWithIcon } from './field_name_with_icon'; + +test('FieldNameWithIcon renders an icon when type is passed', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); +}); + +test('FieldNameWithIcon renders only the name when the type is not passed', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); +}); diff --git a/src/platform/packages/shared/kbn-react-field/src/field_name_with_icon/field_name_with_icon.tsx b/src/platform/packages/shared/kbn-react-field/src/field_name_with_icon/field_name_with_icon.tsx new file mode 100644 index 0000000000000..00ff3d6224b40 --- /dev/null +++ b/src/platform/packages/shared/kbn-react-field/src/field_name_with_icon/field_name_with_icon.tsx @@ -0,0 +1,28 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { EuiFlexGroup } from '@elastic/eui'; +import { FieldIcon, FieldIconProps } from '../field_icon'; + +export interface FieldNameWithIconProps { + name: string; + type?: FieldIconProps['type']; +} + +export const FieldNameWithIcon = ({ name, type }: FieldNameWithIconProps) => { + return type ? ( + + + {name} + + ) : ( + name + ); +}; diff --git a/src/platform/packages/shared/kbn-react-field/src/field_name_with_icon/index.ts b/src/platform/packages/shared/kbn-react-field/src/field_name_with_icon/index.ts new file mode 100644 index 0000000000000..786961f7b13c7 --- /dev/null +++ b/src/platform/packages/shared/kbn-react-field/src/field_name_with_icon/index.ts @@ -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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { FieldNameWithIcon } from './field_name_with_icon'; +export type { FieldNameWithIconProps } from './field_name_with_icon'; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/configuration_maps.ts b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/constants.ts similarity index 66% rename from x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/configuration_maps.ts rename to x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/constants.ts index a2aa1618b1ce3..e0e5323bed9ae 100644 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/configuration_maps.ts +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/constants.ts @@ -7,6 +7,8 @@ import { i18n } from '@kbn/i18n'; +export const EMPTY_CONTENT = '-----'; + export const FIELD_TYPE_MAP = { boolean: { label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableBooleanType', { @@ -25,7 +27,7 @@ export const FIELD_TYPE_MAP = { }, match_only_text: { label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableTextType', { - defaultMessage: 'Text', + defaultMessage: 'Text (match_only_text)', }), }, long: { @@ -43,7 +45,9 @@ export const FIELD_TYPE_MAP = { defaultMessage: 'IP', }), }, -}; +} as const; + +export type FieldTypeOption = keyof typeof FIELD_TYPE_MAP; export const FIELD_STATUS_MAP = { inherited: { @@ -67,3 +71,31 @@ export const FIELD_STATUS_MAP = { }; export type FieldStatus = keyof typeof FIELD_STATUS_MAP; + +export const TABLE_COLUMNS = { + name: { + display: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTablenameHeader', { + defaultMessage: 'Field', + }), + }, + type: { + display: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTabletypeHeader', { + defaultMessage: 'Type', + }), + }, + format: { + display: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableformatHeader', { + defaultMessage: 'Format', + }), + }, + parent: { + display: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableFieldParentHeader', { + defaultMessage: 'Field Parent (Stream)', + }), + }, + status: { + display: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTablestatusHeader', { + defaultMessage: 'Status', + }), + }, +}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/field_actions.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/field_actions.tsx new file mode 100644 index 0000000000000..788e19cc4834a --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/field_actions.tsx @@ -0,0 +1,149 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { EuiButtonIcon, EuiContextMenu, EuiPopover, useGeneratedHtmlId } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useBoolean } from '@kbn/react-hooks'; +import { toMountPoint } from '@kbn/react-kibana-mount'; +import { StreamsAppContextProvider } from '../streams_app_context_provider'; +import { SchemaEditorFlyout } from './flyout'; +import { useSchemaEditorContext } from './schema_editor_context'; +import { SchemaField } from './types'; +import { UnpromoteFieldModal } from './unpromote_field_modal'; +import { useKibana } from '../../hooks/use_kibana'; + +export const FieldActionsCell = ({ field }: { field: SchemaField }) => { + const context = useKibana(); + const schemaEditorContext = useSchemaEditorContext(); + + const { core } = context; + + const contextMenuPopoverId = useGeneratedHtmlId({ + prefix: 'fieldsTableContextMenuPopover', + }); + + const [popoverIsOpen, { off: closePopover, toggle }] = useBoolean(false); + + const panels = useMemo(() => { + const { onFieldUnmap, onFieldUpdate, stream, withFieldSimulation } = schemaEditorContext; + + let actions = []; + + const openFlyout = (props: { isEditingByDefault: boolean } = { isEditingByDefault: false }) => { + const overlay = core.overlays.openFlyout( + toMountPoint( + + overlay.close()} + onSave={onFieldUpdate} + stream={stream} + withFieldSimulation={withFieldSimulation} + {...props} + /> + , + core + ), + { maxWidth: 500 } + ); + }; + + const openUnpromoteModal = () => { + const overlay = core.overlays.openModal( + toMountPoint( + overlay.close()} + onFieldUnmap={onFieldUnmap} + />, + core + ), + { maxWidth: 500 } + ); + }; + + const viewFieldAction = { + name: i18n.translate('xpack.streams.actions.viewFieldLabel', { + defaultMessage: 'View field', + }), + onClick: () => openFlyout(), + }; + + switch (field.status) { + case 'mapped': + actions = [ + viewFieldAction, + { + name: i18n.translate('xpack.streams.actions.editFieldLabel', { + defaultMessage: 'Edit field', + }), + onClick: () => openFlyout({ isEditingByDefault: true }), + }, + { + name: i18n.translate('xpack.streams.actions.unpromoteFieldLabel', { + defaultMessage: 'Unmap field', + }), + onClick: openUnpromoteModal, + }, + ]; + break; + case 'unmapped': + actions = [ + viewFieldAction, + { + name: i18n.translate('xpack.streams.actions.mapFieldLabel', { + defaultMessage: 'Map field', + }), + onClick: () => openFlyout({ isEditingByDefault: true }), + }, + ]; + break; + case 'inherited': + actions = [viewFieldAction]; + break; + } + + return [ + { + id: 0, + title: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableActionsTitle', { + defaultMessage: 'Field actions', + }), + items: actions.map((action) => ({ + name: action.name, + onClick: () => { + action.onClick(); + closePopover(); + }, + })), + }, + ]; + }, [closePopover, context, core, field, schemaEditorContext]); + + return ( + + } + isOpen={popoverIsOpen} + closePopover={closePopover} + panelPaddingSize="none" + > + + + ); +}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/field_parent.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/field_parent.tsx similarity index 100% rename from x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/field_parent.tsx rename to x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/field_parent.tsx diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/field_status.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/field_status.tsx similarity index 69% rename from x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/field_status.tsx rename to x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/field_status.tsx index 827ca3a03ff28..afed825fa1e42 100644 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/field_status.tsx +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/field_status.tsx @@ -5,14 +5,12 @@ * 2.0. */ -import { EuiBadge } from '@elastic/eui'; import React from 'react'; -import { FIELD_STATUS_MAP, FieldStatus } from './configuration_maps'; +import { EuiBadge } from '@elastic/eui'; +import { FieldStatus, FIELD_STATUS_MAP } from './constants'; export const FieldStatusBadge = ({ status }: { status: FieldStatus }) => { return ( - <> - {FIELD_STATUS_MAP[status].label} - + {FIELD_STATUS_MAP[status].label} ); }; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/field_type.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/field_type.tsx similarity index 50% rename from x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/field_type.tsx rename to x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/field_type.tsx index 0a57a9ac65732..6f119236e0d6b 100644 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/field_type.tsx +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/field_type.tsx @@ -5,19 +5,11 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React from 'react'; import { FieldDefinitionConfig } from '@kbn/streams-schema'; -import { FieldIcon } from '@kbn/react-field'; -import { FIELD_TYPE_MAP } from './configuration_maps'; +import { FieldNameWithIcon } from '@kbn/react-field'; +import { FIELD_TYPE_MAP } from './constants'; export const FieldType = ({ type }: { type: FieldDefinitionConfig['type'] }) => { - return ( - - - - - {`${FIELD_TYPE_MAP[type].label}`} - - ); + return ; }; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/filters/filter_group.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/filters/filter_group.tsx similarity index 81% rename from x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/filters/filter_group.tsx rename to x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/filters/filter_group.tsx index 8e5761763aad7..26fce2e903317 100644 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/filters/filter_group.tsx +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/filters/filter_group.tsx @@ -13,8 +13,8 @@ import { EuiSelectableProps, useGeneratedHtmlId, } from '@elastic/eui'; +import { useBoolean } from '@kbn/react-hooks'; import React from 'react'; -import useToggle from 'react-use/lib/useToggle'; export const FilterGroup = ({ filterGroupButtonLabel, @@ -25,7 +25,7 @@ export const FilterGroup = ({ items: EuiSelectableOption[]; onChange: Required['onChange']; }) => { - const [isPopoverOpen, togglePopover] = useToggle(false); + const [isPopoverOpen, { off: closePopover, toggle }] = useBoolean(false); const filterGroupPopoverId = useGeneratedHtmlId({ prefix: 'filterGroupPopover', @@ -35,7 +35,7 @@ export const FilterGroup = ({ item.checked === 'on')} @@ -51,14 +51,10 @@ export const FilterGroup = ({ id={filterGroupPopoverId} button={button} isOpen={isPopoverOpen} - closePopover={() => togglePopover(false)} + closePopover={closePopover} panelPaddingSize="none" > - onChange(...args)} - > + {(list) => (
{ - const [items, setItems] = useState>(() => +export const FieldStatusFilterGroup = ({ onChange }: { onChange: TControlsChangeHandler }) => { + const [items, setItems] = useState(() => Object.entries(FIELD_STATUS_MAP).map(([key, value]) => { return { label: value.label, @@ -37,13 +32,13 @@ export const FieldStatusFilterGroup = ({ const onChangeItems = useCallback['onChange']>( (nextItems) => { setItems(nextItems); - onChangeFilterGroup({ + onChange({ status: nextItems .filter((nextItem) => nextItem.checked === 'on') - .map((item) => item.key as string), + .map((item) => item.key as SchemaFieldStatus), }); }, - [onChangeFilterGroup] + [onChange] ); return ( diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/filters/type_filter_group.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/filters/type_filter_group.tsx similarity index 66% rename from x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/filters/type_filter_group.tsx rename to x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/filters/type_filter_group.tsx index 13f0657d0b133..05afd60590d2b 100644 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/filters/type_filter_group.tsx +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/filters/type_filter_group.tsx @@ -8,24 +8,19 @@ import { useCallback, useState } from 'react'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { EuiSelectableProps } from '@elastic/eui'; -import { FIELD_TYPE_MAP } from '../configuration_maps'; +import { EuiSelectableOption, EuiSelectableProps } from '@elastic/eui'; import { FilterGroup } from './filter_group'; -import { ChangeFilterGroups } from '../hooks/use_query_and_filters'; +import { FIELD_TYPE_MAP } from '../constants'; +import { TControlsChangeHandler } from '../hooks/use_controls'; +import { SchemaFieldType } from '../types'; const BUTTON_LABEL = i18n.translate( 'xpack.streams.streamDetailSchemaEditor.fieldTypeFilterGroupButtonLabel', - { - defaultMessage: 'Type', - } + { defaultMessage: 'Type' } ); -export const FieldTypeFilterGroup = ({ - onChangeFilterGroup, -}: { - onChangeFilterGroup: ChangeFilterGroups; -}) => { - const [items, setItems] = useState>(() => +export const FieldTypeFilterGroup = ({ onChange }: { onChange: TControlsChangeHandler }) => { + const [items, setItems] = useState(() => Object.entries(FIELD_TYPE_MAP).map(([key, value]) => { return { label: value.label, @@ -37,13 +32,13 @@ export const FieldTypeFilterGroup = ({ const onChangeItems = useCallback['onChange']>( (nextItems) => { setItems(nextItems); - onChangeFilterGroup({ + onChange({ type: nextItems .filter((nextItem) => nextItem.checked === 'on') - .map((item) => item.key as string), + .map((item) => item.key as SchemaFieldType), }); }, - [onChangeFilterGroup] + [onChange] ); return ( diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/children_affected_callout.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/flyout/children_affected_callout.tsx similarity index 100% rename from x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/children_affected_callout.tsx rename to x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/flyout/children_affected_callout.tsx diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/ecs_recommendation.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/flyout/ecs_recommendation.tsx similarity index 87% rename from x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/ecs_recommendation.tsx rename to x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/flyout/ecs_recommendation.tsx index d8f7c977a4b37..beccd59b8a875 100644 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/ecs_recommendation.tsx +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/flyout/ecs_recommendation.tsx @@ -11,23 +11,17 @@ import { i18n } from '@kbn/i18n'; const EcsRecommendationText = i18n.translate( 'xpack.streams.streamDetailSchemaEditor.ecsRecommendationText', - { - defaultMessage: 'ECS recommendation', - } + { defaultMessage: 'ECS recommendation' } ); const UknownEcsFieldText = i18n.translate( 'xpack.streams.streamDetailSchemaEditor.uknownEcsFieldText', - { - defaultMessage: 'Not an ECS field', - } + { defaultMessage: 'Not an ECS field' } ); const LoadingText = i18n.translate( 'xpack.streams.streamDetailSchemaEditor.ecsRecommendationLoadingText', - { - defaultMessage: 'Loading...', - } + { defaultMessage: 'Loading...' } ); export const EcsRecommendation = ({ diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/field_form_format.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/flyout/field_form_format.tsx similarity index 77% rename from x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/field_form_format.tsx rename to x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/flyout/field_form_format.tsx index 9b69a68034170..535bdc01f13b0 100644 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/field_form_format.tsx +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/flyout/field_form_format.tsx @@ -17,12 +17,12 @@ import React, { useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { FieldDefinitionConfig } from '@kbn/streams-schema'; import useToggle from 'react-use/lib/useToggle'; -import { SchemaEditorEditingState } from '../hooks/use_editing_state'; +import { SchemaField } from '../types'; -type FieldFormFormatProps = Pick< - SchemaEditorEditingState, - 'nextFieldType' | 'nextFieldFormat' | 'setNextFieldFormat' ->; +interface FieldFormFormatProps { + field: SchemaField; + onChange: (format: SchemaField['format']) => void; +} const DEFAULT_FORMAT = 'strict_date_optional_time||epoch_millis'; @@ -42,7 +42,7 @@ export const typeSupportsFormat = (type?: FieldDefinitionConfig['type']) => { }; export const FieldFormFormat = (props: FieldFormFormatProps) => { - if (!typeSupportsFormat(props.nextFieldType)) { + if (!typeSupportsFormat(props.field.type)) { return null; } return ; @@ -50,13 +50,13 @@ export const FieldFormFormat = (props: FieldFormFormatProps) => { const FieldFormFormatSelection = (props: FieldFormFormatProps) => { const [isFreeform, toggleIsFreeform] = useToggle( - props.nextFieldFormat !== undefined && !isPopularFormat(props.nextFieldFormat) + props.field.format !== undefined && !isPopularFormat(props.field.format) ); const onToggle = useCallback( (e: EuiSwitchEvent) => { - if (!e.target.checked && !isPopularFormat(props.nextFieldFormat)) { - props.setNextFieldFormat(undefined); + if (!e.target.checked && !isPopularFormat(props.field.format)) { + props.onChange(undefined); } toggleIsFreeform(); }, @@ -85,13 +85,13 @@ const PopularFormatsSelector = (props: FieldFormFormatProps) => { return ( { - props.setNextFieldFormat(event.target.value as PopularFormatOption); + props.onChange(event.target.value as PopularFormatOption); }} - value={props.nextFieldFormat} + value={props.field.format} options={POPULAR_FORMATS.map((format) => ({ text: format, value: format, @@ -105,8 +105,8 @@ const FreeformFormatInput = (props: FieldFormFormatProps) => { props.setNextFieldFormat(e.target.value)} + value={props.field.format ?? ''} + onChange={(e) => props.onChange(e.target.value)} /> ); }; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/flyout/field_form_type.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/flyout/field_form_type.tsx new file mode 100644 index 0000000000000..85701cff5ec8a --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/flyout/field_form_type.tsx @@ -0,0 +1,86 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiFlexGroup, EuiFlexItem, EuiSelect } from '@elastic/eui'; +import React, { useEffect } from 'react'; +import { EcsRecommendation } from './ecs_recommendation'; +import { FieldType } from '../field_type'; +import { useKibana } from '../../../hooks/use_kibana'; +import { EMPTY_CONTENT, FIELD_TYPE_MAP, FieldTypeOption } from '../constants'; +import { MappedSchemaField, SchemaField } from '../types'; + +export const FieldFormType = ({ + field, + isEditing, + onTypeChange, +}: { + field: SchemaField; + isEditing: boolean; + onTypeChange: FieldTypeSelectorProps['onChange']; +}) => { + const { useFieldsMetadata } = useKibana().dependencies.start.fieldsMetadata; + + const { fieldsMetadata, loading } = useFieldsMetadata( + { attributes: ['type'], fieldNames: [field.name] }, + [field] + ); + + // Propagate recommendation to state if a type is not already set + const recommendation = fieldsMetadata?.[field.name]?.type; + + useEffect(() => { + if ( + !loading && + recommendation !== undefined && + // Supported type + recommendation in FIELD_TYPE_MAP && + !field.type + ) { + onTypeChange(recommendation as MappedSchemaField['type']); + } + }, [field, loading, recommendation, onTypeChange]); + + return ( + + + {isEditing ? ( + + ) : field.type ? ( + + ) : ( + EMPTY_CONTENT + )} + + + + + + ); +}; + +interface FieldTypeSelectorProps { + isLoading?: boolean; + onChange: (value: FieldTypeOption) => void; + value?: FieldTypeOption; +} + +const FieldTypeSelector = ({ value, onChange, isLoading = false }: FieldTypeSelectorProps) => { + return ( + { + onChange(event.target.value as FieldTypeOption); + }} + value={value} + options={Object.entries(FIELD_TYPE_MAP).map(([optionKey, optionConfig]) => ({ + text: optionConfig.label, + value: optionKey, + }))} + /> + ); +}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/flyout/field_summary.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/flyout/field_summary.tsx new file mode 100644 index 0000000000000..adb98b2946dfb --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/flyout/field_summary.tsx @@ -0,0 +1,215 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiIconTip, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import useToggle from 'react-use/lib/useToggle'; +import { WiredStreamDefinition } from '@kbn/streams-schema'; +import { useStreamsAppRouter } from '../../../hooks/use_streams_app_router'; +import { FieldParent } from '../field_parent'; +import { FieldStatusBadge } from '../field_status'; +import { FieldFormFormat, typeSupportsFormat } from './field_form_format'; +import { FieldFormType } from './field_form_type'; +import { ChildrenAffectedCallout } from './children_affected_callout'; +import { EMPTY_CONTENT } from '../constants'; +import { SchemaField } from '../types'; + +const title = i18n.translate('xpack.streams.streamDetailSchemaEditorFieldSummaryTitle', { + defaultMessage: 'Field summary', +}); + +const FIELD_SUMMARIES = { + fieldStatus: { + label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldSummaryFieldNameHeader', { + defaultMessage: 'Status', + }), + }, + fieldType: { + label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldSummaryFieldTypeHeader', { + defaultMessage: 'Type', + }), + }, + fieldFormat: { + label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldSummaryFieldFormatHeader', { + defaultMessage: 'Format', + }), + }, + fieldParent: { + label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldSummaryFieldParentHeader', { + defaultMessage: 'Field Parent', + }), + }, +}; + +interface FieldSummaryProps { + field: SchemaField; + isEditingByDefault: boolean; + stream: WiredStreamDefinition; + onChange: (field: Partial) => void; +} + +export const FieldSummary = (props: FieldSummaryProps) => { + const { field, isEditingByDefault, onChange, stream } = props; + + const router = useStreamsAppRouter(); + + const [isEditing, toggleEditMode] = useToggle(isEditingByDefault); + + return ( + <> + + + + + {title} + + + {field.status !== 'inherited' && !isEditing ? ( + + + + + {i18n.translate('xpack.streams.fieldSummary.editButtonLabel', { + defaultMessage: 'Edit', + })} + + + + + ) : field.status === 'inherited' ? ( + + + + + {i18n.translate('xpack.streams.fieldSummary.editInParentButtonLabel', { + defaultMessage: 'Edit in parent stream', + })} + + + + + ) : null} + + + + + + + + + {FIELD_SUMMARIES.fieldStatus.label}{' '} + + + + + + + + + + + + + + + + + + + {FIELD_SUMMARIES.fieldType.label} + + + + onChange({ type })} + /> + + + + + + {typeSupportsFormat(field.type) && ( + <> + + + + {FIELD_SUMMARIES.fieldFormat.label} + + + + {isEditing ? ( + onChange({ format })} /> + ) : ( + `${field.format ?? EMPTY_CONTENT}` + )} + + + + + )} + + + + + {FIELD_SUMMARIES.fieldParent.label} + + + + + + + + + + + + + + {isEditing && stream.ingest.routing.length > 0 ? ( + + + + ) : null} + + ); +}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/flyout/index.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/flyout/index.tsx new file mode 100644 index 0000000000000..8124672853553 --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/flyout/index.tsx @@ -0,0 +1,106 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiFlyoutFooter, + EuiTitle, + EuiButton, +} from '@elastic/eui'; +import React, { useReducer } from 'react'; +import { i18n } from '@kbn/i18n'; +import { WiredStreamDefinition } from '@kbn/streams-schema'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; +import { SamplePreviewTable } from './sample_preview_table'; +import { FieldSummary } from './field_summary'; +import { SchemaField } from '../types'; + +export interface SchemaEditorFlyoutProps { + field: SchemaField; + isEditingByDefault?: boolean; + onClose?: () => void; + onSave: (field: SchemaField) => void; + stream: WiredStreamDefinition; + withFieldSimulation?: boolean; +} + +export const SchemaEditorFlyout = ({ + field, + stream, + onClose, + onSave, + isEditingByDefault = false, + withFieldSimulation = false, +}: SchemaEditorFlyoutProps) => { + const [nextField, setNextField] = useReducer( + (prev: SchemaField, updated: Partial) => + ({ + ...prev, + ...updated, + } as SchemaField), + field + ); + + const [{ loading: isSaving }, saveChanges] = useAsyncFn(async () => { + await onSave(nextField); + if (onClose) onClose(); + }, [nextField, onClose, onSave]); + + return ( + <> + + +

{field.name}

+
+
+ + + + + {withFieldSimulation && ( + + + + )} + + + + + + + {i18n.translate('xpack.streams.schemaEditorFlyout.closeButtonLabel', { + defaultMessage: 'Cancel', + })} + + + {i18n.translate('xpack.streams.fieldForm.saveButtonLabel', { + defaultMessage: 'Save changes', + })} + + + + + ); +}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/sample_preview_table.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/flyout/sample_preview_table.tsx similarity index 71% rename from x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/sample_preview_table.tsx rename to x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/flyout/sample_preview_table.tsx index 4ebad532c130d..7ba282fad1be3 100644 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/sample_preview_table.tsx +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/flyout/sample_preview_table.tsx @@ -6,27 +6,26 @@ */ import React, { useMemo } from 'react'; -import { StreamsRepositoryClient } from '@kbn/streams-plugin/public/api'; import { css } from '@emotion/react'; import { i18n } from '@kbn/i18n'; import { EuiCallOut } from '@elastic/eui'; -import { NamedFieldDefinitionConfig, WiredStreamGetResponse } from '@kbn/streams-schema'; +import { NamedFieldDefinitionConfig, WiredStreamDefinition } from '@kbn/streams-schema'; +import { useKibana } from '../../../hooks/use_kibana'; import { getFormattedError } from '../../../util/errors'; import { useStreamsAppFetch } from '../../../hooks/use_streams_app_fetch'; import { PreviewTable } from '../../preview_table'; -import { isFullFieldDefinition } from '../hooks/use_editing_state'; import { LoadingPanel } from '../../loading_panel'; +import { SchemaField, isSchemaFieldTyped } from '../types'; interface SamplePreviewTableProps { - definition: WiredStreamGetResponse; - nextFieldDefinition?: Partial; - streamsRepositoryClient: StreamsRepositoryClient; + stream: WiredStreamDefinition; + nextField: SchemaField; } export const SamplePreviewTable = (props: SamplePreviewTableProps) => { - const { nextFieldDefinition, ...rest } = props; - if (isFullFieldDefinition(nextFieldDefinition)) { - return ; + const { nextField, ...rest } = props; + if (isSchemaFieldTyped(nextField)) { + return ; } else { return null; } @@ -35,33 +34,32 @@ export const SamplePreviewTable = (props: SamplePreviewTableProps) => { const SAMPLE_DOCUMENTS_TO_SHOW = 20; const SamplePreviewTableContent = ({ - definition, - nextFieldDefinition, - streamsRepositoryClient, -}: SamplePreviewTableProps & { nextFieldDefinition: NamedFieldDefinitionConfig }) => { + stream, + nextField, +}: SamplePreviewTableProps & { nextField: NamedFieldDefinitionConfig }) => { + const { streamsRepositoryClient } = useKibana().dependencies.start.streams; + const { value, loading, error } = useStreamsAppFetch( ({ signal }) => { return streamsRepositoryClient.fetch('POST /api/streams/{id}/schema/fields_simulation', { signal, params: { path: { - id: definition.stream.name, + id: stream.name, }, body: { - field_definitions: [nextFieldDefinition], + field_definitions: [nextField], }, }, }); }, - [definition.stream.name, nextFieldDefinition, streamsRepositoryClient], - { - disableToastOnError: true, - } + [stream.name, nextField, streamsRepositoryClient], + { disableToastOnError: true } ); const columns = useMemo(() => { - return [nextFieldDefinition.name]; - }, [nextFieldDefinition.name]); + return [nextField.name]; + }, [nextField.name]); if (loading) { return ; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/hooks/use_controls.ts b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/hooks/use_controls.ts new file mode 100644 index 0000000000000..952b6e708b091 --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/hooks/use_controls.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useReducer } from 'react'; +import { EuiSearchBar } from '@elastic/eui'; +import { SchemaFieldStatus, MappedSchemaField } from '../types'; + +const defaultControls = { + query: EuiSearchBar.Query.MATCH_ALL, + status: [] as SchemaFieldStatus[], + type: [] as Array, +} as const; + +export type TControls = typeof defaultControls; + +const mergeReducer = (prev: TControls, updated: Partial) => ({ ...prev, ...updated }); + +export const useControls = () => useReducer(mergeReducer, defaultControls); + +export type TControlsChangeHandler = (update: Partial) => void; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/hooks/use_schema_fields.ts b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/hooks/use_schema_fields.ts new file mode 100644 index 0000000000000..5ed9a571d3e75 --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/hooks/use_schema_fields.ts @@ -0,0 +1,218 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { useAbortController } from '@kbn/observability-utils-browser/hooks/use_abort_controller'; +import { + FieldDefinitionConfig, + NamedFieldDefinitionConfig, + WiredStreamGetResponse, +} from '@kbn/streams-schema'; +import { isEqual, omit } from 'lodash'; +import { useMemo, useCallback } from 'react'; +import { useStreamsAppFetch } from '../../../hooks/use_streams_app_fetch'; +import { useKibana } from '../../../hooks/use_kibana'; +import { MappedSchemaField, SchemaField, isSchemaFieldTyped } from '../types'; + +export const useSchemaFields = ({ + definition, + refreshDefinition, +}: { + definition: WiredStreamGetResponse; + refreshDefinition: () => void; +}) => { + const { + dependencies: { + start: { + streams: { streamsRepositoryClient }, + }, + }, + core: { + notifications: { toasts }, + }, + } = useKibana(); + + const abortController = useAbortController(); + + const { + value: unmappedFieldsValue, + loading: isLoadingUnmappedFields, + refresh: refreshUnmappedFields, + } = useStreamsAppFetch( + ({ signal }) => { + return streamsRepositoryClient.fetch('GET /api/streams/{id}/schema/unmapped_fields', { + signal, + params: { + path: { + id: definition.stream.name, + }, + }, + }); + }, + [definition.stream.name, streamsRepositoryClient] + ); + + const fields = useMemo(() => { + const inheritedFields: SchemaField[] = Object.entries(definition.inherited_fields).map( + ([name, field]) => ({ + name, + type: field.type, + format: field.format, + parent: field.from, + status: 'inherited', + }) + ); + + const mappedFields: SchemaField[] = Object.entries(definition.stream.ingest.wired.fields).map( + ([name, field]) => ({ + name, + type: field.type, + format: field.format, + parent: definition.stream.name, + status: 'mapped', + }) + ); + + const unmappedFields: SchemaField[] = + unmappedFieldsValue?.unmappedFields.map((field) => ({ + name: field, + parent: definition.stream.name, + status: 'unmapped', + })) ?? []; + + return [...inheritedFields, ...mappedFields, ...unmappedFields]; + }, [definition, unmappedFieldsValue]); + + const refreshFields = useCallback(() => { + refreshDefinition(); + refreshUnmappedFields(); + }, [refreshDefinition, refreshUnmappedFields]); + + const updateField = useCallback( + async (field: SchemaField) => { + try { + if (!isSchemaFieldTyped(field)) { + throw new Error('The field is not complete or fully mapped.'); + } + + const nextFieldDefinitionConfig = convertToFieldDefinitionConfig(field); + const persistedFieldDefinitionConfig = definition.stream.ingest.wired.fields[field.name]; + + if (!hasChanges(persistedFieldDefinitionConfig, nextFieldDefinitionConfig)) { + throw new Error('The field is not different, hence updating is not necessary.'); + } + + await streamsRepositoryClient.fetch(`PUT /api/streams/{id}/_ingest`, { + signal: abortController.signal, + params: { + path: { + id: definition.stream.name, + }, + body: { + ingest: { + ...definition.stream.ingest, + wired: { + fields: { + ...definition.stream.ingest.wired.fields, + [field.name]: nextFieldDefinitionConfig, + }, + }, + }, + }, + }, + }); + + toasts.addSuccess( + i18n.translate('xpack.streams.streamDetailSchemaEditorEditSuccessToast', { + defaultMessage: '{field} was successfully edited', + values: { field: field.name }, + }) + ); + + refreshFields(); + } catch (error) { + toasts.addError(error, { + title: i18n.translate('xpack.streams.streamDetailSchemaEditorEditErrorToast', { + defaultMessage: 'Something went wrong editing the {field} field', + values: { field: field.name }, + }), + toastMessage: error.message, + toastLifeTimeMs: 5000, + }); + } + }, + [abortController.signal, definition, refreshFields, streamsRepositoryClient, toasts] + ); + + const unmapField = useCallback( + async (fieldName: SchemaField['name']) => { + try { + const persistedFieldDefinitionConfig = definition.stream.ingest.wired.fields[fieldName]; + + if (!persistedFieldDefinitionConfig) { + throw new Error('The field is not mapped, hence it cannot be unmapped.'); + } + + await streamsRepositoryClient.fetch(`PUT /api/streams/{id}/_ingest`, { + signal: abortController.signal, + params: { + path: { + id: definition.stream.name, + }, + body: { + ingest: { + ...definition.stream.ingest, + wired: { + fields: omit(definition.stream.ingest.wired.fields, fieldName), + }, + }, + }, + }, + }); + + toasts.addSuccess( + i18n.translate('xpack.streams.streamDetailSchemaEditorUnmapSuccessToast', { + defaultMessage: '{field} was successfully unmapped', + values: { field: fieldName }, + }) + ); + + refreshFields(); + } catch (error) { + toasts.addError(error, { + title: i18n.translate('xpack.streams.streamDetailSchemaEditorUnmapErrorToast', { + defaultMessage: 'Something went wrong unmapping the {field} field', + values: { field: fieldName }, + }), + toastMessage: error.message, + toastLifeTimeMs: 5000, + }); + } + }, + [abortController.signal, definition, refreshFields, streamsRepositoryClient, toasts] + ); + + return { + fields, + isLoadingUnmappedFields, + refreshFields, + unmapField, + updateField, + }; +}; + +const convertToFieldDefinitionConfig = (field: MappedSchemaField): FieldDefinitionConfig => ({ + type: field.type, + ...(field.format && field.type === 'date' ? { format: field.format } : {}), +}); + +const hasChanges = ( + field: Partial, + fieldUpdate: Partial +) => { + return !isEqual(field, fieldUpdate); +}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/index.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/index.tsx new file mode 100644 index 0000000000000..0a6cc2d562b3d --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/index.tsx @@ -0,0 +1,58 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiPortal, EuiProgress } from '@elastic/eui'; +import { useControls } from './hooks/use_controls'; +import { SchemaEditorProps } from './types'; +import { SchemaEditorContextProvider } from './schema_editor_context'; +import { Controls } from './schema_editor_controls'; +import { FieldsTable } from './schema_editor_table'; + +export function SchemaEditor({ + fields, + isLoading, + onFieldUnmap, + onFieldUpdate, + onRefreshData, + stream, + withControls = false, + withFieldSimulation = false, + withTableActions = false, +}: SchemaEditorProps) { + const [controls, updateControls] = useControls(); + + return ( + + + {isLoading ? ( + + + + ) : null} + {withControls && ( + + )} + + + + ); +} diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/schema_editor_context.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/schema_editor_context.tsx new file mode 100644 index 0000000000000..4fd5015eba21b --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/schema_editor_context.tsx @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import createContainer from 'constate'; + +import { SchemaEditorProps } from './types'; + +const useSchemaEditor = (props: SchemaEditorProps) => props; + +export const [SchemaEditorContextProvider, useSchemaEditorContext] = + createContainer(useSchemaEditor); diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/schema_editor_controls.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/schema_editor_controls.tsx new file mode 100644 index 0000000000000..e3043f1ebfb2b --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/schema_editor_controls.tsx @@ -0,0 +1,57 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlexItem, EuiFlexGroup, EuiSearchBar, EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FieldStatusFilterGroup } from './filters/status_filter_group'; +import { FieldTypeFilterGroup } from './filters/type_filter_group'; +import { TControls } from './hooks/use_controls'; +import { SchemaEditorProps } from './types'; + +interface ControlsProps { + controls: TControls; + onChange: (nextControls: Partial) => void; + onRefreshData: SchemaEditorProps['onRefreshData']; +} + +export function Controls({ controls, onChange, onRefreshData }: ControlsProps) { + return ( + + + + onChange({ query: nextQuery.query ?? undefined })} + box={{ + incremental: true, + }} + /> + + + + + + + + {onRefreshData && ( + + + {i18n.translate('xpack.streams.schemaEditor.refreshDataButtonLabel', { + defaultMessage: 'Refresh', + })} + + + )} + + + ); +} diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/schema_editor_table.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/schema_editor_table.tsx new file mode 100644 index 0000000000000..9a086c49a9355 --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/schema_editor_table.tsx @@ -0,0 +1,151 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useMemo } from 'react'; +import { + EuiDataGridColumnSortingConfig, + EuiSearchBar, + EuiScreenReaderOnly, + EuiDataGrid, + EuiDataGridCellProps, + EuiDataGridControlColumn, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { WiredStreamDefinition } from '@kbn/streams-schema'; +import { isEmpty } from 'lodash'; +import { TABLE_COLUMNS, EMPTY_CONTENT } from './constants'; +import { FieldActionsCell } from './field_actions'; +import { FieldParent } from './field_parent'; +import { FieldStatusBadge } from './field_status'; +import { TControls } from './hooks/use_controls'; +import { SchemaField } from './types'; +import { FieldType } from './field_type'; + +export function FieldsTable({ + fields, + controls, + stream, + withTableActions, +}: { + fields: SchemaField[]; + controls: TControls; + stream: WiredStreamDefinition; + withTableActions: boolean; +}) { + // Column visibility + const [visibleColumns, setVisibleColumns] = useState(Object.keys(TABLE_COLUMNS)); + // Column sorting + const [sortingColumns, setSortingColumns] = useState([]); + + const filteredFields = useMemo( + () => filterFieldsByControls(fields, controls), + [fields, controls] + ); + + const trailingColumns = useMemo(() => { + if (!withTableActions) return undefined; + + return [createFieldActionsCellRenderer(filteredFields)]; + }, [withTableActions, filteredFields]); + + const RenderCellValue = useMemo( + () => createCellRenderer(filteredFields, stream), + [filteredFields, stream] + ); + + return ( + ({ + id: columnId, + ...value, + }))} + columnVisibility={{ + visibleColumns, + setVisibleColumns, + canDragAndDropColumns: false, + }} + sorting={{ columns: sortingColumns, onSort: setSortingColumns }} + toolbarVisibility={true} + rowCount={filteredFields.length} + renderCellValue={RenderCellValue} + trailingControlColumns={trailingColumns} + gridStyle={{ + border: 'none', + rowHover: 'none', + header: 'underline', + }} + inMemory={{ level: 'sorting' }} + /> + ); +} + +const createCellRenderer = + (fields: SchemaField[], stream: WiredStreamDefinition): EuiDataGridCellProps['renderCellValue'] => + ({ rowIndex, columnId }) => { + const field = fields[rowIndex]; + if (!field) return null; + const { parent, status } = field; + + if (columnId === 'type') { + if (!field.type) return EMPTY_CONTENT; + return ; + } + + if (columnId === 'parent') { + return ; + } + + if (columnId === 'status') { + return ; + } + + return field[columnId as keyof SchemaField] || EMPTY_CONTENT; + }; + +const createFieldActionsCellRenderer = (fields: SchemaField[]): EuiDataGridControlColumn => ({ + id: 'field-actions', + width: 40, + headerCellRender: () => ( + + + {i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableActionsTitle', { + defaultMessage: 'Field actions', + })} + + + ), + rowCellRender: ({ rowIndex }) => { + const field = fields[rowIndex]; + + if (!field) return null; + + return ; + }, +}); + +const filterFieldsByControls = (fields: SchemaField[], controls: TControls) => { + if (!controls.query && isEmpty(controls.type) && isEmpty(controls.status)) { + return fields; + } + + const matchingQueryFields = EuiSearchBar.Query.execute(controls.query, fields, { + defaultFields: ['name', 'type'], + }); + + const filteredByGroupsFields = matchingQueryFields.filter((field) => { + return ( + (isEmpty(controls.type) || (field.type && controls.type.includes(field.type))) && // Filter by applied type + (isEmpty(controls.status) || controls.status.includes(field.status)) // Filter by applied status + ); + }); + + return filteredByGroupsFields; +}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/types.ts b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/types.ts new file mode 100644 index 0000000000000..36cbc11b4a545 --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/types.ts @@ -0,0 +1,44 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FieldDefinitionConfig, WiredStreamDefinition } from '@kbn/streams-schema'; + +export type SchemaFieldStatus = 'inherited' | 'mapped' | 'unmapped'; +export type SchemaFieldType = FieldDefinitionConfig['type']; + +export interface BaseSchemaField extends Omit { + name: string; + parent: string; +} + +export interface MappedSchemaField extends BaseSchemaField { + status: 'inherited' | 'mapped'; + type: SchemaFieldType; +} + +export interface UnmappedSchemaField extends BaseSchemaField { + status: 'unmapped'; + type?: SchemaFieldType | undefined; +} + +export type SchemaField = MappedSchemaField | UnmappedSchemaField; + +export interface SchemaEditorProps { + fields: SchemaField[]; + isLoading?: boolean; + onFieldUnmap: (fieldName: SchemaField['name']) => void; + onFieldUpdate: (field: SchemaField) => void; + onRefreshData?: () => void; + stream: WiredStreamDefinition; + withControls?: boolean; + withFieldSimulation?: boolean; + withTableActions?: boolean; +} + +export const isSchemaFieldTyped = (field: SchemaField): field is MappedSchemaField => { + return !!field && !!field.name && !!field.type; +}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/unpromote_field_modal.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/unpromote_field_modal.tsx new file mode 100644 index 0000000000000..750bac00d8070 --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/schema_editor/unpromote_field_modal.tsx @@ -0,0 +1,48 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiConfirmModal } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; +import { SchemaEditorProps, SchemaField } from './types'; + +export const UnpromoteFieldModal = ({ + field, + onClose, + onFieldUnmap, +}: { + field: SchemaField; + onClose: () => void; + onFieldUnmap: SchemaEditorProps['onFieldUnmap']; +}) => { + const [{ loading }, unmapField] = useAsyncFn(async () => { + await onFieldUnmap(field.name); + if (onClose) onClose(); + }, [field, onClose, onFieldUnmap]); + + return ( + + {i18n.translate('xpack.streams.unpromoteFieldModal.unpromoteFieldWarning', { + defaultMessage: 'Are you sure you want to unmap this field from template mappings?', + })} + + ); +}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/fields_table.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/fields_table.tsx deleted file mode 100644 index 960b25ef39795..0000000000000 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/fields_table.tsx +++ /dev/null @@ -1,395 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useMemo, useState } from 'react'; -import { - EuiButtonIcon, - EuiContextMenu, - EuiDataGrid, - EuiPopover, - EuiSearchBar, - useGeneratedHtmlId, -} from '@elastic/eui'; -import type { - EuiContextMenuPanelDescriptor, - EuiContextMenuPanelItemDescriptor, - EuiDataGridColumnSortingConfig, - EuiDataGridProps, - Query, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import useToggle from 'react-use/lib/useToggle'; -import { isRootStreamDefinition, WiredStreamGetResponse } from '@kbn/streams-schema'; -import { FieldType } from './field_type'; -import { FieldStatusBadge } from './field_status'; -import { FieldEntry, SchemaEditorEditingState } from './hooks/use_editing_state'; -import { SchemaEditorUnpromotingState } from './hooks/use_unpromoting_state'; -import { FieldParent } from './field_parent'; -import { SchemaEditorQueryAndFiltersState } from './hooks/use_query_and_filters'; - -interface FieldsTableContainerProps { - definition: WiredStreamGetResponse; - unmappedFieldsResult?: string[]; - isLoadingUnmappedFields: boolean; - query?: Query; - editingState: SchemaEditorEditingState; - unpromotingState: SchemaEditorUnpromotingState; - queryAndFiltersState: SchemaEditorQueryAndFiltersState; -} - -const COLUMNS = { - name: { - display: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTablenameHeader', { - defaultMessage: 'Field', - }), - }, - type: { - display: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTabletypeHeader', { - defaultMessage: 'Type', - }), - }, - format: { - display: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableformatHeader', { - defaultMessage: 'Format', - }), - }, - parent: { - display: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableFieldParentHeader', { - defaultMessage: 'Field Parent (Stream)', - }), - }, - status: { - display: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTablestatusHeader', { - defaultMessage: 'Status', - }), - }, -}; - -export const EMPTY_CONTENT = '-----'; - -export const FieldsTableContainer = ({ - definition, - unmappedFieldsResult, - query, - editingState, - unpromotingState, - queryAndFiltersState, -}: FieldsTableContainerProps) => { - const inheritedFields = useMemo(() => { - return Object.entries(definition.inherited_fields).map(([name, field]) => ({ - name, - type: field.type, - format: field.format, - parent: field.from, - status: 'inherited' as const, - })); - }, [definition]); - - const filteredInheritedFields = useMemo(() => { - if (!query) return inheritedFields; - return EuiSearchBar.Query.execute(query, inheritedFields, { - defaultFields: ['name', 'type'], - }); - }, [inheritedFields, query]); - - const mappedFields = useMemo(() => { - return Object.entries(definition.stream.ingest.wired.fields).map(([name, field]) => ({ - name, - type: field.type, - format: field.format, - parent: definition.stream.name, - status: 'mapped' as const, - })); - return []; - }, [definition]); - - const filteredMappedFields = useMemo(() => { - if (!query) return mappedFields; - return EuiSearchBar.Query.execute(query, mappedFields, { - defaultFields: ['name', 'type'], - }); - }, [mappedFields, query]); - - const unmappedFields = useMemo(() => { - return unmappedFieldsResult - ? unmappedFieldsResult.map((field) => ({ - name: field, - parent: definition.stream.name, - status: 'unmapped' as const, - })) - : []; - }, [definition.stream.name, unmappedFieldsResult]); - - const filteredUnmappedFields = useMemo(() => { - if (!unmappedFieldsResult) return []; - if (!query) return unmappedFields; - return EuiSearchBar.Query.execute(query, unmappedFields, { - defaultFields: ['name'], - }); - }, [unmappedFieldsResult, query, unmappedFields]); - - const allFilteredFields = useMemo(() => { - return [...filteredInheritedFields, ...filteredMappedFields, ...filteredUnmappedFields]; - }, [filteredInheritedFields, filteredMappedFields, filteredUnmappedFields]); - - const filteredFieldsWithFilterGroupsApplied = useMemo(() => { - const filterGroups = queryAndFiltersState.filterGroups; - let fieldsWithFilterGroupsApplied = allFilteredFields; - - if (filterGroups.type && filterGroups.type.length > 0) { - fieldsWithFilterGroupsApplied = fieldsWithFilterGroupsApplied.filter( - (field) => 'type' in field && filterGroups.type.includes(field.type) - ); - } - - if (filterGroups.status && filterGroups.status.length > 0) { - fieldsWithFilterGroupsApplied = fieldsWithFilterGroupsApplied.filter( - (field) => 'status' in field && filterGroups.status.includes(field.status) - ); - } - - return fieldsWithFilterGroupsApplied; - }, [allFilteredFields, queryAndFiltersState.filterGroups]); - - return ( - - ); -}; - -interface FieldsTableProps { - definition: WiredStreamGetResponse; - fields: FieldEntry[]; - editingState: SchemaEditorEditingState; - unpromotingState: SchemaEditorUnpromotingState; -} - -const FieldsTable = ({ definition, fields, editingState, unpromotingState }: FieldsTableProps) => { - // Column visibility - const [visibleColumns, setVisibleColumns] = useState(Object.keys(COLUMNS)); - - // Column sorting - const [sortingColumns, setSortingColumns] = useState([]); - - const trailingColumns = useMemo(() => { - return !isRootStreamDefinition(definition.stream) - ? ([ - { - id: 'actions', - width: 40, - headerCellRender: () => null, - rowCellRender: ({ rowIndex }) => { - const field = fields[rowIndex]; - - if (!field) return null; - - let actions: ActionsCellActionsDescriptor[] = []; - - switch (field.status) { - case 'mapped': - actions = [ - { - name: i18n.translate('xpack.streams.actions.viewFieldLabel', { - defaultMessage: 'View field', - }), - disabled: editingState.isSaving, - onClick: (fieldEntry: FieldEntry) => { - editingState.selectField(fieldEntry, false); - }, - }, - { - name: i18n.translate('xpack.streams.actions.editFieldLabel', { - defaultMessage: 'Edit field', - }), - disabled: editingState.isSaving, - onClick: (fieldEntry: FieldEntry) => { - editingState.selectField(fieldEntry, true); - }, - }, - { - name: i18n.translate('xpack.streams.actions.unpromoteFieldLabel', { - defaultMessage: 'Unmap field', - }), - disabled: unpromotingState.isUnpromotingField, - onClick: (fieldEntry: FieldEntry) => { - unpromotingState.setSelectedField(fieldEntry.name); - }, - }, - ]; - break; - case 'unmapped': - actions = [ - { - name: i18n.translate('xpack.streams.actions.viewFieldLabel', { - defaultMessage: 'View field', - }), - disabled: editingState.isSaving, - onClick: (fieldEntry: FieldEntry) => { - editingState.selectField(fieldEntry, false); - }, - }, - { - name: i18n.translate('xpack.streams.actions.mapFieldLabel', { - defaultMessage: 'Map field', - }), - disabled: editingState.isSaving, - onClick: (fieldEntry: FieldEntry) => { - editingState.selectField(fieldEntry, true); - }, - }, - ]; - break; - case 'inherited': - actions = [ - { - name: i18n.translate('xpack.streams.actions.viewFieldLabel', { - defaultMessage: 'View field', - }), - disabled: editingState.isSaving, - onClick: (fieldEntry: FieldEntry) => { - editingState.selectField(fieldEntry, false); - }, - }, - ]; - break; - } - - return ( - ({ - name: action.name, - icon: action.icon, - onClick: (event) => { - action.onClick(field); - }, - })), - }, - ]} - /> - ); - }, - }, - ] as EuiDataGridProps['trailingControlColumns']) - : undefined; - }, [definition, editingState, fields, unpromotingState]); - - return ( - ({ - id: columnId, - ...value, - }))} - columnVisibility={{ - visibleColumns, - setVisibleColumns, - canDragAndDropColumns: false, - }} - sorting={{ columns: sortingColumns, onSort: setSortingColumns }} - toolbarVisibility={true} - rowCount={fields.length} - renderCellValue={({ rowIndex, columnId }) => { - const field = fields[rowIndex]; - if (!field) return null; - - if (columnId === 'type') { - const fieldType = field.type; - if (!fieldType) return EMPTY_CONTENT; - return ; - } else if (columnId === 'parent') { - return ( - - ); - } else if (columnId === 'status') { - return ; - } else { - return field[columnId as keyof FieldEntry] || EMPTY_CONTENT; - } - }} - trailingControlColumns={trailingColumns} - gridStyle={{ - border: 'none', - rowHover: 'none', - header: 'underline', - }} - inMemory={{ level: 'sorting' }} - /> - ); -}; - -export const ActionsCell = ({ panels }: { panels: EuiContextMenuPanelDescriptor[] }) => { - const contextMenuPopoverId = useGeneratedHtmlId({ - prefix: 'fieldsTableContextMenuPopover', - }); - - const [popoverIsOpen, togglePopoverIsOpen] = useToggle(false); - - return ( - { - togglePopoverIsOpen(); - }} - /> - } - isOpen={popoverIsOpen} - closePopover={() => togglePopoverIsOpen(false)} - > - ({ - ...panel, - items: panel.items?.map((item) => ({ - name: item.name, - icon: item.icon, - onClick: (event) => { - if (item.onClick) { - item.onClick(event as any); - } - togglePopoverIsOpen(false); - }, - })), - }))} - /> - - ); -}; - -export type ActionsCellActionsDescriptor = Omit & { - onClick: (field: FieldEntry) => void; -}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/field_form_type.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/field_form_type.tsx deleted file mode 100644 index ce03d709ce2b5..0000000000000 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/field_form_type.tsx +++ /dev/null @@ -1,51 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiSelect } from '@elastic/eui'; -import React from 'react'; -import { SchemaEditorEditingState } from '../hooks/use_editing_state'; - -type FieldFormTypeProps = Pick & { - isLoadingRecommendation: boolean; - recommendation?: string; -}; - -const TYPE_OPTIONS = { - long: 'Long', - double: 'Double', - keyword: 'Keyword', - match_only_text: 'Text (match_only_text)', - boolean: 'Boolean', - ip: 'IP', - date: 'Date', -} as const; - -type FieldTypeOption = keyof typeof TYPE_OPTIONS; - -export const FieldFormType = ({ - nextFieldType: value, - setNextFieldType: onChange, - isLoadingRecommendation, - recommendation, -}: FieldFormTypeProps) => { - return ( - { - onChange(event.target.value as FieldTypeOption); - }} - value={value} - options={Object.entries(TYPE_OPTIONS).map(([optionKey, optionValue]) => ({ - text: optionValue, - value: optionKey, - }))} - /> - ); -}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/field_form_type_wrapper.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/field_form_type_wrapper.tsx deleted file mode 100644 index 671e6625112c8..0000000000000 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/field_form_type_wrapper.tsx +++ /dev/null @@ -1,84 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import React, { useEffect } from 'react'; -import { EMPTY_CONTENT } from '../fields_table'; -import { EcsRecommendation } from './ecs_recommendation'; -import { FieldFormType } from './field_form_type'; -import { FieldEntry, SchemaEditorEditingState } from '../hooks/use_editing_state'; -import { FieldType } from '../field_type'; -import { useKibana } from '../../../hooks/use_kibana'; -import { FIELD_TYPE_MAP } from '../configuration_maps'; - -export const FieldFormTypeWrapper = ({ - isEditing, - nextFieldType, - setNextFieldType, - selectedFieldType, - selectedFieldName, -}: { - isEditing: boolean; - nextFieldType: SchemaEditorEditingState['nextFieldType']; - setNextFieldType: SchemaEditorEditingState['setNextFieldType']; - selectedFieldType: FieldEntry['type']; - selectedFieldName: FieldEntry['name']; -}) => { - const { - dependencies: { - start: { - fieldsMetadata: { useFieldsMetadata }, - }, - }, - } = useKibana(); - - const { fieldsMetadata, loading } = useFieldsMetadata( - { - attributes: ['type'], - fieldNames: [selectedFieldName], - }, - [selectedFieldName] - ); - - // Propagate recommendation to state if a type is not already set - useEffect(() => { - const recommendation = fieldsMetadata?.[selectedFieldName]?.type; - if ( - !loading && - recommendation !== undefined && - // Supported type - recommendation in FIELD_TYPE_MAP && - !nextFieldType - ) { - setNextFieldType(recommendation as FieldEntry['type']); - } - }, [fieldsMetadata, loading, nextFieldType, selectedFieldName, setNextFieldType]); - - return ( - - - {isEditing ? ( - - ) : selectedFieldType ? ( - - ) : ( - `${EMPTY_CONTENT}` - )} - - - - - - ); -}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/field_summary.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/field_summary.tsx deleted file mode 100644 index 55a1f67fc308b..0000000000000 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/field_summary.tsx +++ /dev/null @@ -1,215 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiHorizontalRule, - EuiIconTip, - EuiSpacer, - EuiTitle, -} from '@elastic/eui'; -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { useStreamsAppRouter } from '../../../hooks/use_streams_app_router'; -import { FieldParent } from '../field_parent'; -import { FieldStatusBadge } from '../field_status'; -import { FieldFormFormat, typeSupportsFormat } from './field_form_format'; -import { SchemaEditorFlyoutProps } from '.'; -import { FieldFormTypeWrapper } from './field_form_type_wrapper'; - -const EMPTY_CONTENT = '-----'; - -const title = i18n.translate('xpack.streams.streamDetailSchemaEditorFieldSummaryTitle', { - defaultMessage: 'Field summary', -}); - -const FIELD_SUMMARIES = { - fieldStatus: { - label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldSummaryFieldNameHeader', { - defaultMessage: 'Status', - }), - }, - fieldType: { - label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldSummaryFieldTypeHeader', { - defaultMessage: 'Type', - }), - }, - fieldFormat: { - label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldSummaryFieldFormatHeader', { - defaultMessage: 'Format', - }), - }, - fieldParent: { - label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldSummaryFieldParentHeader', { - defaultMessage: 'Field Parent', - }), - }, -}; - -export const FieldSummary = (props: SchemaEditorFlyoutProps) => { - const { - selectedField, - isEditing, - nextFieldType, - setNextFieldType, - nextFieldFormat, - setNextFieldFormat, - toggleIsEditing, - } = props; - - const router = useStreamsAppRouter(); - - if (!selectedField) { - return null; - } - - return ( - - - - - {title} - - - {selectedField.status !== 'inherited' && !isEditing ? ( - - - - toggleIsEditing()} - iconType="pencil" - > - {i18n.translate('xpack.streams.fieldSummary.editButtonLabel', { - defaultMessage: 'Edit', - })} - - - - - ) : selectedField.status === 'inherited' ? ( - - - - - {i18n.translate('xpack.streams.fieldSummary.editInParentButtonLabel', { - defaultMessage: 'Edit in parent stream', - })} - - - - - ) : null} - - - - - - - - - {FIELD_SUMMARIES.fieldStatus.label}{' '} - - - - - - - - - - - - - - - - - - - {FIELD_SUMMARIES.fieldType.label} - - - - - - - - - - {typeSupportsFormat(nextFieldType) && ( - <> - - - - {FIELD_SUMMARIES.fieldFormat.label} - - - - {isEditing ? ( - - ) : ( - `${selectedField.format ?? EMPTY_CONTENT}` - )} - - - - - )} - - - - - {FIELD_SUMMARIES.fieldParent.label} - - - - - - - - - - - - - - ); -}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/index.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/index.tsx deleted file mode 100644 index 834ee2f5a33d2..0000000000000 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/index.tsx +++ /dev/null @@ -1,104 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { StreamsRepositoryClient } from '@kbn/streams-plugin/public/api'; -import { - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutHeader, - EuiFlyoutFooter, - EuiTitle, - EuiButton, -} from '@elastic/eui'; -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { WiredStreamGetResponse } from '@kbn/streams-schema'; -import { SchemaEditorEditingState } from '../hooks/use_editing_state'; -import { ChildrenAffectedCallout } from './children_affected_callout'; -import { SamplePreviewTable } from './sample_preview_table'; -import { FieldSummary } from './field_summary'; - -export type SchemaEditorFlyoutProps = { - streamsRepositoryClient: StreamsRepositoryClient; - definition: WiredStreamGetResponse; -} & SchemaEditorEditingState; - -export const SchemaEditorFlyout = (props: SchemaEditorFlyoutProps) => { - const { - definition, - streamsRepositoryClient, - selectedField, - reset, - nextFieldDefinition, - isEditing, - isSaving, - saveChanges, - } = props; - - return ( - reset()} maxWidth="500px"> - - - - -

{selectedField?.name}

-
-
-
-
- - - - - {isEditing && definition.stream.ingest.routing.length > 0 ? ( - - - - ) : null} - - - - - - - - - - reset()} - flush="left" - > - {i18n.translate('xpack.streams.schemaEditorFlyout.closeButtonLabel', { - defaultMessage: 'Cancel', - })} - - - - saveChanges && saveChanges()} - > - {i18n.translate('xpack.streams.fieldForm.saveButtonLabel', { - defaultMessage: 'Save changes', - })} - - - - -
- ); -}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/hooks/use_editing_state.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/hooks/use_editing_state.tsx deleted file mode 100644 index 6134b6cb0e2e0..0000000000000 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/hooks/use_editing_state.tsx +++ /dev/null @@ -1,185 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { NamedFieldDefinitionConfig, WiredStreamGetResponse } from '@kbn/streams-schema'; -import { StreamsRepositoryClient } from '@kbn/streams-plugin/public/api'; -import { useCallback, useMemo, useState } from 'react'; -import useToggle from 'react-use/lib/useToggle'; -import { useAbortController } from '@kbn/observability-utils-browser/hooks/use_abort_controller'; -import { ToastsStart } from '@kbn/core-notifications-browser'; -import { i18n } from '@kbn/i18n'; -import { omit } from 'lodash'; -import { FieldStatus } from '../configuration_maps'; - -export type SchemaEditorEditingState = ReturnType; - -export interface FieldEntry { - name: NamedFieldDefinitionConfig['name']; - type?: NamedFieldDefinitionConfig['type']; - format?: NamedFieldDefinitionConfig['format']; - parent: string; - status: FieldStatus; -} - -export type EditableFieldDefinition = FieldEntry; - -export const useEditingState = ({ - streamsRepositoryClient, - definition, - refreshDefinition, - refreshUnmappedFields, - toastsService, -}: { - streamsRepositoryClient: StreamsRepositoryClient; - definition: WiredStreamGetResponse; - refreshDefinition: () => void; - refreshUnmappedFields: () => void; - toastsService: ToastsStart; -}) => { - const abortController = useAbortController(); - /* Whether the field is being edited, otherwise it's just displayed. */ - const [isEditing, toggleIsEditing] = useToggle(false); - /* Whether changes are being persisted */ - const [isSaving, toggleIsSaving] = useToggle(false); - /* Holds errors from saving changes */ - const [error, setError] = useState(); - - /* Represents the currently selected field. This should not be edited directly. */ - const [selectedField, setSelectedField] = useState(); - - /** Dirty state */ - /* Dirty state of the field type */ - const [nextFieldType, setNextFieldType] = useState(); - /* Dirty state of the field format */ - const [nextFieldFormat, setNextFieldFormat] = useState< - EditableFieldDefinition['format'] | undefined - >(); - /* Full dirty definition entry that can be persisted against a stream */ - const nextFieldDefinition = useMemo(() => { - return selectedField - ? { - name: selectedField.name, - type: nextFieldType, - ...(nextFieldFormat && nextFieldType === 'date' ? { format: nextFieldFormat } : {}), - } - : undefined; - }, [nextFieldFormat, nextFieldType, selectedField]); - - const selectField = useCallback( - (field: EditableFieldDefinition, selectAndEdit?: boolean) => { - setSelectedField(field); - setNextFieldType(field.type); - setNextFieldFormat(field.format); - toggleIsEditing(selectAndEdit !== undefined ? selectAndEdit : false); - }, - [toggleIsEditing] - ); - - const reset = useCallback(() => { - setSelectedField(undefined); - setNextFieldType(undefined); - setNextFieldFormat(undefined); - toggleIsEditing(false); - toggleIsSaving(false); - setError(undefined); - }, [toggleIsEditing, toggleIsSaving]); - - const saveChanges = useMemo(() => { - return selectedField && - isFullFieldDefinition(nextFieldDefinition) && - hasChanges(selectedField, nextFieldDefinition) - ? async () => { - toggleIsSaving(true); - try { - await streamsRepositoryClient.fetch(`PUT /api/streams/{id}/_ingest`, { - signal: abortController.signal, - params: { - path: { - id: definition.stream.name, - }, - body: { - ingest: { - ...definition.stream.ingest, - wired: { - fields: { - ...Object.fromEntries( - Object.entries(definition.stream.ingest.wired.fields).filter( - ([name, _field]) => name !== nextFieldDefinition.name - ) - ), - [nextFieldDefinition.name]: omit(nextFieldDefinition, 'name'), - }, - }, - }, - }, - }, - }); - toastsService.addSuccess( - i18n.translate('xpack.streams.streamDetailSchemaEditorEditSuccessToast', { - defaultMessage: '{field} was successfully edited', - values: { field: nextFieldDefinition.name }, - }) - ); - reset(); - refreshDefinition(); - refreshUnmappedFields(); - } catch (e) { - toggleIsSaving(false); - setError(e); - toastsService.addError(e, { - title: i18n.translate('xpack.streams.streamDetailSchemaEditorEditErrorToast', { - defaultMessage: 'Something went wrong editing the {field} field', - values: { field: nextFieldDefinition.name }, - }), - }); - } - } - : undefined; - }, [ - abortController.signal, - definition, - nextFieldDefinition, - refreshDefinition, - refreshUnmappedFields, - reset, - selectedField, - streamsRepositoryClient, - toastsService, - toggleIsSaving, - ]); - - return { - selectedField, - selectField, - isEditing, - toggleIsEditing, - nextFieldType, - setNextFieldType, - nextFieldFormat, - setNextFieldFormat, - isSaving, - saveChanges, - reset, - error, - nextFieldDefinition, - }; -}; - -export const isFullFieldDefinition = ( - value?: Partial -): value is NamedFieldDefinitionConfig => { - return !!value && !!value.name && !!value.type; -}; - -const hasChanges = ( - selectedField: Partial, - nextFieldEntry: Partial -) => { - return ( - selectedField.type !== nextFieldEntry.type || selectedField.format !== nextFieldEntry.format - ); -}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/hooks/use_query_and_filters.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/hooks/use_query_and_filters.tsx deleted file mode 100644 index 96bd1b417244a..0000000000000 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/hooks/use_query_and_filters.tsx +++ /dev/null @@ -1,36 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiSearchBar, Query } from '@elastic/eui'; -import { useCallback, useState } from 'react'; - -export type FilterGroups = Record; - -export const useQueryAndFilters = () => { - const [query, setQuery] = useState(EuiSearchBar.Query.MATCH_ALL); - const [filterGroups, setFilterGroups] = useState({}); - - const changeFilterGroups = useCallback( - (nextFilterGroups: FilterGroups) => { - setFilterGroups({ - ...filterGroups, - ...nextFilterGroups, - }); - }, - [filterGroups] - ); - - return { - query, - setQuery, - filterGroups, - changeFilterGroups, - }; -}; - -export type SchemaEditorQueryAndFiltersState = ReturnType; -export type ChangeFilterGroups = ReturnType['changeFilterGroups']; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/hooks/use_unpromoting_state.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/hooks/use_unpromoting_state.tsx deleted file mode 100644 index 2eeb0fc90877a..0000000000000 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/hooks/use_unpromoting_state.tsx +++ /dev/null @@ -1,95 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { StreamsRepositoryClient } from '@kbn/streams-plugin/public/api'; -import { useCallback, useState } from 'react'; -import useToggle from 'react-use/lib/useToggle'; -import { useAbortController } from '@kbn/observability-utils-browser/hooks/use_abort_controller'; -import { ToastsStart } from '@kbn/core-notifications-browser'; -import { i18n } from '@kbn/i18n'; -import { omit } from 'lodash'; -import { WiredStreamGetResponse } from '@kbn/streams-schema'; - -export type SchemaEditorUnpromotingState = ReturnType; - -export const useUnpromotingState = ({ - streamsRepositoryClient, - definition, - refreshDefinition, - refreshUnmappedFields, - toastsService, -}: { - streamsRepositoryClient: StreamsRepositoryClient; - definition: WiredStreamGetResponse; - refreshDefinition: () => void; - refreshUnmappedFields: () => void; - toastsService: ToastsStart; -}) => { - const abortController = useAbortController(); - /* Represents the currently persisted state of the selected field. This should not be edited directly. */ - const [selectedField, setSelectedField] = useState(); - /* Whether changes are being persisted */ - const [isUnpromotingField, toggleIsUnpromotingField] = useToggle(false); - /* Holds errors from saving changes */ - const [error, setError] = useState(); - - const unpromoteField = useCallback(async () => { - if (!selectedField) { - return; - } - toggleIsUnpromotingField(true); - try { - await streamsRepositoryClient.fetch(`PUT /api/streams/{id}/_ingest`, { - signal: abortController.signal, - params: { - path: { - id: definition.stream.name, - }, - body: { - ingest: { - ...definition.stream.ingest, - wired: { - fields: omit(definition.stream.ingest.wired.fields, selectedField), - }, - }, - }, - }, - }); - toggleIsUnpromotingField(false); - setSelectedField(undefined); - refreshDefinition(); - refreshUnmappedFields(); - toastsService.addSuccess( - i18n.translate('xpack.streams.streamDetailSchemaEditorUnmapSuccessToast', { - defaultMessage: '{field} was successfully unmapped', - values: { field: selectedField }, - }) - ); - } catch (e) { - toggleIsUnpromotingField(false); - setError(e); - toastsService.addError(e, { - title: i18n.translate('xpack.streams.streamDetailSchemaEditorUnmapErrorToast', { - defaultMessage: 'Something went wrong unmapping the {field} field', - values: { field: selectedField }, - }), - }); - } - }, [ - abortController.signal, - definition.stream.ingest, - definition.stream.name, - refreshDefinition, - refreshUnmappedFields, - selectedField, - streamsRepositoryClient, - toastsService, - toggleIsUnpromotingField, - ]); - - return { selectedField, setSelectedField, isUnpromotingField, unpromoteField, error }; -}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/index.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/index.tsx index 870d7268be7ed..50c88f4ae0185 100644 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/index.tsx +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/index.tsx @@ -4,22 +4,10 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useCallback, useEffect } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiProgress, EuiPortal, EuiButton } from '@elastic/eui'; -import { css } from '@emotion/css'; -import { i18n } from '@kbn/i18n'; -import { WiredStreamGetResponse } from '@kbn/streams-schema'; -import { useEditingState } from './hooks/use_editing_state'; -import { SchemaEditorFlyout } from './flyout'; -import { useKibana } from '../../hooks/use_kibana'; -import { useUnpromotingState } from './hooks/use_unpromoting_state'; -import { SimpleSearchBar } from './simple_search_bar'; -import { UnpromoteFieldModal } from './unpromote_field_modal'; -import { useStreamsAppFetch } from '../../hooks/use_streams_app_fetch'; -import { FieldsTableContainer } from './fields_table'; -import { FieldTypeFilterGroup } from './filters/type_filter_group'; -import { useQueryAndFilters } from './hooks/use_query_and_filters'; -import { FieldStatusFilterGroup } from './filters/status_filter_group'; +import React from 'react'; +import { WiredStreamGetResponse, isRootStreamDefinition } from '@kbn/streams-schema'; +import { SchemaEditor } from '../schema_editor'; +import { useSchemaFields } from '../schema_editor/hooks/use_schema_fields'; interface SchemaEditorProps { definition?: WiredStreamGetResponse; @@ -37,133 +25,23 @@ const Content = ({ refreshDefinition, isLoadingDefinition, }: Required) => { - const { - dependencies: { - start: { - streams: { streamsRepositoryClient }, - }, - }, - core: { - notifications: { toasts }, - }, - } = useKibana(); - - const queryAndFiltersState = useQueryAndFilters(); - - const { - value: unmappedFieldsValue, - loading: isLoadingUnmappedFields, - refresh: refreshUnmappedFields, - } = useStreamsAppFetch( - ({ signal }) => { - return streamsRepositoryClient.fetch('GET /api/streams/{id}/schema/unmapped_fields', { - signal, - params: { - path: { - id: definition.stream.name, - }, - }, - }); - }, - [definition.stream.name, streamsRepositoryClient] - ); - - const editingState = useEditingState({ - definition, - streamsRepositoryClient, - refreshDefinition, - refreshUnmappedFields, - toastsService: toasts, - }); - - const unpromotingState = useUnpromotingState({ - definition, - streamsRepositoryClient, - refreshDefinition, - refreshUnmappedFields, - toastsService: toasts, - }); - - const { reset } = editingState; - - // If the definition changes (e.g. navigating to parent stream), reset the entire editing state. - useEffect(() => { - reset(); - }, [definition.stream.name, reset]); - - const refreshData = useCallback(() => { - refreshDefinition(); - refreshUnmappedFields(); - }, [refreshDefinition, refreshUnmappedFields]); + const { fields, isLoadingUnmappedFields, refreshFields, unmapField, updateField } = + useSchemaFields({ + definition, + refreshDefinition, + }); return ( - - - {isLoadingDefinition || isLoadingUnmappedFields ? ( - - - - ) : null} - - - - - queryAndFiltersState.setQuery(nextQuery.query ?? undefined) - } - /> - - - - - - - - - - {i18n.translate('xpack.streams.schemaEditor.refreshDataButtonLabel', { - defaultMessage: 'Refresh', - })} - - - - - - - - - {editingState.selectedField && ( - - )} - - {unpromotingState.selectedField && ( - - )} - - + ); }; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/simple_search_bar.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/simple_search_bar.tsx deleted file mode 100644 index 93e972c4b999a..0000000000000 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/simple_search_bar.tsx +++ /dev/null @@ -1,30 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiSearchBar, EuiSearchBarProps } from '@elastic/eui'; -import React from 'react'; - -/* Simple search bar that doesn't attempt to integrate with unified search */ -export const SimpleSearchBar = ({ - query, - onChange, -}: { - query: EuiSearchBarProps['query']; - onChange: Required['onChange']; -}) => { - return ( - { - onChange(nextQuery); - }} - /> - ); -}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/unpromote_field_modal.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/unpromote_field_modal.tsx deleted file mode 100644 index 59d66b44eec44..0000000000000 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/unpromote_field_modal.tsx +++ /dev/null @@ -1,58 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import { - EuiButton, - EuiModal, - EuiModalBody, - EuiModalFooter, - EuiModalHeader, - EuiModalHeaderTitle, - useGeneratedHtmlId, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; -import { SchemaEditorUnpromotingState } from './hooks/use_unpromoting_state'; - -export const UnpromoteFieldModal = ({ - unpromotingState, -}: { - unpromotingState: SchemaEditorUnpromotingState; -}) => { - const { setSelectedField, selectedField, unpromoteField, isUnpromotingField } = unpromotingState; - - const modalTitleId = useGeneratedHtmlId(); - - if (!selectedField) return null; - - return ( - setSelectedField(undefined)}> - - {selectedField} - - - - {i18n.translate('xpack.streams.unpromoteFieldModal.unpromoteFieldWarning', { - defaultMessage: 'Are you sure you want to unmap this field from template mappings?', - })} - - - - unpromoteField()} - disabled={isUnpromotingField} - color="danger" - fill - > - {i18n.translate('xpack.streams.unpromoteFieldModal.unpromoteFieldButtonLabel', { - defaultMessage: 'Unmap field', - })} - - - - ); -}; diff --git a/x-pack/solutions/observability/plugins/streams_app/tsconfig.json b/x-pack/solutions/observability/plugins/streams_app/tsconfig.json index 8e8ab46705baa..6bfe3e0e6a4d9 100644 --- a/x-pack/solutions/observability/plugins/streams_app/tsconfig.json +++ b/x-pack/solutions/observability/plugins/streams_app/tsconfig.json @@ -31,7 +31,6 @@ "@kbn/observability-utils-server", "@kbn/ui-theme", "@kbn/calculate-auto", - "@kbn/core-notifications-browser", "@kbn/kibana-react-plugin", "@kbn/es-query", "@kbn/server-route-repository-client", @@ -45,7 +44,6 @@ "@kbn/code-editor", "@kbn/ui-theme", "@kbn/navigation-plugin", - "@kbn/core-notifications-browser", "@kbn/index-lifecycle-management-common-shared", "@kbn/streams-schema", "@kbn/react-hooks",