kibana/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx

538 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import './field_item.scss';
import React, { useState } from 'react';
import DateMath from '@elastic/datemath';
import {
EuiButtonGroup,
EuiFlexGroup,
EuiFlexItem,
EuiIconTip,
EuiLoadingSpinner,
EuiPopover,
EuiPopoverFooter,
EuiPopoverTitle,
EuiProgress,
EuiText,
EuiToolTip,
} from '@elastic/eui';
import {
Axis,
BarSeries,
Chart,
niceTimeFormatter,
Position,
ScaleType,
Settings,
TooltipType,
} from '@elastic/charts';
import { i18n } from '@kbn/i18n';
import { DataPublicPluginStart } from 'src/plugins/data/public';
import { EuiHighlight } from '@elastic/eui';
import {
Query,
KBN_FIELD_TYPES,
ES_FIELD_TYPES,
Filter,
esQuery,
IIndexPattern,
} from '../../../../../src/plugins/data/public';
import { FieldButton } from '../../../../../src/plugins/kibana_react/public';
import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public';
import { DraggedField } from './indexpattern';
import { DragDrop } from '../drag_drop';
import { DatasourceDataPanelProps, DataType } from '../types';
import { BucketedAggregation, FieldStatsResponse } from '../../common';
import { IndexPattern, IndexPatternField } from './types';
import { LensFieldIcon } from './lens_field_icon';
import { trackUiEvent } from '../lens_ui_telemetry';
import { debouncedComponent } from '../debounced_component';
export interface FieldItemProps {
core: DatasourceDataPanelProps['core'];
data: DataPublicPluginStart;
field: IndexPatternField;
indexPattern: IndexPattern;
highlight?: string;
exists: boolean;
query: Query;
dateRange: DatasourceDataPanelProps['dateRange'];
chartsThemeService: ChartsPluginSetup['theme'];
filters: Filter[];
hideDetails?: boolean;
}
interface State {
isLoading: boolean;
totalDocuments?: number;
sampledDocuments?: number;
sampledValues?: number;
histogram?: BucketedAggregation<number | string>;
topValues?: BucketedAggregation<number | string>;
}
function wrapOnDot(str?: string) {
// u200B is a non-width white-space character, which allows
// the browser to efficiently word-wrap right after the dot
// without us having to draw a lot of extra DOM elements, etc
return str ? str.replace(/\./g, '.\u200B') : '';
}
export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) {
const {
core,
field,
indexPattern,
highlight,
exists,
query,
dateRange,
filters,
hideDetails,
} = props;
const [infoIsOpen, setOpen] = useState(false);
const [state, setState] = useState<State>({
isLoading: false,
});
function fetchData() {
if (state.isLoading) {
return;
}
setState((s) => ({ ...s, isLoading: true }));
core.http
.post(`/api/lens/index_stats/${indexPattern.title}/field`, {
body: JSON.stringify({
dslQuery: esQuery.buildEsQuery(
indexPattern as IIndexPattern,
query,
filters,
esQuery.getEsQueryConfig(core.uiSettings)
),
fromDate: dateRange.fromDate,
toDate: dateRange.toDate,
timeFieldName: indexPattern.timeFieldName,
field,
}),
})
.then((results: FieldStatsResponse<string | number>) => {
setState((s) => ({
...s,
isLoading: false,
totalDocuments: results.totalDocuments,
sampledDocuments: results.sampledDocuments,
sampledValues: results.sampledValues,
histogram: results.histogram,
topValues: results.topValues,
}));
})
.catch(() => {
setState((s) => ({ ...s, isLoading: false }));
});
}
function togglePopover() {
if (hideDetails) {
return;
}
setOpen(!infoIsOpen);
if (!infoIsOpen) {
trackUiEvent('indexpattern_field_info_click');
fetchData();
}
}
const value = React.useMemo(
() => ({ field, indexPatternId: indexPattern.id, id: field.name } as DraggedField),
[field, indexPattern.id]
);
const lensFieldIcon = <LensFieldIcon type={field.type as DataType} />;
const lensInfoIcon = (
<EuiIconTip
anchorClassName="lnsFieldItem__infoIcon"
content={
hideDetails
? i18n.translate('xpack.lens.indexPattern.fieldItemTooltip', {
defaultMessage: 'Drag and drop to visualize.',
})
: exists
? i18n.translate('xpack.lens.indexPattern.fieldStatsButtonLabel', {
defaultMessage: 'Click for a field preview, or drag and drop to visualize.',
})
: i18n.translate('xpack.lens.indexPattern.fieldStatsButtonEmptyLabel', {
defaultMessage:
'This field doesnt have any data but you can still drag and drop to visualize.',
})
}
type="iInCircle"
color="subdued"
size="s"
/>
);
return (
<EuiPopover
ownFocus
className="lnsFieldItem__popoverAnchor"
display="block"
data-test-subj="lnsFieldListPanelField"
container={document.querySelector<HTMLElement>('.application') || undefined}
button={
<DragDrop
label={field.displayName}
value={value}
data-test-subj={`lnsFieldListPanelField-${field.name}`}
draggable
>
<FieldButton
className={`lnsFieldItem lnsFieldItem--${field.type} lnsFieldItem--${
exists ? 'exists' : 'missing'
}`}
isActive={infoIsOpen}
onClick={togglePopover}
aria-label={i18n.translate('xpack.lens.indexPattern.fieldStatsButtonAriaLabel', {
defaultMessage: '{fieldName}: {fieldType}. Hit enter for a field preview.',
values: {
fieldName: field.displayName,
fieldType: field.type,
},
})}
fieldIcon={lensFieldIcon}
fieldName={
<EuiHighlight search={wrapOnDot(highlight)}>
{wrapOnDot(field.displayName)}
</EuiHighlight>
}
fieldInfoIcon={lensInfoIcon}
/>
</DragDrop>
}
isOpen={infoIsOpen}
closePopover={() => setOpen(false)}
anchorPosition="rightUp"
panelClassName="lnsFieldItem__fieldPanel"
>
<FieldItemPopoverContents {...state} {...props} />
</EuiPopover>
);
};
export const FieldItem = debouncedComponent(InnerFieldItem);
function FieldItemPopoverContents(props: State & FieldItemProps) {
const {
histogram,
topValues,
indexPattern,
field,
dateRange,
core,
sampledValues,
chartsThemeService,
data: { fieldFormats },
} = props;
const chartTheme = chartsThemeService.useChartsTheme();
const chartBaseTheme = chartsThemeService.useChartsBaseTheme();
let histogramDefault = !!props.histogram;
const totalValuesCount =
topValues && topValues.buckets.reduce((prev, bucket) => bucket.count + prev, 0);
const otherCount = sampledValues && totalValuesCount ? sampledValues - totalValuesCount : 0;
if (
totalValuesCount &&
histogram &&
histogram.buckets.length &&
topValues &&
topValues.buckets.length
) {
// Default to histogram when top values are less than 10% of total
histogramDefault = otherCount / totalValuesCount > 0.9;
}
const [showingHistogram, setShowingHistogram] = useState(histogramDefault);
let formatter: { convert: (data: unknown) => string };
if (indexPattern.fieldFormatMap && indexPattern.fieldFormatMap[field.name]) {
const FormatType = fieldFormats.getType(indexPattern.fieldFormatMap[field.name].id);
if (FormatType) {
formatter = new FormatType(
indexPattern.fieldFormatMap[field.name].params,
core.uiSettings.get.bind(core.uiSettings)
);
} else {
formatter = { convert: (data: unknown) => JSON.stringify(data) };
}
} else {
formatter = fieldFormats.getDefaultInstance(
field.type as KBN_FIELD_TYPES,
field.esTypes as ES_FIELD_TYPES[]
);
}
const fromDate = DateMath.parse(dateRange.fromDate);
const toDate = DateMath.parse(dateRange.toDate);
let title = <></>;
if (props.isLoading) {
return <EuiLoadingSpinner />;
} else if (
(!props.histogram || props.histogram.buckets.length === 0) &&
(!props.topValues || props.topValues.buckets.length === 0)
) {
return (
<EuiText size="s">
{i18n.translate('xpack.lens.indexPattern.fieldStatsNoData', {
defaultMessage:
'This field is empty because it doesnt exist in the 500 sampled documents. Adding this field to the configuration may result in a blank chart.',
})}
</EuiText>
);
}
if (histogram && histogram.buckets.length && topValues && topValues.buckets.length) {
title = (
<EuiButtonGroup
className="lnsFieldItem__buttonGroup"
buttonSize="compressed"
isFullWidth
legend={i18n.translate('xpack.lens.indexPattern.fieldStatsDisplayToggle', {
defaultMessage: 'Toggle either the',
})}
options={[
{
label: i18n.translate('xpack.lens.indexPattern.fieldTopValuesLabel', {
defaultMessage: 'Top values',
}),
id: 'topValues',
},
{
label: i18n.translate('xpack.lens.indexPattern.fieldDistributionLabel', {
defaultMessage: 'Distribution',
}),
id: 'histogram',
},
]}
onChange={(optionId: string) => {
setShowingHistogram(optionId === 'histogram');
}}
idSelected={showingHistogram ? 'histogram' : 'topValues'}
/>
);
} else if (field.type === 'date') {
title = (
<>
{i18n.translate('xpack.lens.indexPattern.fieldTimeDistributionLabel', {
defaultMessage: 'Time distribution',
})}
</>
);
} else if (topValues && topValues.buckets.length) {
title = (
<>
{i18n.translate('xpack.lens.indexPattern.fieldTopValuesLabel', {
defaultMessage: 'Top values',
})}
</>
);
}
function wrapInPopover(el: React.ReactElement) {
return (
<>
{title ? <EuiPopoverTitle>{title}</EuiPopoverTitle> : <></>}
{el}
{props.totalDocuments ? (
<EuiPopoverFooter>
<EuiText size="xs" textAlign="center">
{props.sampledDocuments && (
<>
{i18n.translate('xpack.lens.indexPattern.percentageOfLabel', {
defaultMessage: '{percentage}% of',
values: {
percentage: Math.round((props.sampledDocuments / props.totalDocuments) * 100),
},
})}
</>
)}{' '}
<strong>
{fieldFormats
.getDefaultInstance(KBN_FIELD_TYPES.NUMBER, [ES_FIELD_TYPES.INTEGER])
.convert(props.totalDocuments)}
</strong>{' '}
{i18n.translate('xpack.lens.indexPattern.ofDocumentsLabel', {
defaultMessage: 'documents',
})}
</EuiText>
</EuiPopoverFooter>
) : (
<></>
)}
</>
);
}
if (histogram && histogram.buckets.length) {
const specId = i18n.translate('xpack.lens.indexPattern.fieldStatsCountLabel', {
defaultMessage: 'Count',
});
if (field.type === 'date') {
return wrapInPopover(
<Chart data-test-subj="lnsFieldListPanel-histogram" size={{ height: 200, width: 300 - 32 }}>
<Settings
tooltip={{ type: TooltipType.None }}
theme={chartTheme}
baseTheme={chartBaseTheme}
xDomain={
fromDate && toDate
? {
min: fromDate.valueOf(),
max: toDate.valueOf(),
minInterval: Math.round((toDate.valueOf() - fromDate.valueOf()) / 10),
}
: undefined
}
/>
<Axis
id="key"
position={Position.Bottom}
tickFormat={
fromDate && toDate
? niceTimeFormatter([fromDate.valueOf(), toDate.valueOf()])
: undefined
}
showOverlappingTicks={true}
/>
<BarSeries
data={histogram.buckets}
id={specId}
xAccessor={'key'}
yAccessors={['count']}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
timeZone="local"
/>
</Chart>
);
} else if (showingHistogram || !topValues || !topValues.buckets.length) {
return wrapInPopover(
<Chart data-test-subj="lnsFieldListPanel-histogram" size={{ height: 200, width: '100%' }}>
<Settings
rotation={90}
tooltip={{ type: TooltipType.None }}
theme={chartTheme}
baseTheme={chartBaseTheme}
/>
<Axis
id="key"
position={Position.Left}
showOverlappingTicks={true}
tickFormat={(d) => formatter.convert(d)}
/>
<BarSeries
data={histogram.buckets}
id={specId}
xAccessor={'key'}
yAccessors={['count']}
xScaleType={ScaleType.Linear}
yScaleType={ScaleType.Linear}
/>
</Chart>
);
}
}
if (props.topValues && props.topValues.buckets.length) {
return wrapInPopover(
<div data-test-subj="lnsFieldListPanel-topValues">
{props.topValues.buckets.map((topValue) => {
const formatted = formatter.convert(topValue.key);
return (
<div className="lnsFieldItem__topValue" key={topValue.key}>
<EuiFlexGroup alignItems="stretch" key={topValue.key} gutterSize="xs">
<EuiFlexItem grow={true} className="eui-textTruncate">
{formatted === '' ? (
<EuiText size="xs" color="subdued">
<em>
{i18n.translate('xpack.lens.indexPattern.fieldPanelEmptyStringValue', {
defaultMessage: 'Empty string',
})}
</em>
</EuiText>
) : (
<EuiToolTip content={formatted} delay="long">
<EuiText size="xs" color="subdued" className="eui-textTruncate">
{formatted}
</EuiText>
</EuiToolTip>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="xs" textAlign="left" color="accent">
{Math.round((topValue.count / props.sampledValues!) * 100)}%
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiProgress
className="lnsFieldItem__topValueProgress"
value={topValue.count / props.sampledValues!}
max={1}
size="s"
color="accent"
/>
</div>
);
})}
{otherCount ? (
<>
<EuiFlexGroup alignItems="stretch" gutterSize="xs">
<EuiFlexItem grow={true} className="eui-textTruncate">
<EuiText size="xs" className="eui-textTruncate" color="subdued">
{i18n.translate('xpack.lens.indexPattern.otherDocsLabel', {
defaultMessage: 'Other',
})}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false} className="eui-textTruncate">
<EuiText size="xs" color="subdued">
{Math.round((otherCount / props.sampledValues!) * 100)}%
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiProgress
className="lnsFieldItem__topValueProgress"
value={otherCount / props.sampledValues!}
max={1}
size="s"
color="subdued"
/>
</>
) : (
<></>
)}
</div>
);
}
return <></>;
}