From 738aa739b361b539eb996ad8ec0c491734b2cf3f Mon Sep 17 00:00:00 2001 From: Kerry Gallagher Date: Thu, 23 Jan 2025 10:14:12 +0000 Subject: [PATCH] [Streams] Schema Editor UX improvements (#207066) ## Summary Closes https://github.com/elastic/streams-program/issues/53 ## Overview of changes Enhanced filters for status / type. I omitted field parent (in designs) as this feels superfluous (can likely be facilitated via the inherited status). ![Screenshot 2025-01-21 at 14 39 53](https://github.com/user-attachments/assets/4ea43477-0fb7-4f29-9522-d2fabfd653a3) Children in the children affected callout are now linked ![Screenshot 2025-01-21 at 14 40 23](https://github.com/user-attachments/assets/0ce040c0-f6fc-479c-8941-25a493f1349a) ECS recommendations are given for type if available ![Screenshot 2025-01-21 at 14 40 41](https://github.com/user-attachments/assets/ab47f839-8a59-47af-898d-f7eb93de3107) For format (with date type) some popular options are now provided in a select. It's not exhaustive as there are **a lot** of format options. A toggle to switch to a freeform mode is provided. ![Screenshot 2025-01-21 at 14 41 10](https://github.com/user-attachments/assets/f89a9c14-d711-495d-a6df-54288d12592b) ![Screenshot 2025-01-21 at 14 41 20](https://github.com/user-attachments/assets/078733bd-dc19-435f-a10a-271723ab2c9f) Data Grid toolbar is added ![Screenshot 2025-01-21 at 14 41 42](https://github.com/user-attachments/assets/f234b965-9d90-452c-b0e5-8f918bc85756) Field parent link in badges is now more obvious ![Screenshot 2025-01-21 at 14 41 56](https://github.com/user-attachments/assets/001ed451-7930-48da-beba-95865b79a0bd) The only thing I haven't added from the nice to haves was the refresh button, I think we should wait to see if we actually need this (as it's technically a refresh of two entities - the definition and the unmapped fields). --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../get_mock_streams_app_context.tsx | 2 + .../plugins/streams_app/kibana.jsonc | 3 +- .../configuration_maps.ts | 69 +++++++++++++ .../field_parent.tsx | 26 ++--- .../field_status.tsx | 27 +----- .../field_type.tsx | 52 +--------- .../fields_table.tsx | 45 +++++++-- .../filters/filter_group.tsx | 75 ++++++++++++++ .../filters/status_filter_group.tsx | 52 ++++++++++ .../filters/type_filter_group.tsx | 52 ++++++++++ .../flyout/children_affected_callout.tsx | 25 +++-- .../flyout/ecs_recommendation.tsx | 46 +++++++++ .../flyout/field_form_format.tsx | 97 +++++++++++++++++-- .../flyout/field_form_type.tsx | 9 +- .../flyout/field_form_type_wrapper.tsx | 84 ++++++++++++++++ .../flyout/field_summary.tsx | 21 ++-- .../hooks/use_editing_state.tsx | 2 +- .../hooks/use_query_and_filters.tsx | 36 +++++++ .../stream_detail_schema_editor/index.tsx | 41 +++++--- .../plugins/streams_app/public/types.ts | 2 + .../plugins/streams_app/tsconfig.json | 1 + 21 files changed, 630 insertions(+), 137 deletions(-) create mode 100644 x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/configuration_maps.ts create mode 100644 x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/filters/filter_group.tsx create mode 100644 x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/filters/status_filter_group.tsx create mode 100644 x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/filters/type_filter_group.tsx create mode 100644 x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/ecs_recommendation.tsx create mode 100644 x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/field_form_type_wrapper.tsx create mode 100644 x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/hooks/use_query_and_filters.tsx diff --git a/x-pack/solutions/observability/plugins/streams_app/.storybook/get_mock_streams_app_context.tsx b/x-pack/solutions/observability/plugins/streams_app/.storybook/get_mock_streams_app_context.tsx index c684e65b567f4..6c0121decd4b9 100644 --- a/x-pack/solutions/observability/plugins/streams_app/.storybook/get_mock_streams_app_context.tsx +++ b/x-pack/solutions/observability/plugins/streams_app/.storybook/get_mock_streams_app_context.tsx @@ -14,6 +14,7 @@ import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/ import type { SharePublicStart } from '@kbn/share-plugin/public/plugin'; import { NavigationPublicStart } from '@kbn/navigation-plugin/public/types'; import type { SavedObjectTaggingPluginStart } from '@kbn/saved-objects-tagging-plugin/public'; +import { fieldsMetadataPluginPublicMock } from '@kbn/fields-metadata-plugin/public/mocks'; import type { StreamsAppKibanaContext } from '../public/hooks/use_kibana'; export function getMockStreamsAppContext(): StreamsAppKibanaContext { @@ -33,6 +34,7 @@ export function getMockStreamsAppContext(): StreamsAppKibanaContext { share: {} as unknown as SharePublicStart, navigation: {} as unknown as NavigationPublicStart, savedObjectsTagging: {} as unknown as SavedObjectTaggingPluginStart, + fieldsMetadata: fieldsMetadataPluginPublicMock.createStartContract(), }, }, services: { diff --git a/x-pack/solutions/observability/plugins/streams_app/kibana.jsonc b/x-pack/solutions/observability/plugins/streams_app/kibana.jsonc index dd1026e3aaf32..ebc3c57c63abc 100644 --- a/x-pack/solutions/observability/plugins/streams_app/kibana.jsonc +++ b/x-pack/solutions/observability/plugins/streams_app/kibana.jsonc @@ -17,7 +17,8 @@ "unifiedSearch", "share", "savedObjectsTagging", - "navigation" + "navigation", + "fieldsMetadata", ], "requiredBundles": [ "kibanaReact" 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/stream_detail_schema_editor/configuration_maps.ts new file mode 100644 index 0000000000000..a2aa1618b1ce3 --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/configuration_maps.ts @@ -0,0 +1,69 @@ +/* + * 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'; + +export const FIELD_TYPE_MAP = { + boolean: { + label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableBooleanType', { + defaultMessage: 'Boolean', + }), + }, + date: { + label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableDateType', { + defaultMessage: 'Date', + }), + }, + keyword: { + label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableKeywordType', { + defaultMessage: 'Keyword', + }), + }, + match_only_text: { + label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableTextType', { + defaultMessage: 'Text', + }), + }, + long: { + label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableNumberType', { + defaultMessage: 'Number (long)', + }), + }, + double: { + label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableNumberType', { + defaultMessage: 'Number (double)', + }), + }, + ip: { + label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableIpType', { + defaultMessage: 'IP', + }), + }, +}; + +export const FIELD_STATUS_MAP = { + inherited: { + color: 'hollow', + label: i18n.translate('xpack.streams.streamDetailSchemaEditorInheritedStatusLabel', { + defaultMessage: 'Inherited', + }), + }, + mapped: { + color: 'success', + label: i18n.translate('xpack.streams.streamDetailSchemaEditorMappedStatusLabel', { + defaultMessage: 'Mapped', + }), + }, + unmapped: { + color: 'default', + label: i18n.translate('xpack.streams.streamDetailSchemaEditorUnmappedStatusLabel', { + defaultMessage: 'Unmapped', + }), + }, +}; + +export type FieldStatus = keyof typeof FIELD_STATUS_MAP; 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/stream_detail_schema_editor/field_parent.tsx index 5f8b6f4af0ffe..07ceeb09feea1 100644 --- 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/stream_detail_schema_editor/field_parent.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiBadge } from '@elastic/eui'; +import { EuiBadge, EuiLink } from '@elastic/eui'; import React from 'react'; import { useStreamsAppRouter } from '../../hooks/use_streams_app_router'; @@ -18,17 +18,19 @@ export const FieldParent = ({ }) => { const router = useStreamsAppRouter(); return linkEnabled ? ( - - {parent} + + + {parent} + ) : ( {parent} 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/stream_detail_schema_editor/field_status.tsx index dda456a9f49f7..827ca3a03ff28 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/stream_detail_schema_editor/field_status.tsx @@ -6,33 +6,10 @@ */ import { EuiBadge } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import React from 'react'; +import { FIELD_STATUS_MAP, FieldStatus } from './configuration_maps'; -export type FieldStatus = 'inherited' | 'mapped' | 'unmapped'; - -export const FIELD_STATUS_MAP = { - inherited: { - color: 'hollow', - label: i18n.translate('xpack.streams.streamDetailSchemaEditorInheritedStatusLabel', { - defaultMessage: 'Inherited', - }), - }, - mapped: { - color: 'success', - label: i18n.translate('xpack.streams.streamDetailSchemaEditorMappedStatusLabel', { - defaultMessage: 'Mapped', - }), - }, - unmapped: { - color: 'default', - label: i18n.translate('xpack.streams.streamDetailSchemaEditorUnmappedStatusLabel', { - defaultMessage: 'Unmapped', - }), - }, -}; - -export const FieldStatus = ({ status }: { status: FieldStatus }) => { +export const FieldStatusBadge = ({ status }: { status: FieldStatus }) => { return ( <> {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/stream_detail_schema_editor/field_type.tsx index 14203f0b5d998..0a57a9ac65732 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/stream_detail_schema_editor/field_type.tsx @@ -5,61 +5,17 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiToken } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React from 'react'; import { FieldDefinitionConfig } from '@kbn/streams-schema'; - -export const FIELD_TYPE_MAP = { - boolean: { - icon: 'tokenBoolean', - label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableBooleanType', { - defaultMessage: 'Boolean', - }), - }, - date: { - icon: 'tokenDate', - label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableDateType', { - defaultMessage: 'Date', - }), - }, - keyword: { - icon: 'tokenKeyword', - label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableKeywordType', { - defaultMessage: 'Keyword', - }), - }, - match_only_text: { - icon: 'tokenText', - label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableTextType', { - defaultMessage: 'Text', - }), - }, - long: { - icon: 'tokenNumber', - label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableNumberType', { - defaultMessage: 'Number', - }), - }, - double: { - icon: 'tokenNumber', - label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableNumberType', { - defaultMessage: 'Number', - }), - }, - ip: { - icon: 'tokenIP', - label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableIpType', { - defaultMessage: 'IP', - }), - }, -}; +import { FieldIcon } from '@kbn/react-field'; +import { FIELD_TYPE_MAP } from './configuration_maps'; export const FieldType = ({ type }: { type: FieldDefinitionConfig['type'] }) => { return ( - + {`${FIELD_TYPE_MAP[type].label}`} 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 index 7175d8863d6d9..4243ac8c4a9c8 100644 --- 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 @@ -17,6 +17,7 @@ import { import type { EuiContextMenuPanelDescriptor, EuiContextMenuPanelItemDescriptor, + EuiDataGridColumnSortingConfig, EuiDataGridProps, Query, } from '@elastic/eui'; @@ -28,10 +29,11 @@ import { ReadStreamDefinition, } from '@kbn/streams-schema'; import { FieldType } from './field_type'; -import { FieldStatus } from './field_status'; +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: ReadStreamDefinition; @@ -40,6 +42,7 @@ interface FieldsTableContainerProps { query?: Query; editingState: SchemaEditorEditingState; unpromotingState: SchemaEditorUnpromotingState; + queryAndFiltersState: SchemaEditorQueryAndFiltersState; } const COLUMNS = { @@ -78,6 +81,7 @@ export const FieldsTableContainer = ({ query, editingState, unpromotingState, + queryAndFiltersState, }: FieldsTableContainerProps) => { const inheritedFields = useMemo(() => { return Object.entries(definition.inherited_fields).map(([name, field]) => ({ @@ -138,9 +142,28 @@ export const FieldsTableContainer = ({ 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 ( { + // Column visibility const [visibleColumns, setVisibleColumns] = useState(Object.keys(COLUMNS)); + // Column sorting + const [sortingColumns, setSortingColumns] = useState([]); + const trailingColumns = useMemo(() => { return !isRootStreamDefinition(definition.stream) ? ([ @@ -168,6 +195,8 @@ const FieldsTable = ({ definition, fields, editingState, unpromotingState }: Fie rowCellRender: ({ rowIndex }) => { const field = fields[rowIndex]; + if (!field) return null; + let actions: ActionsCellActionsDescriptor[] = []; switch (field.status) { @@ -275,19 +304,22 @@ const FieldsTable = ({ definition, fields, editingState, unpromotingState }: Fie defaultMessage: 'Preview', } )} - columns={visibleColumns.map((columnId) => ({ + columns={Object.entries(COLUMNS).map(([columnId, value]) => ({ id: columnId, - ...COLUMNS[columnId as keyof typeof COLUMNS], + ...value, }))} columnVisibility={{ visibleColumns, setVisibleColumns, canDragAndDropColumns: false, }} - toolbarVisibility={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; @@ -297,7 +329,7 @@ const FieldsTable = ({ definition, fields, editingState, unpromotingState }: Fie ); } else if (columnId === 'status') { - return ; + return ; } else { return field[columnId as keyof FieldEntry] || EMPTY_CONTENT; } @@ -308,6 +340,7 @@ const FieldsTable = ({ definition, fields, editingState, unpromotingState }: Fie rowHover: 'none', header: 'underline', }} + inMemory={{ level: 'sorting' }} /> ); }; 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/stream_detail_schema_editor/filters/filter_group.tsx new file mode 100644 index 0000000000000..8e5761763aad7 --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/filters/filter_group.tsx @@ -0,0 +1,75 @@ +/* + * 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 { + EuiFilterButton, + EuiFilterGroup, + EuiPopover, + EuiSelectable, + EuiSelectableOption, + EuiSelectableProps, + useGeneratedHtmlId, +} from '@elastic/eui'; +import React from 'react'; +import useToggle from 'react-use/lib/useToggle'; + +export const FilterGroup = ({ + filterGroupButtonLabel, + items, + onChange, +}: { + filterGroupButtonLabel: string; + items: EuiSelectableOption[]; + onChange: Required['onChange']; +}) => { + const [isPopoverOpen, togglePopover] = useToggle(false); + + const filterGroupPopoverId = useGeneratedHtmlId({ + prefix: 'filterGroupPopover', + }); + + const button = ( + item.checked === 'on')} + numActiveFilters={items.filter((item) => item.checked === 'on').length} + > + {filterGroupButtonLabel} + + ); + + return ( + + togglePopover(false)} + panelPaddingSize="none" + > + onChange(...args)} + > + {(list) => ( +
+ {list} +
+ )} +
+
+
+ ); +}; diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/filters/status_filter_group.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/filters/status_filter_group.tsx new file mode 100644 index 0000000000000..bf350a5816f7a --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/filters/status_filter_group.tsx @@ -0,0 +1,52 @@ +/* + * 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 { useCallback, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { EuiSelectableProps } from '@elastic/eui'; +import { FIELD_STATUS_MAP } from '../configuration_maps'; +import { FilterGroup } from './filter_group'; +import { ChangeFilterGroups } from '../hooks/use_query_and_filters'; + +const BUTTON_LABEL = i18n.translate( + 'xpack.streams.streamDetailSchemaEditor.fieldStatusFilterGroupButtonLabel', + { + defaultMessage: 'Status', + } +); + +export const FieldStatusFilterGroup = ({ + onChangeFilterGroup, +}: { + onChangeFilterGroup: ChangeFilterGroups; +}) => { + const [items, setItems] = useState>(() => + Object.entries(FIELD_STATUS_MAP).map(([key, value]) => { + return { + label: value.label, + key, + }; + }) + ); + + const onChangeItems = useCallback['onChange']>( + (nextItems) => { + setItems(nextItems); + onChangeFilterGroup({ + status: nextItems + .filter((nextItem) => nextItem.checked === 'on') + .map((item) => item.key as string), + }); + }, + [onChangeFilterGroup] + ); + + 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/stream_detail_schema_editor/filters/type_filter_group.tsx new file mode 100644 index 0000000000000..13f0657d0b133 --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/filters/type_filter_group.tsx @@ -0,0 +1,52 @@ +/* + * 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 { 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 { FilterGroup } from './filter_group'; +import { ChangeFilterGroups } from '../hooks/use_query_and_filters'; + +const BUTTON_LABEL = i18n.translate( + 'xpack.streams.streamDetailSchemaEditor.fieldTypeFilterGroupButtonLabel', + { + defaultMessage: 'Type', + } +); + +export const FieldTypeFilterGroup = ({ + onChangeFilterGroup, +}: { + onChangeFilterGroup: ChangeFilterGroups; +}) => { + const [items, setItems] = useState>(() => + Object.entries(FIELD_TYPE_MAP).map(([key, value]) => { + return { + label: value.label, + key, + }; + }) + ); + + const onChangeItems = useCallback['onChange']>( + (nextItems) => { + setItems(nextItems); + onChangeFilterGroup({ + type: nextItems + .filter((nextItem) => nextItem.checked === 'on') + .map((item) => item.key as string), + }); + }, + [onChangeFilterGroup] + ); + + 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/stream_detail_schema_editor/flyout/children_affected_callout.tsx index 838983ea2c4c6..c8a75725a3098 100644 --- 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/stream_detail_schema_editor/flyout/children_affected_callout.tsx @@ -6,15 +6,25 @@ */ import React from 'react'; -import { EuiCallOut } from '@elastic/eui'; +import { EuiCallOut, EuiLink } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; import { RoutingDefinition } from '@kbn/streams-schema'; +import { useStreamsAppRouter } from '../../../hooks/use_streams_app_router'; export const ChildrenAffectedCallout = ({ childStreams, }: { childStreams: RoutingDefinition[]; }) => { + const router = useStreamsAppRouter(); + const childStreamLinks = childStreams.map((stream) => { + return ( + + {stream.destination} + + ); + }); return ( - {i18n.translate('xpack.streams.childStreamsWarning.text', { - defaultMessage: "Editing this field will affect it's dependant streams: {affectedStreams} ", - values: { - affectedStreams: childStreams.map((stream) => stream.destination).join(', '), - }, - })} + [i > 0 && ', ', link]), + }} + /> ); }; 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/stream_detail_schema_editor/flyout/ecs_recommendation.tsx new file mode 100644 index 0000000000000..d8f7c977a4b37 --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/ecs_recommendation.tsx @@ -0,0 +1,46 @@ +/* + * 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 { EuiText } from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; + +const EcsRecommendationText = i18n.translate( + 'xpack.streams.streamDetailSchemaEditor.ecsRecommendationText', + { + defaultMessage: 'ECS recommendation', + } +); + +const UknownEcsFieldText = i18n.translate( + 'xpack.streams.streamDetailSchemaEditor.uknownEcsFieldText', + { + defaultMessage: 'Not an ECS field', + } +); + +const LoadingText = i18n.translate( + 'xpack.streams.streamDetailSchemaEditor.ecsRecommendationLoadingText', + { + defaultMessage: 'Loading...', + } +); + +export const EcsRecommendation = ({ + recommendation, + isLoading, +}: { + recommendation?: string; + isLoading: boolean; +}) => { + return ( + + {`${EcsRecommendationText}: `} + {isLoading ? LoadingText : recommendation !== undefined ? recommendation : UknownEcsFieldText} + + ); +}; 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/stream_detail_schema_editor/flyout/field_form_format.tsx index 9b8ba2bdbe6db..9b69a68034170 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/stream_detail_schema_editor/flyout/field_form_format.tsx @@ -5,9 +5,18 @@ * 2.0. */ -import { EuiFieldText } from '@elastic/eui'; -import React from 'react'; +import { + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiSelect, + EuiSwitch, + EuiSwitchEvent, +} from '@elastic/eui'; +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'; type FieldFormFormatProps = Pick< @@ -15,25 +24,93 @@ type FieldFormFormatProps = Pick< 'nextFieldType' | 'nextFieldFormat' | 'setNextFieldFormat' >; +const DEFAULT_FORMAT = 'strict_date_optional_time||epoch_millis'; + +const POPULAR_FORMATS = [ + DEFAULT_FORMAT, + 'strict_date_optional_time', + 'date_optional_time', + 'epoch_millis', + 'basic_date_time', +] as const; + +type PopularFormatOption = (typeof POPULAR_FORMATS)[number]; + export const typeSupportsFormat = (type?: FieldDefinitionConfig['type']) => { if (!type) return false; return ['date'].includes(type); }; -export const FieldFormFormat = ({ - nextFieldType: fieldType, - nextFieldFormat: value, - setNextFieldFormat: onChange, -}: FieldFormFormatProps) => { - if (!typeSupportsFormat(fieldType)) { +export const FieldFormFormat = (props: FieldFormFormatProps) => { + if (!typeSupportsFormat(props.nextFieldType)) { return null; } + return ; +}; + +const FieldFormFormatSelection = (props: FieldFormFormatProps) => { + const [isFreeform, toggleIsFreeform] = useToggle( + props.nextFieldFormat !== undefined && !isPopularFormat(props.nextFieldFormat) + ); + + const onToggle = useCallback( + (e: EuiSwitchEvent) => { + if (!e.target.checked && !isPopularFormat(props.nextFieldFormat)) { + props.setNextFieldFormat(undefined); + } + toggleIsFreeform(); + }, + [props, toggleIsFreeform] + ); + + return ( + + + {isFreeform ? : } + + + + + + ); +}; + +const PopularFormatsSelector = (props: FieldFormFormatProps) => { + return ( + { + props.setNextFieldFormat(event.target.value as PopularFormatOption); + }} + value={props.nextFieldFormat} + options={POPULAR_FORMATS.map((format) => ({ + text: format, + value: format, + }))} + /> + ); +}; + +const FreeformFormatInput = (props: FieldFormFormatProps) => { return ( onChange(e.target.value)} + value={props.nextFieldFormat ?? ''} + onChange={(e) => props.setNextFieldFormat(e.target.value)} /> ); }; + +const isPopularFormat = (value?: string): value is PopularFormatOption => { + return POPULAR_FORMATS.includes(value as PopularFormatOption); +}; 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 index c4e601e306f1d..ce03d709ce2b5 100644 --- 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 @@ -9,7 +9,10 @@ import { EuiSelect } from '@elastic/eui'; import React from 'react'; import { SchemaEditorEditingState } from '../hooks/use_editing_state'; -type FieldFormTypeProps = Pick; +type FieldFormTypeProps = Pick & { + isLoadingRecommendation: boolean; + recommendation?: string; +}; const TYPE_OPTIONS = { long: 'Long', @@ -26,9 +29,13 @@ type FieldTypeOption = keyof typeof TYPE_OPTIONS; export const FieldFormType = ({ nextFieldType: value, setNextFieldType: onChange, + isLoadingRecommendation, + recommendation, }: FieldFormTypeProps) => { return ( { 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 new file mode 100644 index 0000000000000..671e6625112c8 --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/flyout/field_form_type_wrapper.tsx @@ -0,0 +1,84 @@ +/* + * 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 index 796e7531258d3..55a1f67fc308b 100644 --- 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 @@ -18,11 +18,10 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { useStreamsAppRouter } from '../../../hooks/use_streams_app_router'; import { FieldParent } from '../field_parent'; -import { FieldStatus } from '../field_status'; +import { FieldStatusBadge } from '../field_status'; import { FieldFormFormat, typeSupportsFormat } from './field_form_format'; -import { FieldFormType } from './field_form_type'; import { SchemaEditorFlyoutProps } from '.'; -import { FieldType } from '../field_type'; +import { FieldFormTypeWrapper } from './field_form_type_wrapper'; const EMPTY_CONTENT = '-----'; @@ -144,7 +143,7 @@ export const FieldSummary = (props: SchemaEditorFlyoutProps) => { - + @@ -159,13 +158,13 @@ export const FieldSummary = (props: SchemaEditorFlyoutProps) => { - {isEditing ? ( - - ) : selectedField.type ? ( - - ) : ( - `${EMPTY_CONTENT}` - )} + 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 index 93ab16a976f07..52f626ff58da8 100644 --- 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 @@ -17,7 +17,7 @@ import { useAbortController } from '@kbn/observability-utils-browser/hooks/use_a import { ToastsStart } from '@kbn/core-notifications-browser'; import { i18n } from '@kbn/i18n'; import { omit } from 'lodash'; -import { FieldStatus } from '../field_status'; +import { FieldStatus } from '../configuration_maps'; export type SchemaEditorEditingState = ReturnType; 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 new file mode 100644 index 0000000000000..96bd1b417244a --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/hooks/use_query_and_filters.tsx @@ -0,0 +1,36 @@ +/* + * 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/index.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_schema_editor/index.tsx index 1af840d2c4110..68257b00348be 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,15 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useEffect, useState } from 'react'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiProgress, - EuiSearchBar, - EuiPortal, - Query, -} from '@elastic/eui'; +import React, { useEffect } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiProgress, EuiPortal } from '@elastic/eui'; import { css } from '@emotion/css'; import { WiredReadStreamDefinition } from '@kbn/streams-schema'; import { useEditingState } from './hooks/use_editing_state'; @@ -23,6 +16,9 @@ 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'; interface SchemaEditorProps { definition?: WiredReadStreamDefinition; @@ -51,7 +47,7 @@ const Content = ({ }, } = useKibana(); - const [query, setQuery] = useState(EuiSearchBar.Query.MATCH_ALL); + const queryAndFiltersState = useQueryAndFilters(); const { value: unmappedFieldsValue, @@ -103,10 +99,24 @@ const Content = ({ ) : null} - setQuery(nextQuery.query ?? undefined)} - /> + + + + queryAndFiltersState.setQuery(nextQuery.query ?? undefined) + } + /> + + + + + + + + diff --git a/x-pack/solutions/observability/plugins/streams_app/public/types.ts b/x-pack/solutions/observability/plugins/streams_app/public/types.ts index 8896a7aedfb4d..0dae95969722b 100644 --- a/x-pack/solutions/observability/plugins/streams_app/public/types.ts +++ b/x-pack/solutions/observability/plugins/streams_app/public/types.ts @@ -18,6 +18,7 @@ import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/ import type { SharePublicSetup, SharePublicStart } from '@kbn/share-plugin/public/plugin'; import type { SavedObjectTaggingPluginStart } from '@kbn/saved-objects-tagging-plugin/public'; import { NavigationPublicStart } from '@kbn/navigation-plugin/public/types'; +import { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public'; /* eslint-disable @typescript-eslint/no-empty-interface*/ export interface ConfigSchema {} @@ -40,6 +41,7 @@ export interface StreamsAppStartDependencies { share: SharePublicStart; savedObjectsTagging: SavedObjectTaggingPluginStart; navigation: NavigationPublicStart; + fieldsMetadata: FieldsMetadataPublicStart; } export interface StreamsAppPublicSetup {} diff --git a/x-pack/solutions/observability/plugins/streams_app/tsconfig.json b/x-pack/solutions/observability/plugins/streams_app/tsconfig.json index 1d187a12a3164..5b21ca3789374 100644 --- a/x-pack/solutions/observability/plugins/streams_app/tsconfig.json +++ b/x-pack/solutions/observability/plugins/streams_app/tsconfig.json @@ -57,6 +57,7 @@ "@kbn/deeplinks-analytics", "@kbn/dashboard-plugin", "@kbn/react-kibana-mount", + "@kbn/fields-metadata-plugin", "@kbn/zod" ] }