[DataViewField] Allow to add a custom description for data view fields (#168577)

- Closes https://github.com/elastic/kibana/issues/89726

## Summary

This PR extends Data View Field flyout with a new textarea to enter and
save a custom field description. This description will be shown in a
field popover for Discover sidebar, in Doc Viewer and also on Data View
management page. Current limit for the custom description is 300.

When creating/editing a field:
<img width="600" alt="Screenshot 2024-03-07 at 18 59 24"
src="433e66d1-9366-4906-8aea-33b77ae81c16">

In the field popover(truncated):
<img width="500" alt="Screenshot 2024-03-07 at 18 56 52"
src="8753a11d-6b27-40c1-adaa-de35addb50df">

In the field popover(expanded):
<img width="500" alt="Screenshot 2024-03-07 at 18 57 00"
src="b593169e-305e-4d4b-853a-4937324e2470">

In Doc Viewer popover(always expanded):
<img width="500" alt="Screenshot 2024-03-07 at 18 57 21"
src="106562a2-baad-4952-a9cc-fa779f96c1e1">

On Data View Management page(truncated):
<img width="500" alt="Screenshot 2024-03-07 at 18 57 42"
src="031ed482-5c84-484f-ae9e-6b1e7622c17c">



<details>
<summary>Initial implementation examples</summary>

 
![Oct-11-2023
11-45-45](533ddd34-a1bd-4553-8fc9-7b9d006f0837)


<img width="600" alt="Screenshot 2023-10-11 at 11 52 22"
src="8e40da6f-fcfc-4e36-9314-d3fc34e6ecab">

<img width="600" alt="Screenshot 2023-10-11 at 11 46 44"
src="d5ee22a7-0314-4742-b75f-6534e1b4024d">

<img width="600" alt="Screenshot 2023-10-11 at 11 46 29"
src="dcd809df-7942-4165-8b83-4a83267cea00">

<img width="600" alt="Screenshot 2023-10-11 at 11 47 15"
src="197add4d-b185-4631-a2b9-eaf013aad8ba">

<img width="600" alt="Screenshot 2023-10-11 at 12 05 29"
src="9b619e20-c3d1-4c20-ac65-8b922ad1da72">

</details>

### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Matthew Kime <matt@mattki.me>
Co-authored-by: amyjtechwriter <61687663+amyjtechwriter@users.noreply.github.com>
This commit is contained in:
Julia Rechkunova 2024-03-19 17:30:59 +01:00 committed by GitHub
parent 64ea83912a
commit fc2c389cce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 1003 additions and 176 deletions

View file

@ -24,3 +24,8 @@ export {
} from './src/utils/field_name_wildcard_matcher';
export { FieldIcon, type FieldIconProps, getFieldIconProps } from './src/components/field_icon';
export { FieldDescription, type FieldDescriptionProps } from './src/components/field_description';
export {
FieldDescriptionIconButton,
type FieldDescriptionIconButtonProps,
} from './src/components/field_description_icon_button';

View file

@ -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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { FieldDescription } from './field_description';
import { render, screen } from '@testing-library/react';
describe('FieldDescription', () => {
it('should render correctly when no custom description', async () => {
render(<FieldDescription field={{ name: 'bytes' }} />);
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(<FieldDescription field={{ name: 'bytes', customDescription }} />);
const desc = screen.queryByTestId('fieldDescription-bytes');
expect(desc).toHaveTextContent(customDescription);
const button = screen.queryByTestId('toggleFieldDescription-bytes');
expect(button).toBeNull();
});
it('should render correctly with a long custom description', async () => {
const customDescription = 'test this long desc '.repeat(8).trim();
render(<FieldDescription field={{ name: 'bytes', customDescription }} />);
expect(screen.queryByTestId('fieldDescription-bytes')).toHaveTextContent(customDescription);
screen.queryByTestId('toggleFieldDescription-bytes')?.click();
expect(screen.queryByTestId('fieldDescription-bytes')).toHaveTextContent(
`${customDescription}View less`
);
screen.queryByTestId('toggleFieldDescription-bytes')?.click();
expect(screen.queryByTestId('fieldDescription-bytes')).toHaveTextContent(customDescription);
});
it('should render a long custom description without truncation', async () => {
const customDescription = 'test this long desc '.repeat(8).trim();
render(<FieldDescription field={{ name: 'bytes', customDescription }} truncate={false} />);
expect(screen.queryByTestId('fieldDescription-bytes')).toHaveTextContent(customDescription);
const button = screen.queryByTestId('toggleFieldDescription-bytes');
expect(button).toBeNull();
});
});

View file

@ -0,0 +1,88 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiText, EuiButtonEmpty, EuiTextBlockTruncate, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
const MAX_VISIBLE_LENGTH = 110;
export interface FieldDescriptionProps {
field: {
name: string;
customDescription?: string;
};
color?: 'subdued';
truncate?: boolean;
}
export const FieldDescription: React.FC<FieldDescriptionProps> = ({
field,
color,
truncate = true,
}) => {
const { euiTheme } = useEuiTheme();
const customDescription = (field?.customDescription || '').trim();
const isTooLong = Boolean(truncate && customDescription.length > MAX_VISIBLE_LENGTH);
const [isTruncated, setIsTruncated] = useState<boolean>(isTooLong);
if (!customDescription) {
return null;
}
return (
<div data-test-subj={`fieldDescription-${field.name}`}>
{isTruncated ? (
<EuiText color={color} size="xs" className="eui-textBreakWord eui-textLeft">
<button
data-test-subj={`toggleFieldDescription-${field.name}`}
title={i18n.translate('fieldUtils.fieldDescription.viewMoreButton', {
defaultMessage: 'View full field description',
})}
className="eui-textBreakWord eui-textLeft"
onClick={() => setIsTruncated(false)}
css={css`
padding: 0;
margin: 0;
color: ${color === 'subdued' ? euiTheme.colors.subduedText : euiTheme.colors.text};
line-height: inherit;
font-size: inherit;
&:hover,
&:active,
&:focus {
color: ${euiTheme.colors.link};
}
`}
>
<EuiTextBlockTruncate lines={2}>{customDescription}</EuiTextBlockTruncate>
</button>
</EuiText>
) : (
<>
<EuiText color={color} size="xs" className="eui-textBreakWord eui-textLeft">
{customDescription}
</EuiText>
{isTooLong && (
<EuiButtonEmpty
size="xs"
flush="both"
data-test-subj={`toggleFieldDescription-${field.name}`}
onClick={() => setIsTruncated(true)}
>
{i18n.translate('fieldUtils.fieldDescription.viewLessButton', {
defaultMessage: 'View less',
})}
</EuiButtonEmpty>
)}
</>
)}
</div>
);
};

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { FieldDescription, type FieldDescriptionProps } from './field_description';

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { FieldDescriptionIconButton } from './field_description_icon_button';
import { render, screen } from '@testing-library/react';
describe('FieldDescriptionIconButton', () => {
it('should render correctly when no custom description', async () => {
const { container } = render(<FieldDescriptionIconButton field={{ name: 'bytes' }} />);
expect(container).toBeEmptyDOMElement();
});
it('should render correctly with a short custom description', async () => {
const customDescription = 'test this desc';
render(<FieldDescriptionIconButton field={{ name: 'bytes', customDescription }} />);
expect(screen.queryByTestId('fieldDescription-bytes')).toBeNull();
screen.queryByTestId('fieldDescriptionPopoverButton-bytes')?.click();
expect(screen.queryByTestId('fieldDescription-bytes')).toHaveTextContent(customDescription);
});
it('should render correctly with a long custom description', async () => {
const customDescription = 'test this long desc '.repeat(8).trim();
render(<FieldDescriptionIconButton field={{ name: 'bytes', customDescription }} />);
expect(screen.queryByTestId('fieldDescription-bytes')).toBeNull();
screen.queryByTestId('fieldDescriptionPopoverButton-bytes')?.click();
expect(screen.queryByTestId('fieldDescription-bytes')).toHaveTextContent(customDescription);
});
});

View file

@ -0,0 +1,60 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import { EuiButtonIcon, EuiPopover, EuiPopoverProps, useEuiTheme } from '@elastic/eui';
import { FieldDescription, FieldDescriptionProps } from '../field_description';
export type FieldDescriptionIconButtonProps = Pick<EuiPopoverProps, 'css'> & {
field: FieldDescriptionProps['field'];
};
export const FieldDescriptionIconButton: React.FC<FieldDescriptionIconButtonProps> = ({
field,
...otherProps
}) => {
const { euiTheme } = useEuiTheme();
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
if (!field?.customDescription) {
return null;
}
const buttonTitle = i18n.translate('fieldUtils.fieldDescriptionIconButtonTitle', {
defaultMessage: 'View field description',
});
return (
<span>
<EuiPopover
{...otherProps}
isOpen={isPopoverOpen}
closePopover={() => setIsPopoverOpen(false)}
panelProps={{
css: css`
max-width: ${euiTheme.base * 20}px;
`,
}}
button={
<EuiButtonIcon
iconType="iInCircle"
title={buttonTitle}
aria-label={buttonTitle}
size="xs"
data-test-subj={`fieldDescriptionPopoverButton-${field.name}`}
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
/>
}
>
<FieldDescription field={field} truncate={false} />
</EuiPopover>
</span>
);
};

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export {
FieldDescriptionIconButton,
type FieldDescriptionIconButtonProps,
} from './field_description_icon_button';

View file

@ -17,6 +17,7 @@ export interface FieldBase {
name: DataViewField['name'];
type?: DataViewField['type'];
displayName?: DataViewField['displayName'];
customDescription?: DataViewField['customDescription'];
count?: DataViewField['count'];
timeSeriesMetric?: DataViewField['timeSeriesMetric'];
esTypes?: DataViewField['esTypes'];

View file

@ -33,7 +33,7 @@ pageLoadAssetSize:
dataViewEditor: 28082
dataViewFieldEditor: 27000
dataViewManagement: 5176
dataViews: 51000
dataViews: 55000
dataVisualizer: 27530
devTools: 38637
discover: 99999

View file

@ -1,5 +1,63 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FieldName renders a custom description icon 1`] = `
Array [
<div
class="euiFlexItem kbnDocViewer__fieldIcon emotion-euiFlexItem-growZero"
>
<span
class="euiToken kbnFieldIcon emotion-euiToken-square-light-s-euiColorVis1"
>
<span
data-euiicon-type="tokenString"
title="String"
>
String
</span>
</span>
</div>,
<div
class="euiFlexGroup emotion-euiFlexGroup-none-flexStart-flexStart-row"
>
<div
class="euiFlexItem kbnDocViewer__fieldName eui-textBreakAll emotion-euiFlexItem-growZero"
>
<span
class="euiToolTipAnchor eui-textBreakAll emotion-euiToolTipAnchor-inlineBlock"
>
<span>
test
</span>
</span>
</div>
<div
class="euiFlexItem emotion-euiFlexItem-growZero"
>
<span>
<div
class="euiPopover emotion-euiPopover-inline-block"
>
<button
aria-label="View field description"
class="euiButtonIcon emotion-euiButtonIcon-xs-empty-primary"
data-test-subj="fieldDescriptionPopoverButton-bytes"
title="View field description"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="iInCircle"
/>
</button>
</div>
</span>
</div>
</div>,
]
`;
exports[`FieldName renders a geo field 1`] = `
Array [
<div
@ -17,7 +75,7 @@ Array [
</span>
</div>,
<div
class="euiFlexGroup emotion-euiFlexGroup-wrap-none-flexStart-flexStart-row"
class="euiFlexGroup emotion-euiFlexGroup-none-flexStart-flexStart-row"
>
<div
class="euiFlexItem kbnDocViewer__fieldName eui-textBreakAll emotion-euiFlexItem-growZero"
@ -51,7 +109,7 @@ Array [
</span>
</div>,
<div
class="euiFlexGroup emotion-euiFlexGroup-wrap-none-flexStart-flexStart-row"
class="euiFlexGroup emotion-euiFlexGroup-none-flexStart-flexStart-row"
>
<div
class="euiFlexItem kbnDocViewer__fieldName eui-textBreakAll emotion-euiFlexItem-growZero"
@ -85,7 +143,7 @@ Array [
</span>
</div>,
<div
class="euiFlexGroup emotion-euiFlexGroup-wrap-none-flexStart-flexStart-row"
class="euiFlexGroup emotion-euiFlexGroup-none-flexStart-flexStart-row"
>
<div
class="euiFlexItem kbnDocViewer__fieldName eui-textBreakAll emotion-euiFlexItem-growZero"
@ -119,7 +177,7 @@ Array [
</span>
</div>,
<div
class="euiFlexGroup emotion-euiFlexGroup-wrap-none-flexStart-flexStart-row"
class="euiFlexGroup emotion-euiFlexGroup-none-flexStart-flexStart-row"
>
<div
class="euiFlexItem kbnDocViewer__fieldName eui-textBreakAll emotion-euiFlexItem-growZero"
@ -153,7 +211,7 @@ Array [
</span>
</div>,
<div
class="euiFlexGroup emotion-euiFlexGroup-wrap-none-flexStart-flexStart-row"
class="euiFlexGroup emotion-euiFlexGroup-none-flexStart-flexStart-row"
>
<div
class="euiFlexItem kbnDocViewer__fieldName eui-textBreakAll emotion-euiFlexItem-growZero"
@ -187,7 +245,7 @@ Array [
</span>
</div>,
<div
class="euiFlexGroup emotion-euiFlexGroup-wrap-none-flexStart-flexStart-row"
class="euiFlexGroup emotion-euiFlexGroup-none-flexStart-flexStart-row"
>
<div
class="euiFlexItem kbnDocViewer__fieldName eui-textBreakAll emotion-euiFlexItem-growZero"

View file

@ -8,6 +8,7 @@
import React from 'react';
import { render } from 'enzyme';
import type { DataViewField } from '@kbn/data-views-plugin/common';
import { stubLogstashDataView as dataView } from '@kbn/data-plugin/common/stubs';
import { FieldName } from './field_name';
@ -49,4 +50,20 @@ describe('FieldName', function () {
);
expect(component).toMatchSnapshot();
});
test('renders a custom description icon', () => {
const component = render(
<FieldName
fieldType="string"
fieldName="test"
fieldMapping={
{
...dataView.getFieldByName('bytes')!.spec,
customDescription: 'test description',
} as DataViewField
}
/>
);
expect(component).toMatchSnapshot();
});
});

View file

@ -14,7 +14,7 @@ import { i18n } from '@kbn/i18n';
import { FieldIcon, FieldIconProps } from '@kbn/react-field';
import type { DataViewField } from '@kbn/data-views-plugin/public';
import { getDataViewFieldSubtypeMulti } from '@kbn/es-query';
import { getFieldTypeName } from '@kbn/field-utils';
import { FieldDescriptionIconButton, getFieldTypeName } from '@kbn/field-utils';
interface Props {
fieldName: string;
@ -46,7 +46,7 @@ export function FieldName({
<FieldIcon type={fieldType!} label={typeName} scripted={scripted} {...fieldIconProps} />
</EuiFlexItem>
<EuiFlexGroup wrap={true} gutterSize="none" responsive={false} alignItems="flexStart">
<EuiFlexGroup gutterSize="none" responsive={false} alignItems="flexStart" direction="row">
<EuiFlexItem className="kbnDocViewer__fieldName eui-textBreakAll" grow={false}>
<EuiToolTip
position="top"
@ -58,6 +58,12 @@ export function FieldName({
</EuiToolTip>
</EuiFlexItem>
{fieldMapping?.customDescription ? (
<EuiFlexItem grow={false}>
<FieldDescriptionIconButton field={fieldMapping} />
</EuiFlexItem>
) : null}
{isMultiField && (
<EuiToolTip
position="top"

View file

@ -15,8 +15,10 @@ import {
EuiPopoverProps,
EuiToolTip,
EuiTitle,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FieldDescription } from '@kbn/field-utils';
import type { DataViewField } from '@kbn/data-views-plugin/common';
import type { AddFieldFilterHandler } from '../../types';
@ -75,80 +77,88 @@ export const FieldPopoverHeader: React.FC<FieldPopoverHeaderProps> = ({
});
return (
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem grow={true}>
<EuiTitle size="xxs">
<h5 className="eui-textBreakWord">{field.displayName}</h5>
</EuiTitle>
</EuiFlexItem>
{onAddFieldToWorkspace && (
<EuiFlexItem grow={false} data-test-subj="fieldPopoverHeader_addField">
<EuiToolTip
content={buttonAddFieldToWorkspaceProps?.['aria-label'] ?? addFieldToWorkspaceTooltip}
>
<EuiButtonIcon
data-test-subj={`fieldPopoverHeader_addField-${field.name}`}
aria-label={addFieldToWorkspaceTooltip}
{...(buttonAddFieldToWorkspaceProps || {})}
iconType="plusInCircle"
onClick={() => {
closePopover();
onAddFieldToWorkspace(field);
}}
/>
</EuiToolTip>
<>
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem grow={true}>
<EuiTitle size="xxs">
<h5 className="eui-textBreakWord">{field.displayName}</h5>
</EuiTitle>
</EuiFlexItem>
)}
{onAddFilter && field.filterable && !field.scripted && (
<EuiFlexItem grow={false} data-test-subj="fieldPopoverHeader_addExistsFilter">
<EuiToolTip content={buttonAddFilterProps?.['aria-label'] ?? addExistsFilterTooltip}>
<EuiButtonIcon
data-test-subj={`fieldPopoverHeader_addExistsFilter-${field.name}`}
aria-label={addExistsFilterTooltip}
{...(buttonAddFilterProps || {})}
iconType="filter"
onClick={() => {
closePopover();
onAddFilter('_exists_', field.name, '+');
}}
/>
</EuiToolTip>
</EuiFlexItem>
)}
{onEditField &&
(field.isRuntimeField || !['unknown', 'unknown_selected'].includes(field.type)) && (
<EuiFlexItem grow={false} data-test-subj="fieldPopoverHeader_editField">
<EuiToolTip content={buttonEditFieldProps?.['aria-label'] ?? editFieldTooltip}>
{onAddFieldToWorkspace && (
<EuiFlexItem grow={false} data-test-subj="fieldPopoverHeader_addField">
<EuiToolTip
content={buttonAddFieldToWorkspaceProps?.['aria-label'] ?? addFieldToWorkspaceTooltip}
>
<EuiButtonIcon
data-test-subj={`fieldPopoverHeader_editField-${field.name}`}
aria-label={editFieldTooltip}
{...(buttonEditFieldProps || {})}
iconType="pencil"
data-test-subj={`fieldPopoverHeader_addField-${field.name}`}
aria-label={addFieldToWorkspaceTooltip}
{...(buttonAddFieldToWorkspaceProps || {})}
iconType="plusInCircle"
onClick={() => {
closePopover();
onEditField(field.name);
onAddFieldToWorkspace(field);
}}
/>
</EuiToolTip>
</EuiFlexItem>
)}
{onDeleteField && field.isRuntimeField && (
<EuiFlexItem grow={false} data-test-subj="fieldPopoverHeader_deleteField">
<EuiToolTip content={buttonDeleteFieldProps?.['aria-label'] ?? deleteFieldTooltip}>
<EuiButtonIcon
data-test-subj={`fieldPopoverHeader_deleteField-${field.name}`}
aria-label={deleteFieldTooltip}
{...(buttonDeleteFieldProps || {})}
color="danger"
iconType="trash"
onClick={() => {
closePopover();
onDeleteField(field.name);
}}
/>
</EuiToolTip>
</EuiFlexItem>
)}
</EuiFlexGroup>
{onAddFilter && field.filterable && !field.scripted && (
<EuiFlexItem grow={false} data-test-subj="fieldPopoverHeader_addExistsFilter">
<EuiToolTip content={buttonAddFilterProps?.['aria-label'] ?? addExistsFilterTooltip}>
<EuiButtonIcon
data-test-subj={`fieldPopoverHeader_addExistsFilter-${field.name}`}
aria-label={addExistsFilterTooltip}
{...(buttonAddFilterProps || {})}
iconType="filter"
onClick={() => {
closePopover();
onAddFilter('_exists_', field.name, '+');
}}
/>
</EuiToolTip>
</EuiFlexItem>
)}
{onEditField &&
(field.isRuntimeField || !['unknown', 'unknown_selected'].includes(field.type)) && (
<EuiFlexItem grow={false} data-test-subj="fieldPopoverHeader_editField">
<EuiToolTip content={buttonEditFieldProps?.['aria-label'] ?? editFieldTooltip}>
<EuiButtonIcon
data-test-subj={`fieldPopoverHeader_editField-${field.name}`}
aria-label={editFieldTooltip}
{...(buttonEditFieldProps || {})}
iconType="pencil"
onClick={() => {
closePopover();
onEditField(field.name);
}}
/>
</EuiToolTip>
</EuiFlexItem>
)}
{onDeleteField && field.isRuntimeField && (
<EuiFlexItem grow={false} data-test-subj="fieldPopoverHeader_deleteField">
<EuiToolTip content={buttonDeleteFieldProps?.['aria-label'] ?? deleteFieldTooltip}>
<EuiButtonIcon
data-test-subj={`fieldPopoverHeader_deleteField-${field.name}`}
aria-label={deleteFieldTooltip}
{...(buttonDeleteFieldProps || {})}
color="danger"
iconType="trash"
onClick={() => {
closePopover();
onDeleteField(field.name);
}}
/>
</EuiToolTip>
</EuiFlexItem>
)}
</EuiFlexGroup>
{field.customDescription ? (
<>
<EuiSpacer size="xs" />
<FieldDescription field={field} />
</>
) : null}
</>
);
};

View file

@ -81,7 +81,7 @@ describe('<FieldEditor />', () => {
test('initial state should have "set custom label", "set value" and "set format" turned off', async () => {
testBed = await setup();
['customLabel', 'value', 'format'].forEach((row) => {
['customLabel', 'customDescription', 'value', 'format'].forEach((row) => {
const testSubj = `${row}Row.toggle`;
const toggle = testBed.find(testSubj);
const isOn = toggle.props()['aria-checked'];

View file

@ -40,7 +40,7 @@ export const waitForUpdates = async (testBed?: TestBed) => {
export const getCommonActions = (testBed: TestBed) => {
const toggleFormRow = async (
row: 'customLabel' | 'value' | 'format',
row: 'customLabel' | 'customDescription' | 'value' | 'format',
value: 'on' | 'off' = 'on'
) => {
const testSubj = `${row}Row.toggle`;

View file

@ -12,7 +12,13 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { EuiCode } from '@elastic/eui';
import { AdvancedParametersSection } from './advanced_parameters_section';
import { FormRow } from './form_row';
import { PopularityField, FormatField, ScriptField, CustomLabelField } from './form_fields';
import {
PopularityField,
FormatField,
ScriptField,
CustomLabelField,
CustomDescriptionField,
} from './form_fields';
import { useFieldEditorContext } from '../field_editor_context';
const geti18nTexts = (): {
@ -26,6 +32,18 @@ const geti18nTexts = (): {
defaultMessage: `Create a label to display in place of the field name in Discover, Maps, Lens, Visualize, and TSVB. Useful for shortening a long field name. Queries and filters use the original field name.`,
}),
},
customDescription: {
title: i18n.translate('indexPatternFieldEditor.editor.form.customDescriptionTitle', {
defaultMessage: 'Set custom description',
}),
description: i18n.translate(
'indexPatternFieldEditor.editor.form.customDescriptionDescription',
{
defaultMessage:
"Add a description to the field. It's displayed next to the field on the Discover, Lens, and Data View Management pages.",
}
),
},
value: {
title: i18n.translate('indexPatternFieldEditor.editor.form.valueTitle', {
defaultMessage: 'Set value',
@ -76,6 +94,17 @@ export const FieldDetail = ({}) => {
<CustomLabelField />
</FormRow>
{/* Set custom description */}
<FormRow
title={i18nTexts.customDescription.title}
description={i18nTexts.customDescription.description}
formFieldPath="__meta__.isCustomDescriptionVisible"
data-test-subj="customDescriptionRow"
withDividerRule
>
<CustomDescriptionField />
</FormRow>
{/* Set value */}
{fieldTypeToProcess === 'runtime' && (
<FormRow
@ -102,6 +131,7 @@ export const FieldDetail = ({}) => {
{/* Advanced settings */}
<AdvancedParametersSection>
{/* Popularity score (higher value means it will be positioned higher in the fields list) */}
<FormRow
title={i18nTexts.popularity.title}
description={i18nTexts.popularity.description}

View file

@ -47,6 +47,7 @@ export interface FieldFormInternal extends Omit<Field, 'type' | 'internalType' |
type: TypeSelection;
__meta__: {
isCustomLabelVisible: boolean;
isCustomDescriptionVisible: boolean;
isValueVisible: boolean;
isFormatVisible: boolean;
isPopularityVisible: boolean;
@ -86,6 +87,7 @@ const formDeserializer = (field: Field): FieldFormInternal => {
format,
__meta__: {
isCustomLabelVisible: field.customLabel !== undefined,
isCustomDescriptionVisible: field.customDescription !== undefined,
isValueVisible: field.script !== undefined,
isFormatVisible: field.format !== undefined,
isPopularityVisible: field.popularity !== undefined,
@ -126,6 +128,7 @@ const FieldEditorComponent = ({ field, onChange, onFormModifiedChange }: Props)
form,
discard: [
'__meta__.isCustomLabelVisible',
'__meta__.isCustomDescriptionVisible',
'__meta__.isValueVisible',
'__meta__.isFormatVisible',
'__meta__.isPopularityVisible',

View file

@ -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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { UseField, TextAreaField } from '../../../shared_imports';
export const CustomDescriptionField = () => {
return <UseField path="customDescription" component={TextAreaField} />;
};

View file

@ -10,6 +10,8 @@ export { TypeField } from './type_field';
export { CustomLabelField } from './custom_label_field';
export { CustomDescriptionField } from './custom_description_field';
export { PopularityField } from './popularity_field';
export { ScriptField } from './script_field';

View file

@ -8,12 +8,12 @@
import { i18n } from '@kbn/i18n';
import { EuiComboBoxOptionOption } from '@elastic/eui';
import { MAX_DATA_VIEW_FIELD_DESCRIPTION_LENGTH } from '@kbn/data-views-plugin/common';
import { fieldValidators, FieldConfig, RuntimeType, ValidationFunc } from '../../shared_imports';
import { RUNTIME_FIELD_OPTIONS } from './constants';
import type { PreviewState } from '../preview/types';
const { containsCharsField, emptyField, numberGreaterThanField } = fieldValidators;
const { containsCharsField, emptyField, numberGreaterThanField, maxLengthField } = fieldValidators;
const i18nTexts = {
invalidScriptErrorMessage: i18n.translate(
'indexPatternFieldEditor.editor.form.scriptEditorPainlessValidationMessage',
@ -110,6 +110,36 @@ export const schema = {
},
],
},
customDescription: {
label: i18n.translate('indexPatternFieldEditor.editor.form.customDescriptionLabel', {
defaultMessage: 'Custom description',
}),
validations: [
{
validator: emptyField(
i18n.translate(
'indexPatternFieldEditor.editor.form.validations.customDescriptionIsRequiredErrorMessage',
{
defaultMessage: 'Give a description to the field.',
}
)
),
},
{
validator: maxLengthField({
length: MAX_DATA_VIEW_FIELD_DESCRIPTION_LENGTH,
message: i18n.translate(
'indexPatternFieldEditor.editor.form.validations.customDescriptionMaxLengthErrorMessage',
{
values: { length: MAX_DATA_VIEW_FIELD_DESCRIPTION_LENGTH },
defaultMessage:
'The length of the description is too long. The maximum length is {length} characters.',
}
),
}),
},
],
},
popularity: {
label: i18n.translate('indexPatternFieldEditor.editor.form.popularityLabel', {
defaultMessage: 'Popularity',
@ -146,6 +176,9 @@ export const schema = {
isCustomLabelVisible: {
defaultValue: false,
},
isCustomDescriptionVisible: {
defaultValue: false,
},
isValueVisible: {
defaultValue: false,
},

View file

@ -178,6 +178,7 @@ export const FieldEditorFlyoutContentContainer = ({
// Update custom label, popularity and format
dataView.setFieldCustomLabel(updatedField.name, updatedField.customLabel);
dataView.setFieldCustomDescription(updatedField.name, updatedField.customDescription);
editedField.count = updatedField.popularity || 0;
if (updatedField.format) {

View file

@ -22,6 +22,7 @@ export const deserializeField = (dataView: DataView, field?: DataViewField): Fie
type: editType,
script: field.runtimeField ? field.runtimeField.script : undefined,
customLabel: field.customLabel,
customDescription: field.customDescription,
popularity: field.count,
format: dataView.getFormatterForFieldNoDefault(field.name)?.toJSON(),
fields: field.runtimeField?.fields,

View file

@ -121,6 +121,7 @@ export const getFieldEditorOpener =
esTypes: [],
type: undefined,
customLabel: undefined,
customDescription: undefined,
count: undefined,
spec: {
parentName: undefined,
@ -159,6 +160,7 @@ export const getFieldEditorOpener =
field = {
name: fieldNameToEdit!,
customLabel: dataViewField.customLabel,
customDescription: dataViewField.customDescription,
popularity: dataViewField.count,
format: dataView.getFormatterForFieldNoDefault(fieldNameToEdit!)?.toJSON(),
...dataView.getRuntimeField(fieldNameToEdit!)!,
@ -169,6 +171,7 @@ export const getFieldEditorOpener =
name: fieldNameToEdit!,
type: (dataViewField?.esTypes ? dataViewField.esTypes[0] : 'keyword') as RuntimeType,
customLabel: dataViewField.customLabel,
customDescription: dataViewField.customDescription,
popularity: dataViewField.count,
format: dataView.getFormatterForFieldNoDefault(fieldNameToEdit!)?.toJSON(),
parentName: dataViewField.spec.parentName,

View file

@ -56,6 +56,7 @@ export { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers';
export {
TextField,
TextAreaField,
ToggleField,
NumericField,
} from '@kbn/es-ui-shared-plugin/static/forms/components';

View file

@ -8,7 +8,7 @@
import React, { PureComponent } from 'react';
import { OverlayModalStart, ThemeServiceStart } from '@kbn/core/public';
import { FieldDescription } from '@kbn/field-utils';
import {
EuiIcon,
EuiInMemoryTable,
@ -24,6 +24,7 @@ import {
EuiText,
EuiBasicTable,
EuiCode,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
@ -265,6 +266,12 @@ export const renderFieldName = (field: IndexedFieldItem, timeFieldName?: string)
</EuiToolTip>
</div>
) : null}
{field.customDescription ? (
<div>
<EuiSpacer size="xs" />
<FieldDescription field={field} color="subdued" />
</div>
) : null}
</span>
);

View file

@ -35,6 +35,7 @@
"@kbn/shared-ux-router",
"@kbn/core-ui-settings-browser",
"@kbn/react-kibana-context-render",
"@kbn/field-utils",
"@kbn/no-data-page-plugin",
"@kbn/core-application-browser",
"@kbn/core-doc-links-browser",

View file

@ -52,6 +52,11 @@ export const DATA_VIEW_SAVED_OBJECT_TYPE = 'index-pattern';
*/
export const PLUGIN_NAME = 'DataViews';
/**
* Max length for the custom field description
*/
export const MAX_DATA_VIEW_FIELD_DESCRIPTION_LENGTH = 300;
/**
* Fields for wildcard path.
* @public

View file

@ -17,6 +17,7 @@ import {
searchOptionsSchemas,
} from '@kbn/content-management-utils';
import { DataViewType } from '../..';
import { MAX_DATA_VIEW_FIELD_DESCRIPTION_LENGTH } from '../../constants';
import { serializedFieldFormatSchema, fieldSpecSchema } from '../../schemas';
const dataViewAttributesSchema = schema.object(
@ -40,6 +41,11 @@ const dataViewAttributesSchema = schema.object(
schema.string(),
schema.object({
customLabel: schema.maybe(schema.string()),
customDescription: schema.maybe(
schema.string({
maxLength: MAX_DATA_VIEW_FIELD_DESCRIPTION_LENGTH,
})
),
count: schema.maybe(schema.number()),
})
)

View file

@ -6,6 +6,7 @@ FldList [
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 5,
"customDescription": undefined,
"customLabel": "A Runtime Field",
"defaultFormatter": undefined,
"esTypes": Array [

View file

@ -302,6 +302,23 @@ export abstract class AbstractDataView {
this.setFieldAttrs(fieldName, 'customLabel', customLabel === null ? undefined : customLabel);
}
/**
* Set field custom description
* @param fieldName name of field to set custom description on
* @param customDescription custom description value. If undefined, custom description is removed
*/
protected setFieldCustomDescriptionInternal(
fieldName: string,
customDescription: string | undefined | null
) {
this.setFieldAttrs(
fieldName,
'customDescription',
customDescription === null ? undefined : customDescription
);
}
/**
* Set field formatter
* @param fieldName name of field to set format on

View file

@ -278,7 +278,7 @@ export class DataView extends AbstractDataView implements DataViewBase {
throw new CharacterNotAllowedInField('*', name);
}
const { type, script, customLabel, format, popularity } = runtimeField;
const { type, script, customLabel, customDescription, format, popularity } = runtimeField;
if (type === 'composite') {
return this.addCompositeRuntimeField(name, runtimeField);
@ -291,6 +291,7 @@ export class DataView extends AbstractDataView implements DataViewBase {
{ type, script },
{
customLabel,
customDescription,
format,
popularity,
}
@ -404,6 +405,27 @@ export class DataView extends AbstractDataView implements DataViewBase {
this.setFieldCustomLabelInternal(fieldName, customLabel);
}
/**
* Set field custom description
* @param fieldName name of field to set custom label on
* @param customDescription custom description value. If undefined, custom description is removed
*/
public setFieldCustomDescription(
fieldName: string,
customDescription: string | undefined | null
) {
const fieldObject = this.fields.getByName(fieldName);
const newCustomDescription: string | undefined =
customDescription === null ? undefined : customDescription;
if (fieldObject) {
fieldObject.customDescription = newCustomDescription;
}
this.setFieldCustomDescriptionInternal(fieldName, customDescription);
}
/**
* Set field count
* @param fieldName name of field to set count on
@ -465,6 +487,7 @@ export class DataView extends AbstractDataView implements DataViewBase {
// Every child field gets the complete runtime field script for consumption by searchSource
this.updateOrAddRuntimeField(`${name}.${subFieldName}`, subField.type, runtimeFieldSpec, {
customLabel: subField.customLabel,
customDescription: subField.customDescription,
format: subField.format,
popularity: subField.popularity,
})
@ -507,6 +530,7 @@ export class DataView extends AbstractDataView implements DataViewBase {
// Apply configuration to the field
this.setFieldCustomLabel(fieldName, config.customLabel);
this.setFieldCustomDescription(fieldName, config.customDescription);
if (config.popularity || config.popularity === null) {
this.setFieldCount(fieldName, config.popularity);

View file

@ -714,6 +714,7 @@ export class DataViewsService {
collector[field.name] = {
...field,
customLabel: fieldAttrs?.[field.name]?.customLabel,
customDescription: fieldAttrs?.[field.name]?.customDescription,
count: fieldAttrs?.[field.name]?.count,
};
return collector;
@ -897,6 +898,7 @@ export class DataViewsService {
searchable: true,
readFromDocValues: false,
customLabel: fieldAttrs?.[name]?.customLabel,
customDescription: fieldAttrs?.[name]?.customDescription,
count: fieldAttrs?.[name]?.count,
};

View file

@ -135,6 +135,22 @@ export class DataViewField implements DataViewFieldBase {
this.spec.customLabel = customLabel;
}
/**
* Returns custom description if set, otherwise undefined.
*/
public get customDescription() {
return this.spec.customDescription;
}
/**
* Sets custom description for field, or unsets if passed undefined.
* @param customDescription custom label value
*/
public set customDescription(customDescription) {
this.spec.customDescription = customDescription;
}
/**
* Description of field type conflicts across different indices in the same index pattern.
*/
@ -374,6 +390,7 @@ export class DataViewField implements DataViewFieldBase {
readFromDocValues: this.readFromDocValues,
subType: this.subType,
customLabel: this.customLabel,
customDescription: this.customDescription,
defaultFormatter: this.defaultFormatter,
};
}
@ -401,6 +418,7 @@ export class DataViewField implements DataViewFieldBase {
subType: this.subType,
format: getFormatterForField ? getFormatterForField(this).toJSON() : undefined,
customLabel: this.customLabel,
customDescription: this.customDescription,
shortDotsEnable: this.spec.shortDotsEnable,
runtimeField: this.runtimeField,
isMapped: this.isMapped,

View file

@ -11,6 +11,7 @@ export {
DEFAULT_ASSETS_TO_IGNORE,
META_FIELDS,
DATA_VIEW_SAVED_OBJECT_TYPE,
MAX_DATA_VIEW_FIELD_DESCRIPTION_LENGTH,
} from './constants';
export { LATEST_VERSION } from './content_management/v1/constants';

View file

@ -7,6 +7,7 @@
*/
import { schema, Type } from '@kbn/config-schema';
import { MAX_DATA_VIEW_FIELD_DESCRIPTION_LENGTH } from './constants';
import { RuntimeType } from '.';
export const serializedFieldFormatSchema = schema.object({
@ -38,6 +39,11 @@ const primitiveRuntimeFieldSchemaShared = {
),
format: schema.maybe(serializedFieldFormatSchema),
customLabel: schema.maybe(schema.string()),
customDescription: schema.maybe(
schema.string({
maxLength: MAX_DATA_VIEW_FIELD_DESCRIPTION_LENGTH,
})
),
popularity: schema.maybe(
schema.number({
min: 0,
@ -68,6 +74,11 @@ const compositeRuntimeFieldSchemaShared = {
type: runtimeFieldNonCompositeFieldsSpecTypeSchema,
format: schema.maybe(serializedFieldFormatSchema),
customLabel: schema.maybe(schema.string()),
customDescription: schema.maybe(
schema.string({
maxLength: MAX_DATA_VIEW_FIELD_DESCRIPTION_LENGTH,
})
),
popularity: schema.maybe(
schema.number({
min: 0,
@ -134,6 +145,11 @@ export const fieldSpecSchemaFields = {
})
),
customLabel: schema.maybe(schema.string()),
customDescription: schema.maybe(
schema.string({
maxLength: MAX_DATA_VIEW_FIELD_DESCRIPTION_LENGTH,
})
),
shortDotsEnable: schema.maybe(schema.boolean()),
searchable: schema.maybe(schema.boolean()),
aggregatable: schema.maybe(schema.boolean()),

View file

@ -79,6 +79,10 @@ export interface FieldConfiguration {
* Custom label
*/
customLabel?: string;
/**
* Custom description
*/
customDescription?: string;
/**
* Popularity - used for discover
*/
@ -183,6 +187,10 @@ export type FieldAttrSet = {
* Custom field label
*/
customLabel?: string;
/**
* Custom field description
*/
customDescription?: string;
/**
* Popularity count - used for discover
*/
@ -425,6 +433,10 @@ export type FieldSpec = DataViewFieldBase & {
* Custom label for field, used for display in kibana
*/
customLabel?: string;
/**
* Custom description for field, used for display in kibana
*/
customDescription?: string;
/**
* Runtime field definition
*/

View file

@ -13,6 +13,7 @@ import { SerializedFieldFormat } from '@kbn/field-formats-plugin/common';
import { DataViewsService } from '../../../../common';
import { handleErrors } from '../util/handle_errors';
import { serializedFieldFormatSchema } from '../../../../common/schemas';
import { MAX_DATA_VIEW_FIELD_DESCRIPTION_LENGTH } from '../../../../common/constants';
import { dataViewSpecSchema } from '../../schema';
import { DataViewSpecRestResponse } from '../../route_types';
import type {
@ -60,6 +61,11 @@ export const updateFields = async ({
dataView.setFieldCustomLabel(fieldName, field.customLabel);
}
if (field.customDescription !== undefined) {
changeCount++;
dataView.setFieldCustomDescription(fieldName, field.customDescription);
}
if (field.count !== undefined) {
changeCount++;
dataView.setFieldCount(fieldName, field.count);
@ -85,6 +91,7 @@ export const updateFields = async ({
interface FieldUpdateType {
customLabel?: string | null;
customDescription?: string | null;
count?: number | null;
format?: SerializedFieldFormat | null;
}
@ -98,6 +105,14 @@ const fieldUpdateSchema = schema.object({
})
)
),
customDescription: schema.maybe(
schema.nullable(
schema.string({
minLength: 1,
maxLength: MAX_DATA_VIEW_FIELD_DESCRIPTION_LENGTH,
})
)
),
count: schema.maybe(schema.nullable(schema.number())),
format: schema.maybe(schema.nullable(serializedFieldFormatSchema)),
});

View file

@ -41,6 +41,7 @@ export type TypeMetaRestResponse = {
export type FieldAttrSetRestResponse = {
customLabel?: string;
customDescription?: string;
count?: number;
};
@ -94,6 +95,7 @@ export type FieldSpecRestResponse = DataViewFieldBaseRestResponse & {
readFromDocValues?: boolean;
indexed?: boolean;
customLabel?: string;
customDescription?: string;
runtimeField?: RuntimeFieldSpecRestResponse;
fixedInterval?: string[];
timeZone?: string[];

View file

@ -15,6 +15,7 @@ import {
serializedFieldFormatSchema,
fieldSpecSchemaFields,
} from '../../common/schemas';
import { MAX_DATA_VIEW_FIELD_DESCRIPTION_LENGTH } from '../../common/constants';
export const dataViewSpecSchema = schema.object({
title: schema.string(),
@ -38,6 +39,11 @@ export const dataViewSpecSchema = schema.object({
schema.string(),
schema.object({
customLabel: schema.maybe(schema.string()),
customDescription: schema.maybe(
schema.string({
maxLength: MAX_DATA_VIEW_FIELD_DESCRIPTION_LENGTH,
})
),
count: schema.maybe(schema.number()),
})
)

View file

@ -69,6 +69,29 @@ export default function ({ getService }: FtrProviderContext) {
expect(response2.body.statusCode).to.be(400);
expect(response2.body.message).to.be('Change set is empty.');
});
it('returns validation error', async () => {
const title = `foo-${Date.now()}-${Math.random()}*`;
const response1 = await supertest.post(config.path).send({
[config.serviceKey]: {
title,
},
});
const response2 = await supertest
.post(`${config.path}/${response1.body[config.serviceKey].id}/fields`)
.send({
fields: {
foo: {
customDescription: 'too long value'.repeat(50),
},
},
});
expect(response2.status).to.be(400);
expect(response2.body.statusCode).to.be(400);
expect(response2.body.message).to.contain('it must have a maximum length');
});
});
});
});

View file

@ -67,6 +67,7 @@ export default function ({ getService }: FtrProviderContext) {
},
bar: {
count: 456,
customDescription: 'desc',
},
},
});
@ -75,6 +76,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(response2.body[config.serviceKey].fieldAttrs.foo.count).to.be(123);
expect(response2.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be('test');
expect(response2.body[config.serviceKey].fieldAttrs.bar.count).to.be(456);
expect(response2.body[config.serviceKey].fieldAttrs.bar.customDescription).to.be('desc');
const response3 = await supertest.get(
`${config.path}/${response1.body[config.serviceKey].id}`
@ -84,6 +86,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(response3.body[config.serviceKey].fieldAttrs.foo.count).to.be(123);
expect(response3.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be('test');
expect(response3.body[config.serviceKey].fieldAttrs.bar.count).to.be(456);
expect(response3.body[config.serviceKey].fieldAttrs.bar.customDescription).to.be('desc');
});
describe('count', () => {
@ -194,126 +197,146 @@ export default function ({ getService }: FtrProviderContext) {
});
});
describe('customLabel', () => {
it('can set field "customLabel" attribute on non-existing field', async () => {
const title = `foo-${Date.now()}-${Math.random()}*`;
const response1 = await supertest.post(config.path).send({
[config.serviceKey]: {
title,
},
['customLabel', 'customDescription'].forEach((customStringAttribute) => {
describe(`can set optional ${customStringAttribute}`, () => {
it(`can set field "${customStringAttribute}" attribute on non-existing field`, async () => {
const title = `foo-${Date.now()}-${Math.random()}*`;
const response1 = await supertest.post(config.path).send({
[config.serviceKey]: {
title,
},
});
expect(response1.status).to.be(200);
expect(response1.body[config.serviceKey].fieldAttrs.foo).to.be(undefined);
const response2 = await supertest
.post(`${config.path}/${response1.body[config.serviceKey].id}/fields`)
.send({
fields: {
foo: {
[customStringAttribute]: 'foo',
},
},
});
expect(response2.status).to.be(200);
expect(response2.body[config.serviceKey].fieldAttrs.foo[customStringAttribute]).to.be(
'foo'
);
const response3 = await supertest.get(
`${config.path}/${response1.body[config.serviceKey].id}`
);
expect(response3.status).to.be(200);
expect(response3.body[config.serviceKey].fieldAttrs.foo[customStringAttribute]).to.be(
'foo'
);
});
expect(response1.status).to.be(200);
expect(response1.body[config.serviceKey].fieldAttrs.foo).to.be(undefined);
const response2 = await supertest
.post(`${config.path}/${response1.body[config.serviceKey].id}/fields`)
.send({
fields: {
foo: {
customLabel: 'foo',
it(`can update "${customStringAttribute}" attribute in index_pattern attribute map`, async () => {
const title = `foo-${Date.now()}-${Math.random()}*`;
const response1 = await supertest.post(config.path).send({
[config.serviceKey]: {
title,
fieldAttrs: {
foo: {
[customStringAttribute]: 'foo',
},
},
},
});
expect(response2.status).to.be(200);
expect(response2.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be('foo');
expect(response1.status).to.be(200);
expect(response1.body[config.serviceKey].fieldAttrs.foo[customStringAttribute]).to.be(
'foo'
);
const response3 = await supertest.get(
`${config.path}/${response1.body[config.serviceKey].id}`
);
expect(response3.status).to.be(200);
expect(response3.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be('foo');
});
it('can update "customLabel" attribute in index_pattern attribute map', async () => {
const title = `foo-${Date.now()}-${Math.random()}*`;
const response1 = await supertest.post(config.path).send({
[config.serviceKey]: {
title,
fieldAttrs: {
foo: {
customLabel: 'foo',
const response2 = await supertest
.post(`${config.path}/${response1.body[config.serviceKey].id}/fields`)
.send({
fields: {
foo: {
[customStringAttribute]: 'bar',
},
},
},
},
});
expect(response2.status).to.be(200);
expect(response2.body[config.serviceKey].fieldAttrs.foo[customStringAttribute]).to.be(
'bar'
);
const response3 = await supertest.get(
`${config.path}/${response1.body[config.serviceKey].id}`
);
expect(response3.status).to.be(200);
expect(response3.body[config.serviceKey].fieldAttrs.foo[customStringAttribute]).to.be(
'bar'
);
});
expect(response1.status).to.be(200);
expect(response1.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be('foo');
const response2 = await supertest
.post(`${config.path}/${response1.body[config.serviceKey].id}/fields`)
.send({
fields: {
foo: {
customLabel: 'bar',
it(`can delete "${customStringAttribute}" attribute from index_pattern attribute map`, async () => {
const title = `foo-${Date.now()}-${Math.random()}*`;
const response1 = await supertest.post(config.path).send({
[config.serviceKey]: {
title,
fieldAttrs: {
foo: {
[customStringAttribute]: 'foo',
},
},
},
});
expect(response2.status).to.be(200);
expect(response2.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be('bar');
expect(response1.status).to.be(200);
expect(response1.body[config.serviceKey].fieldAttrs.foo[customStringAttribute]).to.be(
'foo'
);
const response3 = await supertest.get(
`${config.path}/${response1.body[config.serviceKey].id}`
);
expect(response3.status).to.be(200);
expect(response3.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be('bar');
});
it('can delete "customLabel" attribute from index_pattern attribute map', async () => {
const title = `foo-${Date.now()}-${Math.random()}*`;
const response1 = await supertest.post(config.path).send({
[config.serviceKey]: {
title,
fieldAttrs: {
foo: {
customLabel: 'foo',
const response2 = await supertest
.post(`${config.path}/${response1.body[config.serviceKey].id}/fields`)
.send({
fields: {
foo: {
[customStringAttribute]: null,
},
},
},
},
});
expect(response2.status).to.be(200);
expect(response2.body[config.serviceKey].fieldAttrs.foo[customStringAttribute]).to.be(
undefined
);
const response3 = await supertest.get(
`${config.path}/${response1.body[config.serviceKey].id}`
);
expect(response3.status).to.be(200);
expect(response3.body[config.serviceKey].fieldAttrs.foo[customStringAttribute]).to.be(
undefined
);
});
expect(response1.status).to.be(200);
expect(response1.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be('foo');
const response2 = await supertest
.post(`${config.path}/${response1.body[config.serviceKey].id}/fields`)
.send({
it(`can set field "${customStringAttribute}" attribute on an existing field`, async () => {
await supertest.post(`${config.path}/${indexPattern.id}/fields`).send({
fields: {
foo: {
customLabel: null,
[customStringAttribute]: 'baz',
},
},
});
expect(response2.status).to.be(200);
expect(response2.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be(undefined);
const response1 = await supertest.get(`${config.path}/${indexPattern.id}`);
const response3 = await supertest.get(
`${config.path}/${response1.body[config.serviceKey].id}`
);
expect(response3.status).to.be(200);
expect(response3.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be(undefined);
});
it('can set field "customLabel" attribute on an existing field', async () => {
await supertest.post(`${config.path}/${indexPattern.id}/fields`).send({
fields: {
foo: {
customLabel: 'baz',
},
},
expect(response1.status).to.be(200);
expect(response1.body[config.serviceKey].fields.foo[customStringAttribute]).to.be(
'baz'
);
});
const response1 = await supertest.get(`${config.path}/${indexPattern.id}`);
expect(response1.status).to.be(200);
expect(response1.body[config.serviceKey].fields.foo.customLabel).to.be('baz');
});
});

View file

@ -16,6 +16,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const fieldEditor = getService('fieldEditor');
const security = getService('security');
const dataGrid = getService('dataGrid');
const PageObjects = getPageObjects([
'common',
'discover',
@ -68,6 +69,57 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await PageObjects.discover.getDocHeader()).to.have.string(customLabel);
});
it('allows adding custom description to existing fields', async function () {
const customDescription = 'custom bytes description here';
const customDescription2 = `${customDescription} updated`;
// set a custom description
await PageObjects.discover.editField('bytes');
await fieldEditor.enableCustomDescription();
await fieldEditor.setCustomDescription(customDescription);
await fieldEditor.save();
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.unifiedFieldList.clickFieldListItem('bytes');
await retry.waitFor('field popover text', async () => {
return (await testSubjects.getVisibleText('fieldDescription-bytes')) === customDescription;
});
await PageObjects.unifiedFieldList.clickFieldListItemToggle('bytes');
// edit the custom description
await PageObjects.discover.editField('bytes');
await fieldEditor.enableCustomDescription();
await fieldEditor.setCustomDescription(customDescription2);
await fieldEditor.save();
await fieldEditor.waitUntilClosed();
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.unifiedFieldList.clickFieldListItem('bytes');
await retry.waitFor('field popover text', async () => {
return (await testSubjects.getVisibleText('fieldDescription-bytes')) === customDescription2;
});
await PageObjects.unifiedFieldList.clickFieldListItemToggle('bytes');
// check it in the doc viewer too
await dataGrid.clickRowToggle({ rowIndex: 0 });
await testSubjects.click('fieldDescriptionPopoverButton-bytes');
await retry.waitFor('doc viewer popover text', async () => {
return (await testSubjects.getVisibleText('fieldDescription-bytes')) === customDescription2;
});
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
await PageObjects.discover.editField('bytes');
await fieldEditor.enableCustomDescription();
await fieldEditor.setCustomDescription(customDescription);
await fieldEditor.save();
expect(await fieldEditor.getFormError()).to.contain(
'The length of the description is too long. The maximum length is 300 characters.'
);
await fieldEditor.closeFlyoutAndDiscardChanges();
});
it('allows creation of a new field', async function () {
const field = '_runtimefield';
await createRuntimeField(field);

View file

@ -27,6 +27,18 @@ export class FieldEditorService extends FtrService {
public async setCustomLabel(name: string) {
await this.testSubjects.setValue('customLabelRow > input', name);
}
public async enableCustomDescription() {
await this.testSubjects.setEuiSwitch('customDescriptionRow > toggle', 'check');
}
public async setCustomDescription(description: string) {
await this.testSubjects.setValue('customDescriptionRow > input', description);
}
public async getFormError() {
const alert = await this.find.byCssSelector(
'[data-test-subj=indexPatternFieldEditorForm] > [role="alert"]'
);
return await alert.getVisibleText();
}
public async enableValue() {
await this.testSubjects.setEuiSwitch('valueRow > toggle', 'check');
}
@ -124,7 +136,21 @@ export class FieldEditorService extends FtrService {
});
}
public async confirmDiscardChanges() {
await this.retry.try(async () => {
await this.testSubjects.clickWhenNotDisabledWithoutRetry('confirmModalConfirmButton', {
timeout: 1000,
});
});
}
public async waitUntilClosed() {
await this.testSubjects.waitForDeleted('fieldEditor');
}
public async closeFlyoutAndDiscardChanges() {
await this.testSubjects.click('fieldEditor > closeFlyoutButton');
await this.confirmDiscardChanges();
await this.waitUntilClosed();
}
}

View file

@ -3246,6 +3246,28 @@ Object {
},
"type": "number",
},
"customDescription": Object {
"flags": Object {
"default": [Function],
"error": [Function],
"presence": "optional",
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
"customLabel": Object {
"flags": Object {
"default": [Function],
@ -3402,6 +3424,28 @@ Object {
],
"type": "number",
},
"customDescription": Object {
"flags": Object {
"default": [Function],
"error": [Function],
"presence": "optional",
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
"customLabel": Object {
"flags": Object {
"default": [Function],
@ -3527,6 +3571,28 @@ Object {
"presence": "optional",
},
"keys": Object {
"customDescription": Object {
"flags": Object {
"default": [Function],
"error": [Function],
"presence": "optional",
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
"customLabel": Object {
"flags": Object {
"default": [Function],
@ -3770,6 +3836,28 @@ Object {
"presence": "optional",
},
"keys": Object {
"customDescription": Object {
"flags": Object {
"default": [Function],
"error": [Function],
"presence": "optional",
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
"customLabel": Object {
"flags": Object {
"default": [Function],
@ -4248,6 +4336,28 @@ Object {
"presence": "optional",
},
"keys": Object {
"customDescription": Object {
"flags": Object {
"default": [Function],
"error": [Function],
"presence": "optional",
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
"customLabel": Object {
"flags": Object {
"default": [Function],
@ -4491,6 +4601,28 @@ Object {
"presence": "optional",
},
"keys": Object {
"customDescription": Object {
"flags": Object {
"default": [Function],
"error": [Function],
"presence": "optional",
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
"customLabel": Object {
"flags": Object {
"default": [Function],

View file

@ -97,6 +97,7 @@ export function buildIndexPatternField(
scripted: field.scripted,
isMapped: field.isMapped,
customLabel: field.customLabel,
customDescription: field.customDescription,
runtimeField: field.runtimeField,
runtime: Boolean(field.runtimeField),
timeSeriesDimension: field.timeSeriesDimension,