[Discover/Lens] Unify field popover (#141787)

* [UnifiedFieldList] Add a new FieldPopover component

* [Discover] Integrate FieldPopover into Discover page

* [Discover] Integrate FieldPopover into Lens page

* [Discover] Update for tests

* [Discover] Add "Add field as column" action to Discover

* [Discover] Deprecate i18n keys

* [Discover] Update tests

* [Discover] Fix field type

* [Discover] Fix sidebar pagination when lazy js modules are being loaded

* [Discover] Extract new FieldVisualizeButton

* [Discover] Integrate it into Lens

* [Discover] Fix checks

* [Discover] Add popover tests

* [Discover] Add popover header tests

* [Discover] Add field visualize button tests

* [Discover] Update docs

* [Discover] Update tests

* [Discover] Fix button state on rerender

* [Discover] Reorganize components

* [Discover] Update tests

* Merge remote-tracking branch 'upstream/main' into 140363-unified-popover

# Conflicts:
#	x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx

* [Discover] Fix for ad hoc data views

* [Discover] Fix multi fields toggle action

* [Discover] Fix display name in the popover

* [Discover] A fix for tests

* [Discover] Update the icon to plusInCircle

* [UnifiedFieldList] Remove redundant styles

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
This commit is contained in:
Julia Rechkunova 2022-10-10 10:10:58 +02:00 committed by GitHub
parent d7924aa750
commit 91dbdc7888
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1268 additions and 629 deletions

View file

@ -18,16 +18,18 @@ import {
import { i18n } from '@kbn/i18n';
import type { DataView } from '@kbn/data-views-plugin/public';
import { SavedSearch } from '@kbn/saved-search-plugin/public';
import {
getVisualizeInformation,
triggerVisualizeActions,
} from '@kbn/unified-field-list-plugin/public';
import { HitsCounter } from '../hits_counter';
import { GetStateReturn } from '../../services/discover_state';
import { DiscoverHistogram } from './histogram';
import { DataCharts$, DataTotalHits$ } from '../../hooks/use_saved_search';
import { useChartPanels } from './use_chart_panels';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
import {
getVisualizeInformation,
triggerVisualizeActions,
} from '../sidebar/lib/visualize_trigger_utils';
import { getUiActions } from '../../../../kibana_services';
import { PLUGIN_ID } from '../../../../../common';
const DiscoverHistogramMemoized = memo(DiscoverHistogram);
export const CHART_HIDDEN_KEY = 'discover:chartHidden';
@ -72,7 +74,13 @@ export function DiscoverChart({
useEffect(() => {
if (!timeField) return;
getVisualizeInformation(timeField, dataView, savedSearch.columns || []).then((info) => {
getVisualizeInformation(
getUiActions(),
timeField,
dataView,
savedSearch.columns || [],
[]
).then((info) => {
setCanVisualize(Boolean(info));
});
}, [dataView, savedSearch.columns, timeField]);
@ -81,7 +89,13 @@ export function DiscoverChart({
if (!timeField) {
return;
}
triggerVisualizeActions(timeField, savedSearch.columns || [], dataView);
triggerVisualizeActions(
getUiActions(),
timeField,
savedSearch.columns || [],
PLUGIN_ID,
dataView
);
}, [dataView, savedSearch.columns, timeField]);
const onShowChartOptions = useCallback(() => {

View file

@ -1,29 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 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 { storiesOf } from '@storybook/react';
import React from 'react';
import { DiscoverFieldVisualizeInner } from '../discover_field_visualize_inner';
import { numericField as field } from './fields';
const visualizeInfo = {
href: 'http://localhost:9001/',
field,
};
const handleVisualizeLinkClick = () => {
alert('Clicked');
};
storiesOf('components/sidebar/DiscoverFieldVisualizeInner', module).add('default', () => (
<DiscoverFieldVisualizeInner
field={field}
visualizeInfo={visualizeInfo}
handleVisualizeLinkClick={handleVisualizeLinkClick}
/>
));

View file

@ -1,8 +1,3 @@
.dscSidebarItem__fieldPopoverPanel {
min-width: $euiSizeXXL * 6.5;
max-width: $euiSizeXXL * 7.5;
}
.dscSidebarItem--multi {
.kbnFieldButton__button {
padding-left: 0;

View file

@ -8,7 +8,6 @@
import { act } from 'react-dom/test-utils';
import { EuiPopover, EuiProgress, EuiButtonIcon } from '@elastic/eui';
import { ReactWrapper } from 'enzyme';
import React from 'react';
import { findTestSubject } from '@elastic/eui/lib/test';
import { mountWithIntl } from '@kbn/test-jest-helpers';
@ -85,6 +84,7 @@ async function getComponent({
getDetails: jest.fn(() => ({ buckets: [], error: '', exists: 1, total: 2 })),
...(onAddFilterExists && { onAddFilter: jest.fn() }),
onAddField: jest.fn(),
onEditField: jest.fn(),
onRemoveField: jest.fn(),
showFieldStats,
selected,
@ -140,6 +140,9 @@ async function getComponent({
<DiscoverField {...props} />
</KibanaContextProvider>
);
// wait for lazy modules
await new Promise((resolve) => setTimeout(resolve, 0));
await comp.update();
return { comp, props };
}
@ -233,13 +236,8 @@ describe('discover sidebar field', function () {
aggregatable: true,
searchable: true,
});
let comp: ReactWrapper;
await act(async () => {
const result = await getComponent({ showFieldStats: true, field, onAddFilterExists: true });
comp = result.comp;
await comp.update();
});
const { comp } = await getComponent({ showFieldStats: true, field, onAddFilterExists: true });
await act(async () => {
const fieldItem = findTestSubject(comp, 'field-machine.os.raw-showDetails');
@ -247,15 +245,91 @@ describe('discover sidebar field', function () {
await comp.update();
});
await comp!.update();
await comp.update();
expect(comp!.find(EuiPopover).prop('isOpen')).toBe(true);
expect(findTestSubject(comp!, 'dscFieldStats-title').text()).toBe('Top values');
expect(findTestSubject(comp!, 'dscFieldStats-topValues-bucket')).toHaveLength(2);
expect(comp.find(EuiPopover).prop('isOpen')).toBe(true);
expect(findTestSubject(comp, 'dscFieldStats-title').text()).toBe('Top values');
expect(findTestSubject(comp, 'dscFieldStats-topValues-bucket')).toHaveLength(2);
expect(
findTestSubject(comp!, 'dscFieldStats-topValues-formattedFieldValue').first().text()
findTestSubject(comp, 'dscFieldStats-topValues-formattedFieldValue').first().text()
).toBe('osx');
expect(comp!.find(EuiProgress)).toHaveLength(2);
expect(findTestSubject(comp!, 'dscFieldStats-topValues').find(EuiButtonIcon)).toHaveLength(4);
expect(comp.find(EuiProgress)).toHaveLength(2);
expect(findTestSubject(comp, 'dscFieldStats-topValues').find(EuiButtonIcon)).toHaveLength(4);
});
it('should include popover actions', async function () {
const field = new DataViewField({
name: 'extension.keyword',
type: 'string',
esTypes: ['keyword'],
aggregatable: true,
searchable: true,
});
const { comp, props } = await getComponent({ field, onAddFilterExists: true });
await act(async () => {
const fieldItem = findTestSubject(comp, 'field-extension.keyword-showDetails');
await fieldItem.simulate('click');
await comp.update();
});
await comp.update();
expect(comp.find(EuiPopover).prop('isOpen')).toBe(true);
expect(
comp.find('[data-test-subj="fieldPopoverHeader_addField-extension.keyword"]').exists()
).toBeTruthy();
expect(
comp
.find('[data-test-subj="discoverFieldListPanelAddExistFilter-extension.keyword"]')
.exists()
).toBeTruthy();
expect(
comp.find('[data-test-subj="discoverFieldListPanelEdit-extension.keyword"]').exists()
).toBeTruthy();
expect(
comp.find('[data-test-subj="discoverFieldListPanelDelete-extension.keyword"]').exists()
).toBeFalsy();
await act(async () => {
const fieldItem = findTestSubject(comp, 'fieldPopoverHeader_addField-extension.keyword');
await fieldItem.simulate('click');
await comp.update();
});
expect(props.onAddField).toHaveBeenCalledWith('extension.keyword');
await comp.update();
expect(comp.find(EuiPopover).prop('isOpen')).toBe(false);
});
it('should not include + action for selected fields', async function () {
const field = new DataViewField({
name: 'extension.keyword',
type: 'string',
esTypes: ['keyword'],
aggregatable: true,
searchable: true,
});
const { comp } = await getComponent({
field,
onAddFilterExists: true,
selected: true,
});
await act(async () => {
const fieldItem = findTestSubject(comp, 'field-extension.keyword-showDetails');
await fieldItem.simulate('click');
await comp.update();
});
await comp.update();
expect(comp.find(EuiPopover).prop('isOpen')).toBe(true);
expect(
comp.find('[data-test-subj="fieldPopoverHeader_addField-extension.keyword"]').exists()
).toBeFalsy();
});
});

View file

@ -9,32 +9,27 @@
import './discover_field.scss';
import React, { useState, useCallback, memo, useMemo } from 'react';
import {
EuiPopover,
EuiPopoverTitle,
EuiButtonIcon,
EuiToolTip,
EuiTitle,
EuiIcon,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
} from '@elastic/eui';
import { EuiButtonIcon, EuiToolTip, EuiTitle, EuiIcon, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { UiCounterMetricType } from '@kbn/analytics';
import classNames from 'classnames';
import { FieldButton, FieldIcon } from '@kbn/react-field';
import type { DataViewField, DataView } from '@kbn/data-views-plugin/public';
import { FieldStats } from '@kbn/unified-field-list-plugin/public';
import { getFieldCapabilities } from '../../../../utils/get_field_capabilities';
import {
FieldStats,
FieldPopover,
FieldPopoverHeader,
FieldPopoverHeaderProps,
FieldPopoverVisualize,
} from '@kbn/unified-field-list-plugin/public';
import { getTypeForFieldIcon } from '../../../../utils/get_type_for_field_icon';
import { DiscoverFieldDetails } from './discover_field_details';
import { FieldDetails } from './types';
import { getFieldTypeName } from '../../../../utils/get_field_type_name';
import { DiscoverFieldVisualize } from './discover_field_visualize';
import type { AppState } from '../../services/discover_state';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
import { SHOW_LEGACY_FIELD_TOP_VALUES } from '../../../../../common';
import { SHOW_LEGACY_FIELD_TOP_VALUES, PLUGIN_ID } from '../../../../../common';
import { getUiActions } from '../../../../kibana_services';
function wrapOnDot(str?: string) {
// u200B is a non-width white-space character, which allows
@ -93,7 +88,7 @@ interface ActionButtonProps {
field: DataViewField;
isSelected?: boolean;
alwaysShow: boolean;
toggleDisplay: (field: DataViewField) => void;
toggleDisplay: (field: DataViewField, isSelected?: boolean) => void;
}
const ActionButton: React.FC<ActionButtonProps> = memo(
@ -121,7 +116,7 @@ const ActionButton: React.FC<ActionButtonProps> = memo(
}
ev.preventDefault();
ev.stopPropagation();
toggleDisplay(field);
toggleDisplay(field, isSelected);
}}
data-test-subj={`fieldToggle-${field.name}`}
aria-label={i18n.translate('discover.fieldChooser.discoverField.addButtonAriaLabel', {
@ -149,7 +144,7 @@ const ActionButton: React.FC<ActionButtonProps> = memo(
}
ev.preventDefault();
ev.stopPropagation();
toggleDisplay(field);
toggleDisplay(field, isSelected);
}}
data-test-subj={`fieldToggle-${field.name}`}
aria-label={i18n.translate(
@ -311,23 +306,48 @@ function DiscoverFieldComponent({
[setOpen, onAddFilter]
);
const toggleDisplay = useCallback(
(f: DataViewField) => {
if (selected) {
const togglePopover = useCallback(() => {
setOpen((value) => !value);
}, [setOpen]);
const closePopover = useCallback(() => {
setOpen(false);
}, [setOpen]);
const toggleDisplay: ActionButtonProps['toggleDisplay'] = useCallback(
(f, isCurrentlySelected) => {
closePopover();
if (isCurrentlySelected) {
onRemoveField(f.name);
} else {
onAddField(f.name);
}
},
[onAddField, onRemoveField, selected]
[onAddField, onRemoveField, closePopover]
);
const togglePopover = useCallback(() => {
setOpen(!infoIsOpen);
}, [infoIsOpen]);
const rawMultiFields = useMemo(() => multiFields?.map((f) => f.field), [multiFields]);
const customPopoverHeaderProps: Partial<FieldPopoverHeaderProps> = useMemo(
() => ({
buttonAddFieldToWorkspaceProps: {
'aria-label': i18n.translate('discover.fieldChooser.discoverField.addFieldTooltip', {
defaultMessage: 'Add field as column',
}),
},
buttonAddFilterProps: {
'data-test-subj': `discoverFieldListPanelAddExistFilter-${field.name}`,
},
buttonEditFieldProps: {
'data-test-subj': `discoverFieldListPanelEdit-${field.name}`,
},
buttonDeleteFieldProps: {
'data-test-subj': `discoverFieldListPanelDelete-${field.name}`,
},
}),
[field.name]
);
if (field.type === '_source') {
return (
<FieldButton
@ -348,85 +368,6 @@ function DiscoverFieldComponent({
);
}
const { canEdit, canDelete } = getFieldCapabilities(dataView, field);
const canEditField = onEditField && canEdit;
const canDeleteField = onDeleteField && canDelete;
const addExistFilterTooltip = i18n.translate(
'discover.fieldChooser.discoverField.addExistFieldLabel',
{
defaultMessage: 'Filter for field present',
}
);
const editFieldTooltip = i18n.translate('discover.fieldChooser.discoverField.editFieldLabel', {
defaultMessage: 'Edit data view field',
});
const deleteFieldTooltip = i18n.translate(
'discover.fieldChooser.discoverField.deleteFieldLabel',
{
defaultMessage: 'Delete data view field',
}
);
const popoverTitle = (
<EuiPopoverTitle style={{ textTransform: 'none' }} className="eui-textBreakWord">
<EuiFlexGroup responsive={false} gutterSize="s">
<EuiFlexItem grow={true}>
<h5>{field.displayName}</h5>
</EuiFlexItem>
{onAddFilter && !dataView.metaFields.includes(field.name) && !field.scripted && (
<EuiFlexItem grow={false} data-test-subj="discoverFieldListPanelAddExistFilterItem">
<EuiToolTip content={addExistFilterTooltip}>
<EuiButtonIcon
onClick={() => {
setOpen(false);
onAddFilter('_exists_', field.name, '+');
}}
iconType="filter"
data-test-subj={`discoverFieldListPanelAddExistFilter-${field.name}`}
aria-label={addExistFilterTooltip}
/>
</EuiToolTip>
</EuiFlexItem>
)}
{canEditField && (
<EuiFlexItem grow={false} data-test-subj="discoverFieldListPanelEditItem">
<EuiToolTip content={editFieldTooltip}>
<EuiButtonIcon
onClick={() => {
if (onEditField) {
togglePopover();
onEditField(field.name);
}
}}
iconType="pencil"
data-test-subj={`discoverFieldListPanelEdit-${field.name}`}
aria-label={editFieldTooltip}
/>
</EuiToolTip>
</EuiFlexItem>
)}
{canDeleteField && (
<EuiFlexItem grow={false} data-test-subj="discoverFieldListPanelDeleteItem">
<EuiToolTip content={deleteFieldTooltip}>
<EuiButtonIcon
onClick={() => {
onDeleteField?.(field.name);
}}
iconType="trash"
data-test-subj={`discoverFieldListPanelDelete-${field.name}`}
color="danger"
aria-label={deleteFieldTooltip}
/>
</EuiToolTip>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiPopoverTitle>
);
const button = (
<FieldButton
size="s"
@ -447,6 +388,7 @@ function DiscoverFieldComponent({
fieldInfoIcon={field.type === 'conflict' && <FieldInfoIcon />}
/>
);
if (!isDocumentRecord) {
return button;
}
@ -512,29 +454,38 @@ function DiscoverFieldComponent({
</>
)}
<DiscoverFieldVisualize
<FieldPopoverVisualize
field={field}
dataView={dataView}
multiFields={rawMultiFields}
trackUiMetric={trackUiMetric}
contextualFields={contextualFields}
originatingApp={PLUGIN_ID}
uiActions={getUiActions()}
/>
</>
);
};
return (
<EuiPopover
display="block"
button={button}
<FieldPopover
isOpen={infoIsOpen}
closePopover={() => setOpen(false)}
button={button}
closePopover={closePopover}
data-test-subj="discoverFieldListPanelPopover"
anchorPosition="rightUp"
panelClassName="dscSidebarItem__fieldPopoverPanel"
>
{popoverTitle}
{infoIsOpen && renderPopover()}
</EuiPopover>
renderHeader={() => (
<FieldPopoverHeader
field={field}
closePopover={closePopover}
onAddFieldToWorkspace={!selected ? toggleDisplay : undefined}
onAddFilter={onAddFilter}
onEditField={onEditField}
onDeleteField={onDeleteField}
{...customPopoverHeaderProps}
/>
)}
renderContent={renderPopover}
/>
);
}

View file

@ -85,7 +85,7 @@ describe('discover sidebar', function () {
let props: DiscoverSidebarProps;
let comp: ReactWrapper<DiscoverSidebarProps>;
beforeAll(() => {
beforeAll(async () => {
props = getCompProps();
mockDiscoverServices.data.dataViews.getIdsWithTitle = jest
.fn()
@ -95,11 +95,14 @@ describe('discover sidebar', function () {
return { ...dataView, isPersisted: () => true };
});
comp = mountWithIntl(
comp = await mountWithIntl(
<KibanaContextProvider services={mockDiscoverServices}>
<DiscoverSidebar {...props} />
</KibanaContextProvider>
);
// wait for lazy modules
await new Promise((resolve) => setTimeout(resolve, 0));
await comp.update();
});
it('should have Selected Fields and Available Fields with Popular Fields sections', function () {
@ -126,8 +129,10 @@ describe('discover sidebar', function () {
expect(props.editField).toHaveBeenCalledWith();
});
it('should render "Edit field" button', () => {
it('should render "Edit field" button', async () => {
findTestSubject(comp, 'field-bytes').simulate('click');
await new Promise((resolve) => setTimeout(resolve, 0));
await comp.update();
const editFieldButton = findTestSubject(comp, 'discoverFieldListPanelEdit-bytes');
expect(editFieldButton.length).toBe(1);
editFieldButton.simulate('click');

View file

@ -28,10 +28,11 @@ import { isEqual } from 'lodash';
import { FormattedMessage } from '@kbn/i18n-react';
import { DataViewPicker } from '@kbn/unified-search-plugin/public';
import { DataViewField, getFieldSubtypeMulti } from '@kbn/data-views-plugin/public';
import { triggerVisualizeActionsTextBasedLanguages } from '@kbn/unified-field-list-plugin/public';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
import { DiscoverField } from './discover_field';
import { DiscoverFieldSearch } from './discover_field_search';
import { FIELDS_LIMIT_SETTING } from '../../../../../common';
import { FIELDS_LIMIT_SETTING, PLUGIN_ID } from '../../../../../common';
import { groupFields } from './lib/group_fields';
import { getDetails } from './lib/get_details';
import { FieldFilterState, getDefaultFieldFilter, setFieldFilterProp } from './lib/field_filter';
@ -40,7 +41,7 @@ import { DiscoverSidebarResponsiveProps } from './discover_sidebar_responsive';
import { VIEW_MODE } from '../../../../components/view_mode_toggle';
import { DISCOVER_TOUR_STEP_ANCHOR_IDS } from '../../../../components/discover_tour';
import type { DataTableRecord } from '../../../../types';
import { triggerVisualizeActionsTextBasedLanguages } from './lib/visualize_trigger_utils';
import { getUiActions } from '../../../../kibana_services';
/**
* Default number of available fields displayed and added on scroll
@ -187,7 +188,8 @@ export function DiscoverSidebarComponent({
// In this case the fieldsPerPage needs to be adapted
const fieldsRenderedHeight = availableFieldsContainer.current.clientHeight;
const avgHeightPerItem = Math.round(fieldsRenderedHeight / fieldsToRender);
const newFieldsPerPage = Math.round(clientHeight / avgHeightPerItem) + 10;
const newFieldsPerPage =
(avgHeightPerItem > 0 ? Math.round(clientHeight / avgHeightPerItem) : 0) + 10;
if (newFieldsPerPage >= FIELDS_PER_PAGE && newFieldsPerPage !== fieldsPerPage) {
setFieldsPerPage(newFieldsPerPage);
setFieldsToRender(newFieldsPerPage);
@ -314,7 +316,13 @@ export function DiscoverSidebarComponent({
const visualizeAggregateQuery = useCallback(() => {
const aggregateQuery =
state.query && isOfAggregateQueryType(state.query) ? state.query : undefined;
triggerVisualizeActionsTextBasedLanguages(columns, selectedDataView, aggregateQuery);
triggerVisualizeActionsTextBasedLanguages(
getUiActions(),
columns,
PLUGIN_ID,
selectedDataView,
aggregateQuery
);
}, [columns, selectedDataView, state.query]);
if (!selectedDataView) {

View file

@ -190,7 +190,9 @@ describe('discover responsive sidebar', function () {
<DiscoverSidebarResponsive {...props} />
</KibanaContextProvider>
);
comp.update();
// wait for lazy modules
await new Promise((resolve) => setTimeout(resolve, 0));
await comp.update();
});
});

View file

@ -6,7 +6,52 @@ This Kibana plugin contains components and services for field list UI (as in fie
## Components
* `<FieldStats .../>` - loads and renders stats (Top values, Histogram) for a data view field.
* `<FieldStats .../>` - loads and renders stats (Top values, Distribution) for a data view field.
* `<FieldVisualizeButton .../>` - renders a button to open this field in Lens.
* `<FieldPopover .../>` - a popover container component for a field.
* `<FieldPopoverHeader .../>` - this header component included a field name and common actions.
*
* `<FieldPopoverVisualize .../>` - renders Visualize action in the popover footer.
These components can be combined and customized as the following:
```
<FieldPopover
isOpen={isOpen}
closePopover={closePopover}
button={<your trigger>}
renderHeader={() =>
<FieldPopoverHeader
field={field}
closePopover={closePopover}
onAddFieldToWorkspace={onAddFieldToWorkspace}
onAddFilter={onAddFilter}
onEditField={onEditField}
onDeleteField={onDeleteField}
...
/>
}
renderContent={() =>
<>
<FieldStats
field={field}
dataViewOrDataViewId={dataView}
onAddFilter={onAddFilter}
...
/>
<FieldPopoverVisualize
field={field}
datatView={dataView}
originatingApp={'<your app name>'}
...
/>
</>
}
...
/>
```
## Public Services

View file

@ -9,7 +9,7 @@
"description": "Contains functionality for the field list which can be integrated into apps",
"server": true,
"ui": true,
"requiredPlugins": ["dataViews", "data", "fieldFormats", "charts"],
"requiredPlugins": ["dataViews", "data", "fieldFormats", "charts", "uiActions"],
"optionalPlugins": [],
"requiredBundles": []
}

View file

@ -0,0 +1,4 @@
.unifiedFieldList__fieldPopover__fieldPopoverPanel {
min-width: $euiSizeXXL * 6.5;
max-width: $euiSizeXXL * 7.5;
}

View file

@ -0,0 +1,94 @@
/*
* 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 { EuiButton, EuiText, EuiPopoverTitle, EuiPopoverFooter } from '@elastic/eui';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { stubLogstashDataView as dataView } from '@kbn/data-views-plugin/common/data_view.stub';
import { FieldPopover } from './field_popover';
import { FieldPopoverHeader } from './field_popover_header';
describe('UnifiedFieldList <FieldPopover />', () => {
it('should render correctly header only', async () => {
const wrapper = mountWithIntl(
<FieldPopover
isOpen
closePopover={jest.fn()}
button={<EuiButton title="test" />}
renderHeader={() => <EuiText>{'header'}</EuiText>}
/>
);
expect(wrapper.find(EuiText).text()).toBe('header');
expect(wrapper.find(EuiPopoverTitle)).toHaveLength(0);
expect(wrapper.find(EuiPopoverFooter)).toHaveLength(0);
});
it('should render correctly with header and content', async () => {
const wrapper = mountWithIntl(
<FieldPopover
isOpen
closePopover={jest.fn()}
button={<EuiButton title="test" />}
renderHeader={() => <EuiText>{'header'}</EuiText>}
renderContent={() => <EuiText>{'content'}</EuiText>}
/>
);
expect(wrapper.find(EuiText).first().text()).toBe('header');
expect(wrapper.find(EuiText).last().text()).toBe('content');
expect(wrapper.find(EuiPopoverTitle)).toHaveLength(1);
});
it('should render nothing if popover is closed', async () => {
const wrapper = mountWithIntl(
<FieldPopover
isOpen={false}
closePopover={jest.fn()}
button={<EuiButton title="test" />}
renderHeader={() => <EuiText>{'header'}</EuiText>}
renderContent={() => <EuiText>{'content'}</EuiText>}
/>
);
expect(wrapper.text()).toBe('');
expect(wrapper.find(EuiPopoverTitle)).toHaveLength(0);
});
it('should render correctly with popover header and content', async () => {
const mockClose = jest.fn();
const mockEdit = jest.fn();
const fieldName = 'extension';
const wrapper = mountWithIntl(
<FieldPopover
isOpen
closePopover={jest.fn()}
button={<EuiButton title="test" />}
renderHeader={() => (
<FieldPopoverHeader
field={dataView.fields.find((field) => field.name === fieldName)!}
closePopover={mockClose}
onEditField={mockEdit}
/>
)}
renderContent={() => <EuiText>{'content'}</EuiText>}
/>
);
expect(wrapper.find(EuiPopoverTitle).text()).toBe(fieldName);
expect(wrapper.find(EuiText).last().text()).toBe('content');
wrapper
.find(`[data-test-subj="fieldPopoverHeader_editField-${fieldName}"]`)
.first()
.simulate('click');
expect(mockClose).toHaveBeenCalled();
expect(mockEdit).toHaveBeenCalledWith(fieldName);
});
});

View file

@ -0,0 +1,47 @@
/*
* 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 { EuiPopover, EuiPopoverProps, EuiPopoverTitle } from '@elastic/eui';
import './field_popover.scss';
export interface FieldPopoverProps extends EuiPopoverProps {
renderHeader?: () => React.ReactNode;
renderContent?: () => React.ReactNode;
}
export const FieldPopover: React.FC<FieldPopoverProps> = ({
isOpen,
closePopover,
renderHeader,
renderContent,
...otherPopoverProps
}) => {
const header = (isOpen && renderHeader?.()) || null;
const content = (isOpen && renderContent?.()) || null;
return (
<EuiPopover
ownFocus
isOpen={isOpen}
closePopover={closePopover}
display="block"
anchorPosition="rightUp"
data-test-subj="fieldPopover"
panelClassName="unifiedFieldList__fieldPopover__fieldPopoverPanel"
{...otherPopoverProps}
>
{isOpen && (
<>
{content && header ? <EuiPopoverTitle>{header}</EuiPopoverTitle> : header}
{content}
</>
)}
</EuiPopover>
);
};

View file

@ -0,0 +1,167 @@
/*
* 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 { EuiButtonIcon } from '@elastic/eui';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { stubLogstashDataView as dataView } from '@kbn/data-views-plugin/common/data_view.stub';
import { FieldPopoverHeader } from './field_popover_header';
describe('UnifiedFieldList <FieldPopoverHeader />', () => {
it('should render correctly without actions', async () => {
const mockClose = jest.fn();
const fieldName = 'extension';
const wrapper = mountWithIntl(
<FieldPopoverHeader
field={dataView.fields.find((field) => field.name === fieldName)!}
closePopover={mockClose}
/>
);
expect(wrapper.text()).toBe(fieldName);
expect(wrapper.find(EuiButtonIcon)).toHaveLength(0);
});
it('should render correctly with all actions', async () => {
const mockClose = jest.fn();
const fieldName = 'extension';
const field = dataView.fields.find((f) => f.name === fieldName)!;
jest.spyOn(field, 'isRuntimeField', 'get').mockImplementation(() => true);
const wrapper = mountWithIntl(
<FieldPopoverHeader
field={field}
closePopover={mockClose}
onAddFieldToWorkspace={jest.fn()}
onAddFilter={jest.fn()}
onEditField={jest.fn()}
onDeleteField={jest.fn()}
/>
);
expect(wrapper.text()).toBe(fieldName);
expect(
wrapper.find(`[data-test-subj="fieldPopoverHeader_addField-${fieldName}"]`).exists()
).toBeTruthy();
expect(
wrapper.find(`[data-test-subj="fieldPopoverHeader_addExistsFilter-${fieldName}"]`).exists()
).toBeTruthy();
expect(
wrapper.find(`[data-test-subj="fieldPopoverHeader_editField-${fieldName}"]`).exists()
).toBeTruthy();
expect(
wrapper.find(`[data-test-subj="fieldPopoverHeader_deleteField-${fieldName}"]`).exists()
).toBeTruthy();
expect(wrapper.find(EuiButtonIcon)).toHaveLength(4);
});
it('should correctly handle add-field action', async () => {
const mockClose = jest.fn();
const mockAddField = jest.fn();
const fieldName = 'extension';
const field = dataView.fields.find((f) => f.name === fieldName)!;
const wrapper = mountWithIntl(
<FieldPopoverHeader
field={field}
closePopover={mockClose}
onAddFieldToWorkspace={mockAddField}
/>
);
wrapper
.find(`[data-test-subj="fieldPopoverHeader_addField-${fieldName}"]`)
.first()
.simulate('click');
expect(mockClose).toHaveBeenCalled();
expect(mockAddField).toHaveBeenCalledWith(field);
});
it('should correctly handle add-exists-filter action', async () => {
const mockClose = jest.fn();
const mockAddFilter = jest.fn();
const fieldName = 'extension';
const field = dataView.fields.find((f) => f.name === fieldName)!;
// available
let wrapper = mountWithIntl(
<FieldPopoverHeader field={field} closePopover={mockClose} onAddFilter={mockAddFilter} />
);
wrapper
.find(`[data-test-subj="fieldPopoverHeader_addExistsFilter-${fieldName}"]`)
.first()
.simulate('click');
expect(mockClose).toHaveBeenCalled();
expect(mockAddFilter).toHaveBeenCalledWith('_exists_', fieldName, '+');
// hidden
jest.spyOn(field, 'filterable', 'get').mockImplementation(() => false);
wrapper = mountWithIntl(
<FieldPopoverHeader field={field} closePopover={mockClose} onAddFilter={mockAddFilter} />
);
expect(
wrapper.find(`[data-test-subj="fieldPopoverHeader_addExistsFilter-${fieldName}"]`).exists()
).toBeFalsy();
});
it('should correctly handle edit-field action', async () => {
const mockClose = jest.fn();
const mockEditField = jest.fn();
const fieldName = 'extension';
const field = dataView.fields.find((f) => f.name === fieldName)!;
// available
jest.spyOn(field, 'isRuntimeField', 'get').mockImplementation(() => true);
let wrapper = mountWithIntl(
<FieldPopoverHeader field={field} closePopover={mockClose} onEditField={mockEditField} />
);
wrapper
.find(`[data-test-subj="fieldPopoverHeader_editField-${fieldName}"]`)
.first()
.simulate('click');
expect(mockClose).toHaveBeenCalled();
expect(mockEditField).toHaveBeenCalledWith(fieldName);
// hidden
jest.spyOn(field, 'isRuntimeField', 'get').mockImplementation(() => false);
jest.spyOn(field, 'type', 'get').mockImplementation(() => 'unknown');
wrapper = mountWithIntl(
<FieldPopoverHeader field={field} closePopover={mockClose} onEditField={mockEditField} />
);
expect(
wrapper.find(`[data-test-subj="fieldPopoverHeader_editField-${fieldName}"]`).exists()
).toBeFalsy();
});
it('should correctly handle delete-field action', async () => {
const mockClose = jest.fn();
const mockDeleteField = jest.fn();
const fieldName = 'extension';
const field = dataView.fields.find((f) => f.name === fieldName)!;
// available
jest.spyOn(field, 'isRuntimeField', 'get').mockImplementation(() => true);
let wrapper = mountWithIntl(
<FieldPopoverHeader field={field} closePopover={mockClose} onDeleteField={mockDeleteField} />
);
wrapper
.find(`[data-test-subj="fieldPopoverHeader_deleteField-${fieldName}"]`)
.first()
.simulate('click');
expect(mockClose).toHaveBeenCalled();
expect(mockDeleteField).toHaveBeenCalledWith(fieldName);
// hidden
jest.spyOn(field, 'isRuntimeField', 'get').mockImplementation(() => false);
wrapper = mountWithIntl(
<FieldPopoverHeader field={field} closePopover={mockClose} onDeleteField={mockDeleteField} />
);
expect(
wrapper.find(`[data-test-subj="fieldPopoverHeader_deleteField-${fieldName}"]`).exists()
).toBeFalsy();
});
});

View file

@ -0,0 +1,154 @@
/*
* 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 {
EuiButtonIcon,
EuiButtonIconProps,
EuiFlexGroup,
EuiFlexItem,
EuiPopoverProps,
EuiToolTip,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { DataViewField } from '@kbn/data-views-plugin/common';
import type { AddFieldFilterHandler } from '../../types';
export interface FieldPopoverHeaderProps {
field: DataViewField;
closePopover: EuiPopoverProps['closePopover'];
buttonAddFieldToWorkspaceProps?: Partial<EuiButtonIconProps>;
buttonAddFilterProps?: Partial<EuiButtonIconProps>;
buttonEditFieldProps?: Partial<EuiButtonIconProps>;
buttonDeleteFieldProps?: Partial<EuiButtonIconProps>;
onAddFieldToWorkspace?: (field: DataViewField) => unknown;
onAddFilter?: AddFieldFilterHandler;
onEditField?: (fieldName: string) => unknown;
onDeleteField?: (fieldName: string) => unknown;
}
export const FieldPopoverHeader: React.FC<FieldPopoverHeaderProps> = ({
field,
closePopover,
buttonAddFieldToWorkspaceProps,
buttonAddFilterProps,
buttonEditFieldProps,
buttonDeleteFieldProps,
onAddFieldToWorkspace,
onAddFilter,
onEditField,
onDeleteField,
}) => {
if (!field) {
return null;
}
const addFieldToWorkspaceTooltip = i18n.translate(
'unifiedFieldList.fieldPopover.addFieldToWorkspaceLabel',
{
defaultMessage: 'Add "{field}" field',
values: {
field: field.displayName,
},
}
);
const addExistsFilterTooltip = i18n.translate(
'unifiedFieldList.fieldPopover.addExistsFilterLabel',
{
defaultMessage: 'Filter for field present',
}
);
const editFieldTooltip = i18n.translate('unifiedFieldList.fieldPopover.editFieldLabel', {
defaultMessage: 'Edit data view field',
});
const deleteFieldTooltip = i18n.translate('unifiedFieldList.fieldPopover.deleteFieldLabel', {
defaultMessage: 'Delete data view field',
});
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>
</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}>
<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>
);
};

View file

@ -0,0 +1,21 @@
/*
* 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 { EuiPopoverFooter } from '@elastic/eui';
import { FieldVisualizeButton, type FieldVisualizeButtonProps } from '../field_visualize_button';
export type FieldPopoverVisualizeProps = Omit<FieldVisualizeButtonProps, 'wrapInContainer'>;
const wrapInContainer = (element: React.ReactElement): React.ReactElement => {
return <EuiPopoverFooter>{element}</EuiPopoverFooter>;
};
export const FieldPopoverVisualize: React.FC<FieldPopoverVisualizeProps> = (props) => {
return <FieldVisualizeButton {...props} wrapInContainer={wrapInContainer} />;
};

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { type FieldPopoverProps, FieldPopover } from './field_popover';
export { type FieldPopoverHeaderProps, FieldPopoverHeader } from './field_popover_header';
export { type FieldPopoverVisualizeProps, FieldPopoverVisualize } from './field_popover_visualize';

View file

@ -0,0 +1,131 @@
/*
* 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 { act } from 'react-dom/test-utils';
import { ReactWrapper } from 'enzyme';
import { EuiButton, EuiPopoverFooter } from '@elastic/eui';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { stubLogstashDataView as dataView } from '@kbn/data-views-plugin/common/data_view.stub';
import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
import { FieldVisualizeButton } from './field_visualize_button';
import {
ACTION_VISUALIZE_LENS_FIELD,
VISUALIZE_FIELD_TRIGGER,
VISUALIZE_GEO_FIELD_TRIGGER,
createAction,
VisualizeFieldContext,
} from '@kbn/ui-actions-plugin/public';
import { TriggerContract } from '@kbn/ui-actions-plugin/public/triggers';
const ORIGINATING_APP = 'test';
const mockExecuteAction = jest.fn();
const uiActions = uiActionsPluginMock.createStartContract();
const visualizeAction = createAction<VisualizeFieldContext>({
type: ACTION_VISUALIZE_LENS_FIELD,
id: ACTION_VISUALIZE_LENS_FIELD,
getDisplayName: () => 'test',
isCompatible: async () => true,
execute: async (context: VisualizeFieldContext) => {
mockExecuteAction(context);
},
getHref: async () => '/app/test',
});
jest.spyOn(uiActions, 'getTriggerCompatibleActions').mockResolvedValue([visualizeAction]);
jest.spyOn(uiActions, 'getTrigger').mockReturnValue({
id: ACTION_VISUALIZE_LENS_FIELD,
exec: mockExecuteAction,
} as unknown as TriggerContract<object>);
describe('UnifiedFieldList <FieldVisualizeButton />', () => {
it('should render correctly', async () => {
const fieldName = 'extension';
const field = dataView.fields.find((f) => f.name === fieldName)!;
const fieldNameKeyword = 'extension.keyword';
const fieldKeyword = dataView.fields.find((f) => f.name === fieldNameKeyword)!;
const contextualFields = ['bytes'];
jest.spyOn(field, 'visualizable', 'get').mockImplementationOnce(() => false);
jest.spyOn(fieldKeyword, 'visualizable', 'get').mockImplementationOnce(() => true);
let wrapper: ReactWrapper;
await act(async () => {
wrapper = await mountWithIntl(
<FieldVisualizeButton
field={field}
dataView={dataView}
multiFields={[fieldKeyword]}
contextualFields={contextualFields}
originatingApp={ORIGINATING_APP}
uiActions={uiActions}
wrapInContainer={(element) => <EuiPopoverFooter>{element}</EuiPopoverFooter>}
/>
);
});
await wrapper!.update();
expect(uiActions.getTriggerCompatibleActions).toHaveBeenCalledWith(VISUALIZE_FIELD_TRIGGER, {
contextualFields,
dataViewSpec: dataView.toSpec(false),
fieldName: fieldNameKeyword,
});
expect(wrapper!.text()).toBe('Visualize');
wrapper!.find(EuiButton).simulate('click');
expect(mockExecuteAction).toHaveBeenCalledWith({
contextualFields,
dataViewSpec: dataView.toSpec(false),
fieldName: fieldNameKeyword,
originatingApp: ORIGINATING_APP,
});
expect(wrapper!.find(EuiButton).prop('href')).toBe('/app/test');
expect(wrapper!.find(EuiPopoverFooter).find(EuiButton).exists()).toBeTruthy(); // wrapped in a container
});
it('should render correctly for geo fields', async () => {
const fieldName = 'geo.coordinates';
const field = dataView.fields.find((f) => f.name === fieldName)!;
jest.spyOn(field, 'visualizable', 'get').mockImplementationOnce(() => true);
let wrapper: ReactWrapper;
await act(async () => {
wrapper = await mountWithIntl(
<FieldVisualizeButton
field={field}
dataView={dataView}
originatingApp={ORIGINATING_APP}
uiActions={uiActions}
/>
);
});
await wrapper!.update();
expect(uiActions.getTriggerCompatibleActions).toHaveBeenCalledWith(
VISUALIZE_GEO_FIELD_TRIGGER,
{
contextualFields: [],
dataViewSpec: dataView.toSpec(false),
fieldName,
}
);
expect(wrapper!.text()).toBe('Visualize');
wrapper!.find(EuiButton).simulate('click');
expect(mockExecuteAction).toHaveBeenCalledWith({
contextualFields: [],
dataViewSpec: dataView.toSpec(false),
fieldName,
originatingApp: ORIGINATING_APP,
});
});
});

View file

@ -7,29 +7,48 @@
*/
import React, { useEffect, useState } from 'react';
import { EuiButtonProps } from '@elastic/eui';
import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import { triggerVisualizeActions, VisualizeInformation } from './lib/visualize_trigger_utils';
import { getVisualizeInformation } from './lib/visualize_trigger_utils';
import { DiscoverFieldVisualizeInner } from './discover_field_visualize_inner';
import type { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { FieldVisualizeButtonInner } from './field_visualize_button_inner';
import {
triggerVisualizeActions,
getVisualizeInformation,
type VisualizeInformation,
} from './visualize_trigger_utils';
interface Props {
export interface FieldVisualizeButtonProps {
field: DataViewField;
dataView: DataView;
originatingApp: string; // plugin id
uiActions: UiActionsStart;
multiFields?: DataViewField[];
contextualFields: string[];
contextualFields?: string[]; // names of fields which were also selected (like columns in Discover grid)
trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void;
buttonProps?: Partial<EuiButtonProps>;
wrapInContainer?: (element: React.ReactElement) => React.ReactElement;
}
export const DiscoverFieldVisualize: React.FC<Props> = React.memo(
({ field, dataView, contextualFields, trackUiMetric, multiFields }) => {
export const FieldVisualizeButton: React.FC<FieldVisualizeButtonProps> = React.memo(
({
field,
dataView,
contextualFields,
trackUiMetric,
multiFields,
originatingApp,
uiActions,
buttonProps,
wrapInContainer,
}) => {
const [visualizeInfo, setVisualizeInfo] = useState<VisualizeInformation>();
useEffect(() => {
getVisualizeInformation(field, dataView, contextualFields, multiFields).then(
getVisualizeInformation(uiActions, field, dataView, contextualFields, multiFields).then(
setVisualizeInfo
);
}, [contextualFields, field, dataView, multiFields]);
}, [contextualFields, field, dataView, multiFields, uiActions]);
if (!visualizeInfo) {
return null;
@ -43,17 +62,26 @@ export const DiscoverFieldVisualize: React.FC<Props> = React.memo(
const triggerVisualization = (updatedDataView: DataView) => {
trackUiMetric?.(METRIC_TYPE.CLICK, 'visualize_link_click');
triggerVisualizeActions(visualizeInfo.field, contextualFields, updatedDataView);
triggerVisualizeActions(
uiActions,
visualizeInfo.field,
contextualFields,
originatingApp,
updatedDataView
);
};
triggerVisualization(dataView);
};
return (
<DiscoverFieldVisualizeInner
const element = (
<FieldVisualizeButtonInner
field={field}
visualizeInfo={visualizeInfo}
handleVisualizeLinkClick={handleVisualizeLinkClick}
buttonProps={buttonProps}
/>
);
return wrapInContainer?.(element) || element;
}
);

View file

@ -7,35 +7,40 @@
*/
import React from 'react';
import { EuiButton, EuiPopoverFooter } from '@elastic/eui';
import { EuiButton, EuiButtonProps } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import type { DataViewField } from '@kbn/data-views-plugin/public';
import { VisualizeInformation } from './lib/visualize_trigger_utils';
import { VisualizeInformation } from './visualize_trigger_utils';
interface DiscoverFieldVisualizeInnerProps {
interface FieldVisualizeButtonInnerProps {
field: DataViewField;
visualizeInfo: VisualizeInformation;
handleVisualizeLinkClick: (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => void;
buttonProps?: Partial<EuiButtonProps>;
}
export const DiscoverFieldVisualizeInner = (props: DiscoverFieldVisualizeInnerProps) => {
const { field, visualizeInfo, handleVisualizeLinkClick } = props;
export const FieldVisualizeButtonInner: React.FC<FieldVisualizeButtonInnerProps> = ({
field,
visualizeInfo,
handleVisualizeLinkClick,
buttonProps,
}) => {
return (
<EuiPopoverFooter>
<>
{/* eslint-disable-next-line @elastic/eui/href-or-on-click */}
<EuiButton
fullWidth
size="s"
data-test-subj={`fieldVisualize-${field.name}`}
{...(buttonProps || {})}
href={visualizeInfo.href}
onClick={handleVisualizeLinkClick}
data-test-subj={`fieldVisualize-${field.name}`}
>
<FormattedMessage
id="discover.fieldChooser.visualizeButton.label"
id="unifiedFieldList.fieldVisualizeButton.label"
defaultMessage="Visualize"
/>
</EuiButton>
</EuiPopoverFooter>
</>
);
};

View file

@ -0,0 +1,16 @@
/*
* 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 { type FieldVisualizeButtonProps, FieldVisualizeButton } from './field_visualize_button';
export {
triggerVisualizeActions,
triggerVisualizeActionsTextBasedLanguages,
getVisualizeInformation,
type VisualizeInformation,
} from './visualize_trigger_utils';

View file

@ -7,7 +7,7 @@
*/
import type { DataViewField, DataView } from '@kbn/data-views-plugin/public';
import type { Action } from '@kbn/ui-actions-plugin/public';
import type { Action, UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { getVisualizeInformation } from './visualize_trigger_utils';
const field = {
@ -26,11 +26,9 @@ const mockGetActions = jest.fn<Promise<Array<Action<object>>>, [string, { fieldN
() => Promise.resolve([])
);
jest.mock('../../../../../kibana_services', () => ({
getUiActions: () => ({
getTriggerCompatibleActions: mockGetActions,
}),
}));
const uiActions = {
getTriggerCompatibleActions: mockGetActions,
} as unknown as UiActionsStart;
const action: Action = {
id: 'action',
@ -51,7 +49,13 @@ describe('visualize_trigger_utils', () => {
describe('getVisualizeInformation', () => {
it('should return for a visualizeable field with an action', async () => {
mockGetActions.mockResolvedValue([action]);
const information = await getVisualizeInformation(field, dataViewMock, [], undefined);
const information = await getVisualizeInformation(
uiActions,
field,
dataViewMock,
[],
undefined
);
expect(information).not.toBeUndefined();
expect(information?.field).toHaveProperty('name', 'fieldName');
expect(information?.href).toBeUndefined();
@ -59,7 +63,13 @@ describe('visualize_trigger_utils', () => {
it('should return field and href from the action', async () => {
mockGetActions.mockResolvedValue([{ ...action, getHref: () => Promise.resolve('hreflink') }]);
const information = await getVisualizeInformation(field, dataViewMock, [], undefined);
const information = await getVisualizeInformation(
uiActions,
field,
dataViewMock,
[],
undefined
);
expect(information).not.toBeUndefined();
expect(information?.field).toHaveProperty('name', 'fieldName');
expect(information).toHaveProperty('href', 'hreflink');
@ -68,6 +78,7 @@ describe('visualize_trigger_utils', () => {
it('should return undefined if no field has a compatible action', async () => {
mockGetActions.mockResolvedValue([]);
const information = await getVisualizeInformation(
uiActions,
{ ...field, name: 'rootField' } as DataViewField,
dataViewMock,
[],
@ -82,6 +93,7 @@ describe('visualize_trigger_utils', () => {
it('should return information for the root field, when multi fields and root are having actions', async () => {
mockGetActions.mockResolvedValue([action]);
const information = await getVisualizeInformation(
uiActions,
{ ...field, name: 'rootField' } as DataViewField,
dataViewMock,
[],
@ -102,6 +114,7 @@ describe('visualize_trigger_utils', () => {
return [];
});
const information = await getVisualizeInformation(
uiActions,
{ ...field, name: 'rootField' } as DataViewField,
dataViewMock,
[],

View file

@ -7,6 +7,7 @@
*/
import {
type UiActionsStart,
VISUALIZE_FIELD_TRIGGER,
VISUALIZE_GEO_FIELD_TRIGGER,
visualizeFieldTrigger,
@ -15,8 +16,6 @@ import {
import type { AggregateQuery } from '@kbn/es-query';
import type { DataViewField, DataView } from '@kbn/data-views-plugin/public';
import { KBN_FIELD_TYPES } from '@kbn/data-plugin/public';
import { getUiActions } from '../../../../../kibana_services';
import { PLUGIN_ID } from '../../../../../../common';
export function getTriggerConstant(type: string) {
return type === KBN_FIELD_TYPES.GEO_POINT || type === KBN_FIELD_TYPES.GEO_SHAPE
@ -31,12 +30,13 @@ function getTrigger(type: string) {
}
async function getCompatibleActions(
uiActions: UiActionsStart,
fieldName: string,
dataView: DataView,
contextualFields: string[],
contextualFields: string[] = [],
trigger: typeof VISUALIZE_FIELD_TRIGGER | typeof VISUALIZE_GEO_FIELD_TRIGGER
) {
const compatibleActions = await getUiActions().getTriggerCompatibleActions(trigger, {
const compatibleActions = await uiActions.getTriggerCompatibleActions(trigger, {
dataViewSpec: dataView.toSpec(false),
fieldName,
contextualFields,
@ -45,8 +45,10 @@ async function getCompatibleActions(
}
export function triggerVisualizeActions(
uiActions: UiActionsStart,
field: DataViewField,
contextualFields: string[],
contextualFields: string[] = [],
originatingApp: string,
dataView?: DataView
) {
if (!dataView) return;
@ -55,13 +57,15 @@ export function triggerVisualizeActions(
dataViewSpec: dataView.toSpec(false),
fieldName: field.name,
contextualFields,
originatingApp: PLUGIN_ID,
originatingApp,
};
getUiActions().getTrigger(trigger).exec(triggerOptions);
uiActions.getTrigger(trigger).exec(triggerOptions);
}
export function triggerVisualizeActionsTextBasedLanguages(
uiActions: UiActionsStart,
contextualFields: string[],
originatingApp: string,
dataView?: DataView,
query?: AggregateQuery
) {
@ -70,10 +74,10 @@ export function triggerVisualizeActionsTextBasedLanguages(
dataViewSpec: dataView.toSpec(false),
fieldName: '',
contextualFields,
originatingApp: PLUGIN_ID,
originatingApp,
query,
};
getUiActions().getTrigger(VISUALIZE_FIELD_TRIGGER).exec(triggerOptions);
uiActions.getTrigger(VISUALIZE_FIELD_TRIGGER).exec(triggerOptions);
}
export interface VisualizeInformation {
@ -86,9 +90,10 @@ export interface VisualizeInformation {
* that has a compatible visualize uiAction.
*/
export async function getVisualizeInformation(
uiActions: UiActionsStart,
field: DataViewField,
dataView: DataView | undefined,
contextualFields: string[],
contextualFields: string[] = [],
multiFields: DataViewField[] = []
): Promise<VisualizeInformation | undefined> {
if (field.name === '_id' || !dataView?.id) {
@ -102,6 +107,7 @@ export async function getVisualizeInformation(
}
// Retrieve compatible actions for the specific field
const actions = await getCompatibleActions(
uiActions,
f.name,
dataView,
contextualFields,
@ -111,7 +117,7 @@ export async function getVisualizeInformation(
// if the field has compatible actions use this field for visualizing
if (actions.length > 0) {
const triggerOptions = {
dataViewSpec: dataView?.toSpec(),
dataViewSpec: dataView?.toSpec(false),
fieldName: f.name,
contextualFields,
trigger: getTrigger(f.type),

View file

@ -16,6 +16,22 @@ export type {
} from '../common/types';
export type { FieldStatsProps, FieldStatsServices } from './components/field_stats';
export { FieldStats } from './components/field_stats';
export {
FieldPopover,
type FieldPopoverProps,
FieldPopoverHeader,
type FieldPopoverHeaderProps,
FieldPopoverVisualize,
type FieldPopoverVisualizeProps,
} from './components/field_popover';
export {
FieldVisualizeButton,
type FieldVisualizeButtonProps,
getVisualizeInformation,
triggerVisualizeActions,
triggerVisualizeActionsTextBasedLanguages,
type VisualizeInformation,
} from './components/field_visualize_button';
export { loadFieldStats } from './services/field_stats';
export { loadFieldExisting } from './services/field_existing';

View file

@ -14,4 +14,8 @@ export interface UnifiedFieldListPluginSetup {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface UnifiedFieldListPluginStart {}
export type AddFieldFilterHandler = (field: DataViewField, value: unknown, type: '+' | '-') => void;
export type AddFieldFilterHandler = (
field: DataViewField | '_exists_',
value: unknown,
type: '+' | '-'
) => void;

View file

@ -18,6 +18,7 @@
{ "path": "../kibana_react/tsconfig.json" },
{ "path": "../data_views/tsconfig.json" },
{ "path": "../data/tsconfig.json" },
{ "path": "../charts/tsconfig.json" }
{ "path": "../charts/tsconfig.json" },
{ "path": "../ui_actions/tsconfig.json" }
]
}

View file

@ -53,7 +53,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await testSubjects.exists('toggleFieldFilterButton')).to.be(true);
expect(await testSubjects.exists('fieldTypesHelpButton')).to.be(true);
await testSubjects.click('field-@message-showDetails');
expect(await testSubjects.exists('discoverFieldListPanelEditItem')).to.be(true);
expect(await testSubjects.exists('discoverFieldListPanelEdit-@message')).to.be(true);
await PageObjects.discover.selectTextBaseLang('SQL');

View file

@ -47,6 +47,9 @@ export function convertDataViewIntoLensIndexPattern(
meta: dataView.metaFields.includes(field.name),
esTypes: field.esTypes,
scripted: field.scripted,
isMapped: field.isMapped,
customLabel: field.customLabel,
runtimeField: field.runtimeField,
runtime: Boolean(field.runtimeField),
timeSeriesMetricType: field.timeSeriesMetric,
timeSeriesRollup: field.isRolledUpField,

View file

@ -9,14 +9,17 @@ import { i18n } from '@kbn/i18n';
import { DOCUMENT_FIELD_NAME } from '../../common';
import type { IndexPatternField } from '../types';
const customLabel = i18n.translate('xpack.lens.indexPattern.records', {
defaultMessage: 'Records',
});
/**
* This is a special-case field which allows us to perform
* document-level operations such as count.
*/
export const documentField: IndexPatternField = {
displayName: i18n.translate('xpack.lens.indexPattern.records', {
defaultMessage: 'Records',
}),
displayName: customLabel,
customLabel,
name: DOCUMENT_FIELD_NAME,
type: 'document',
aggregatable: true,

View file

@ -54,7 +54,3 @@
min-width: 260px;
max-width: 300px;
}
.lnsFieldItem__fieldPanelTitle {
text-transform: none;
}

View file

@ -23,9 +23,9 @@ import { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/common';
import { DataView, DataViewField } from '@kbn/data-views-plugin/common';
import { loadFieldStats } from '@kbn/unified-field-list-plugin/public/services/field_stats';
import { FieldStats } from '@kbn/unified-field-list-plugin/public';
import { FieldStats, FieldVisualizeButton } from '@kbn/unified-field-list-plugin/public';
import { DOCUMENT_FIELD_NAME } from '../../common';
import { LensFieldIcon } from '../shared_components';
@ -69,6 +69,14 @@ const InnerFieldItemWrapper: React.FC<FieldItemProps> = (props) => {
);
};
async function getComponent(props: FieldItemProps) {
const instance = await mountWithIntl(<InnerFieldItemWrapper {...props} />);
// wait for lazy modules
await new Promise((resolve) => setTimeout(resolve, 0));
await instance.update();
return instance;
}
describe('IndexPattern Field Item', () => {
let defaultProps: FieldItemProps;
let indexPattern: IndexPattern;
@ -122,6 +130,13 @@ describe('IndexPattern Field Item', () => {
aggregatable: true,
searchable: true,
},
{
name: 'geo.coordinates',
displayName: 'geo.coordinates',
type: 'geo_shape',
aggregatable: true,
searchable: true,
},
documentField,
],
} as IndexPattern;
@ -173,8 +188,8 @@ describe('IndexPattern Field Item', () => {
(loadFieldStats as jest.Mock).mockImplementation(() => Promise.resolve({}));
});
it('should display displayName of a field', () => {
const wrapper = mountWithIntl(<InnerFieldItemWrapper {...defaultProps} />);
it('should display displayName of a field', async () => {
const wrapper = await getComponent(defaultProps);
// Using .toContain over .toEqual because this element includes text from <EuiScreenReaderOnly>
// which can't be seen, but shows in the text content
@ -183,13 +198,11 @@ describe('IndexPattern Field Item', () => {
);
});
it('should show gauge icon for gauge fields', () => {
const wrapper = mountWithIntl(
<InnerFieldItemWrapper
{...defaultProps}
field={{ ...defaultProps.field, timeSeriesMetricType: 'gauge' }}
/>
);
it('should show gauge icon for gauge fields', async () => {
const wrapper = await getComponent({
...defaultProps,
field: { ...defaultProps.field, timeSeriesMetricType: 'gauge' },
});
// Using .toContain over .toEqual because this element includes text from <EuiScreenReaderOnly>
// which can't be seen, but shows in the text content
@ -198,9 +211,11 @@ describe('IndexPattern Field Item', () => {
it('should render edit field button if callback is set', async () => {
const editFieldSpy = jest.fn();
const wrapper = mountWithIntl(
<InnerFieldItemWrapper {...defaultProps} editField={editFieldSpy} hideDetails />
);
const wrapper = await getComponent({
...defaultProps,
editField: editFieldSpy,
hideDetails: true,
});
await clickField(wrapper, 'bytes');
await wrapper.update();
const popoverContent = wrapper.find(EuiPopover).prop('children');
@ -210,7 +225,7 @@ describe('IndexPattern Field Item', () => {
{popoverContent as ReactElement}
</KibanaContextProvider>
)
.find('[data-test-subj="lnsFieldListPanelEdit"]')
.find('[data-test-subj="fieldPopoverHeader_editField-bytes"]')
.first()
.simulate('click');
});
@ -219,14 +234,12 @@ describe('IndexPattern Field Item', () => {
it('should not render edit field button for document field', async () => {
const editFieldSpy = jest.fn();
const wrapper = mountWithIntl(
<InnerFieldItemWrapper
{...defaultProps}
field={documentField}
editField={editFieldSpy}
hideDetails
/>
);
const wrapper = await getComponent({
...defaultProps,
field: documentField,
editField: editFieldSpy,
hideDetails: true,
});
await clickField(wrapper, documentField.name);
await wrapper.update();
const popoverContent = wrapper.find(EuiPopover).prop('children');
@ -236,12 +249,20 @@ describe('IndexPattern Field Item', () => {
{popoverContent as ReactElement}
</KibanaContextProvider>
)
.find('[data-test-subj="lnsFieldListPanelEdit"]')
.find('[data-test-subj="fieldPopoverHeader_editField-bytes"]')
.exists()
).toBeFalsy();
});
it('should pass add filter callback and pass result to filter manager', async () => {
let resolveFunction: (arg: unknown) => void;
(loadFieldStats as jest.Mock).mockImplementation(() => {
return new Promise((resolve) => {
resolveFunction = resolve;
});
});
const field = {
name: 'test',
displayName: 'testLabel',
@ -252,25 +273,37 @@ describe('IndexPattern Field Item', () => {
};
const editFieldSpy = jest.fn();
const wrapper = mountWithIntl(
<InnerFieldItemWrapper {...defaultProps} field={field} editField={editFieldSpy} />
);
const wrapper = await getComponent({
...defaultProps,
field,
editField: editFieldSpy,
});
await clickField(wrapper, field.name);
await wrapper.update();
const popoverContent = wrapper.find(EuiPopover).prop('children');
const instance = mountWithIntl(
<KibanaContextProvider services={mockedServices}>
{popoverContent as ReactElement}
</KibanaContextProvider>
);
const onAddFilter = instance.find(FieldStats).prop('onAddFilter');
onAddFilter!(field as DataViewField, 'abc', '+');
await act(async () => {
resolveFunction!({
totalDocuments: 4633,
sampledDocuments: 4633,
sampledValues: 4633,
topValues: {
buckets: [{ count: 147, key: 'abc' }],
},
});
});
await wrapper.update();
wrapper.find(`[data-test-subj="plus-${field.name}-abc"]`).first().simulate('click');
expect(mockedServices.data.query.filterManager.addFilters).toHaveBeenCalledWith([
expect.objectContaining({ query: { match_phrase: { test: 'abc' } } }),
]);
});
it('should request field stats every time the button is clicked', async () => {
const dataViewField = new DataViewField(defaultProps.field);
let resolveFunction: (arg: unknown) => void;
(loadFieldStats as jest.Mock).mockImplementation(() => {
@ -279,7 +312,7 @@ describe('IndexPattern Field Item', () => {
});
});
const wrapper = mountWithIntl(<InnerFieldItemWrapper {...defaultProps} />);
const wrapper = await getComponent(defaultProps);
await clickField(wrapper, 'bytes');
@ -299,7 +332,7 @@ describe('IndexPattern Field Item', () => {
},
fromDate: 'now-7d',
toDate: 'now',
field: defaultProps.field,
field: dataViewField,
});
expect(wrapper.find(EuiPopover).prop('isOpen')).toEqual(true);
@ -384,14 +417,15 @@ describe('IndexPattern Field Item', () => {
},
fromDate: 'now-14d',
toDate: 'now-7d',
field: defaultProps.field,
field: dataViewField,
});
});
it('should not request field stats for document field', async () => {
const wrapper = await mountWithIntl(
<InnerFieldItemWrapper {...defaultProps} field={documentField} />
);
const wrapper = await getComponent({
...defaultProps,
field: documentField,
});
await clickField(wrapper, DOCUMENT_FIELD_NAME);
@ -404,18 +438,16 @@ describe('IndexPattern Field Item', () => {
});
it('should not request field stats for range fields', async () => {
const wrapper = await mountWithIntl(
<InnerFieldItemWrapper
{...defaultProps}
field={{
name: 'ip_range',
displayName: 'ip_range',
type: 'ip_range',
aggregatable: true,
searchable: true,
}}
/>
);
const wrapper = await getComponent({
...defaultProps,
field: {
name: 'ip_range',
displayName: 'ip_range',
type: 'ip_range',
aggregatable: true,
searchable: true,
},
});
await clickField(wrapper, 'ip_range');
@ -425,6 +457,30 @@ describe('IndexPattern Field Item', () => {
expect(wrapper.find(EuiPopover).prop('isOpen')).toEqual(true);
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
expect(wrapper.find(FieldStats).text()).toBe('Analysis is not available for this field.');
expect(wrapper.find(FieldVisualizeButton).exists()).toBeFalsy();
});
it('should not request field stats for geo fields but render Visualize button', async () => {
const wrapper = await getComponent({
...defaultProps,
field: {
name: 'geo.coordinates',
displayName: 'geo.coordinates',
type: 'geo_shape',
aggregatable: true,
searchable: true,
},
});
await clickField(wrapper, 'geo.coordinates');
await wrapper.update();
expect(loadFieldStats).toHaveBeenCalled();
expect(wrapper.find(EuiPopover).prop('isOpen')).toEqual(true);
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
expect(wrapper.find(FieldStats).text()).toBe('Analysis is not available for this field.');
expect(wrapper.find(FieldVisualizeButton).exists()).toBeTruthy();
});
it('should display Explore in discover button', async () => {

View file

@ -8,36 +8,30 @@
import './field_item.scss';
import React, { useCallback, useState, useMemo } from 'react';
import {
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiIconTip,
EuiPopover,
EuiPopoverTitle,
EuiPopoverFooter,
EuiText,
EuiTitle,
EuiToolTip,
EuiButton,
} from '@elastic/eui';
import { EuiIconTip, EuiText, EuiButton, EuiPopoverFooter } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { FieldButton } from '@kbn/react-field';
import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import { EuiHighlight } from '@elastic/eui';
import { Filter, Query } from '@kbn/es-query';
import { DataViewField } from '@kbn/data-views-plugin/common';
import { DataViewField, type DataView } from '@kbn/data-views-plugin/common';
import { ChartsPluginSetup } from '@kbn/charts-plugin/public';
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { AddFieldFilterHandler, FieldStats } from '@kbn/unified-field-list-plugin/public';
import {
AddFieldFilterHandler,
FieldStats,
FieldPopover,
FieldPopoverHeader,
FieldPopoverVisualize,
} from '@kbn/unified-field-list-plugin/public';
import { generateFilters, getEsQueryConfig } from '@kbn/data-plugin/public';
import { DragDrop, DragDropIdentifier } from '../drag_drop';
import { DatasourceDataPanelProps, DataType, DraggedField } from '../types';
import { APP_ID } from '../../common/constants';
import { DragDrop } from '../drag_drop';
import { DatasourceDataPanelProps, DataType } from '../types';
import { DOCUMENT_FIELD_NAME } from '../../common';
import type { IndexPattern, IndexPatternField } from '../types';
import { LensFieldIcon } from '../shared_components/field_picker/lens_field_icon';
import { VisualizeGeoFieldButton } from './visualize_geo_field_button';
import type { LensAppServices } from '../app_plugin/types';
import { debouncedComponent } from '../debounced_component';
import { getFieldType } from './pure_utils';
@ -81,50 +75,64 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) {
itemIndex,
groupIndex,
dropOntoWorkspace,
hasSuggestionForField,
editField,
removeField,
} = props;
const dataViewField = useMemo(() => new DataViewField(field), [field]);
const services = useKibana<LensAppServices>().services;
const filterManager = services?.data?.query?.filterManager;
const [infoIsOpen, setOpen] = useState(false);
const closeAndEdit = useMemo(
const togglePopover = useCallback(() => {
setOpen((value) => !value);
}, [setOpen]);
const closePopover = useCallback(() => {
setOpen(false);
}, [setOpen]);
const addFilterAndClose: AddFieldFilterHandler | undefined = useMemo(
() =>
editField
? (name: string) => {
editField(name);
setOpen(false);
filterManager
? (clickedField, values, operation) => {
closePopover();
const newFilters = generateFilters(
filterManager,
clickedField,
values,
operation,
indexPattern
);
filterManager.addFilters(newFilters);
}
: undefined,
[editField, setOpen]
[indexPattern, filterManager, closePopover]
);
const closeAndRemove = useMemo(
const editFieldAndClose = useMemo(
() =>
editField && dataViewField.name !== DOCUMENT_FIELD_NAME
? (name: string) => {
closePopover();
editField(name);
}
: undefined,
[editField, closePopover, dataViewField.name]
);
const removeFieldAndClose = useMemo(
() =>
removeField
? (name: string) => {
closePopover();
removeField(name);
setOpen(false);
}
: undefined,
[removeField, setOpen]
[removeField, closePopover]
);
const dropOntoWorkspaceAndClose = useCallback(
(droppedField: DragDropIdentifier) => {
dropOntoWorkspace(droppedField);
setOpen(false);
},
[dropOntoWorkspace, setOpen]
);
function togglePopover() {
setOpen(!infoIsOpen);
}
const onDragStart = useCallback(() => {
setOpen(false);
}, [setOpen]);
const value = useMemo(
() => ({
field,
@ -137,6 +145,16 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) {
}),
[field, indexPattern.id, itemIndex]
);
const dropOntoWorkspaceAndClose = useCallback(() => {
closePopover();
dropOntoWorkspace(value);
}, [dropOntoWorkspace, closePopover, value]);
const onDragStart = useCallback(() => {
setOpen(false);
}, [setOpen]);
const order = useMemo(() => [0, groupIndex, itemIndex], [groupIndex, itemIndex]);
const lensFieldIcon = <LensFieldIcon type={getFieldType(field) as DataType} />;
@ -162,12 +180,15 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) {
size="s"
/>
);
return (
<li>
<EuiPopover
ownFocus
<FieldPopover
isOpen={infoIsOpen}
closePopover={closePopover}
panelClassName="lnsFieldItem__fieldPanel"
initialFocus=".lnsFieldItem__fieldPanel"
className="lnsFieldItem__popoverAnchor"
display="block"
data-test-subj="lnsFieldListPanelField"
container={document.querySelector<HTMLElement>('.application') || undefined}
button={
@ -206,152 +227,67 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) {
/>
</DragDrop>
}
isOpen={infoIsOpen}
closePopover={() => setOpen(false)}
anchorPosition="rightUp"
panelClassName="lnsFieldItem__fieldPanel"
initialFocus=".lnsFieldItem__fieldPanel"
>
{infoIsOpen && (
<FieldItemPopoverContents
{...props}
editField={closeAndEdit}
removeField={closeAndRemove}
dropOntoWorkspace={dropOntoWorkspaceAndClose}
/>
)}
</EuiPopover>
renderHeader={() => {
const canAddToWorkspace = hasSuggestionForField(value);
const buttonTitle = canAddToWorkspace
? i18n.translate('xpack.lens.indexPattern.moveToWorkspace', {
defaultMessage: 'Add {field} to workspace',
values: {
field: value.field.name,
},
})
: i18n.translate('xpack.lens.indexPattern.moveToWorkspaceDisabled', {
defaultMessage:
"This field can't be added to the workspace automatically. You can still use it directly in the configuration panel.",
});
return (
<FieldPopoverHeader
field={dataViewField}
closePopover={closePopover}
buttonAddFieldToWorkspaceProps={{
isDisabled: !canAddToWorkspace,
'aria-label': buttonTitle,
}}
onAddFieldToWorkspace={dropOntoWorkspaceAndClose}
onAddFilter={addFilterAndClose}
onEditField={editFieldAndClose}
onDeleteField={removeFieldAndClose}
/>
);
}}
renderContent={
!hideDetails
? () => (
<FieldItemPopoverContents
{...props}
dataViewField={dataViewField}
onAddFilter={addFilterAndClose}
/>
)
: undefined
}
/>
</li>
);
};
export const FieldItem = debouncedComponent(InnerFieldItem);
function FieldPanelHeader({
indexPatternId,
field,
hasSuggestionForField,
dropOntoWorkspace,
editField,
removeField,
}: {
field: IndexPatternField;
indexPatternId: string;
hasSuggestionForField: DatasourceDataPanelProps['hasSuggestionForField'];
dropOntoWorkspace: DatasourceDataPanelProps['dropOntoWorkspace'];
editField?: (name: string) => void;
removeField?: (name: string) => void;
}) {
const draggableField = {
indexPatternId,
id: field.name,
field,
humanData: {
label: field.displayName,
},
};
return (
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem>
<EuiTitle size="xxs">
<h5 className="eui-textBreakWord lnsFieldItem__fieldPanelTitle">{field.displayName}</h5>
</EuiTitle>
</EuiFlexItem>
<DragToWorkspaceButton
isEnabled={hasSuggestionForField(draggableField)}
dropOntoWorkspace={dropOntoWorkspace}
field={draggableField}
/>
{editField && field.name !== DOCUMENT_FIELD_NAME && (
<EuiFlexItem grow={false}>
<EuiToolTip
content={i18n.translate('xpack.lens.indexPattern.editFieldLabel', {
defaultMessage: 'Edit data view field',
})}
>
<EuiButtonIcon
onClick={() => editField(field.name)}
iconType="pencil"
data-test-subj="lnsFieldListPanelEdit"
aria-label={i18n.translate('xpack.lens.indexPattern.editFieldLabel', {
defaultMessage: 'Edit data view field',
})}
/>
</EuiToolTip>
</EuiFlexItem>
)}
{removeField && field.runtime && (
<EuiFlexItem grow={false}>
<EuiToolTip
content={i18n.translate('xpack.lens.indexPattern.removeFieldLabel', {
defaultMessage: 'Remove data view field',
})}
>
<EuiButtonIcon
onClick={() => removeField(field.name)}
iconType="trash"
data-test-subj="lnsFieldListPanelRemove"
color="danger"
aria-label={i18n.translate('xpack.lens.indexPattern.removeFieldLabel', {
defaultMessage: 'Remove data view field',
})}
/>
</EuiToolTip>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
}
function FieldItemPopoverContents(props: FieldItemProps) {
const {
query,
filters,
indexPattern,
field,
dateRange,
dropOntoWorkspace,
editField,
removeField,
hasSuggestionForField,
hideDetails,
uiActions,
core,
} = props;
function FieldItemPopoverContents(
props: FieldItemProps & {
dataViewField: DataViewField;
onAddFilter: AddFieldFilterHandler | undefined;
}
) {
const { query, filters, indexPattern, dataViewField, dateRange, core, onAddFilter, uiActions } =
props;
const services = useKibana<LensAppServices>().services;
const onAddFilter: AddFieldFilterHandler = useCallback(
(clickedField, values, operation) => {
const filterManager = services.data.query.filterManager;
const newFilters = generateFilters(
filterManager,
clickedField,
values,
operation,
indexPattern
);
filterManager.addFilters(newFilters);
},
[indexPattern, services.data.query.filterManager]
);
const panelHeader = (
<FieldPanelHeader
indexPatternId={indexPattern.id}
field={field}
dropOntoWorkspace={dropOntoWorkspace}
hasSuggestionForField={hasSuggestionForField}
editField={editField}
removeField={removeField}
/>
);
const exploreInDiscover = useMemo(() => {
const meta = {
id: indexPattern.id,
columns: [field.name],
columns: [dataViewField.name],
filters: {
enabled: {
lucene: [],
@ -380,15 +316,10 @@ function FieldItemPopoverContents(props: FieldItemProps) {
query: newQuery,
columns: meta.columns,
});
}, [field.name, filters, indexPattern, query, services]);
if (hideDetails) {
return panelHeader;
}
}, [dataViewField.name, filters, indexPattern, query, services]);
return (
<>
<EuiPopoverTitle>{panelHeader}</EuiPopoverTitle>
<FieldStats
services={services}
query={query}
@ -397,25 +328,9 @@ function FieldItemPopoverContents(props: FieldItemProps) {
toDate={dateRange.toDate}
dataViewOrDataViewId={indexPattern.id} // TODO: Refactor to pass a variable with DataView type instead of IndexPattern
onAddFilter={onAddFilter}
field={field as DataViewField}
field={dataViewField}
data-test-subj="lnsFieldListPanel"
overrideMissingContent={(params) => {
if (field.type === 'geo_point' || field.type === 'geo_shape') {
return (
<>
{params.element}
<EuiPopoverFooter>
<VisualizeGeoFieldButton
uiActions={uiActions}
indexPattern={indexPattern}
fieldName={field.name}
/>
</EuiPopoverFooter>
</>
);
}
if (params?.noDataFound) {
// TODO: should we replace this with a default message "Analysis is not available for this field?"
const isUsingSampling = core.uiSettings.get('lens:useFieldExistenceSampling');
@ -439,58 +354,32 @@ function FieldItemPopoverContents(props: FieldItemProps) {
return params.element;
}}
/>
{exploreInDiscover && field.type !== 'geo_point' && field.type !== 'geo_shape' && (
{dataViewField.type === 'geo_point' || dataViewField.type === 'geo_shape' ? (
<FieldPopoverVisualize
field={dataViewField}
dataView={{ ...indexPattern, toSpec: () => indexPattern.spec } as unknown as DataView}
originatingApp={APP_ID}
uiActions={uiActions}
buttonProps={{
'data-test-subj': `lensVisualize-GeoField-${dataViewField.name}`,
}}
/>
) : exploreInDiscover ? (
<EuiPopoverFooter>
<EuiButton
fullWidth
size="s"
href={exploreInDiscover}
target="_blank"
data-test-subj={`lnsFieldListPanel-exploreInDiscover-${field.name}`}
data-test-subj={`lnsFieldListPanel-exploreInDiscover-${dataViewField.name}`}
>
{i18n.translate('xpack.lens.indexPattern.fieldExploreInDiscover', {
defaultMessage: 'Explore values in Discover',
})}
</EuiButton>
</EuiPopoverFooter>
)}
) : null}
</>
);
}
const DragToWorkspaceButton = ({
field,
dropOntoWorkspace,
isEnabled,
}: {
field: DraggedField;
dropOntoWorkspace: DatasourceDataPanelProps['dropOntoWorkspace'];
isEnabled: boolean;
}) => {
const buttonTitle = isEnabled
? i18n.translate('xpack.lens.indexPattern.moveToWorkspace', {
defaultMessage: 'Add {field} to workspace',
values: {
field: field.field.name,
},
})
: i18n.translate('xpack.lens.indexPattern.moveToWorkspaceDisabled', {
defaultMessage:
"This field can't be added to the workspace automatically. You can still use it directly in the configuration panel.",
});
return (
<EuiFlexItem grow={false}>
<EuiToolTip content={buttonTitle}>
<EuiButtonIcon
aria-label={buttonTitle}
isDisabled={!isEnabled}
iconType="plusInCircle"
onClick={() => {
dropOntoWorkspace(field);
}}
/>
</EuiToolTip>
</EuiFlexItem>
);
};

View file

@ -50,7 +50,7 @@ function ofName(
timeScale: string | undefined,
reducedTimeRange: string | undefined
) {
if (field?.customLabel) {
if (field?.customLabel && field?.type !== 'document') {
return field.customLabel;
}

View file

@ -1,76 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { MouseEvent, useEffect, useState } from 'react';
import { EuiButton } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import {
visualizeGeoFieldTrigger,
VISUALIZE_GEO_FIELD_TRIGGER,
UiActionsStart,
} from '@kbn/ui-actions-plugin/public';
import { APP_ID } from '../../common/constants';
import { IndexPattern } from '../types';
interface Props {
indexPattern: IndexPattern;
fieldName: string;
uiActions: UiActionsStart;
}
export function VisualizeGeoFieldButton(props: Props) {
const [href, setHref] = useState<string | undefined>(undefined);
async function loadHref() {
const actions = await props.uiActions.getTriggerCompatibleActions(VISUALIZE_GEO_FIELD_TRIGGER, {
dataViewSpec: props.indexPattern.spec,
fieldName: props.fieldName,
});
const triggerOptions = {
dataViewSpec: props.indexPattern.spec,
fieldName: props.fieldName,
trigger: visualizeGeoFieldTrigger,
};
const loadedHref = actions.length ? await actions[0].getHref?.(triggerOptions) : undefined;
setHref(loadedHref);
}
useEffect(
() => {
loadHref();
},
/* eslint-disable-next-line react-hooks/exhaustive-deps */
[]
);
function onClick(event: MouseEvent) {
event.preventDefault();
props.uiActions.getTrigger(VISUALIZE_GEO_FIELD_TRIGGER).exec({
dataViewSpec: props.indexPattern.spec,
fieldName: props.fieldName,
originatingApp: APP_ID,
});
}
return (
<>
{/* eslint-disable-next-line @elastic/eui/href-or-on-click */}
<EuiButton
fullWidth
onClick={onClick}
href={href}
size="s"
data-test-subj={`lensVisualize-GeoField-${props.fieldName}`}
>
<FormattedMessage
id="xpack.lens.indexPattern.fieldItem.visualizeGeoFieldButtonText"
defaultMessage="Visualize"
/>
</EuiButton>
</>
);
}

View file

@ -2136,8 +2136,6 @@
"discover.fieldChooser.detailViews.emptyStringText": "Chaîne vide",
"discover.fieldChooser.discoverField.actions": "Actions",
"discover.fieldChooser.discoverField.addFieldTooltip": "Ajouter le champ en tant que colonne",
"discover.fieldChooser.discoverField.deleteFieldLabel": "Supprimer le champ de la vue de données",
"discover.fieldChooser.discoverField.editFieldLabel": "Modifier le champ de la vue de données",
"discover.fieldChooser.discoverField.fieldTopValuesLabel": "Top 5 des valeurs",
"discover.fieldChooser.discoverField.multiField": "champ multiple",
"discover.fieldChooser.discoverField.multiFields": "Champs multiples",
@ -2165,7 +2163,6 @@
"discover.fieldChooser.searchPlaceHolder": "Rechercher les noms de champs",
"discover.fieldChooser.toggleFieldFilterButtonHideAriaLabel": "Masquer les paramètres de filtre de champs",
"discover.fieldChooser.toggleFieldFilterButtonShowAriaLabel": "Afficher les paramètres de filtre de champs",
"discover.fieldChooser.visualizeButton.label": "Visualiser",
"discover.fieldList.flyoutBackIcon": "Retour",
"discover.fieldList.flyoutHeading": "Liste des champs",
"discover.fieldNameDescription.booleanField": "Valeurs vraies ou fausses.",
@ -17684,7 +17681,6 @@
"xpack.lens.indexPattern.defaultFormatLabel": "Par défaut",
"xpack.lens.indexPattern.derivative": "Différences",
"xpack.lens.indexPattern.differences.signature": "indicateur : nombre",
"xpack.lens.indexPattern.editFieldLabel": "Modifier le champ de la vue de données",
"xpack.lens.indexPattern.emptyDimensionButton": "Dimension vide",
"xpack.lens.indexPattern.emptyFieldsLabel": "Champs vides",
"xpack.lens.indexPattern.emptyFieldsLabelHelp": "Les champs vides ne contenaient aucune valeur dans les 500 premiers documents basés sur vos filtres.",
@ -17799,7 +17795,6 @@
"xpack.lens.indexPattern.ranges.lessThanTooltip": "Inférieur à",
"xpack.lens.indexPattern.records": "Enregistrements",
"xpack.lens.indexPattern.referenceFunctionPlaceholder": "Sous-fonction",
"xpack.lens.indexPattern.removeFieldLabel": "Retirer le champ de la vue de données",
"xpack.lens.indexPattern.sortField.invalid": "Champ non valide. Vérifiez votre vue de données ou choisissez un autre champ.",
"xpack.lens.indexPattern.standardDeviation": "Écart-type",
"xpack.lens.indexPattern.standardDeviation.description": "Agrégation d'indicateurs à valeur unique qui calcule lécart-type des valeurs numériques extraites des documents agrégés",

View file

@ -2132,8 +2132,6 @@
"discover.fieldChooser.detailViews.emptyStringText": "空の文字列",
"discover.fieldChooser.discoverField.actions": "アクション",
"discover.fieldChooser.discoverField.addFieldTooltip": "フィールドを列として追加",
"discover.fieldChooser.discoverField.deleteFieldLabel": "データビューフィールドを削除",
"discover.fieldChooser.discoverField.editFieldLabel": "データビューフィールドを編集",
"discover.fieldChooser.discoverField.fieldTopValuesLabel": "トップ5の値",
"discover.fieldChooser.discoverField.multiField": "複数フィールド",
"discover.fieldChooser.discoverField.multiFields": "マルチフィールド",
@ -2161,7 +2159,6 @@
"discover.fieldChooser.searchPlaceHolder": "検索フィールド名",
"discover.fieldChooser.toggleFieldFilterButtonHideAriaLabel": "フィールド設定を非表示",
"discover.fieldChooser.toggleFieldFilterButtonShowAriaLabel": "フィールド設定を表示",
"discover.fieldChooser.visualizeButton.label": "可視化",
"discover.fieldList.flyoutBackIcon": "戻る",
"discover.fieldList.flyoutHeading": "フィールドリスト",
"discover.fieldNameDescription.booleanField": "True および False 値。",
@ -17667,7 +17664,6 @@
"xpack.lens.indexPattern.defaultFormatLabel": "デフォルト",
"xpack.lens.indexPattern.derivative": "差異",
"xpack.lens.indexPattern.differences.signature": "メトリック:数値",
"xpack.lens.indexPattern.editFieldLabel": "データビューフィールドを編集",
"xpack.lens.indexPattern.emptyDimensionButton": "空のディメンション",
"xpack.lens.indexPattern.emptyFieldsLabel": "空のフィールド",
"xpack.lens.indexPattern.emptyFieldsLabelHelp": "空のフィールドには、フィルターに基づく最初の 500 件のドキュメントの値が含まれていませんでした。",
@ -17782,7 +17778,6 @@
"xpack.lens.indexPattern.ranges.lessThanTooltip": "より小さい",
"xpack.lens.indexPattern.records": "記録",
"xpack.lens.indexPattern.referenceFunctionPlaceholder": "サブ関数",
"xpack.lens.indexPattern.removeFieldLabel": "データビューフィールドを削除",
"xpack.lens.indexPattern.sortField.invalid": "無効なフィールドです。データビューを確認するか、別のフィールドを選択してください。",
"xpack.lens.indexPattern.standardDeviation": "標準偏差",
"xpack.lens.indexPattern.standardDeviation.description": "集約されたドキュメントから抽出された数値の標準偏差を計算する単一値メトリック集約",

View file

@ -2136,8 +2136,6 @@
"discover.fieldChooser.detailViews.emptyStringText": "空字符串",
"discover.fieldChooser.discoverField.actions": "操作",
"discover.fieldChooser.discoverField.addFieldTooltip": "将字段添加为列",
"discover.fieldChooser.discoverField.deleteFieldLabel": "删除数据视图字段",
"discover.fieldChooser.discoverField.editFieldLabel": "编辑数据视图字段",
"discover.fieldChooser.discoverField.fieldTopValuesLabel": "排名前 5 值",
"discover.fieldChooser.discoverField.multiField": "多字段",
"discover.fieldChooser.discoverField.multiFields": "多字段",
@ -2165,7 +2163,6 @@
"discover.fieldChooser.searchPlaceHolder": "搜索字段名称",
"discover.fieldChooser.toggleFieldFilterButtonHideAriaLabel": "隐藏字段筛选设置",
"discover.fieldChooser.toggleFieldFilterButtonShowAriaLabel": "显示字段筛选设置",
"discover.fieldChooser.visualizeButton.label": "Visualize",
"discover.fieldList.flyoutBackIcon": "返回",
"discover.fieldList.flyoutHeading": "字段列表",
"discover.fieldNameDescription.booleanField": "True 和 False 值。",
@ -17692,7 +17689,6 @@
"xpack.lens.indexPattern.defaultFormatLabel": "默认",
"xpack.lens.indexPattern.derivative": "差异",
"xpack.lens.indexPattern.differences.signature": "指标:数字",
"xpack.lens.indexPattern.editFieldLabel": "编辑数据视图字段",
"xpack.lens.indexPattern.emptyDimensionButton": "空维度",
"xpack.lens.indexPattern.emptyFieldsLabel": "空字段",
"xpack.lens.indexPattern.emptyFieldsLabelHelp": "空字段在基于您的筛选的前 500 个文档中不包含任何值。",
@ -17807,7 +17803,6 @@
"xpack.lens.indexPattern.ranges.lessThanTooltip": "小于",
"xpack.lens.indexPattern.records": "记录",
"xpack.lens.indexPattern.referenceFunctionPlaceholder": "子函数",
"xpack.lens.indexPattern.removeFieldLabel": "移除数据视图字段",
"xpack.lens.indexPattern.sortField.invalid": "字段无效。检查数据视图或选取其他字段。",
"xpack.lens.indexPattern.standardDeviation": "标准偏差",
"xpack.lens.indexPattern.standardDeviation.description": "单值指标聚合,计算从聚合文档提取的数值的标准偏差",

View file

@ -102,7 +102,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('should allow removing a field', async () => {
await PageObjects.lens.clickField('runtimefield');
await PageObjects.lens.removeField();
await PageObjects.lens.removeField('runtimefield');
await fieldEditor.confirmDelete();
await PageObjects.lens.waitForFieldMissing('runtimefield');
});

View file

@ -50,7 +50,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('should able to edit field', async () => {
await PageObjects.lens.clickField('runtimefield');
await PageObjects.lens.editField();
await PageObjects.lens.editField('runtimefield');
await fieldEditor.setName('runtimefield2', true, true);
await fieldEditor.save();
await fieldEditor.confirmSave();
@ -69,7 +69,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('should able to remove field', async () => {
await PageObjects.lens.clickField('runtimefield2');
await PageObjects.lens.removeField();
await PageObjects.lens.removeField('runtimefield2');
await fieldEditor.confirmDelete();
await PageObjects.lens.waitForFieldMissing('runtimefield2');
});

View file

@ -290,17 +290,17 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
await testSubjects.click(`lnsFieldListPanelField-${field}`);
},
async editField() {
async editField(field: string) {
await retry.try(async () => {
await testSubjects.click('lnsFieldListPanelEdit');
await testSubjects.missingOrFail('lnsFieldListPanelEdit');
await testSubjects.click(`fieldPopoverHeader_editField-${field}`);
await testSubjects.missingOrFail(`fieldPopoverHeader_editField-${field}`);
});
},
async removeField() {
async removeField(field: string) {
await retry.try(async () => {
await testSubjects.click('lnsFieldListPanelRemove');
await testSubjects.missingOrFail('lnsFieldListPanelRemove');
await testSubjects.click(`fieldPopoverHeader_deleteField-${field}`);
await testSubjects.missingOrFail(`fieldPopoverHeader_deleteField-${field}`);
});
},