diff --git a/packages/kbn-field-utils/src/components/field_description/field_description.test.tsx b/packages/kbn-field-utils/src/components/field_description/field_description.test.tsx index 55474f0fb5af..c7670d16d499 100644 --- a/packages/kbn-field-utils/src/components/field_description/field_description.test.tsx +++ b/packages/kbn-field-utils/src/components/field_description/field_description.test.tsx @@ -9,17 +9,18 @@ import React from 'react'; import { FieldDescription } from './field_description'; import { render, screen } from '@testing-library/react'; +import { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public'; describe('FieldDescription', () => { it('should render correctly when no custom description', async () => { - render(); + render(); const desc = screen.queryByTestId('fieldDescription-bytes'); expect(desc).toBeNull(); }); it('should render correctly with a short custom description', async () => { const customDescription = 'test this desc'; - render(); + render(); const desc = screen.queryByTestId('fieldDescription-bytes'); expect(desc).toHaveTextContent(customDescription); const button = screen.queryByTestId('toggleFieldDescription-bytes'); @@ -28,7 +29,7 @@ describe('FieldDescription', () => { it('should render correctly with a long custom description', async () => { const customDescription = 'test this long desc '.repeat(8).trim(); - render(); + render(); expect(screen.queryByTestId('fieldDescription-bytes')).toHaveTextContent(customDescription); screen.queryByTestId('toggleFieldDescription-bytes')?.click(); expect(screen.queryByTestId('fieldDescription-bytes')).toHaveTextContent( @@ -40,9 +41,106 @@ describe('FieldDescription', () => { it('should render a long custom description without truncation', async () => { const customDescription = 'test this long desc '.repeat(8).trim(); - render(); + render( + + ); expect(screen.queryByTestId('fieldDescription-bytes')).toHaveTextContent(customDescription); const button = screen.queryByTestId('toggleFieldDescription-bytes'); expect(button).toBeNull(); }); + + it('should render correctly with markdown', async () => { + const fieldsMetadataService: Partial = { + useFieldsMetadata: jest.fn(() => ({ + fieldsMetadata: { + bytes: { description: 'ESC desc', type: 'long' }, + }, + loading: false, + error: undefined, + reload: jest.fn(), + })), + }; + const customDescription = 'test this `markdown` desc'; + render( + + ); + const desc = screen.queryByTestId('fieldDescription-bytes'); + expect(desc).toHaveTextContent('test this markdown desc'); + expect(fieldsMetadataService.useFieldsMetadata).not.toHaveBeenCalled(); + }); + + it('should fetch ECS metadata', async () => { + const fieldsMetadataService: Partial = { + useFieldsMetadata: jest.fn(() => ({ + fieldsMetadata: { + bytes: { description: 'ESC desc', type: 'long' }, + }, + loading: false, + error: undefined, + reload: jest.fn(), + })), + }; + render( + + ); + const desc = screen.queryByTestId('fieldDescription-bytes'); + expect(desc).toHaveTextContent('ESC desc'); + expect(fieldsMetadataService.useFieldsMetadata).toHaveBeenCalledWith({ + attributes: ['description', 'type'], + fieldNames: ['bytes'], + }); + }); + + it('should not show ECS metadata if types do not match', async () => { + const fieldsMetadataService: Partial = { + useFieldsMetadata: jest.fn(() => ({ + fieldsMetadata: { + bytes: { description: 'ESC desc', type: 'keyword' }, + }, + loading: false, + error: undefined, + reload: jest.fn(), + })), + }; + render( + + ); + const desc = screen.queryByTestId('fieldDescription-bytes'); + expect(desc).toBeNull(); + }); + + it('should not show ECS metadata if none found', async () => { + const fieldsMetadataService: Partial = { + useFieldsMetadata: jest.fn(() => ({ + fieldsMetadata: {}, + loading: false, + error: undefined, + reload: jest.fn(), + })), + }; + render( + + ); + const desc = screen.queryByTestId('fieldDescription-extension.keyword'); + expect(desc).toBeNull(); + expect(fieldsMetadataService.useFieldsMetadata).toHaveBeenCalledWith({ + attributes: ['description', 'type'], + fieldNames: ['extension'], + }); + }); }); diff --git a/packages/kbn-field-utils/src/components/field_description/field_description.tsx b/packages/kbn-field-utils/src/components/field_description/field_description.tsx index 5991106b806a..b17fa7e856c8 100644 --- a/packages/kbn-field-utils/src/components/field_description/field_description.tsx +++ b/packages/kbn-field-utils/src/components/field_description/field_description.tsx @@ -8,27 +8,81 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiText, EuiButtonEmpty, EuiTextBlockTruncate, useEuiTheme } from '@elastic/eui'; +import { Markdown } from '@kbn/shared-ux-markdown'; +import { + EuiText, + EuiButtonEmpty, + EuiTextBlockTruncate, + EuiSkeletonText, + useEuiTheme, +} from '@elastic/eui'; import { css } from '@emotion/react'; +import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public'; +import { esFieldTypeToKibanaFieldType } from '@kbn/field-types'; const MAX_VISIBLE_LENGTH = 110; -export interface FieldDescriptionProps { +const removeKeywordSuffix = (name: string) => { + return name.endsWith('.keyword') ? name.slice(0, -8) : name; +}; + +export interface FieldDescriptionContentProps { field: { name: string; customDescription?: string; + type: string; }; color?: 'subdued'; truncate?: boolean; + Wrapper?: React.FC<{ children: React.ReactNode }>; +} + +export interface FieldDescriptionProps extends FieldDescriptionContentProps { + fieldsMetadataService?: FieldsMetadataPublicStart; } export const FieldDescription: React.FC = ({ - field, - color, - truncate = true, + fieldsMetadataService, + ...props }) => { + if (fieldsMetadataService && !props.field.customDescription) { + return ; + } + + return ; +}; + +const EcsFieldDescriptionFallback: React.FC< + FieldDescriptionProps & { fieldsMetadataService: FieldsMetadataPublicStart } +> = ({ fieldsMetadataService, ...props }) => { + const fieldName = removeKeywordSuffix(props.field.name); + const { fieldsMetadata, loading } = fieldsMetadataService.useFieldsMetadata({ + attributes: ['description', 'type'], + fieldNames: [fieldName], + }); + + const escFieldDescription = fieldsMetadata?.[fieldName]?.description; + const escFieldType = fieldsMetadata?.[fieldName]?.type; + + return ( + + + + ); +}; + +export const FieldDescriptionContent: React.FC< + FieldDescriptionContentProps & { ecsFieldDescription?: string } +> = ({ field, color, truncate = true, ecsFieldDescription, Wrapper }) => { const { euiTheme } = useEuiTheme(); - const customDescription = (field?.customDescription || '').trim(); + const customDescription = (field?.customDescription || ecsFieldDescription || '').trim(); const isTooLong = Boolean(truncate && customDescription.length > MAX_VISIBLE_LENGTH); const [isTruncated, setIsTruncated] = useState(isTooLong); @@ -36,7 +90,7 @@ export const FieldDescription: React.FC = ({ return null; } - return ( + const result = (
{isTruncated ? ( @@ -61,13 +115,15 @@ export const FieldDescription: React.FC = ({ } `} > - {customDescription} + + {customDescription} + ) : ( <> - {customDescription} + {customDescription} {isTooLong && ( = ({ )}
); + + return Wrapper ? {result} : result; }; diff --git a/packages/kbn-field-utils/tsconfig.json b/packages/kbn-field-utils/tsconfig.json index 9ac5ba7e942b..5434723e4fd6 100644 --- a/packages/kbn-field-utils/tsconfig.json +++ b/packages/kbn-field-utils/tsconfig.json @@ -11,6 +11,8 @@ "@kbn/field-types", "@kbn/expressions-plugin", "@kbn/data-view-utils", + "@kbn/fields-metadata-plugin", + "@kbn/shared-ux-markdown", ], "exclude": ["target/**/*"] } diff --git a/packages/kbn-unified-field-list/src/components/field_popover/field_popover_header.tsx b/packages/kbn-unified-field-list/src/components/field_popover/field_popover_header.tsx index 43bebba6e660..e664a447c568 100644 --- a/packages/kbn-unified-field-list/src/components/field_popover/field_popover_header.tsx +++ b/packages/kbn-unified-field-list/src/components/field_popover/field_popover_header.tsx @@ -20,6 +20,7 @@ import { import { i18n } from '@kbn/i18n'; import { FieldDescription } from '@kbn/field-utils'; import type { DataViewField } from '@kbn/data-views-plugin/common'; +import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public'; import type { AddFieldFilterHandler } from '../../types'; export interface FieldPopoverHeaderProps { @@ -33,6 +34,9 @@ export interface FieldPopoverHeaderProps { onAddFilter?: AddFieldFilterHandler; onEditField?: (fieldName: string) => unknown; onDeleteField?: (fieldName: string) => unknown; + services?: { + fieldsMetadata?: FieldsMetadataPublicStart; + }; } export const FieldPopoverHeader: React.FC = ({ @@ -46,6 +50,7 @@ export const FieldPopoverHeader: React.FC = ({ onAddFilter, onEditField, onDeleteField, + services, }) => { if (!field) { return null; @@ -153,12 +158,20 @@ export const FieldPopoverHeader: React.FC = ({ )} - {field.customDescription ? ( - <> - - - - ) : null} + + + ); +}; + +const FieldDescriptionWrapper: React.FC = ({ children }) => { + return ( + <> + + {children} ); }; diff --git a/packages/kbn-unified-field-list/src/containers/unified_field_list_item/field_list_item.tsx b/packages/kbn-unified-field-list/src/containers/unified_field_list_item/field_list_item.tsx index d9e02d423cd9..0f74965c9687 100644 --- a/packages/kbn-unified-field-list/src/containers/unified_field_list_item/field_list_item.tsx +++ b/packages/kbn-unified-field-list/src/containers/unified_field_list_item/field_list_item.tsx @@ -10,6 +10,7 @@ import React, { memo, useCallback, useMemo, useState } from 'react'; import { EuiSpacer, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { UiCounterMetricType } from '@kbn/analytics'; +import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public'; import { Draggable } from '@kbn/dom-drag-drop'; import type { DataView, DataViewField } from '@kbn/data-views-plugin/public'; import type { SearchMode } from '../../types'; @@ -119,6 +120,7 @@ export interface UnifiedFieldListItemProps { */ services: UnifiedFieldListItemStatsProps['services'] & { uiActions?: FieldPopoverFooterProps['uiActions']; + fieldsMetadata?: FieldsMetadataPublicStart; }; /** * Current search mode @@ -367,6 +369,7 @@ function UnifiedFieldListItemComponent({ data-test-subj={stateService.creationOptions.dataTestSubj?.fieldListItemPopoverDataTestSubj} renderHeader={() => ( { const [containerRef, setContainerRef] = useState(null); - const { fieldFormats, storage, uiSettings } = getUnifiedDocViewerServices(); + const { fieldFormats, storage, uiSettings, fieldsMetadata } = getUnifiedDocViewerServices(); const showMultiFields = uiSettings.get(SHOW_MULTIFIELDS); const currentDataViewId = dataView.id!; @@ -387,9 +387,13 @@ export const DocViewerTable = ({ isPinned={pinned} /> - {isDetails && fieldMapping?.customDescription ? ( + {isDetails && !!fieldMapping ? (
- +
) : null} @@ -409,7 +413,7 @@ export const DocViewerTable = ({ return null; }, - [rows, searchText] + [rows, searchText, fieldsMetadata] ); const renderCellPopover = useCallback( diff --git a/test/functional/apps/discover/group7/_runtime_fields_editor.ts b/test/functional/apps/discover/group7/_runtime_fields_editor.ts index 3028096adc60..a7f8c677d865 100644 --- a/test/functional/apps/discover/group7/_runtime_fields_editor.ts +++ b/test/functional/apps/discover/group7/_runtime_fields_editor.ts @@ -113,6 +113,51 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dataGrid.closeFlyout(); }); + it('allows to replace ECS description with a custom field description', async function () { + await PageObjects.unifiedFieldList.clickFieldListItem('@timestamp'); + await retry.waitFor('field popover text', async () => { + return (await testSubjects.getVisibleText('fieldDescription-@timestamp')).startsWith( + 'Date' + ); + }); + await PageObjects.unifiedFieldList.closeFieldPopover(); + // check it in the doc viewer too + await dataGrid.clickRowToggle({ rowIndex: 0 }); + await dataGrid.expandFieldNameCellInFlyout('@timestamp'); + await retry.waitFor('doc viewer popover text', async () => { + return (await testSubjects.getVisibleText('fieldDescription-@timestamp')).startsWith( + 'Date' + ); + }); + await dataGrid.closeFlyout(); + + const customDescription = 'custom @timestamp description here'; + // set a custom description + await PageObjects.discover.editField('@timestamp'); + await fieldEditor.enableCustomDescription(); + await fieldEditor.setCustomDescription(customDescription); + await fieldEditor.save(); + await fieldEditor.waitUntilClosed(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.unifiedFieldList.clickFieldListItem('@timestamp'); + await retry.waitFor('field popover text', async () => { + return ( + (await testSubjects.getVisibleText('fieldDescription-@timestamp')) === customDescription + ); + }); + await PageObjects.unifiedFieldList.closeFieldPopover(); + // check it in the doc viewer too + await dataGrid.clickRowToggle({ rowIndex: 0 }); + await dataGrid.expandFieldNameCellInFlyout('@timestamp'); + await retry.waitFor('doc viewer popover text', async () => { + return ( + (await testSubjects.getVisibleText('fieldDescription-@timestamp')) === customDescription + ); + }); + + await dataGrid.closeFlyout(); + }); + it('should show a validation error when adding a too long custom description to existing fields', async function () { const customDescription = 'custom bytes long description here'.repeat(10); // set a custom description