Improved Visualize button in field popover (#103099)

* Improve field popover

* Slightly improve type safteyness

* Add unit tests for visualize trigger utils

* Remove unused div

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Tim Roes 2021-06-24 19:31:24 +02:00 committed by GitHub
parent 5abac25ba3
commit bf6c53bb45
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 288 additions and 1038 deletions

View file

@ -1,705 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`discover sidebar field details footer renders properly 1`] = `
<DiscoverFieldDetailsFooter
details={
Object {
"buckets": Array [],
"columns": Array [],
"error": "",
"exists": 1,
"total": 2,
}
}
field={
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 10,
"customLabel": undefined,
"esTypes": Array [
"long",
],
"lang": undefined,
"name": "bytes",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "number",
}
}
indexPattern={
StubIndexPattern {
"_reindexFields": [Function],
"fieldFormatMap": Object {},
"fields": FldList [
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 10,
"customLabel": undefined,
"esTypes": Array [
"long",
],
"lang": undefined,
"name": "bytes",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "number",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 20,
"customLabel": undefined,
"esTypes": Array [
"boolean",
],
"lang": undefined,
"name": "ssl",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "boolean",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 30,
"customLabel": undefined,
"esTypes": Array [
"date",
],
"lang": undefined,
"name": "@timestamp",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "date",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 30,
"customLabel": undefined,
"esTypes": Array [
"date",
],
"lang": undefined,
"name": "time",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "date",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"customLabel": undefined,
"esTypes": Array [
"keyword",
],
"lang": undefined,
"name": "@tags",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "string",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"customLabel": undefined,
"esTypes": Array [
"date",
],
"lang": undefined,
"name": "utc_time",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "date",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"customLabel": undefined,
"esTypes": Array [
"integer",
],
"lang": undefined,
"name": "phpmemory",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "number",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"customLabel": undefined,
"esTypes": Array [
"ip",
],
"lang": undefined,
"name": "ip",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "ip",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"customLabel": undefined,
"esTypes": Array [
"attachment",
],
"lang": undefined,
"name": "request_body",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "attachment",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"customLabel": undefined,
"esTypes": Array [
"geo_point",
],
"lang": undefined,
"name": "point",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "geo_point",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"customLabel": undefined,
"esTypes": Array [
"geo_shape",
],
"lang": undefined,
"name": "area",
"readFromDocValues": false,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "geo_shape",
},
Object {
"aggregatable": false,
"conflictDescriptions": undefined,
"count": 0,
"customLabel": undefined,
"esTypes": Array [
"murmur3",
],
"lang": undefined,
"name": "hashed",
"readFromDocValues": false,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "murmur3",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"customLabel": undefined,
"esTypes": Array [
"geo_point",
],
"lang": undefined,
"name": "geo.coordinates",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "geo_point",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"customLabel": undefined,
"esTypes": Array [
"text",
],
"lang": undefined,
"name": "extension",
"readFromDocValues": false,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "string",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"customLabel": undefined,
"esTypes": Array [
"keyword",
],
"lang": undefined,
"name": "extension.keyword",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": Object {
"multi": Object {
"parent": "extension",
},
},
"type": "string",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"customLabel": undefined,
"esTypes": Array [
"text",
],
"lang": undefined,
"name": "machine.os",
"readFromDocValues": false,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "string",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"customLabel": undefined,
"esTypes": Array [
"keyword",
],
"lang": undefined,
"name": "machine.os.raw",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": Object {
"multi": Object {
"parent": "machine.os",
},
},
"type": "string",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"customLabel": undefined,
"esTypes": Array [
"keyword",
],
"lang": undefined,
"name": "geo.src",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "string",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"customLabel": undefined,
"esTypes": Array [
"_id",
],
"lang": undefined,
"name": "_id",
"readFromDocValues": false,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "string",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"customLabel": undefined,
"esTypes": Array [
"_type",
],
"lang": undefined,
"name": "_type",
"readFromDocValues": false,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "string",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"customLabel": undefined,
"esTypes": Array [
"_source",
],
"lang": undefined,
"name": "_source",
"readFromDocValues": false,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "_source",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"customLabel": undefined,
"esTypes": Array [
"text",
],
"lang": undefined,
"name": "non-filterable",
"readFromDocValues": false,
"script": undefined,
"scripted": false,
"searchable": false,
"subType": undefined,
"type": "string",
},
Object {
"aggregatable": false,
"conflictDescriptions": undefined,
"count": 0,
"customLabel": undefined,
"esTypes": Array [
"text",
],
"lang": undefined,
"name": "non-sortable",
"readFromDocValues": false,
"script": undefined,
"scripted": false,
"searchable": false,
"subType": undefined,
"type": "string",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"customLabel": undefined,
"esTypes": Array [
"conflict",
],
"lang": undefined,
"name": "custom_user_field",
"readFromDocValues": true,
"script": undefined,
"scripted": false,
"searchable": true,
"subType": undefined,
"type": "conflict",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"customLabel": undefined,
"esTypes": Array [
"text",
],
"lang": "expression",
"name": "script string",
"readFromDocValues": false,
"script": "'i am a string'",
"scripted": true,
"searchable": true,
"subType": undefined,
"type": "string",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"customLabel": undefined,
"esTypes": Array [
"long",
],
"lang": "expression",
"name": "script number",
"readFromDocValues": false,
"script": "1234",
"scripted": true,
"searchable": true,
"subType": undefined,
"type": "number",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"customLabel": undefined,
"esTypes": Array [
"date",
],
"lang": "painless",
"name": "script date",
"readFromDocValues": false,
"script": "1234",
"scripted": true,
"searchable": true,
"subType": undefined,
"type": "date",
},
Object {
"aggregatable": true,
"conflictDescriptions": undefined,
"count": 0,
"customLabel": undefined,
"esTypes": Array [
"murmur3",
],
"lang": "expression",
"name": "script murmur3",
"readFromDocValues": false,
"script": "1234",
"scripted": true,
"searchable": true,
"subType": undefined,
"type": "murmur3",
},
],
"fieldsFetcher": Object {
"apiClient": Object {
"baseUrl": "",
},
},
"flattenHit": [Function],
"formatField": [Function],
"formatHit": [Function],
"getComputedFields": [Function],
"getConfig": [Function],
"getFieldByName": [Function],
"getFormatterForField": [Function],
"getNonScriptedFields": [Function],
"getScriptedFields": [Function],
"getSourceFiltering": [Function],
"id": "logstash-*",
"isTimeBased": [Function],
"metaFields": Array [
"_id",
"_type",
"_source",
],
"popularizeField": [Function],
"setFieldFormat": [Function],
"stubSetFieldFormat": [Function],
"timeFieldName": "time",
"title": "logstash-*",
}
}
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"formatDate": [Function],
"formatHTMLMessage": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatPlural": [Function],
"formatRelative": [Function],
"formatTime": [Function],
"formats": Object {
"date": Object {
"full": Object {
"day": "numeric",
"month": "long",
"weekday": "long",
"year": "numeric",
},
"long": Object {
"day": "numeric",
"month": "long",
"year": "numeric",
},
"medium": Object {
"day": "numeric",
"month": "short",
"year": "numeric",
},
"short": Object {
"day": "numeric",
"month": "numeric",
"year": "2-digit",
},
},
"number": Object {
"currency": Object {
"style": "currency",
},
"percent": Object {
"style": "percent",
},
},
"relative": Object {
"days": Object {
"units": "day",
},
"hours": Object {
"units": "hour",
},
"minutes": Object {
"units": "minute",
},
"months": Object {
"units": "month",
},
"seconds": Object {
"units": "second",
},
"years": Object {
"units": "year",
},
},
"time": Object {
"full": Object {
"hour": "numeric",
"minute": "numeric",
"second": "numeric",
"timeZoneName": "short",
},
"long": Object {
"hour": "numeric",
"minute": "numeric",
"second": "numeric",
"timeZoneName": "short",
},
"medium": Object {
"hour": "numeric",
"minute": "numeric",
"second": "numeric",
},
"short": Object {
"hour": "numeric",
"minute": "numeric",
},
},
},
"formatters": Object {
"getDateTimeFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralFormat": [Function],
"getRelativeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"now": [Function],
"onError": [Function],
"textComponent": Symbol(react.fragment),
"timeZone": null,
}
}
onAddFilter={[MockFunction]}
>
<EuiPopoverFooter>
<div
className="euiPopoverFooter"
>
<EuiText
size="xs"
textAlign="center"
>
<div
className="euiText euiText--extraSmall"
>
<EuiTextAlign
textAlign="center"
>
<div
className="euiTextAlign euiTextAlign--center"
>
<EuiLink
data-test-subj="onAddFilterButton"
onClick={[Function]}
>
<button
className="euiLink euiLink--primary"
data-test-subj="onAddFilterButton"
disabled={false}
onClick={[Function]}
type="button"
>
<FormattedMessage
defaultMessage="Exists in {value} / {totalValue} records"
id="discover.fieldChooser.detailViews.existsInRecordsText"
values={
Object {
"totalValue": 2,
"value": 1,
}
}
>
Exists in 1 / 2 records
</FormattedMessage>
</button>
</EuiLink>
</div>
</EuiTextAlign>
</div>
</EuiText>
</div>
</EuiPopoverFooter>
</DiscoverFieldDetailsFooter>
`;

View file

@ -8,7 +8,7 @@
import './discover_field.scss';
import React, { useState, useCallback, memo } from 'react';
import React, { useState, useCallback, memo, useMemo } from 'react';
import {
EuiPopover,
EuiPopoverTitle,
@ -18,6 +18,7 @@ import {
EuiIcon,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { UiCounterMetricType } from '@kbn/analytics';
@ -27,7 +28,7 @@ import { FieldIcon, FieldButton } from '../../../../../../../kibana_react/public
import { FieldDetails } from './types';
import { IndexPatternField, IndexPattern } from '../../../../../../../data/public';
import { getFieldTypeName } from './lib/get_field_type_name';
import { DiscoverFieldDetailsFooter } from './discover_field_details_footer';
import { DiscoverFieldVisualize } from './discover_field_visualize';
function wrapOnDot(str?: string) {
// u200B is a non-width white-space character, which allows
@ -172,6 +173,7 @@ const MultiFields: React.FC<MultiFieldsProps> = memo(
})}
</h5>
</EuiTitle>
<EuiSpacer size="xs" />
{multiFields.map((entry) => (
<FieldButton
size="s"
@ -282,6 +284,8 @@ function DiscoverFieldComponent({
setOpen(!infoIsOpen);
}, [infoIsOpen]);
const rawMultiFields = useMemo(() => multiFields?.map((f) => f.field), [multiFields]);
if (field.type === '_source') {
return (
<FieldButton
@ -399,23 +403,24 @@ function DiscoverFieldComponent({
field={field}
details={details}
onAddFilter={onAddFilter}
trackUiMetric={trackUiMetric}
/>
{multiFields && (
<MultiFields
multiFields={multiFields}
alwaysShowActionButton={alwaysShowActionButton}
toggleDisplay={toggleDisplay}
/>
)}
{!details.error && (
<DiscoverFieldDetailsFooter
indexPattern={indexPattern}
field={field}
details={details}
onAddFilter={onAddFilter}
/>
<>
<EuiSpacer size="m" />
<MultiFields
multiFields={multiFields}
alwaysShowActionButton={alwaysShowActionButton}
toggleDisplay={toggleDisplay}
/>
</>
)}
<DiscoverFieldVisualize
field={field}
indexPattern={indexPattern}
multiFields={rawMultiFields}
trackUiMetric={trackUiMetric}
details={details}
/>
</>
)}
</EuiPopover>

View file

@ -1,10 +0,0 @@
.dscFieldDetails {
color: $euiTextColor;
margin-bottom: $euiSizeS;
}
.dscFieldDetails__visualizeBtn {
@include euiFontSizeXS;
height: $euiSizeL !important;
min-width: $euiSize * 4;
}

View file

@ -25,10 +25,11 @@ const indexPattern = getStubIndexPattern(
);
describe('discover sidebar field details', function () {
const onAddFilter = jest.fn();
const defaultProps = {
indexPattern,
details: { buckets: [], error: '', exists: 1, total: 2, columns: [] },
onAddFilter: jest.fn(),
onAddFilter,
};
function mountComponent(field: IndexPatternField) {
@ -36,7 +37,7 @@ describe('discover sidebar field details', function () {
return mountWithIntl(<DiscoverFieldDetails {...compProps} />);
}
it('should enable the visualize link for a number field', function () {
it('click on addFilter calls the function', function () {
const visualizableField = new IndexPatternField({
name: 'bytes',
type: 'number',
@ -47,37 +48,9 @@ describe('discover sidebar field details', function () {
aggregatable: true,
readFromDocValues: true,
});
const comp = mountComponent(visualizableField);
expect(findTestSubject(comp, 'fieldVisualize-bytes')).toBeTruthy();
});
it('should disable the visualize link for an _id field', function () {
const conflictField = new IndexPatternField({
name: '_id',
type: 'string',
esTypes: ['_id'],
count: 0,
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
});
const comp = mountComponent(conflictField);
expect(findTestSubject(comp, 'fieldVisualize-_id')).toEqual({});
});
it('should disable the visualize link for an unknown field', function () {
const unknownField = new IndexPatternField({
name: 'test',
type: 'unknown',
esTypes: ['double'],
count: 0,
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
});
const comp = mountComponent(unknownField);
expect(findTestSubject(comp, 'fieldVisualize-test')).toEqual({});
const component = mountComponent(visualizableField);
const onAddButton = findTestSubject(component, 'onAddFilterButton');
onAddButton.simulate('click');
expect(onAddFilter).toHaveBeenCalledWith('_exists_', visualizableField.name, '+');
});
});

View file

@ -6,27 +6,18 @@
* Side Public License, v 1.
*/
import React, { useState, useEffect } from 'react';
import { EuiIconTip, EuiText, EuiButton, EuiSpacer } from '@elastic/eui';
import React from 'react';
import { EuiText, EuiSpacer, EuiLink } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics';
import { DiscoverFieldBucket } from './discover_field_bucket';
import { getWarnings } from './lib/get_warnings';
import {
triggerVisualizeActions,
isFieldVisualizable,
getVisualizeHref,
} from './lib/visualize_trigger_utils';
import { Bucket, FieldDetails } from './types';
import { IndexPatternField, IndexPattern } from '../../../../../../../data/public';
import './discover_field_details.scss';
interface DiscoverFieldDetailsProps {
field: IndexPatternField;
indexPattern: IndexPattern;
details: FieldDetails;
onAddFilter: (field: IndexPatternField | string, value: string, type: '+' | '-') => void;
trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void;
}
export function DiscoverFieldDetails({
@ -34,46 +25,12 @@ export function DiscoverFieldDetails({
indexPattern,
details,
onAddFilter,
trackUiMetric,
}: DiscoverFieldDetailsProps) {
const warnings = getWarnings(field);
const [showVisualizeLink, setShowVisualizeLink] = useState<boolean>(false);
const [visualizeLink, setVisualizeLink] = useState<string>('');
useEffect(() => {
isFieldVisualizable(field, indexPattern.id, details.columns).then(
(flag) => {
setShowVisualizeLink(flag);
// get href only if Visualize button is enabled
getVisualizeHref(field, indexPattern.id, details.columns).then(
(uri) => {
if (uri) setVisualizeLink(uri);
},
() => {
setVisualizeLink('');
}
);
},
() => {
setShowVisualizeLink(false);
}
);
}, [field, indexPattern.id, details.columns]);
const handleVisualizeLinkClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
// regular link click. let the uiActions code handle the navigation and show popup if needed
event.preventDefault();
if (trackUiMetric) {
trackUiMetric(METRIC_TYPE.CLICK, 'visualize_link_click');
}
triggerVisualizeActions(field, indexPattern.id, details.columns);
};
return (
<>
<div className="dscFieldDetails">
{details.error && <EuiText size="xs">{details.error}</EuiText>}
{!details.error && (
{details.error && <EuiText size="xs">{details.error}</EuiText>}
{!details.error && (
<>
<div style={{ marginTop: '4px' }}>
{details.buckets.map((bucket: Bucket, idx: number) => (
<DiscoverFieldBucket
@ -84,30 +41,35 @@ export function DiscoverFieldDetails({
/>
))}
</div>
)}
{showVisualizeLink && (
<>
<EuiSpacer size="xs" />
{/* eslint-disable-next-line @elastic/eui/href-or-on-click */}
<EuiButton
onClick={(e) => handleVisualizeLinkClick(e)}
href={visualizeLink}
size="s"
className="dscFieldDetails__visualizeBtn"
data-test-subj={`fieldVisualize-${field.name}`}
>
<EuiSpacer size="xs" />
<EuiText size="xs">
{!indexPattern.metaFields.includes(field.name) && !field.scripted ? (
<EuiLink
onClick={() => onAddFilter('_exists_', field.name, '+')}
data-test-subj="onAddFilterButton"
>
<FormattedMessage
id="discover.fieldChooser.detailViews.existsInRecordsText"
defaultMessage="Exists in {value} / {totalValue} records"
values={{
value: details.exists,
totalValue: details.total,
}}
/>
</EuiLink>
) : (
<FormattedMessage
id="discover.fieldChooser.detailViews.visualizeLinkText"
defaultMessage="Visualize"
id="discover.fieldChooser.detailViews.valueOfRecordsText"
defaultMessage="{value} / {totalValue} records"
values={{
value: details.exists,
totalValue: details.total,
}}
/>
</EuiButton>
{warnings.length > 0 && (
<EuiIconTip type="alert" color="warning" content={warnings.join(' ')} />
)}
</>
)}
</div>
</EuiText>
</>
)}
</>
);
}

View file

@ -1,71 +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 React from 'react';
import { findTestSubject } from '@elastic/eui/lib/test';
import { mountWithIntl } from '@kbn/test/jest';
// @ts-expect-error
import stubbedLogstashFields from '../../../../../__fixtures__/logstash_fields';
import { coreMock } from '../../../../../../../../core/public/mocks';
import { IndexPatternField } from '../../../../../../../data/public';
import { getStubIndexPattern } from '../../../../../../../data/public/test_utils';
import { DiscoverFieldDetailsFooter } from './discover_field_details_footer';
const indexPattern = getStubIndexPattern(
'logstash-*',
(cfg: unknown) => cfg,
'time',
stubbedLogstashFields(),
coreMock.createSetup()
);
describe('discover sidebar field details footer', function () {
const onAddFilter = jest.fn();
const defaultProps = {
indexPattern,
details: { buckets: [], error: '', exists: 1, total: 2, columns: [] },
onAddFilter,
};
function mountComponent(field: IndexPatternField) {
const compProps = { ...defaultProps, field };
return mountWithIntl(<DiscoverFieldDetailsFooter {...compProps} />);
}
it('renders properly', function () {
const visualizableField = new IndexPatternField({
name: 'bytes',
type: 'number',
esTypes: ['long'],
count: 10,
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
});
const component = mountComponent(visualizableField);
expect(component).toMatchSnapshot();
});
it('click on addFilter calls the function', function () {
const visualizableField = new IndexPatternField({
name: 'bytes',
type: 'number',
esTypes: ['long'],
count: 10,
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
});
const component = mountComponent(visualizableField);
const onAddButton = findTestSubject(component, 'onAddFilterButton');
onAddButton.simulate('click');
expect(onAddFilter).toHaveBeenCalledWith('_exists_', visualizableField.name, '+');
});
});

View file

@ -1,59 +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 React from 'react';
import { EuiLink, EuiPopoverFooter, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { IndexPatternField } from '../../../../../../../data/common/index_patterns/fields';
import { IndexPattern } from '../../../../../../../data/common/index_patterns/index_patterns';
import { FieldDetails } from './types';
interface DiscoverFieldDetailsFooterProps {
field: IndexPatternField;
indexPattern: IndexPattern;
details: FieldDetails;
onAddFilter: (field: IndexPatternField | string, value: string, type: '+' | '-') => void;
}
export function DiscoverFieldDetailsFooter({
field,
indexPattern,
details,
onAddFilter,
}: DiscoverFieldDetailsFooterProps) {
return (
<EuiPopoverFooter>
<EuiText size="xs" textAlign="center">
{!indexPattern.metaFields.includes(field.name) && !field.scripted ? (
<EuiLink
onClick={() => onAddFilter('_exists_', field.name, '+')}
data-test-subj="onAddFilterButton"
>
<FormattedMessage
id="discover.fieldChooser.detailViews.existsInRecordsText"
defaultMessage="Exists in {value} / {totalValue} records"
values={{
value: details.exists,
totalValue: details.total,
}}
/>
</EuiLink>
) : (
<FormattedMessage
id="discover.fieldChooser.detailViews.valueOfRecordsText"
defaultMessage="{value} / {totalValue} records"
values={{
value: details.exists,
totalValue: details.total,
}}
/>
)}
</EuiText>
</EuiPopoverFooter>
);
}

View file

@ -0,0 +1,66 @@
/*
* 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, { useEffect, useState } from 'react';
import { EuiButton, EuiPopoverFooter } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics';
import type { IndexPattern, IndexPatternField } from 'src/plugins/data/common';
import { triggerVisualizeActions, VisualizeInformation } from './lib/visualize_trigger_utils';
import type { FieldDetails } from './types';
import { getVisualizeInformation } from './lib/visualize_trigger_utils';
interface Props {
field: IndexPatternField;
indexPattern: IndexPattern;
details: FieldDetails;
multiFields?: IndexPatternField[];
trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void;
}
export const DiscoverFieldVisualize: React.FC<Props> = React.memo(
({ field, indexPattern, details, trackUiMetric, multiFields }) => {
const [visualizeInfo, setVisualizeInfo] = useState<VisualizeInformation>();
useEffect(() => {
getVisualizeInformation(field, indexPattern.id, details.columns, multiFields).then(
setVisualizeInfo
);
}, [details.columns, field, indexPattern, multiFields]);
if (!visualizeInfo) {
return null;
}
const handleVisualizeLinkClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
// regular link click. let the uiActions code handle the navigation and show popup if needed
event.preventDefault();
trackUiMetric?.(METRIC_TYPE.CLICK, 'visualize_link_click');
triggerVisualizeActions(visualizeInfo.field, indexPattern.id, details.columns);
};
return (
<EuiPopoverFooter>
{/* eslint-disable-next-line @elastic/eui/href-or-on-click */}
<EuiButton
fullWidth
size="s"
href={visualizeInfo.href}
onClick={handleVisualizeLinkClick}
data-test-subj={`fieldVisualize-${field.name}`}
>
<FormattedMessage
id="discover.fieldChooser.visualizeButton.label"
defaultMessage="Visualize"
/>
</EuiButton>
</EuiPopoverFooter>
);
}
);

View file

@ -1,33 +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 { i18n } from '@kbn/i18n';
import { IndexPatternField } from '../../../../../../../../data/public';
export function getWarnings(field: IndexPatternField) {
let warnings = [];
if (field.scripted) {
warnings.push(
i18n.translate(
'discover.fieldChooser.discoverField.scriptedFieldsTakeLongExecuteDescription',
{
defaultMessage: 'Scripted fields can take a long time to execute.',
}
)
);
}
if (warnings.length > 1) {
warnings = warnings.map(function (warning, i) {
return (i > 0 ? '\n' : '') + (i + 1) + ' - ' + warning;
});
}
return warnings;
}

View file

@ -0,0 +1,116 @@
/*
* 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 type { IndexPatternField } from 'src/plugins/data/common';
import type { Action } from 'src/plugins/ui_actions/public';
import { getVisualizeInformation } from './visualize_trigger_utils';
const field = {
name: 'fieldName',
type: 'string',
esTypes: ['text'],
count: 1,
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
visualizable: true,
} as IndexPatternField;
const mockGetActions = jest.fn<Promise<Array<Action<object>>>, [string, { fieldName: string }]>(
() => Promise.resolve([])
);
jest.mock('../../../../../../kibana_services', () => ({
getUiActions: () => ({
getTriggerCompatibleActions: mockGetActions,
}),
}));
const action: Action = {
id: 'action',
type: 'VISUALIZE_FIELD',
getIconType: () => undefined,
getDisplayName: () => 'Action',
isCompatible: () => Promise.resolve(true),
execute: () => Promise.resolve(),
};
describe('visualize_trigger_utils', () => {
afterEach(() => {
mockGetActions.mockReset();
});
describe('getVisualizeInformation', () => {
it('should return for a visualizeable field with an action', async () => {
mockGetActions.mockResolvedValue([action]);
const information = await getVisualizeInformation(field, '1', [], undefined);
expect(information).not.toBeUndefined();
expect(information?.field).toHaveProperty('name', 'fieldName');
expect(information?.href).toBeUndefined();
});
it('should return field and href from the action', async () => {
mockGetActions.mockResolvedValue([{ ...action, getHref: () => Promise.resolve('hreflink') }]);
const information = await getVisualizeInformation(field, '1', [], undefined);
expect(information).not.toBeUndefined();
expect(information?.field).toHaveProperty('name', 'fieldName');
expect(information).toHaveProperty('href', 'hreflink');
});
it('should return undefined if no field has a compatible action', async () => {
mockGetActions.mockResolvedValue([]);
const information = await getVisualizeInformation(
{ ...field, name: 'rootField' } as IndexPatternField,
'1',
[],
[
{ ...field, name: 'multi1' },
{ ...field, name: 'multi2' },
] as IndexPatternField[]
);
expect(information).toBeUndefined();
});
it('should return information for the root field, when multi fields and root are having actions', async () => {
mockGetActions.mockResolvedValue([action]);
const information = await getVisualizeInformation(
{ ...field, name: 'rootField' } as IndexPatternField,
'1',
[],
[
{ ...field, name: 'multi1' },
{ ...field, name: 'multi2' },
] as IndexPatternField[]
);
expect(information).not.toBeUndefined();
expect(information?.field).toHaveProperty('name', 'rootField');
});
it('should return information for first multi field that has a compatible action', async () => {
mockGetActions.mockImplementation(async (_, { fieldName }) => {
if (fieldName === 'multi2' || fieldName === 'multi3') {
return [action];
}
return [];
});
const information = await getVisualizeInformation(
{ ...field, name: 'rootField' } as IndexPatternField,
'1',
[],
[
{ ...field, name: 'multi1' },
{ ...field, name: 'multi2' },
{ ...field, name: 'multi3' },
] as IndexPatternField[]
);
expect(information).not.toBeUndefined();
expect(information?.field).toHaveProperty('name', 'multi2');
});
});
});

View file

@ -41,30 +41,6 @@ async function getCompatibleActions(
return compatibleActions;
}
export async function getVisualizeHref(
field: IndexPatternField,
indexPatternId: string | undefined,
contextualFields: string[]
) {
if (!indexPatternId) return undefined;
const triggerOptions = {
indexPatternId,
fieldName: field.name,
contextualFields,
trigger: getTrigger(field.type),
};
const compatibleActions = await getCompatibleActions(
field.name,
indexPatternId,
contextualFields,
getTriggerConstant(field.type)
);
// enable the link only if only one action is registered
return compatibleActions.length === 1
? compatibleActions[0].getHref?.(triggerOptions)
: undefined;
}
export function triggerVisualizeActions(
field: IndexPatternField,
indexPatternId: string | undefined,
@ -80,21 +56,55 @@ export function triggerVisualizeActions(
getUiActions().getTrigger(trigger).exec(triggerOptions);
}
export async function isFieldVisualizable(
export interface VisualizeInformation {
field: IndexPatternField;
href?: string;
}
/**
* Returns the field name and potentially href of the field or the first multi-field
* that has a compatible visualize uiAction.
*/
export async function getVisualizeInformation(
field: IndexPatternField,
indexPatternId: string | undefined,
contextualFields: string[]
) {
contextualFields: string[],
multiFields: IndexPatternField[] = []
): Promise<VisualizeInformation | undefined> {
if (field.name === '_id' || !indexPatternId) {
// for first condition you'd get a 'Fielddata access on the _id field is disallowed' error on ES side.
return false;
// _id fields are not visualizeable in ES
return undefined;
}
const trigger = getTriggerConstant(field.type);
const compatibleActions = await getCompatibleActions(
field.name,
indexPatternId,
contextualFields,
trigger
);
return compatibleActions.length > 0 && field.visualizable;
for (const f of [field, ...multiFields]) {
if (!f.visualizable) {
continue;
}
// Retrieve compatible actions for the specific field
const actions = await getCompatibleActions(
f.name,
indexPatternId,
contextualFields,
getTriggerConstant(f.type)
);
// if the field has compatible actions use this field for visualizing
if (actions.length > 0) {
const triggerOptions = {
indexPatternId,
fieldName: f.name,
contextualFields,
trigger: getTrigger(f.type),
};
return {
field: f,
// We use the href of the first action always. Multiple actions will only work
// via the modal shown by triggerVisualizeActions that should be called via onClick.
href: await actions[0].getHref?.(triggerOptions),
};
}
}
return undefined;
}

View file

@ -1625,7 +1625,6 @@
"discover.fieldChooser.detailViews.filterOutValueButtonAriaLabel": "{field}を除外:\"{value}\"",
"discover.fieldChooser.detailViews.filterValueButtonAriaLabel": "{field}を除外:\"{value}\"",
"discover.fieldChooser.detailViews.valueOfRecordsText": "{value} / {totalValue}件のレコード",
"discover.fieldChooser.detailViews.visualizeLinkText": "可視化",
"discover.fieldChooser.discoverField.addButtonAriaLabel": "{field}を表に追加",
"discover.fieldChooser.discoverField.addFieldTooltip": "フィールドを列として追加",
"discover.fieldChooser.discoverField.deleteFieldLabel": "インデックスパターンフィールドを削除",
@ -1634,7 +1633,6 @@
"discover.fieldChooser.discoverField.multiFields": "マルチフィールド",
"discover.fieldChooser.discoverField.removeButtonAriaLabel": "{field}を表から削除",
"discover.fieldChooser.discoverField.removeFieldTooltip": "フィールドを表から削除",
"discover.fieldChooser.discoverField.scriptedFieldsTakeLongExecuteDescription": "スクリプトフィールドは実行に時間がかかる場合があります。",
"discover.fieldChooser.fieldCalculator.analysisIsNotAvailableForGeoFieldsErrorMessage": "ジオフィールドは分析できません。",
"discover.fieldChooser.fieldCalculator.analysisIsNotAvailableForObjectFieldsErrorMessage": "オブジェクトフィールドは分析できません。",
"discover.fieldChooser.fieldCalculator.fieldIsNotPresentInDocumentsErrorMessage": "このフィールドはElasticsearchマッピングに表示されますが、ドキュメントテーブルの{hitsLength}件のドキュメントには含まれません。可視化や検索は可能な場合があります。",

View file

@ -1634,7 +1634,6 @@
"discover.fieldChooser.detailViews.filterOutValueButtonAriaLabel": "筛除 {field}:“{value}”",
"discover.fieldChooser.detailViews.filterValueButtonAriaLabel": "筛留 {field}:“{value}”",
"discover.fieldChooser.detailViews.valueOfRecordsText": "{value} / {totalValue} 条记录",
"discover.fieldChooser.detailViews.visualizeLinkText": "Visualize",
"discover.fieldChooser.discoverField.addButtonAriaLabel": "将 {field} 添加到表中",
"discover.fieldChooser.discoverField.addFieldTooltip": "将字段添加为列",
"discover.fieldChooser.discoverField.deleteFieldLabel": "删除索引模式字段",
@ -1643,7 +1642,6 @@
"discover.fieldChooser.discoverField.multiFields": "多字段",
"discover.fieldChooser.discoverField.removeButtonAriaLabel": "从表中移除 {field}",
"discover.fieldChooser.discoverField.removeFieldTooltip": "从表中移除字段",
"discover.fieldChooser.discoverField.scriptedFieldsTakeLongExecuteDescription": "脚本字段执行时间会很长。",
"discover.fieldChooser.fieldCalculator.analysisIsNotAvailableForGeoFieldsErrorMessage": "分析不适用于地理字段。",
"discover.fieldChooser.fieldCalculator.analysisIsNotAvailableForObjectFieldsErrorMessage": "分析不适用于对象字段。",
"discover.fieldChooser.fieldCalculator.fieldIsNotPresentInDocumentsErrorMessage": "此字段在您的 Elasticsearch 映射中,但不在文档表中显示的 {hitsLength} 个文档中。您可能仍能够基于它可视化或搜索。",