[ML] Add KQL filter bar, filtering functionality, and compact design to Index data visualizer (#112870)

* [ML] Initial embed

* [ML] Initial embed props

* [ML] Add top nav link to data viz

* Add visible fields

* Add add data service to register links

* Renames, refactor, use constants

* Renames, refactor, use constants

* Update tests and mocks

* Embeddable

* Update hook to update upon time udpate

* Add filter support to query

* Refactor filter utilities

* Add filter support for embeddable

* Fix saved search data undefined

* Prototype aggregated view/document view switcher

* Prototype flyout

* Prototype save document view option in storage

* Fix filter and query conflict with saved search

* Minor styling edits

* [ML] Update functional tests to reflect new arrow icons

* [ML] Add filter buttons and KQL bars

* [ML] Update filter bar onChange behavior

* [ML] Update top values filter onChange behavior

* [ML] Update search filters when opening saved search

* [ML] Clean up

* [ML] Remove fit content for height

* [ML] Fix boolean legend

* [ML] Fix header section when browser width is small to large and when index pattern title is too large

* [ML] Hide expander icon when dimension is xs or s & css fixes

* [ML] Delete embeddables because they are not use

* [ML] Make doc count 0 for empty fields, update t/f test

* [ML] Add unit testing for search utils

* [ML] No need to - padding

* [ML] Fix expand all/collapse all behavior to override individual setting

* [ML] Fix functional tests should be 0/0%

* [ML] Fix docs content spacing, rename classnames, add filters to Discover, lens, and maps

* [ML] Fix icon styling to match Discover but have text/keyword/histogram

* [ML] Fix filters not persisting after page refresh & on query change

* [ML] Rename classnames to BEM style

* [ML] Fix doc count for fields that exists but have no stats

* [ML] Clean up unused styles

* [ML] Fix eui var & icon & file geo

* [ML] Fix navigating to Lens from new saved search broken

* [ML] Change types back to Index pattern for 7.16

* [ML] Update not in docs content and snapshots

* [ML] Fix Lens and indexRefName

* [ML] Fix field icon and texts not aligned, remove span because EuiToolTip now supports EuiToken

* [ML] Fix data view
This commit is contained in:
Quynh Nguyen 2021-09-30 11:37:56 -05:00 committed by GitHub
parent b73d939d6c
commit 747212ce45
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
75 changed files with 1749 additions and 824 deletions

View file

@ -33,19 +33,6 @@ export const JOB_FIELD_TYPES = {
UNKNOWN: 'unknown',
} as const;
export const JOB_FIELD_TYPES_OPTIONS = {
[JOB_FIELD_TYPES.BOOLEAN]: { name: 'Boolean', icon: 'tokenBoolean' },
[JOB_FIELD_TYPES.DATE]: { name: 'Date', icon: 'tokenDate' },
[JOB_FIELD_TYPES.GEO_POINT]: { name: 'Geo point', icon: 'tokenGeo' },
[JOB_FIELD_TYPES.GEO_SHAPE]: { name: 'Geo shape', icon: 'tokenGeo' },
[JOB_FIELD_TYPES.IP]: { name: 'IP address', icon: 'tokenIP' },
[JOB_FIELD_TYPES.KEYWORD]: { name: 'Keyword', icon: 'tokenKeyword' },
[JOB_FIELD_TYPES.NUMBER]: { name: 'Number', icon: 'tokenNumber' },
[JOB_FIELD_TYPES.TEXT]: { name: 'Text', icon: 'tokenString' },
[JOB_FIELD_TYPES.HISTOGRAM]: { name: 'Histogram', icon: 'tokenNumber' },
[JOB_FIELD_TYPES.UNKNOWN]: { name: 'Unknown' },
};
export const OMIT_FIELDS: string[] = ['_source', '_type', '_index', '_id', '_version', '_score'];
export const NON_AGGREGATABLE_FIELD_TYPES = new Set<string>([

View file

@ -6,6 +6,7 @@
*/
import type { SimpleSavedObject } from 'kibana/public';
import { isPopulatedObject } from '../utils/object_utils';
export type { JobFieldType } from './job_field_type';
export type {
FieldRequestConfig,
@ -27,3 +28,7 @@ export interface DataVisualizerTableState {
}
export type SavedSearchSavedObject = SimpleSavedObject<any>;
export function isSavedSearchSavedObject(arg: unknown): arg is SavedSearchSavedObject {
return isPopulatedObject(arg, ['id', 'type', 'attributes']);
}

View file

@ -1,4 +1,4 @@
.embeddedMapContent {
.embeddedMap__content {
width: 100%;
height: 100%;
display: flex;

View file

@ -39,7 +39,7 @@ export function EmbeddedMapComponent({
const baseLayers = useRef<LayerDescriptor[]>();
const {
services: { embeddable: embeddablePlugin, maps: mapsPlugin },
services: { embeddable: embeddablePlugin, maps: mapsPlugin, data },
} = useDataVisualizerKibana();
const factory:
@ -73,7 +73,7 @@ export function EmbeddedMapComponent({
const input: MapEmbeddableInput = {
id: htmlIdGenerator()(),
attributes: { title: '' },
filters: [],
filters: data.query.filterManager.getFilters() ?? [],
hidePanelTitles: true,
viewMode: ViewMode.VIEW,
isLayerTOCOpen: false,
@ -143,7 +143,7 @@ export function EmbeddedMapComponent({
return (
<div
data-test-subj="dataVisualizerEmbeddedMapContent"
className="embeddedMapContent"
className="embeddedMap__content"
ref={embeddableRoot}
/>
);

View file

@ -11,6 +11,7 @@ import { EuiListGroup, EuiListGroupItem } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { ExpandedRowFieldHeader } from '../stats_table/components/expanded_row_field_header';
import { ExpandedRowPanel } from '../stats_table/components/field_data_expanded_row/expanded_row_panel';
interface Props {
examples: Array<string | object>;
}
@ -31,8 +32,7 @@ export const ExamplesList: FC<Props> = ({ examples }) => {
examplesContent = examples.map((example, i) => {
return (
<EuiListGroupItem
className="fieldDataCard__codeContent"
size="s"
size="xs"
key={`example_${i}`}
label={typeof example === 'string' ? example : JSON.stringify(example)}
/>
@ -41,7 +41,10 @@ export const ExamplesList: FC<Props> = ({ examples }) => {
}
return (
<div data-test-subj="dataVisualizerFieldDataExamplesList">
<ExpandedRowPanel
dataTestSubj="dataVisualizerFieldDataExamplesList"
className="dvText__wrapper dvPanel__wrapper"
>
<ExpandedRowFieldHeader>
<FormattedMessage
id="xpack.dataVisualizer.dataGrid.field.examplesList.title"
@ -54,6 +57,6 @@ export const ExamplesList: FC<Props> = ({ examples }) => {
<EuiListGroup showToolTips={true} maxWidth={'s'} gutterSize={'none'} flush={true}>
{examplesContent}
</EuiListGroup>
</div>
</ExpandedRowPanel>
);
};

View file

@ -52,10 +52,7 @@ export const FileBasedDataVisualizerExpandedRow = ({ item }: { item: FileBasedFi
}
return (
<div
className="dataVisualizerFieldExpandedRow"
data-test-subj={`dataVisualizerFieldExpandedRow-${fieldName}`}
>
<div className="dvExpandedRow" data-test-subj={`dataVisualizerFieldExpandedRow-${fieldName}`}>
{getCardContent()}
</div>
);

View file

@ -6,8 +6,6 @@
*/
import React, { FC, useMemo } from 'react';
import { EuiFlexItem } from '@elastic/eui';
import { Feature, Point } from 'geojson';
import type { FieldDataRowProps } from '../../stats_table/types/field_data_row';
import { DocumentStatsTable } from '../../stats_table/components/field_data_expanded_row/document_stats';
@ -15,6 +13,7 @@ import { EmbeddedMapComponent } from '../../embedded_map';
import { convertWKTGeoToLonLat, getGeoPointsLayer } from './format_utils';
import { ExpandedRowContent } from '../../stats_table/components/field_data_expanded_row/expanded_row_content';
import { ExamplesList } from '../../examples_list';
import { ExpandedRowPanel } from '../../stats_table/components/field_data_expanded_row/expanded_row_panel';
export const DEFAULT_GEO_REGEX = RegExp('(?<lat>.+) (?<lon>.+)');
@ -63,17 +62,12 @@ export const GeoPointContent: FC<FieldDataRowProps> = ({ config }) => {
<ExpandedRowContent dataTestSubj={'dataVisualizerGeoPointContent'}>
<DocumentStatsTable config={config} />
{formattedResults && Array.isArray(formattedResults.examples) && (
<EuiFlexItem>
<ExamplesList examples={formattedResults.examples} />
</EuiFlexItem>
<ExamplesList examples={formattedResults.examples} />
)}
{formattedResults && Array.isArray(formattedResults.layerList) && (
<EuiFlexItem
className={'dataVisualizerMapWrapper'}
data-test-subj={'dataVisualizerEmbeddedMap'}
>
<ExpandedRowPanel className={'dvPanel__wrapper dvMap__wrapper'} grow={true}>
<EmbeddedMapComponent layerList={formattedResults.layerList} />
</EuiFlexItem>
</ExpandedRowPanel>
)}
</ExpandedRowContent>
);

View file

@ -6,7 +6,6 @@
*/
import React, { FC, useEffect, useState } from 'react';
import { EuiFlexItem } from '@elastic/eui';
import { IndexPattern } from '../../../../../../../../../src/plugins/data/common';
import { CombinedQuery } from '../../../../index_data_visualizer/types/combined_query';
import { ExpandedRowContent } from '../../stats_table/components/field_data_expanded_row/expanded_row_content';
@ -17,6 +16,7 @@ import { useDataVisualizerKibana } from '../../../../kibana_context';
import { JOB_FIELD_TYPES } from '../../../../../../common';
import { ES_GEO_FIELD_TYPE, LayerDescriptor } from '../../../../../../../maps/common';
import { EmbeddedMapComponent } from '../../embedded_map';
import { ExpandedRowPanel } from '../../stats_table/components/field_data_expanded_row/expanded_row_panel';
export const GeoPointContentWithMap: FC<{
config: FieldVisConfig;
@ -26,7 +26,7 @@ export const GeoPointContentWithMap: FC<{
const { stats } = config;
const [layerList, setLayerList] = useState<LayerDescriptor[]>([]);
const {
services: { maps: mapsPlugin },
services: { maps: mapsPlugin, data },
} = useDataVisualizerKibana();
// Update the layer list with updated geo points upon refresh
@ -42,6 +42,7 @@ export const GeoPointContentWithMap: FC<{
indexPatternId: indexPattern.id,
geoFieldName: config.fieldName,
geoFieldType: config.type as ES_GEO_FIELD_TYPE,
filters: data.query.filterManager.getFilters() ?? [],
query: {
query: combinedQuery.searchString,
language: combinedQuery.searchQueryLanguage,
@ -57,19 +58,16 @@ export const GeoPointContentWithMap: FC<{
}
updateIndexPatternSearchLayer();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [indexPattern, combinedQuery, config, mapsPlugin]);
}, [indexPattern, combinedQuery, config, mapsPlugin, data.query]);
if (stats?.examples === undefined) return null;
return (
<ExpandedRowContent dataTestSubj={'dataVisualizerIndexBasedMapContent'}>
<DocumentStatsTable config={config} />
<EuiFlexItem style={{ maxWidth: '50%' }}>
<ExamplesList examples={stats.examples} />
</EuiFlexItem>
<EuiFlexItem className={'dataVisualizerMapWrapper'}>
<ExamplesList examples={stats.examples} />
<ExpandedRowPanel className={'dvPanel__wrapper dvMap__wrapper'} grow={true}>
<EmbeddedMapComponent layerList={layerList} />
</EuiFlexItem>
</ExpandedRowPanel>
</ExpandedRowContent>
);
};

View file

@ -22,15 +22,21 @@ import { FieldVisConfig } from '../stats_table/types';
import { IndexPattern } from '../../../../../../../../src/plugins/data/common';
import { CombinedQuery } from '../../../index_data_visualizer/types/combined_query';
import { LoadingIndicator } from '../loading_indicator';
import { IndexPatternField } from '../../../../../../../../src/plugins/data/common';
export const IndexBasedDataVisualizerExpandedRow = ({
item,
indexPattern,
combinedQuery,
onAddFilter,
}: {
item: FieldVisConfig;
indexPattern: IndexPattern | undefined;
combinedQuery: CombinedQuery;
/**
* Callback to add a filter to filter bar
*/
onAddFilter?: (field: IndexPatternField | string, value: string, type: '+' | '-') => void;
}) => {
const config = item;
const { loading, type, existsInDocs, fieldName } = config;
@ -42,7 +48,7 @@ export const IndexBasedDataVisualizerExpandedRow = ({
switch (type) {
case JOB_FIELD_TYPES.NUMBER:
return <NumberContent config={config} />;
return <NumberContent config={config} onAddFilter={onAddFilter} />;
case JOB_FIELD_TYPES.BOOLEAN:
return <BooleanContent config={config} />;
@ -61,10 +67,10 @@ export const IndexBasedDataVisualizerExpandedRow = ({
);
case JOB_FIELD_TYPES.IP:
return <IpContent config={config} />;
return <IpContent config={config} onAddFilter={onAddFilter} />;
case JOB_FIELD_TYPES.KEYWORD:
return <KeywordContent config={config} />;
return <KeywordContent config={config} onAddFilter={onAddFilter} />;
case JOB_FIELD_TYPES.TEXT:
return <TextContent config={config} />;
@ -75,10 +81,7 @@ export const IndexBasedDataVisualizerExpandedRow = ({
}
return (
<div
className="dataVisualizerFieldExpandedRow"
data-test-subj={`dataVisualizerFieldExpandedRow-${fieldName}`}
>
<div className="dvExpandedRow" data-test-subj={`dataVisualizerFieldExpandedRow-${fieldName}`}>
{loading === true ? <LoadingIndicator /> : getCardContent()}
</div>
);

View file

@ -28,12 +28,13 @@ export const FieldCountPanel: FC<Props> = ({
<EuiFlexGroup
alignItems="center"
gutterSize="xs"
style={{ marginLeft: 4 }}
data-test-subj="dataVisualizerFieldCountPanel"
responsive={false}
className="dvFieldCount__panel"
>
<TotalFieldsCount fieldsCountStats={fieldsCountStats} />
<MetricFieldsCount metricsStats={metricsStats} />
<EuiFlexItem>
<EuiFlexItem className={'dvFieldCount__item'}>
<EuiSwitch
data-test-subj="dataVisualizerShowEmptyFieldsSwitch"
label={

View file

@ -20,13 +20,14 @@ import {
export function getActions(
indexPattern: IndexPattern,
services: DataVisualizerKibanaReactContextValue['services'],
services: Partial<DataVisualizerKibanaReactContextValue['services']>,
combinedQuery: CombinedQuery,
actionFlyoutRef: MutableRefObject<(() => void | undefined) | undefined>
): Array<Action<FieldVisConfig>> {
const { lens: lensPlugin, indexPatternFieldEditor } = services;
const { lens: lensPlugin, data } = services;
const actions: Array<Action<FieldVisConfig>> = [];
const filters = data?.query.filterManager.getFilters() ?? [];
const refreshPage = () => {
const refresh: Refresh = {
@ -49,7 +50,7 @@ export function getActions(
available: (item: FieldVisConfig) =>
getCompatibleLensDataType(item.type) !== undefined && canUseLensEditor,
onClick: (item: FieldVisConfig) => {
const lensAttributes = getLensAttributes(indexPattern, combinedQuery, item);
const lensAttributes = getLensAttributes(indexPattern, combinedQuery, filters, item);
if (lensAttributes) {
lensPlugin.navigateToPrefilledEditor({
id: `dataVisualizer-${item.fieldName}`,
@ -62,7 +63,7 @@ export function getActions(
}
// Allow to edit index pattern field
if (indexPatternFieldEditor?.userPermissions.editIndexPattern()) {
if (services.indexPatternFieldEditor?.userPermissions.editIndexPattern()) {
actions.push({
name: i18n.translate('xpack.dataVisualizer.index.dataGrid.editIndexPatternFieldTitle', {
defaultMessage: 'Edit index pattern field',
@ -76,7 +77,7 @@ export function getActions(
type: 'icon',
icon: 'indexEdit',
onClick: (item: FieldVisConfig) => {
actionFlyoutRef.current = indexPatternFieldEditor?.openEditor({
actionFlyoutRef.current = services.indexPatternFieldEditor?.openEditor({
ctx: { indexPattern },
fieldName: item.fieldName,
onSave: refreshPage,
@ -100,7 +101,7 @@ export function getActions(
return item.deletable === true;
},
onClick: (item: FieldVisConfig) => {
actionFlyoutRef.current = indexPatternFieldEditor?.openDeleteModal({
actionFlyoutRef.current = services.indexPatternFieldEditor?.openDeleteModal({
ctx: { indexPattern },
fieldName: item.fieldName!,
onDelete: refreshPage,

View file

@ -6,6 +6,7 @@
*/
import { i18n } from '@kbn/i18n';
import type { Filter } from '@kbn/es-query';
import type { IndexPattern } from '../../../../../../../../../src/plugins/data/common';
import type { CombinedQuery } from '../../../../index_data_visualizer/types/combined_query';
import type {
@ -15,6 +16,7 @@ import type {
} from '../../../../../../../lens/public';
import { FieldVisConfig } from '../../stats_table/types';
import { JOB_FIELD_TYPES } from '../../../../../../common';
interface ColumnsAndLayer {
columns: Record<string, IndexPatternColumn>;
layer: XYLayerConfig;
@ -241,6 +243,7 @@ function getColumnsAndLayer(
export function getLensAttributes(
defaultIndexPattern: IndexPattern | undefined,
combinedQuery: CombinedQuery,
filters: Filter[],
item: FieldVisConfig
): TypedLensByValueInput['attributes'] | undefined {
if (defaultIndexPattern === undefined || item.type === undefined || item.fieldName === undefined)
@ -279,7 +282,7 @@ export function getLensAttributes(
},
},
},
filters: [],
filters,
query: { language: combinedQuery.searchQueryLanguage, query: combinedQuery.searchString },
visualization: {
axisTitlesVisibilitySettings: { x: true, yLeft: true, yRight: true },

View file

@ -7,7 +7,7 @@
import React from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { FileBasedFieldVisConfig } from '../stats_table/types';
export const FileBasedNumberContentPreview = ({ config }: { config: FileBasedFieldVisConfig }) => {
@ -23,28 +23,34 @@ export const FileBasedNumberContentPreview = ({ config }: { config: FileBasedFie
<EuiFlexGroup direction={'column'} gutterSize={'xs'}>
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem>
<b>
<EuiText size={'xs'}>
<FormattedMessage id="xpack.dataVisualizer.fieldStats.minTitle" defaultMessage="min" />
</b>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<b>
<EuiText size={'xs'}>
<FormattedMessage
id="xpack.dataVisualizer.fieldStats.medianTitle"
defaultMessage="median"
/>
</b>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<b>
<EuiText size={'xs'}>
<FormattedMessage id="xpack.dataVisualizer.fieldStats.maxTitle" defaultMessage="max" />
</b>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem>{stats.min}</EuiFlexItem>
<EuiFlexItem>{stats.median}</EuiFlexItem>
<EuiFlexItem>{stats.max}</EuiFlexItem>
<EuiFlexItem>
<EuiText size={'xs'}>{stats.min}</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiText size={'xs'}>{stats.median}</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiText size={'xs'}>{stats.max}</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexGroup>
);

View file

@ -2,6 +2,7 @@
exports[`FieldTypeIcon render component when type matches a field type 1`] = `
<EuiToolTip
anchorClassName="dvFieldTypeIcon__anchor"
content="keyword type"
delay="regular"
display="inlineBlock"
@ -9,8 +10,7 @@ exports[`FieldTypeIcon render component when type matches a field type 1`] = `
>
<FieldTypeIconContainer
ariaLabel="keyword type"
color="euiColorVis0"
iconType="tokenText"
iconType="tokenKeyword"
needsAria={false}
/>
</EuiToolTip>

View file

@ -0,0 +1,4 @@
.dvFieldTypeIcon__anchor {
display: flex;
align-items: center;
}

View file

@ -0,0 +1 @@
@import 'field_type_icon';

View file

@ -26,11 +26,10 @@ describe('FieldTypeIcon', () => {
const typeIconComponent = mount(
<FieldTypeIcon type={JOB_FIELD_TYPES.KEYWORD} tooltipEnabled={true} needsAria={false} />
);
const container = typeIconComponent.find({ 'data-test-subj': 'fieldTypeIcon' });
expect(typeIconComponent.find('EuiToolTip').children()).toHaveLength(1);
container.simulate('mouseover');
typeIconComponent.simulate('mouseover');
// Run the timers so the EuiTooltip will be visible
jest.runAllTimers();
@ -38,7 +37,7 @@ describe('FieldTypeIcon', () => {
typeIconComponent.update();
expect(typeIconComponent.find('EuiToolTip').children()).toHaveLength(2);
container.simulate('mouseout');
typeIconComponent.simulate('mouseout');
// Run the timers so the EuiTooltip will be hidden again
jest.runAllTimers();

View file

@ -6,91 +6,62 @@
*/
import React, { FC } from 'react';
import { EuiToken, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { getJobTypeAriaLabel } from '../../util/field_types_utils';
import { JOB_FIELD_TYPES } from '../../../../../common';
import type { JobFieldType } from '../../../../../common';
import './_index.scss';
interface FieldTypeIconProps {
tooltipEnabled: boolean;
type: JobFieldType;
fieldName?: string;
needsAria: boolean;
}
interface FieldTypeIconContainerProps {
ariaLabel: string | null;
iconType: string;
color: string;
color?: string;
needsAria: boolean;
[key: string]: any;
}
// defaultIcon => a unknown datatype
const defaultIcon = { iconType: 'questionInCircle', color: 'gray' };
// Extended & modified version of src/plugins/kibana_react/public/field_icon/field_icon.tsx
export const typeToEuiIconMap: Record<string, { iconType: string; color?: string }> = {
boolean: { iconType: 'tokenBoolean' },
// icon for an index pattern mapping conflict in discover
conflict: { iconType: 'alert', color: 'euiColorVis9' },
date: { iconType: 'tokenDate' },
date_range: { iconType: 'tokenDate' },
geo_point: { iconType: 'tokenGeo' },
geo_shape: { iconType: 'tokenGeo' },
ip: { iconType: 'tokenIP' },
ip_range: { iconType: 'tokenIP' },
// is a plugin's data type https://www.elastic.co/guide/en/elasticsearch/plugins/current/mapper-murmur3-usage.html
murmur3: { iconType: 'tokenFile' },
number: { iconType: 'tokenNumber' },
number_range: { iconType: 'tokenNumber' },
histogram: { iconType: 'tokenHistogram' },
_source: { iconType: 'editorCodeBlock', color: 'gray' },
string: { iconType: 'tokenString' },
text: { iconType: 'tokenString' },
keyword: { iconType: 'tokenKeyword' },
nested: { iconType: 'tokenNested' },
};
export const FieldTypeIcon: FC<FieldTypeIconProps> = ({
tooltipEnabled = false,
type,
fieldName,
needsAria = true,
}) => {
const ariaLabel = getJobTypeAriaLabel(type);
let iconType = 'questionInCircle';
let color = 'euiColorVis6';
switch (type) {
// Set icon types and colors
case JOB_FIELD_TYPES.BOOLEAN:
iconType = 'tokenBoolean';
color = 'euiColorVis5';
break;
case JOB_FIELD_TYPES.DATE:
iconType = 'tokenDate';
color = 'euiColorVis7';
break;
case JOB_FIELD_TYPES.GEO_POINT:
case JOB_FIELD_TYPES.GEO_SHAPE:
iconType = 'tokenGeo';
color = 'euiColorVis8';
break;
case JOB_FIELD_TYPES.TEXT:
iconType = 'document';
color = 'euiColorVis9';
break;
case JOB_FIELD_TYPES.IP:
iconType = 'tokenIP';
color = 'euiColorVis3';
break;
case JOB_FIELD_TYPES.KEYWORD:
iconType = 'tokenText';
color = 'euiColorVis0';
break;
case JOB_FIELD_TYPES.NUMBER:
iconType = 'tokenNumber';
color = fieldName !== undefined ? 'euiColorVis1' : 'euiColorVis2';
break;
case JOB_FIELD_TYPES.HISTOGRAM:
iconType = 'tokenHistogram';
color = 'euiColorVis7';
case JOB_FIELD_TYPES.UNKNOWN:
// Use defaults
break;
}
const containerProps = {
ariaLabel,
iconType,
color,
needsAria,
};
const token = typeToEuiIconMap[type] || defaultIcon;
const containerProps = { ...token, ariaLabel, needsAria };
if (tooltipEnabled === true) {
// wrap the inner component inside <span> because EuiToolTip doesn't seem
// to support having another component directly inside the tooltip anchor
// see https://github.com/elastic/eui/issues/839
return (
<EuiToolTip
position="left"
@ -98,6 +69,7 @@ export const FieldTypeIcon: FC<FieldTypeIconProps> = ({
defaultMessage: '{type} type',
values: { type },
})}
anchorClassName="dvFieldTypeIcon__anchor"
>
<FieldTypeIconContainer {...containerProps} />
</EuiToolTip>
@ -122,12 +94,15 @@ const FieldTypeIconContainer: FC<FieldTypeIconContainerProps> = ({
if (needsAria && ariaLabel) {
wrapperProps['aria-label'] = ariaLabel;
}
return (
<span data-test-subj="fieldTypeIcon" {...rest}>
<span {...wrapperProps}>
<EuiToken iconType={iconType} shape="square" size="s" color={color} />
</span>
</span>
<EuiToken
iconType={iconType}
color={color}
shape="square"
size="s"
data-test-subj="fieldTypeIcon"
{...wrapperProps}
{...rest}
/>
);
};

View file

@ -14,7 +14,7 @@ import type {
FileBasedUnknownFieldVisConfig,
} from '../stats_table/types/field_vis_config';
import { FieldTypeIcon } from '../field_type_icon';
import { JOB_FIELD_TYPES_OPTIONS } from '../../../../../common';
import { jobTypeLabels } from '../../util/field_types_utils';
interface Props {
fields: Array<FileBasedFieldVisConfig | FileBasedUnknownFieldVisConfig>;
@ -39,27 +39,18 @@ export const DataVisualizerFieldTypesFilter: FC<Props> = ({
const fieldTypesTracker = new Set();
const fieldTypes: Option[] = [];
fields.forEach(({ type }) => {
if (
type !== undefined &&
!fieldTypesTracker.has(type) &&
JOB_FIELD_TYPES_OPTIONS[type] !== undefined
) {
const item = JOB_FIELD_TYPES_OPTIONS[type];
if (type !== undefined && !fieldTypesTracker.has(type) && jobTypeLabels[type] !== undefined) {
const label = jobTypeLabels[type];
fieldTypesTracker.add(type);
fieldTypes.push({
value: type,
name: (
<EuiFlexGroup>
<EuiFlexItem grow={true}> {item.name}</EuiFlexItem>
<EuiFlexItem grow={true}> {label}</EuiFlexItem>
{type && (
<EuiFlexItem grow={false}>
<FieldTypeIcon
type={type}
fieldName={item.name}
tooltipEnabled={false}
needsAria={true}
/>
<FieldTypeIcon type={type} tooltipEnabled={false} needsAria={true} />
</EuiFlexItem>
)}
</EuiFlexGroup>

View file

@ -25,7 +25,7 @@ interface Props {
export const getDefaultDataVisualizerListState = (): DataVisualizerTableState => ({
pageIndex: 0,
pageSize: 10,
pageSize: 25,
sortField: 'fieldName',
sortDirection: 'asc',
visibleFieldTypes: [],

View file

@ -98,7 +98,7 @@ export const MultiSelectPicker: FC<{
);
return (
<EuiFilterGroup data-test-subj={dataTestSubj}>
<EuiFilterGroup data-test-subj={dataTestSubj} style={{ marginLeft: 8 }}>
<EuiPopover
ownFocus
data-test-subj={`${dataTestSubj}-popover`}

View file

@ -6,19 +6,16 @@
*/
import React, { FC, Fragment } from 'react';
import { EuiIcon, EuiSpacer, EuiText } from '@elastic/eui';
import { EuiIcon, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
export const NotInDocsContent: FC = () => (
<Fragment>
<EuiSpacer size="xxl" />
<EuiText textAlign="center">
<EuiIcon type="alert" />
</EuiText>
<EuiSpacer size="s" />
<EuiText textAlign="center">
<EuiText textAlign="center" size={'xs'}>
<FormattedMessage
id="xpack.dataVisualizer.dataGrid.field.fieldNotInDocsLabel"
defaultMessage="This field does not appear in any documents for the selected time range"

View file

@ -1,86 +0,0 @@
.fieldDataCard {
height: 420px;
box-shadow: none;
border-color: $euiBorderColor;
// Note the names of these styles need to match the type of the field they are displaying.
.boolean {
color: $euiColorVis5;
border-color: $euiColorVis5;
}
.date {
color: $euiColorVis7;
border-color: $euiColorVis7;
}
.document_count {
color: $euiColorVis2;
border-color: $euiColorVis2;
}
.geo_point {
color: $euiColorVis8;
border-color: $euiColorVis8;
}
.ip {
color: $euiColorVis3;
border-color: $euiColorVis3;
}
.keyword {
color: $euiColorVis0;
border-color: $euiColorVis0;
}
.number {
color: $euiColorVis1;
border-color: $euiColorVis1;
}
.text {
color: $euiColorVis9;
border-color: $euiColorVis9;
}
.type-other,
.unknown {
color: $euiColorVis6;
border-color: $euiColorVis6;
}
.fieldDataCard__content {
@include euiFontSizeS;
height: 385px;
overflow: hidden;
}
.fieldDataCard__codeContent {
@include euiCodeFont;
}
.fieldDataCard__geoContent {
z-index: auto;
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
position: relative;
.embPanel__content {
display: flex;
flex: 1 1 100%;
z-index: 1;
min-height: 0; // Absolute must for Firefox to scroll contents
}
}
.fieldDataCard__stats {
padding: $euiSizeS $euiSizeS 0 $euiSizeS;
text-align: center;
}
.fieldDataCard__valuesTitle {
text-transform: uppercase;
}
}

View file

@ -2,55 +2,90 @@
@import 'components/field_count_stats/index';
@import 'components/field_data_row/index';
.dataVisualizerFieldExpandedRow {
$panelWidthS: #{'max(20%, 225px)'};
$panelWidthL: #{'max(40%, 450px)'};
.dvExpandedRow {
padding-left: $euiSize * 4;
width: 100%;
.fieldDataCard__valuesTitle {
.dvExpandedRow__fieldHeader {
text-transform: uppercase;
text-align: left;
color: $euiColorDarkShade;
font-weight: bold;
padding-bottom: $euiSizeS;
}
.fieldDataCard__codeContent {
@include euiCodeFont;
}
}
.dataVisualizer {
.euiTableRow > .euiTableRowCell {
border-bottom: 0;
border-top: $euiBorderThin;
}
.euiTableRow-isExpandedRow {
.euiTableRowCell {
background-color: $euiColorEmptyShade !important;
border-top: 0;
border-bottom: $euiBorderThin;
&:hover {
background-color: $euiColorEmptyShade !important;
}
@include euiBreakpoint('m', 'l', 'xl') {
.dvTable {
.columnHeader__title {
display: flex;
align-items: center;
}
}
.dataVisualizerSummaryTable {
max-width: 350px;
min-width: 250px;
.columnHeader__icon {
padding-right: $euiSizeXS;
}
.euiTableRow > .euiTableRowCell {
border-bottom: 0;
border-top: $euiBorderThin;
}
.euiTableHeaderCell {
display: none;
.euiTableCellContent {
padding: $euiSizeXS;
}
.euiTableRow-isExpandedRow {
.euiTableRowCell {
background-color: $euiColorEmptyShade !important;
border-top: 0;
border-bottom: $euiBorderThin;
&:hover {
background-color: $euiColorEmptyShade !important;
}
}
}
.dvSummaryTable {
.euiTableRow > .euiTableRowCell {
border-bottom: 0;
}
.euiTableHeaderCell {
display: none;
}
}
.dvSummaryTable__wrapper {
min-width: $panelWidthS;
max-width: $panelWidthS;
}
.dvTopValues__wrapper {
min-width: fit-content;
}
.dvPanel__wrapper {
margin: $euiSizeXS $euiSizeM $euiSizeM 0;
&.dvPanel--compressed {
width: $panelWidthS;
}
&.dvPanel--uniform {
min-width: $panelWidthS;
max-width: $panelWidthS;
}
}
.dvMap__wrapper {
height: $euiSize * 15; //240px
}
.dvText__wrapper {
min-width: $panelWidthS;
}
}
.dataVisualizerSummaryTableWrapper {
max-width: 300px;
}
.dataVisualizerMapWrapper {
min-height: 300px;
min-width: 600px;
}
}

View file

@ -9,7 +9,12 @@ import { EuiText } from '@elastic/eui';
import React from 'react';
export const ExpandedRowFieldHeader = ({ children }: { children: React.ReactNode }) => (
<EuiText size="xs" color={'subdued'} className={'fieldDataCard__valuesTitle'}>
<EuiText
size="xs"
color={'subdued'}
className={'dvExpandedRow__fieldHeader'}
textAlign={'center'}
>
{children}
</EuiText>
);

View file

@ -1,3 +1,12 @@
.dataVisualizerFieldCountContainer {
max-width: 300px;
.dvFieldCount__panel {
margin-left: $euiSizeXS;
@include euiBreakpoint('xs', 's') {
flex-direction: column;
align-items: flex-start;
}
}
.dvFieldCount__item {
max-width: 300px;
min-width: 300px;
}

View file

@ -30,8 +30,9 @@ export const MetricFieldsCount: FC<MetricFieldsCountProps> = ({ metricsStats })
<EuiFlexGroup
gutterSize="s"
alignItems="center"
className="dataVisualizerFieldCountContainer"
className="dvFieldCount__item"
data-test-subj="dataVisualizerMetricFieldsSummary"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiText>

View file

@ -30,8 +30,9 @@ export const TotalFieldsCount: FC<TotalFieldsCountProps> = ({ fieldsCountStats }
<EuiFlexGroup
gutterSize="s"
alignItems="center"
className="dataVisualizerFieldCountContainer"
className="dvFieldCount__item"
data-test-subj="dataVisualizerFieldsSummary"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiText>

View file

@ -6,7 +6,7 @@
*/
import React, { FC, ReactNode, useMemo } from 'react';
import { EuiBasicTable, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { EuiBasicTable, EuiSpacer, RIGHT_ALIGNMENT, HorizontalAlignment } from '@elastic/eui';
import { Axis, BarSeries, Chart, Settings } from '@elastic/charts';
import { FormattedMessage } from '@kbn/i18n/react';
@ -18,6 +18,7 @@ import { roundToDecimalPlace } from '../../../utils';
import { useDataVizChartTheme } from '../../hooks';
import { DocumentStatsTable } from './document_stats';
import { ExpandedRowContent } from './expanded_row_content';
import { ExpandedRowPanel } from './expanded_row_panel';
function getPercentLabel(value: number): string {
if (value === 0) {
@ -35,7 +36,7 @@ function getFormattedValue(value: number, totalCount: number): string {
return `${value} (${getPercentLabel(percentage)})`;
}
const BOOLEAN_DISTRIBUTION_CHART_HEIGHT = 100;
const BOOLEAN_DISTRIBUTION_CHART_HEIGHT = 70;
export const BooleanContent: FC<FieldDataRowProps> = ({ config }) => {
const fieldFormat = 'fieldFormat' in config ? config.fieldFormat : undefined;
@ -68,9 +69,11 @@ export const BooleanContent: FC<FieldDataRowProps> = ({ config }) => {
];
const summaryTableColumns = [
{
field: 'function',
name: '',
render: (summaryItem: { display: ReactNode }) => summaryItem.display,
width: '75px',
render: (_: string, summaryItem: { display: ReactNode }) => summaryItem.display,
width: '25px',
align: RIGHT_ALIGNMENT as HorizontalAlignment,
},
{
field: 'value',
@ -90,18 +93,18 @@ export const BooleanContent: FC<FieldDataRowProps> = ({ config }) => {
<ExpandedRowContent dataTestSubj={'dataVisualizerBooleanContent'}>
<DocumentStatsTable config={config} />
<EuiFlexItem className={'dataVisualizerSummaryTableWrapper'}>
<ExpandedRowPanel className={'dvSummaryTable__wrapper dvPanel__wrapper'}>
<ExpandedRowFieldHeader>{summaryTableTitle}</ExpandedRowFieldHeader>
<EuiBasicTable
className={'dataVisualizerSummaryTable'}
className={'dvSummaryTable'}
compressed
items={summaryTableItems}
columns={summaryTableColumns}
tableCaption={summaryTableTitle}
/>
</EuiFlexItem>
</ExpandedRowPanel>
<EuiFlexItem>
<ExpandedRowPanel className={'dvPanel__wrapper dvPanel--uniform'}>
<ExpandedRowFieldHeader>
<FormattedMessage
id="xpack.dataVisualizer.dataGrid.field.cardBoolean.valuesLabel"
@ -139,7 +142,7 @@ export const BooleanContent: FC<FieldDataRowProps> = ({ config }) => {
yScaleType="linear"
/>
</Chart>
</EuiFlexItem>
</ExpandedRowPanel>
</ExpandedRowContent>
);
};

View file

@ -6,7 +6,7 @@
*/
import React, { FC, useMemo } from 'react';
import { EuiFlexItem, EuiSpacer, EuiText, htmlIdGenerator } from '@elastic/eui';
import { EuiSpacer, EuiText, htmlIdGenerator } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
@ -20,6 +20,7 @@ import {
import { EMSTermJoinConfig } from '../../../../../../../../maps/public';
import { EmbeddedMapComponent } from '../../../embedded_map';
import { FieldVisStats } from '../../../../../../../common/types';
import { ExpandedRowPanel } from './expanded_row_panel';
export const getChoroplethTopValuesLayer = (
fieldName: string,
@ -104,14 +105,19 @@ export const ChoroplethMap: FC<Props> = ({ stats, suggestion }) => {
);
return (
<EuiFlexItem data-test-subj={'fileDataVisualizerChoroplethMapTopValues'}>
<div style={{ width: '100%', minHeight: 300 }}>
<ExpandedRowPanel
dataTestSubj={'fileDataVisualizerChoroplethMapTopValues'}
className={'dvPanel__wrapper'}
grow={true}
>
<div className={'dvMap__wrapper'}>
<EmbeddedMapComponent layerList={layerList} />
</div>
{isTopValuesSampled === true && (
<>
<EuiSpacer size="xs" />
<EuiText size="xs" textAlign={'left'}>
<div>
<EuiSpacer size={'s'} />
<EuiText size="xs" textAlign={'center'}>
<FormattedMessage
id="xpack.dataVisualizer.dataGrid.fieldExpandedRow.choroplethMapTopValues.calculatedFromSampleDescription"
defaultMessage="Calculated from sample of {topValuesSamplerShardSize} documents per shard"
@ -120,8 +126,8 @@ export const ChoroplethMap: FC<Props> = ({ stats, suggestion }) => {
}}
/>
</EuiText>
</>
</div>
)}
</EuiFlexItem>
</ExpandedRowPanel>
);
};

View file

@ -6,16 +6,18 @@
*/
import React, { FC, ReactNode } from 'react';
import { EuiBasicTable, EuiFlexItem } from '@elastic/eui';
import { EuiBasicTable, HorizontalAlignment } from '@elastic/eui';
// @ts-ignore
import { formatDate } from '@elastic/eui/lib/services/format';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { RIGHT_ALIGNMENT } from '@elastic/eui';
import type { FieldDataRowProps } from '../../types/field_data_row';
import { ExpandedRowFieldHeader } from '../expanded_row_field_header';
import { DocumentStatsTable } from './document_stats';
import { ExpandedRowContent } from './expanded_row_content';
import { ExpandedRowPanel } from './expanded_row_panel';
const TIME_FORMAT = 'MMM D YYYY, HH:mm:ss.SSS';
interface SummaryTableItem {
function: string;
@ -60,8 +62,10 @@ export const DateContent: FC<FieldDataRowProps> = ({ config }) => {
const summaryTableColumns = [
{
name: '',
render: (summaryItem: { display: ReactNode }) => summaryItem.display,
width: '75px',
field: 'function',
render: (func: string, summaryItem: { display: ReactNode }) => summaryItem.display,
width: '70px',
align: RIGHT_ALIGNMENT as HorizontalAlignment,
},
{
field: 'value',
@ -73,10 +77,10 @@ export const DateContent: FC<FieldDataRowProps> = ({ config }) => {
return (
<ExpandedRowContent dataTestSubj={'dataVisualizerDateContent'}>
<DocumentStatsTable config={config} />
<EuiFlexItem className={'dataVisualizerSummaryTableWrapper'}>
<ExpandedRowPanel className={'dvSummaryTable__wrapper dvPanel__wrapper'}>
<ExpandedRowFieldHeader>{summaryTableTitle}</ExpandedRowFieldHeader>
<EuiBasicTable<SummaryTableItem>
className={'dataVisualizerSummaryTable'}
className={'dvSummaryTable'}
data-test-subj={'dataVisualizerDateSummaryTable'}
compressed
items={summaryTableItems}
@ -84,7 +88,7 @@ export const DateContent: FC<FieldDataRowProps> = ({ config }) => {
tableCaption={summaryTableTitle}
tableLayout="auto"
/>
</EuiFlexItem>
</ExpandedRowPanel>
</ExpandedRowContent>
);
};

View file

@ -8,16 +8,19 @@
import { FormattedMessage } from '@kbn/i18n/react';
import React, { FC, ReactNode } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiBasicTable, EuiFlexItem } from '@elastic/eui';
import { EuiBasicTable, HorizontalAlignment, RIGHT_ALIGNMENT } from '@elastic/eui';
import { ExpandedRowFieldHeader } from '../expanded_row_field_header';
import { FieldDataRowProps } from '../../types';
import { roundToDecimalPlace } from '../../../utils';
import { ExpandedRowPanel } from './expanded_row_panel';
const metaTableColumns = [
{
field: 'function',
name: '',
render: (metaItem: { display: ReactNode }) => metaItem.display,
width: '75px',
render: (_: string, metaItem: { display: ReactNode }) => metaItem.display,
width: '25px',
align: RIGHT_ALIGNMENT as HorizontalAlignment,
},
{
field: 'value',
@ -76,18 +79,18 @@ export const DocumentStatsTable: FC<FieldDataRowProps> = ({ config }) => {
];
return (
<EuiFlexItem
data-test-subj={'dataVisualizerDocumentStatsContent'}
className={'dataVisualizerSummaryTableWrapper'}
<ExpandedRowPanel
dataTestSubj={'dataVisualizerDocumentStatsContent'}
className={'dvSummaryTable__wrapper dvPanel__wrapper'}
>
<ExpandedRowFieldHeader>{metaTableTitle}</ExpandedRowFieldHeader>
<EuiBasicTable
className={'dataVisualizerSummaryTable'}
className={'dvSummaryTable'}
compressed
items={metaTableItems}
columns={metaTableColumns}
tableCaption={metaTableTitle}
/>
</EuiFlexItem>
</ExpandedRowPanel>
);
};

View file

@ -6,7 +6,7 @@
*/
import React, { FC, ReactNode } from 'react';
import { EuiFlexGroup } from '@elastic/eui';
import { EuiFlexGrid } from '@elastic/eui';
interface Props {
children: ReactNode;
@ -14,12 +14,8 @@ interface Props {
}
export const ExpandedRowContent: FC<Props> = ({ children, dataTestSubj }) => {
return (
<EuiFlexGroup
data-test-subj={dataTestSubj}
gutterSize={'xl'}
className={'dataVisualizerExpandedRow'}
>
<EuiFlexGrid data-test-subj={dataTestSubj} gutterSize={'s'}>
{children}
</EuiFlexGroup>
</EuiFlexGrid>
);
};

View file

@ -0,0 +1,31 @@
/*
* 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, { FC, ReactNode } from 'react';
import { EuiPanel } from '@elastic/eui';
import { EuiFlexItemProps } from '@elastic/eui/src/components/flex/flex_item';
interface Props {
children: ReactNode;
dataTestSubj?: string;
grow?: EuiFlexItemProps['grow'];
className?: string;
}
export const ExpandedRowPanel: FC<Props> = ({ children, dataTestSubj, grow, className }) => {
return (
<EuiPanel
data-test-subj={dataTestSubj}
hasShadow={false}
hasBorder={true}
grow={!!grow}
className={className ?? ''}
paddingSize={'s'}
>
{children}
</EuiPanel>
);
};

View file

@ -11,7 +11,7 @@ import { TopValues } from '../../../top_values';
import { DocumentStatsTable } from './document_stats';
import { ExpandedRowContent } from './expanded_row_content';
export const IpContent: FC<FieldDataRowProps> = ({ config }) => {
export const IpContent: FC<FieldDataRowProps> = ({ config, onAddFilter }) => {
const { stats } = config;
if (stats === undefined) return null;
const { count, sampleCount, cardinality } = stats;
@ -21,7 +21,12 @@ export const IpContent: FC<FieldDataRowProps> = ({ config }) => {
return (
<ExpandedRowContent dataTestSubj={'dataVisualizerIPContent'}>
<DocumentStatsTable config={config} />
<TopValues stats={stats} fieldFormat={fieldFormat} barColor="secondary" />
<TopValues
stats={stats}
fieldFormat={fieldFormat}
barColor="secondary"
onAddFilter={onAddFilter}
/>
</ExpandedRowContent>
);
};

View file

@ -14,7 +14,7 @@ import { DocumentStatsTable } from './document_stats';
import { ExpandedRowContent } from './expanded_row_content';
import { ChoroplethMap } from './choropleth_map';
export const KeywordContent: FC<FieldDataRowProps> = ({ config }) => {
export const KeywordContent: FC<FieldDataRowProps> = ({ config, onAddFilter }) => {
const [EMSSuggestion, setEMSSuggestion] = useState<EMSTermJoinConfig | null | undefined>();
const { stats, fieldName } = config;
const fieldFormat = 'fieldFormat' in config ? config.fieldFormat : undefined;
@ -44,7 +44,12 @@ export const KeywordContent: FC<FieldDataRowProps> = ({ config }) => {
return (
<ExpandedRowContent dataTestSubj={'dataVisualizerKeywordContent'}>
<DocumentStatsTable config={config} />
<TopValues stats={stats} fieldFormat={fieldFormat} barColor="secondary" />
<TopValues
stats={stats}
fieldFormat={fieldFormat}
barColor="secondary"
onAddFilter={onAddFilter}
/>
{EMSSuggestion && stats && <ChoroplethMap stats={stats} suggestion={EMSSuggestion} />}
</ExpandedRowContent>
);

View file

@ -6,7 +6,13 @@
*/
import React, { FC, ReactNode, useEffect, useState } from 'react';
import { EuiBasicTable, EuiFlexItem, EuiText } from '@elastic/eui';
import {
EuiBasicTable,
EuiFlexItem,
EuiText,
HorizontalAlignment,
RIGHT_ALIGNMENT,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
@ -21,8 +27,9 @@ import { TopValues } from '../../../top_values';
import { ExpandedRowFieldHeader } from '../expanded_row_field_header';
import { DocumentStatsTable } from './document_stats';
import { ExpandedRowContent } from './expanded_row_content';
import { ExpandedRowPanel } from './expanded_row_panel';
const METRIC_DISTRIBUTION_CHART_WIDTH = 325;
const METRIC_DISTRIBUTION_CHART_WIDTH = 260;
const METRIC_DISTRIBUTION_CHART_HEIGHT = 200;
interface SummaryTableItem {
@ -31,7 +38,7 @@ interface SummaryTableItem {
value: number | string | undefined | null;
}
export const NumberContent: FC<FieldDataRowProps> = ({ config }) => {
export const NumberContent: FC<FieldDataRowProps> = ({ config, onAddFilter }) => {
const { stats } = config;
useEffect(() => {
@ -83,7 +90,8 @@ export const NumberContent: FC<FieldDataRowProps> = ({ config }) => {
{
name: '',
render: (summaryItem: { display: ReactNode }) => summaryItem.display,
width: '75px',
width: '25px',
align: RIGHT_ALIGNMENT as HorizontalAlignment,
},
{
field: 'value',
@ -101,23 +109,33 @@ export const NumberContent: FC<FieldDataRowProps> = ({ config }) => {
return (
<ExpandedRowContent dataTestSubj={'dataVisualizerNumberContent'}>
<DocumentStatsTable config={config} />
<EuiFlexItem className={'dataVisualizerSummaryTableWrapper'}>
<ExpandedRowPanel className={'dvSummaryTable__wrapper dvPanel__wrapper'} grow={1}>
<ExpandedRowFieldHeader>{summaryTableTitle}</ExpandedRowFieldHeader>
<EuiBasicTable<SummaryTableItem>
className={'dataVisualizerSummaryTable'}
className={'dvSummaryTable'}
compressed
items={summaryTableItems}
columns={summaryTableColumns}
tableCaption={summaryTableTitle}
data-test-subj={'dataVisualizerNumberSummaryTable'}
/>
</EuiFlexItem>
</ExpandedRowPanel>
{stats && (
<TopValues stats={stats} fieldFormat={fieldFormat} barColor="secondary" compressed={true} />
<TopValues
stats={stats}
fieldFormat={fieldFormat}
barColor="secondary"
compressed={true}
onAddFilter={onAddFilter}
/>
)}
{distribution && (
<EuiFlexItem data-test-subj={'dataVisualizerFieldDataMetricDistribution'}>
<ExpandedRowPanel
dataTestSubj={'dataVisualizerFieldDataMetricDistribution'}
className="dvPanel__wrapper"
grow={false}
>
<EuiFlexItem grow={false}>
<ExpandedRowFieldHeader>
<FormattedMessage
@ -136,7 +154,7 @@ export const NumberContent: FC<FieldDataRowProps> = ({ config }) => {
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="xs">
<EuiText size="xs" textAlign={'center'}>
<FormattedMessage
id="xpack.dataVisualizer.dataGrid.fieldExpandedRow.numberContent.displayingPercentilesLabel"
defaultMessage="Displaying {minPercent} - {maxPercent} percentiles"
@ -147,7 +165,7 @@ export const NumberContent: FC<FieldDataRowProps> = ({ config }) => {
/>
</EuiText>
</EuiFlexItem>
</EuiFlexItem>
</ExpandedRowPanel>
)}
</ExpandedRowContent>
);

View file

@ -6,7 +6,6 @@
*/
import React, { FC } from 'react';
import { EuiFlexItem } from '@elastic/eui';
import type { FieldDataRowProps } from '../../types/field_data_row';
import { ExamplesList } from '../../../examples_list';
import { DocumentStatsTable } from './document_stats';
@ -15,14 +14,12 @@ import { ExpandedRowContent } from './expanded_row_content';
export const OtherContent: FC<FieldDataRowProps> = ({ config }) => {
const { stats } = config;
if (stats === undefined) return null;
return (
return stats.count === undefined ? (
<>{Array.isArray(stats.examples) && <ExamplesList examples={stats.examples} />}</>
) : (
<ExpandedRowContent dataTestSubj={'dataVisualizerOtherContent'}>
<DocumentStatsTable config={config} />
{Array.isArray(stats.examples) && (
<EuiFlexItem>
<ExamplesList examples={stats.examples} />
</EuiFlexItem>
)}
{Array.isArray(stats.examples) && <ExamplesList examples={stats.examples} />}
</ExpandedRowContent>
);
};

View file

@ -6,7 +6,7 @@
*/
import React, { FC, Fragment } from 'react';
import { EuiCallOut, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { EuiCallOut, EuiSpacer, EuiFlexItem } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
@ -26,7 +26,7 @@ export const TextContent: FC<FieldDataRowProps> = ({ config }) => {
return (
<ExpandedRowContent dataTestSubj={'dataVisualizerTextContent'}>
<EuiFlexItem>
<EuiFlexItem grow={false} className="dvText__wrapper">
{numExamples > 0 && <ExamplesList examples={examples} />}
{numExamples === 0 && (
<Fragment>
@ -44,7 +44,7 @@ export const TextContent: FC<FieldDataRowProps> = ({ config }) => {
id="xpack.dataVisualizer.dataGrid.fieldText.fieldNotPresentDescription"
defaultMessage="This field was not present in the {sourceParam} field of documents queried."
values={{
sourceParam: <span className="fieldDataCard__codeContent">_source</span>,
sourceParam: <span className="dvExpandedRow__codeContent">_source</span>,
}}
/>
@ -54,10 +54,10 @@ export const TextContent: FC<FieldDataRowProps> = ({ config }) => {
id="xpack.dataVisualizer.dataGrid.fieldText.fieldMayBePopulatedDescription"
defaultMessage="It may be populated, for example, using a {copyToParam} parameter in the document mapping, or be pruned from the {sourceParam} field after indexing through the use of {includesParam} and {excludesParam} parameters."
values={{
copyToParam: <span className="fieldDataCard__codeContent">copy_to</span>,
sourceParam: <span className="fieldDataCard__codeContent">_source</span>,
includesParam: <span className="fieldDataCard__codeContent">includes</span>,
excludesParam: <span className="fieldDataCard__codeContent">excludes</span>,
copyToParam: <span className="dvExpandedRow__codeContent">copy_to</span>,
sourceParam: <span className="dvExpandedRow__codeContent">_source</span>,
includesParam: <span className="dvExpandedRow__codeContent">includes</span>,
excludesParam: <span className="dvExpandedRow__codeContent">excludes</span>,
}}
/>
</EuiCallOut>

View file

@ -1,19 +1,22 @@
.dataGridChart__histogram {
width: 100%;
height: $euiSizeXL + $euiSizeXXL;
}
.dataGridChart__column-chart {
width: 100%;
}
.dataGridChart__legend {
@include euiTextTruncate;
@include euiFontSizeXS;
color: $euiColorMediumShade;
display: block;
overflow-x: hidden;
margin: $euiSizeXS 0 0 0;
font-style: italic;
font-weight: normal;
text-align: left;
line-height: 1.1;
font-size: #{$euiFontSizeL / 2}; // 10px
}
.dataGridChart__legend--numeric {
@ -21,9 +24,7 @@
}
.dataGridChart__legendBoolean {
width: 100%;
min-width: $euiButtonMinWidth;
td { text-align: center }
width: #{$euiSizeXS * 2.5} // 10px
}
/* Override to align column header to bottom of cell when no chart is available */

View file

@ -8,7 +8,7 @@
import React, { FC } from 'react';
import classNames from 'classnames';
import { BarSeries, Chart, Settings } from '@elastic/charts';
import { Axis, BarSeries, Chart, Position, ScaleType, Settings } from '@elastic/charts';
import { EuiDataGridColumn } from '@elastic/eui';
import './column_chart.scss';
@ -25,22 +25,9 @@ interface Props {
maxChartColumns?: number;
}
const columnChartTheme = {
background: { color: 'transparent' },
chartMargins: {
left: 0,
right: 0,
top: 0,
bottom: 1,
},
chartPaddings: {
left: 0,
right: 0,
top: 0,
bottom: 0,
},
scales: { barsPadding: 0.1 },
};
const zeroSize = { bottom: 0, left: 0, right: 0, top: 0 };
const size = { width: 100, height: 10 };
export const ColumnChart: FC<Props> = ({
chartData,
columnType,
@ -48,26 +35,34 @@ export const ColumnChart: FC<Props> = ({
hideLabel,
maxChartColumns,
}) => {
const { data, legendText, xScaleType } = useColumnChart(chartData, columnType, maxChartColumns);
const { data, legendText } = useColumnChart(chartData, columnType, maxChartColumns);
return (
<div data-test-subj={dataTestSubj}>
{!isUnsupportedChartData(chartData) && data.length > 0 && (
<div className="dataGridChart__histogram" data-test-subj={`${dataTestSubj}-histogram`}>
<Chart>
<Settings theme={columnChartTheme} />
<BarSeries
id="histogram"
name="count"
xScaleType={xScaleType}
yScaleType="linear"
xAccessor={'key_as_string'}
yAccessors={['doc_count']}
styleAccessor={(d) => d.datum.color}
data={data}
/>
</Chart>
</div>
<Chart size={size}>
<Settings
xDomain={{ min: 0, max: 9 }}
theme={{ chartMargins: zeroSize, chartPaddings: zeroSize }}
/>
<Axis
id="bottom"
position={Position.Bottom}
tickFormat={(idx) => {
return `${data[idx]?.key_as_string ?? ''}`;
}}
hide
/>
<BarSeries
id={'count'}
xScaleType={ScaleType.Linear}
yScaleType={ScaleType.Linear}
xAccessor="x"
yAccessors={['doc_count']}
data={data}
styleAccessor={(d) => d.datum.color}
/>
</Chart>
)}
<div
className={classNames('dataGridChart__legend', {

View file

@ -5,20 +5,21 @@
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText } from '@elastic/eui';
import { EuiIcon, EuiText } from '@elastic/eui';
import React from 'react';
export const DistinctValues = ({ cardinality }: { cardinality?: number }) => {
interface Props {
cardinality?: number;
showIcon?: boolean;
}
export const DistinctValues = ({ cardinality, showIcon }: Props) => {
if (cardinality === undefined) return null;
return (
<EuiFlexGroup alignItems={'center'}>
<EuiFlexItem className={'dataVisualizerColumnHeaderIcon'}>
<EuiIcon type="database" size={'s'} />
</EuiFlexItem>
<EuiText size={'s'}>
<b>{cardinality}</b>
</EuiText>
</EuiFlexGroup>
<>
{showIcon ? <EuiIcon type="database" size={'m'} className={'columnHeader__icon'} /> : null}
<EuiText size={'xs'}>{cardinality}</EuiText>
</>
);
};

View file

@ -5,29 +5,36 @@
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText } from '@elastic/eui';
import { EuiIcon, EuiText } from '@elastic/eui';
import React from 'react';
import type { FieldDataRowProps } from '../../types/field_data_row';
import { roundToDecimalPlace } from '../../../utils';
import { isIndexBasedFieldVisConfig } from '../../types';
export const DocumentStat = ({ config }: FieldDataRowProps) => {
interface Props extends FieldDataRowProps {
showIcon?: boolean;
}
export const DocumentStat = ({ config, showIcon }: Props) => {
const { stats } = config;
if (stats === undefined) return null;
const { count, sampleCount } = stats;
if (count === undefined || sampleCount === undefined) return null;
const docsPercent = roundToDecimalPlace((count / sampleCount) * 100);
// If field exists is docs but we don't have count stats then don't show
// Otherwise if field doesn't appear in docs at all, show 0%
const docsCount =
count ?? (isIndexBasedFieldVisConfig(config) && config.existsInDocs === true ? undefined : 0);
const docsPercent =
docsCount !== undefined && sampleCount !== undefined
? roundToDecimalPlace((docsCount / sampleCount) * 100)
: 0;
return (
<EuiFlexGroup alignItems={'center'}>
<EuiFlexItem className={'dataVisualizerColumnHeaderIcon'}>
<EuiIcon type="document" size={'s'} />
</EuiFlexItem>
<EuiText size={'s'}>
<b>{count}</b> ({docsPercent}%)
return docsCount !== undefined ? (
<>
{showIcon ? <EuiIcon type="document" size={'m'} className={'columnHeader__icon'} /> : null}
<EuiText size={'xs'}>
{docsCount} ({docsPercent}%)
</EuiText>
</EuiFlexGroup>
);
</>
) : null;
};

View file

@ -6,7 +6,7 @@
*/
import React, { FC, useEffect, useState } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import classNames from 'classnames';
import {
MetricDistributionChart,
@ -16,8 +16,8 @@ import {
import { FieldVisConfig } from '../../types';
import { kibanaFieldFormat, formatSingleValue } from '../../../utils';
const METRIC_DISTRIBUTION_CHART_WIDTH = 150;
const METRIC_DISTRIBUTION_CHART_HEIGHT = 80;
const METRIC_DISTRIBUTION_CHART_WIDTH = 100;
const METRIC_DISTRIBUTION_CHART_HEIGHT = 10;
export interface NumberContentPreviewProps {
config: FieldVisConfig;
@ -59,8 +59,11 @@ export const IndexBasedNumberContentPreview: FC<NumberContentPreviewProps> = ({
<div className={'dataGridChart__legend'} data-test-subj={`${dataTestSubj}-legend`}>
{legendText && (
<>
<EuiSpacer size="s" />
<EuiFlexGroup direction={'row'} data-test-subj={`${dataTestSubj}-legend`}>
<EuiFlexGroup
direction={'row'}
data-test-subj={`${dataTestSubj}-legend`}
responsive={false}
>
<EuiFlexItem className={'dataGridChart__legend'}>
{kibanaFieldFormat(legendText.min, fieldFormat)}
</EuiFlexItem>

View file

@ -122,8 +122,8 @@ describe('getLegendText()', () => {
})}
</>
);
expect(getByText('true')).toBeInTheDocument();
expect(getByText('false')).toBeInTheDocument();
expect(getByText('t')).toBeInTheDocument();
expect(getByText('f')).toBeInTheDocument();
});
it('should return the chart legend text for ordinal chart data with less than max categories', () => {
expect(getLegendText({ ...validOrdinalChartData, data: [{ key: 'cat', doc_count: 10 }] })).toBe(

View file

@ -94,11 +94,19 @@ export const getLegendText = (
if (chartData.type === 'boolean') {
return (
<table className="dataGridChart__legendBoolean">
<table>
<tbody>
<tr>
{chartData.data[0] !== undefined && <td>{chartData.data[0].key_as_string}</td>}
{chartData.data[1] !== undefined && <td>{chartData.data[1].key_as_string}</td>}
{chartData.data[0] !== undefined && (
<td className="dataGridChart__legendBoolean">
{chartData.data[0].key_as_string?.slice(0, 1) ?? ''}
</td>
)}
{chartData.data[1] !== undefined && (
<td className="dataGridChart__legendBoolean">
{chartData.data[1].key_as_string?.slice(0, 1) ?? ''}
</td>
)}
</tr>
</tbody>
</table>
@ -185,14 +193,16 @@ export const useColumnChart = (
// The if/else if/else is a work-around because `.map()` doesn't work with union types.
// See TS Caveats for details: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-3.html#caveats
if (isOrdinalChartData(chartData)) {
data = chartData.data.map((d: OrdinalDataItem) => ({
data = chartData.data.map((d: OrdinalDataItem, idx) => ({
...d,
x: idx,
key_as_string: d.key_as_string ?? d.key,
color: getColor(d),
}));
} else if (isNumericChartData(chartData)) {
data = chartData.data.map((d: NumericDataItem) => ({
data = chartData.data.map((d: NumericDataItem, idx) => ({
...d,
x: idx,
key_as_string: d.key_as_string || d.key,
color: getColor(d),
}));

View file

@ -75,14 +75,17 @@ export const MetricDistributionChart: FC<Props> = ({
return (
<MetricDistributionChartTooltipHeader
chartPoint={chartPoint}
maxWidth={width / 2}
maxWidth={width}
fieldFormat={fieldFormat}
/>
);
};
return (
<div data-test-subj="dataVisualizerFieldDataMetricDistributionChart">
<div
data-test-subj="dataVisualizerFieldDataMetricDistributionChart"
className="dataGridChart__histogram"
>
<Chart size={{ width, height }}>
<Settings theme={theme} tooltip={{ headerFormatter }} />
<Axis

View file

@ -5,13 +5,12 @@
* 2.0.
*/
import React, { useMemo, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import {
CENTER_ALIGNMENT,
EuiBasicTableColumn,
EuiButtonIcon,
EuiFlexItem,
EuiIcon,
EuiInMemoryTable,
EuiText,
@ -19,13 +18,13 @@ import {
HorizontalAlignment,
LEFT_ALIGNMENT,
RIGHT_ALIGNMENT,
EuiResizeObserver,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { EuiTableComputedColumnType } from '@elastic/eui/src/components/basic_table/table_types';
import { throttle } from 'lodash';
import { JOB_FIELD_TYPES, JobFieldType, DataVisualizerTableState } from '../../../../../common';
import { FieldTypeIcon } from '../field_type_icon';
import { DocumentStat } from './components/field_data_row/document_stats';
import { DistinctValues } from './components/field_data_row/distinct_values';
import { IndexBasedNumberContentPreview } from './components/field_data_row/number_content_preview';
import { useTableSettings } from './use_table_settings';
@ -37,6 +36,9 @@ import {
} from './types/field_vis_config';
import { FileBasedNumberContentPreview } from '../field_data_row';
import { BooleanContentPreview } from './components/field_data_row';
import { calculateTableColumnsDimensions } from './utils';
import { DistinctValues } from './components/field_data_row/distinct_values';
import { FieldTypeIcon } from '../field_type_icon';
const FIELD_NAME = 'fieldName';
@ -49,6 +51,9 @@ interface DataVisualizerTableProps<T> {
updatePageState: (update: DataVisualizerTableState) => void;
getItemIdToExpandedRowMap: (itemIds: string[], items: T[]) => ItemIdToExpandedRowMap;
extendedColumns?: Array<EuiBasicTableColumn<T>>;
showPreviewByDefault?: boolean;
/** Callback to receive any updates when table or page state is changed **/
onChange?: (update: Partial<DataVisualizerTableState>) => void;
}
export const DataVisualizerTable = <T extends DataVisualizerTableItem>({
@ -57,23 +62,52 @@ export const DataVisualizerTable = <T extends DataVisualizerTableItem>({
updatePageState,
getItemIdToExpandedRowMap,
extendedColumns,
showPreviewByDefault,
onChange,
}: DataVisualizerTableProps<T>) => {
const [expandedRowItemIds, setExpandedRowItemIds] = useState<string[]>([]);
const [expandAll, toggleExpandAll] = useState<boolean>(false);
const [expandAll, setExpandAll] = useState<boolean>(false);
const { onTableChange, pagination, sorting } = useTableSettings<T>(
items,
pageState,
updatePageState
);
const showDistributions: boolean =
('showDistributions' in pageState && pageState.showDistributions) ?? true;
const toggleShowDistribution = () => {
updatePageState({
...pageState,
showDistributions: !showDistributions,
});
};
const [showDistributions, setShowDistributions] = useState<boolean>(showPreviewByDefault ?? true);
const [dimensions, setDimensions] = useState(calculateTableColumnsDimensions());
const [tableWidth, setTableWidth] = useState<number>(1400);
const toggleExpandAll = useCallback(
(shouldExpandAll: boolean) => {
setExpandedRowItemIds(
shouldExpandAll
? // Update list of ids in expandedRowIds to include all
(items.map((item) => item.fieldName).filter((id) => id !== undefined) as string[])
: // Otherwise, reset list of ids in expandedRowIds
[]
);
setExpandAll(shouldExpandAll);
},
[items]
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const resizeHandler = useCallback(
throttle((e: { width: number; height: number }) => {
// When window or table is resized,
// update the column widths and other settings accordingly
setTableWidth(e.width);
setDimensions(calculateTableColumnsDimensions(e.width));
}, 500),
[tableWidth]
);
const toggleShowDistribution = useCallback(() => {
setShowDistributions(!showDistributions);
if (onChange) {
onChange({ showDistributions: !showDistributions });
}
}, [onChange, showDistributions]);
function toggleDetails(item: DataVisualizerTableItem) {
if (item.fieldName === undefined) return;
@ -90,31 +124,32 @@ export const DataVisualizerTable = <T extends DataVisualizerTableItem>({
const columns = useMemo(() => {
const expanderColumn: EuiTableComputedColumnType<DataVisualizerTableItem> = {
name: (
<EuiButtonIcon
data-test-subj={`dataVisualizerToggleDetailsForAllRowsButton ${
expandAll ? 'expanded' : 'collapsed'
}`}
onClick={() => toggleExpandAll(!expandAll)}
aria-label={
!expandAll
? i18n.translate('xpack.dataVisualizer.dataGrid.expandDetailsForAllAriaLabel', {
defaultMessage: 'Expand details for all fields',
})
: i18n.translate('xpack.dataVisualizer.dataGrid.collapseDetailsForAllAriaLabel', {
defaultMessage: 'Collapse details for all fields',
})
}
iconType={expandAll ? 'arrowUp' : 'arrowDown'}
/>
),
name:
dimensions.breakPoint !== 'xs' && dimensions.breakPoint !== 's' ? (
<EuiButtonIcon
data-test-subj={`dataVisualizerToggleDetailsForAllRowsButton ${
expandAll ? 'expanded' : 'collapsed'
}`}
onClick={() => toggleExpandAll(!expandAll)}
aria-label={
!expandAll
? i18n.translate('xpack.dataVisualizer.dataGrid.expandDetailsForAllAriaLabel', {
defaultMessage: 'Expand details for all fields',
})
: i18n.translate('xpack.dataVisualizer.dataGrid.collapseDetailsForAllAriaLabel', {
defaultMessage: 'Collapse details for all fields',
})
}
iconType={expandAll ? 'arrowDown' : 'arrowRight'}
/>
) : null,
align: RIGHT_ALIGNMENT,
width: '40px',
width: dimensions.expander,
isExpander: true,
render: (item: DataVisualizerTableItem) => {
const displayName = item.displayName ?? item.fieldName;
if (item.fieldName === undefined) return null;
const direction = expandedRowItemIds.includes(item.fieldName) ? 'arrowUp' : 'arrowDown';
const direction = expandedRowItemIds.includes(item.fieldName) ? 'arrowDown' : 'arrowRight';
return (
<EuiButtonIcon
data-test-subj={`dataVisualizerDetailsToggle-${item.fieldName}-${direction}`}
@ -147,7 +182,7 @@ export const DataVisualizerTable = <T extends DataVisualizerTableItem>({
render: (fieldType: JobFieldType) => {
return <FieldTypeIcon type={fieldType} tooltipEnabled={true} needsAria={true} />;
},
width: '75px',
width: dimensions.type,
sortable: true,
align: CENTER_ALIGNMENT as HorizontalAlignment,
'data-test-subj': 'dataVisualizerTableColumnType',
@ -163,8 +198,8 @@ export const DataVisualizerTable = <T extends DataVisualizerTableItem>({
const displayName = item.displayName ?? item.fieldName;
return (
<EuiText size="s">
<b data-test-subj={`dataVisualizerDisplayName-${item.fieldName}`}>{displayName}</b>
<EuiText size="xs" data-test-subj={`dataVisualizerDisplayName-${item.fieldName}`}>
{displayName}
</EuiText>
);
},
@ -177,56 +212,65 @@ export const DataVisualizerTable = <T extends DataVisualizerTableItem>({
defaultMessage: 'Documents (%)',
}),
render: (value: number | undefined, item: DataVisualizerTableItem) => (
<DocumentStat config={item} />
<DocumentStat config={item} showIcon={dimensions.showIcon} />
),
sortable: (item: DataVisualizerTableItem) => item?.stats?.count,
align: LEFT_ALIGNMENT as HorizontalAlignment,
'data-test-subj': 'dataVisualizerTableColumnDocumentsCount',
width: dimensions.docCount,
},
{
field: 'stats.cardinality',
name: i18n.translate('xpack.dataVisualizer.dataGrid.distinctValuesColumnName', {
defaultMessage: 'Distinct values',
}),
render: (cardinality?: number) => <DistinctValues cardinality={cardinality} />,
render: (cardinality: number | undefined) => (
<DistinctValues cardinality={cardinality} showIcon={dimensions.showIcon} />
),
sortable: true,
align: LEFT_ALIGNMENT as HorizontalAlignment,
'data-test-subj': 'dataVisualizerTableColumnDistinctValues',
width: dimensions.distinctValues,
},
{
name: (
<div style={{ display: 'flex', alignItems: 'center' }}>
<EuiIcon type={'visBarVertical'} style={{ paddingRight: 4 }} />
<div className={'columnHeader__title'}>
{dimensions.showIcon ? (
<EuiIcon type={'visBarVertical'} className={'columnHeader__icon'} />
) : null}
{i18n.translate('xpack.dataVisualizer.dataGrid.distributionsColumnName', {
defaultMessage: 'Distributions',
})}
<EuiToolTip
content={
!showDistributions
? i18n.translate('xpack.dataVisualizer.dataGrid.showDistributionsTooltip', {
defaultMessage: 'Show distributions',
})
: i18n.translate('xpack.dataVisualizer.dataGrid.hideDistributionsTooltip', {
defaultMessage: 'Hide distributions',
})
}
>
<EuiButtonIcon
style={{ marginLeft: 4 }}
size={'s'}
iconType={showDistributions ? 'eye' : 'eyeClosed'}
onClick={() => toggleShowDistribution()}
aria-label={
{
<EuiToolTip
content={
!showDistributions
? i18n.translate('xpack.dataVisualizer.dataGrid.showDistributionsAriaLabel', {
? i18n.translate('xpack.dataVisualizer.dataGrid.showDistributionsTooltip', {
defaultMessage: 'Show distributions',
})
: i18n.translate('xpack.dataVisualizer.dataGrid.hideDistributionsAriaLabel', {
: i18n.translate('xpack.dataVisualizer.dataGrid.hideDistributionsTooltip', {
defaultMessage: 'Hide distributions',
})
}
/>
</EuiToolTip>
>
<EuiButtonIcon
style={{ marginLeft: 4 }}
size={'s'}
iconType={!showDistributions ? 'eye' : 'eyeClosed'}
onClick={() => toggleShowDistribution()}
aria-label={
showDistributions
? i18n.translate('xpack.dataVisualizer.dataGrid.showDistributionsAriaLabel', {
defaultMessage: 'Show distributions',
})
: i18n.translate('xpack.dataVisualizer.dataGrid.hideDistributionsAriaLabel', {
defaultMessage: 'Hide distributions',
})
}
/>
</EuiToolTip>
}
</div>
),
render: (item: DataVisualizerTableItem) => {
@ -252,41 +296,49 @@ export const DataVisualizerTable = <T extends DataVisualizerTableItem>({
return null;
},
width: dimensions.distributions,
align: LEFT_ALIGNMENT as HorizontalAlignment,
'data-test-subj': 'dataVisualizerTableColumnDistribution',
},
];
return extendedColumns ? [...baseColumns, ...extendedColumns] : baseColumns;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [expandAll, showDistributions, updatePageState, extendedColumns]);
}, [
expandAll,
showDistributions,
updatePageState,
extendedColumns,
dimensions.breakPoint,
toggleExpandAll,
]);
const itemIdToExpandedRowMap = useMemo(() => {
let itemIds = expandedRowItemIds;
if (expandAll) {
itemIds = items.map((i) => i[FIELD_NAME]).filter((f) => f !== undefined) as string[];
}
const itemIds = expandedRowItemIds;
return getItemIdToExpandedRowMap(itemIds, items);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [expandAll, items, expandedRowItemIds]);
}, [items, expandedRowItemIds, getItemIdToExpandedRowMap]);
return (
<EuiFlexItem data-test-subj="dataVisualizerTableContainer">
<EuiInMemoryTable<T>
className={'dataVisualizer'}
items={items}
itemId={FIELD_NAME}
columns={columns}
pagination={pagination}
sorting={sorting}
isExpandable={true}
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
isSelectable={false}
onTableChange={onTableChange}
data-test-subj={'dataVisualizerTable'}
rowProps={(item) => ({
'data-test-subj': `dataVisualizerRow row-${item.fieldName}`,
})}
/>
</EuiFlexItem>
<EuiResizeObserver onResize={resizeHandler}>
{(resizeRef) => (
<div data-test-subj="dataVisualizerTableContainer" ref={resizeRef}>
<EuiInMemoryTable<T>
className={'dvTable'}
items={items}
itemId={FIELD_NAME}
columns={columns}
pagination={pagination}
sorting={sorting}
isExpandable={true}
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
isSelectable={false}
onTableChange={onTableChange}
data-test-subj={'dataVisualizerTable'}
rowProps={(item) => ({
'data-test-subj': `dataVisualizerRow row-${item.fieldName}`,
})}
/>
</div>
)}
</EuiResizeObserver>
);
};

View file

@ -6,7 +6,9 @@
*/
import type { FieldVisConfig, FileBasedFieldVisConfig } from './field_vis_config';
import { IndexPatternField } from '../../../../../../../../../src/plugins/data/common';
export interface FieldDataRowProps {
config: FieldVisConfig | FileBasedFieldVisConfig;
onAddFilter?: (field: IndexPatternField | string, value: string, type: '+' | '-') => void;
}

View file

@ -10,7 +10,7 @@ import { useCallback, useMemo } from 'react';
import { DataVisualizerTableState } from '../../../../../common';
const PAGE_SIZE_OPTIONS = [10, 25, 50];
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
interface UseTableSettingsReturnValue<T> {
onTableChange: EuiBasicTableProps<T>['onChange'];

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { getBreakpoint } from '@elastic/eui';
import { FileBasedFieldVisConfig } from './types';
export const getTFPercentage = (config: FileBasedFieldVisConfig) => {
@ -36,3 +37,45 @@ export const getTFPercentage = (config: FileBasedFieldVisConfig) => {
falseCount,
};
};
export const calculateTableColumnsDimensions = (width?: number) => {
const defaultSettings = {
expander: '40px',
type: '75px',
docCount: '225px',
distinctValues: '225px',
distributions: '225px',
showIcon: true,
breakPoint: 'xl',
};
if (width === undefined) return defaultSettings;
const breakPoint = getBreakpoint(width);
switch (breakPoint) {
case 'xs':
case 's':
return {
expander: '25px',
type: '40px',
docCount: 'auto',
distinctValues: 'auto',
distributions: 'auto',
showIcon: false,
breakPoint,
};
case 'm':
case 'l':
return {
expander: '25px',
type: '40px',
docCount: 'auto',
distinctValues: 'auto',
distributions: 'auto',
showIcon: false,
breakPoint,
};
default:
return defaultSettings;
}
};

View file

@ -4,16 +4,4 @@
.topValuesValueLabelContainer {
margin-right: $euiSizeM;
&.topValuesValueLabelContainer--small {
width:70px;
}
&.topValuesValueLabelContainer--large {
width: 200px;
}
}
.topValuesPercentLabelContainer {
margin-left: $euiSizeM;
width:70px;
}

View file

@ -12,21 +12,25 @@ import {
EuiProgress,
EuiSpacer,
EuiText,
EuiToolTip,
EuiButtonIcon,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import classNames from 'classnames';
import { i18n } from '@kbn/i18n';
import { roundToDecimalPlace, kibanaFieldFormat } from '../utils';
import { ExpandedRowFieldHeader } from '../stats_table/components/expanded_row_field_header';
import { FieldVisStats } from '../../../../../common/types';
import { ExpandedRowPanel } from '../stats_table/components/field_data_expanded_row/expanded_row_panel';
import { IndexPatternField } from '../../../../../../../../src/plugins/data/common/data_views/fields';
interface Props {
stats: FieldVisStats | undefined;
fieldFormat?: any;
barColor?: 'primary' | 'secondary' | 'danger' | 'subdued' | 'accent';
compressed?: boolean;
onAddFilter?: (field: IndexPatternField | string, value: string, type: '+' | '-') => void;
}
function getPercentLabel(docCount: number, topValuesSampleSize: number): string {
@ -38,13 +42,23 @@ function getPercentLabel(docCount: number, topValuesSampleSize: number): string
}
}
export const TopValues: FC<Props> = ({ stats, fieldFormat, barColor, compressed }) => {
export const TopValues: FC<Props> = ({ stats, fieldFormat, barColor, compressed, onAddFilter }) => {
if (stats === undefined) return null;
const { topValues, topValuesSampleSize, topValuesSamplerShardSize, count, isTopValuesSampled } =
stats;
const {
topValues,
topValuesSampleSize,
topValuesSamplerShardSize,
count,
isTopValuesSampled,
fieldName,
} = stats;
const progressBarMax = isTopValuesSampled === true ? topValuesSampleSize : count;
return (
<EuiFlexItem data-test-subj={'dataVisualizerFieldDataTopValues'}>
<ExpandedRowPanel
dataTestSubj={'dataVisualizerFieldDataTopValues'}
className={classNames('dvPanel__wrapper', compressed ? 'dvPanel--compressed' : undefined)}
>
<ExpandedRowFieldHeader>
<FormattedMessage
id="xpack.dataVisualizer.dataGrid.field.topValuesLabel"
@ -54,49 +68,90 @@ export const TopValues: FC<Props> = ({ stats, fieldFormat, barColor, compressed
<div
data-test-subj="dataVisualizerFieldDataTopValuesContent"
className={'fieldDataTopValuesContainer'}
className={classNames('fieldDataTopValuesContainer', 'dvTopValues__wrapper')}
>
{Array.isArray(topValues) &&
topValues.map((value) => (
<EuiFlexGroup gutterSize="xs" alignItems="center" key={value.key}>
<EuiFlexItem
grow={false}
className={classNames(
'eui-textTruncate',
'topValuesValueLabelContainer',
`topValuesValueLabelContainer--${compressed === true ? 'small' : 'large'}`
)}
>
<EuiToolTip content={kibanaFieldFormat(value.key, fieldFormat)} position="right">
<EuiText size="xs" textAlign={'right'} color="subdued">
{kibanaFieldFormat(value.key, fieldFormat)}
</EuiText>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem data-test-subj="dataVisualizerFieldDataTopValueBar">
<EuiProgress
value={value.doc_count}
max={progressBarMax}
color={barColor}
size="m"
size="xs"
label={kibanaFieldFormat(value.key, fieldFormat)}
className={classNames('eui-textTruncate', 'topValuesValueLabelContainer')}
valueText={
progressBarMax !== undefined
? getPercentLabel(value.doc_count, progressBarMax)
: undefined
}
/>
</EuiFlexItem>
{progressBarMax !== undefined && (
<EuiFlexItem
grow={false}
className={classNames('eui-textTruncate', 'topValuesPercentLabelContainer')}
>
<EuiText size="xs" textAlign="left" color="subdued">
{getPercentLabel(value.doc_count, progressBarMax)}
</EuiText>
</EuiFlexItem>
)}
{fieldName !== undefined && value.key !== undefined && onAddFilter !== undefined ? (
<>
<EuiButtonIcon
iconSize="s"
iconType="plusInCircle"
onClick={() =>
onAddFilter(
fieldName,
typeof value.key === 'number' ? value.key.toString() : value.key,
'+'
)
}
aria-label={i18n.translate(
'xpack.dataVisualizer.dataGrid.field.addFilterAriaLabel',
{
defaultMessage: 'Filter for {fieldName}: "{value}"',
values: { fieldName, value: value.key },
}
)}
data-test-subj={`dvFieldDataTopValuesAddFilterButton-${value.key}-${value.key}`}
style={{
minHeight: 'auto',
minWidth: 'auto',
paddingRight: 2,
paddingLeft: 2,
paddingTop: 0,
paddingBottom: 0,
}}
/>
<EuiButtonIcon
iconSize="s"
iconType="minusInCircle"
onClick={() =>
onAddFilter(
fieldName,
typeof value.key === 'number' ? value.key.toString() : value.key,
'-'
)
}
aria-label={i18n.translate(
'xpack.dataVisualizer.dataGrid.field.removeFilterAriaLabel',
{
defaultMessage: 'Filter out {fieldName}: "{value}"',
values: { fieldName, value: value.key },
}
)}
data-test-subj={`dvFieldDataTopValuesExcludeFilterButton-${value.key}-${value.key}`}
style={{
minHeight: 'auto',
minWidth: 'auto',
paddingTop: 0,
paddingBottom: 0,
paddingRight: 2,
paddingLeft: 2,
}}
/>
</>
) : null}
</EuiFlexGroup>
))}
{isTopValuesSampled === true && (
<Fragment>
<EuiSpacer size="xs" />
<EuiText size="xs" textAlign={'left'}>
<EuiText size="xs" textAlign={'center'}>
<FormattedMessage
id="xpack.dataVisualizer.dataGrid.field.topValues.calculatedFromSampleDescription"
defaultMessage="Calculated from sample of {topValuesSamplerShardSize} documents per shard"
@ -108,6 +163,6 @@ export const TopValues: FC<Props> = ({ stats, fieldFormat, barColor, compressed
</Fragment>
)}
</div>
</EuiFlexItem>
</ExpandedRowPanel>
);
};

View file

@ -23,6 +23,9 @@ export const jobTypeAriaLabels = {
geoPointParam: 'geo point',
},
}),
GEO_SHAPE: i18n.translate('xpack.dataVisualizer.fieldTypeIcon.geoShapeTypeAriaLabel', {
defaultMessage: 'geo shape type',
}),
IP: i18n.translate('xpack.dataVisualizer.fieldTypeIcon.ipTypeAriaLabel', {
defaultMessage: 'ip type',
}),
@ -32,6 +35,9 @@ export const jobTypeAriaLabels = {
NUMBER: i18n.translate('xpack.dataVisualizer.fieldTypeIcon.numberTypeAriaLabel', {
defaultMessage: 'number type',
}),
HISTOGRAM: i18n.translate('xpack.dataVisualizer.fieldTypeIcon.histogramTypeAriaLabel', {
defaultMessage: 'histogram type',
}),
TEXT: i18n.translate('xpack.dataVisualizer.fieldTypeIcon.textTypeAriaLabel', {
defaultMessage: 'text type',
}),
@ -40,6 +46,48 @@ export const jobTypeAriaLabels = {
}),
};
export const jobTypeLabels = {
[JOB_FIELD_TYPES.BOOLEAN]: i18n.translate('xpack.dataVisualizer.fieldTypeIcon.booleanTypeLabel', {
defaultMessage: 'Boolean',
}),
[JOB_FIELD_TYPES.DATE]: i18n.translate('xpack.dataVisualizer.fieldTypeIcon.dateTypeLabel', {
defaultMessage: 'Date',
}),
[JOB_FIELD_TYPES.GEO_POINT]: i18n.translate(
'xpack.dataVisualizer.fieldTypeIcon.geoPointTypeLabel',
{
defaultMessage: 'Geo point',
}
),
[JOB_FIELD_TYPES.GEO_SHAPE]: i18n.translate(
'xpack.dataVisualizer.fieldTypeIcon.geoShapeTypeLabel',
{
defaultMessage: 'Geo shape',
}
),
[JOB_FIELD_TYPES.IP]: i18n.translate('xpack.dataVisualizer.fieldTypeIcon.ipTypeLabel', {
defaultMessage: 'IP',
}),
[JOB_FIELD_TYPES.KEYWORD]: i18n.translate('xpack.dataVisualizer.fieldTypeIcon.keywordTypeLabel', {
defaultMessage: 'Keyword',
}),
[JOB_FIELD_TYPES.NUMBER]: i18n.translate('xpack.dataVisualizer.fieldTypeIcon.numberTypeLabel', {
defaultMessage: 'Number',
}),
[JOB_FIELD_TYPES.HISTOGRAM]: i18n.translate(
'xpack.dataVisualizer.fieldTypeIcon.histogramTypeLabel',
{
defaultMessage: 'Histogram',
}
),
[JOB_FIELD_TYPES.TEXT]: i18n.translate('xpack.dataVisualizer.fieldTypeIcon.textTypeLabel', {
defaultMessage: 'Text',
}),
[JOB_FIELD_TYPES.UNKNOWN]: i18n.translate('xpack.dataVisualizer.fieldTypeIcon.unknownTypeLabel', {
defaultMessage: 'Unknown',
}),
};
export const getJobTypeAriaLabel = (type: string) => {
const requestedFieldType = Object.keys(JOB_FIELD_TYPES).find(
(k) => JOB_FIELD_TYPES[k as keyof typeof JOB_FIELD_TYPES] === type

View file

@ -40,6 +40,7 @@ export const ActionsPanel: FC<Props> = ({
const {
services: {
data,
application: { capabilities },
share: {
urlGenerators: { getUrlGenerator },
@ -60,6 +61,9 @@ export const ActionsPanel: FC<Props> = ({
const state: DiscoverUrlGeneratorState = {
indexPatternId,
};
state.filters = data.query.filterManager.getFilters() ?? [];
if (searchString && searchQueryLanguage !== undefined) {
state.query = { query: searchString, language: searchQueryLanguage };
}
@ -113,6 +117,7 @@ export const ActionsPanel: FC<Props> = ({
capabilities,
getUrlGenerator,
additionalLinks,
data.query,
]);
// Note we use display:none for the DataRecognizer section as it needs to be

View file

@ -0,0 +1,13 @@
.dataViewTitleHeader {
min-width: 300px;
display: flex;
flex-direction: row;
align-items: center;
}
@include euiBreakpoint('xs', 's', 'm', 'l') {
.dataVisualizerPageHeader {
flex-direction: column;
align-items: flex-start;
}
}

View file

@ -23,12 +23,12 @@ import { EuiTableActionsColumnType } from '@elastic/eui/src/components/basic_tab
import { FormattedMessage } from '@kbn/i18n/react';
import { Required } from 'utility-types';
import { i18n } from '@kbn/i18n';
import { Filter } from '@kbn/es-query';
import {
IndexPatternField,
KBN_FIELD_TYPES,
UI_SETTINGS,
Query,
IndexPattern,
generateFilters,
} from '../../../../../../../../src/plugins/data/public';
import { FullTimeRangeSelector } from '../full_time_range_selector';
import { usePageUrlState, useUrlState } from '../../../common/util/url_state';
@ -65,10 +65,12 @@ import { DatePickerWrapper } from '../../../common/components/date_picker_wrappe
import { dataVisualizerRefresh$ } from '../../services/timefilter_refresh_service';
import { HelpMenu } from '../../../common/components/help_menu';
import { TimeBuckets } from '../../services/time_buckets';
import { extractSearchData } from '../../utils/saved_search_utils';
import { createMergedEsQuery, getEsQueryFromSavedSearch } from '../../utils/saved_search_utils';
import { DataVisualizerIndexPatternManagement } from '../index_pattern_management';
import { ResultLink } from '../../../common/components/results_links';
import { extractErrorProperties } from '../../utils/error_utils';
import { IndexPatternField, IndexPattern } from '../../../../../../../../src/plugins/data/common';
import './_index.scss';
interface DataVisualizerPageState {
overallStats: OverallStats;
@ -85,7 +87,7 @@ const defaultSearchQuery = {
match_all: {},
};
function getDefaultPageState(): DataVisualizerPageState {
export function getDefaultPageState(): DataVisualizerPageState {
return {
overallStats: {
totalCount: 0,
@ -103,22 +105,25 @@ function getDefaultPageState(): DataVisualizerPageState {
documentCountStats: undefined,
};
}
export const getDefaultDataVisualizerListState =
(): Required<DataVisualizerIndexBasedAppState> => ({
pageIndex: 0,
pageSize: 10,
sortField: 'fieldName',
sortDirection: 'asc',
visibleFieldTypes: [],
visibleFieldNames: [],
samplerShardSize: 5000,
searchString: '',
searchQuery: defaultSearchQuery,
searchQueryLanguage: SEARCH_QUERY_LANGUAGE.KUERY,
showDistributions: true,
showAllFields: false,
showEmptyFields: false,
});
export const getDefaultDataVisualizerListState = (
overrides?: Partial<DataVisualizerIndexBasedAppState>
): Required<DataVisualizerIndexBasedAppState> => ({
pageIndex: 0,
pageSize: 25,
sortField: 'fieldName',
sortDirection: 'asc',
visibleFieldTypes: [],
visibleFieldNames: [],
samplerShardSize: 5000,
searchString: '',
searchQuery: defaultSearchQuery,
searchQueryLanguage: SEARCH_QUERY_LANGUAGE.KUERY,
filters: [],
showDistributions: true,
showAllFields: false,
showEmptyFields: false,
...overrides,
});
export interface IndexDataVisualizerViewProps {
currentIndexPattern: IndexPattern;
@ -129,7 +134,7 @@ const restorableDefaults = getDefaultDataVisualizerListState();
export const IndexDataVisualizerView: FC<IndexDataVisualizerViewProps> = (dataVisualizerProps) => {
const { services } = useDataVisualizerKibana();
const { docLinks, notifications, uiSettings } = services;
const { docLinks, notifications, uiSettings, data } = services;
const { toasts } = notifications;
const [dataVisualizerListState, setDataVisualizerListState] = usePageUrlState(
@ -150,6 +155,15 @@ export const IndexDataVisualizerView: FC<IndexDataVisualizerViewProps> = (dataVi
}
}, [dataVisualizerProps?.currentSavedSearch]);
useEffect(() => {
return () => {
// When navigating away from the index pattern
// Reset all previously set filters
// to make sure new page doesn't have unrelated filters
data.query.filterManager.removeAll();
};
}, [currentIndexPattern.id, data.query.filterManager]);
const getTimeBuckets = useCallback(() => {
return new TimeBuckets({
[UI_SETTINGS.HISTOGRAM_MAX_BARS]: uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS),
@ -227,13 +241,17 @@ export const IndexDataVisualizerView: FC<IndexDataVisualizerViewProps> = (dataVi
const defaults = getDefaultPageState();
const { searchQueryLanguage, searchString, searchQuery } = useMemo(() => {
const searchData = extractSearchData(
currentSavedSearch,
currentIndexPattern,
uiSettings.get(UI_SETTINGS.QUERY_STRING_OPTIONS)
);
const searchData = getEsQueryFromSavedSearch({
indexPattern: currentIndexPattern,
uiSettings,
savedSearch: currentSavedSearch,
filterManager: data.query.filterManager,
});
if (searchData === undefined || dataVisualizerListState.searchString !== '') {
if (dataVisualizerListState.filters) {
data.query.filterManager.setFilters(dataVisualizerListState.filters);
}
return {
searchQuery: dataVisualizerListState.searchQuery,
searchString: dataVisualizerListState.searchString,
@ -247,26 +265,31 @@ export const IndexDataVisualizerView: FC<IndexDataVisualizerViewProps> = (dataVi
};
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSavedSearch, currentIndexPattern, dataVisualizerListState]);
}, [currentSavedSearch, currentIndexPattern, dataVisualizerListState, data.query]);
const setSearchParams = (searchParams: {
searchQuery: Query['query'];
searchString: Query['query'];
queryLanguage: SearchQueryLanguage;
}) => {
// When the user loads saved search and then clear or modify the query
// we should remove the saved search and replace it with the index pattern id
if (currentSavedSearch !== null) {
setCurrentSavedSearch(null);
}
const setSearchParams = useCallback(
(searchParams: {
searchQuery: Query['query'];
searchString: Query['query'];
queryLanguage: SearchQueryLanguage;
filters: Filter[];
}) => {
// When the user loads saved search and then clear or modify the query
// we should remove the saved search and replace it with the index pattern id
if (currentSavedSearch !== null) {
setCurrentSavedSearch(null);
}
setDataVisualizerListState({
...dataVisualizerListState,
searchQuery: searchParams.searchQuery,
searchString: searchParams.searchString,
searchQueryLanguage: searchParams.queryLanguage,
});
};
setDataVisualizerListState({
...dataVisualizerListState,
searchQuery: searchParams.searchQuery,
searchString: searchParams.searchString,
searchQueryLanguage: searchParams.queryLanguage,
filters: searchParams.filters,
});
},
[currentSavedSearch, dataVisualizerListState, setDataVisualizerListState]
);
const samplerShardSize =
dataVisualizerListState.samplerShardSize ?? restorableDefaults.samplerShardSize;
@ -305,6 +328,52 @@ export const IndexDataVisualizerView: FC<IndexDataVisualizerViewProps> = (dataVi
const [nonMetricConfigs, setNonMetricConfigs] = useState(defaults.nonMetricConfigs);
const [nonMetricsLoaded, setNonMetricsLoaded] = useState(defaults.nonMetricsLoaded);
const onAddFilter = useCallback(
(field: IndexPatternField | string, values: string, operation: '+' | '-') => {
const newFilters = generateFilters(
data.query.filterManager,
field,
values,
operation,
String(currentIndexPattern.id)
);
if (newFilters) {
data.query.filterManager.addFilters(newFilters);
}
// Merge current query with new filters
const mergedQuery = {
query: searchString || '',
language: searchQueryLanguage,
};
const combinedQuery = createMergedEsQuery(
{
query: searchString || '',
language: searchQueryLanguage,
},
data.query.filterManager.getFilters() ?? [],
currentIndexPattern,
uiSettings
);
setSearchParams({
searchQuery: combinedQuery,
searchString: mergedQuery.query,
queryLanguage: mergedQuery.language as SearchQueryLanguage,
filters: data.query.filterManager.getFilters(),
});
},
[
currentIndexPattern,
data.query.filterManager,
searchQueryLanguage,
searchString,
setSearchParams,
uiSettings,
]
);
useEffect(() => {
const timeUpdateSubscription = merge(
timefilter.getTimeUpdate$(),
@ -666,11 +735,11 @@ export const IndexDataVisualizerView: FC<IndexDataVisualizerViewProps> = (dataVi
const fieldData = nonMetricFieldData.find((f) => f.fieldName === field.spec.name);
const nonMetricConfig = {
...fieldData,
...(fieldData ? fieldData : {}),
fieldFormat: currentIndexPattern.getFormatterForField(field),
aggregatable: field.aggregatable,
scripted: field.scripted,
loading: fieldData.existsInDocs,
loading: fieldData?.existsInDocs,
deletable: field.runtimeField !== undefined,
};
@ -751,13 +820,14 @@ export const IndexDataVisualizerView: FC<IndexDataVisualizerViewProps> = (dataVi
item={item}
indexPattern={currentIndexPattern}
combinedQuery={{ searchQueryLanguage, searchString }}
onAddFilter={onAddFilter}
/>
);
}
return m;
}, {} as ItemIdToExpandedRowMap);
},
[currentIndexPattern, searchQueryLanguage, searchString]
[currentIndexPattern, searchQueryLanguage, searchString, onAddFilter]
);
// Some actions open up fly-out or popup
@ -809,17 +879,10 @@ export const IndexDataVisualizerView: FC<IndexDataVisualizerViewProps> = (dataVi
<EuiPageBody>
<EuiFlexGroup gutterSize="m">
<EuiFlexItem>
<EuiPageContentHeader>
<EuiPageContentHeader className="dataVisualizerPageHeader">
<EuiPageContentHeaderSection>
<div
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
}}
>
<EuiTitle size="l">
<div className="dataViewTitleHeader">
<EuiTitle>
<h1>{currentIndexPattern.title}</h1>
</EuiTitle>
<DataVisualizerIndexPatternManagement
@ -829,23 +892,26 @@ export const IndexDataVisualizerView: FC<IndexDataVisualizerViewProps> = (dataVi
</div>
</EuiPageContentHeaderSection>
<EuiPageContentHeaderSection data-test-subj="dataVisualizerTimeRangeSelectorSection">
<EuiFlexGroup alignItems="center" justifyContent="flexEnd" gutterSize="s">
{currentIndexPattern.timeFieldName !== undefined && (
<EuiFlexItem grow={false}>
<FullTimeRangeSelector
indexPattern={currentIndexPattern}
query={undefined}
disabled={false}
timefilter={timefilter}
/>
</EuiFlexItem>
)}
<EuiFlexGroup
alignItems="center"
justifyContent="flexEnd"
gutterSize="s"
data-test-subj="dataVisualizerTimeRangeSelectorSection"
>
{currentIndexPattern.timeFieldName !== undefined && (
<EuiFlexItem grow={false}>
<DatePickerWrapper />
<FullTimeRangeSelector
indexPattern={currentIndexPattern}
query={undefined}
disabled={false}
timefilter={timefilter}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPageContentHeaderSection>
)}
<EuiFlexItem grow={false}>
<DatePickerWrapper />
</EuiFlexItem>
</EuiFlexGroup>
</EuiPageContentHeader>
</EuiFlexItem>
</EuiFlexGroup>
@ -862,8 +928,6 @@ export const IndexDataVisualizerView: FC<IndexDataVisualizerViewProps> = (dataVi
/>
</EuiFlexItem>
)}
<EuiSpacer size={'m'} />
<SearchPanel
indexPattern={currentIndexPattern}
searchString={searchString}
@ -879,8 +943,9 @@ export const IndexDataVisualizerView: FC<IndexDataVisualizerViewProps> = (dataVi
visibleFieldNames={visibleFieldNames}
setVisibleFieldNames={setVisibleFieldNames}
showEmptyFields={showEmptyFields}
onAddFilter={onAddFilter}
/>
<EuiSpacer size={'l'} />
<EuiSpacer size={'m'} />
<FieldCountPanel
showEmptyFields={showEmptyFields}
toggleShowEmptyFields={toggleShowEmptyFields}

View file

@ -8,32 +8,28 @@
import React, { FC, useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { JOB_FIELD_TYPES_OPTIONS, JobFieldType } from '../../../../../common';
import { JobFieldType } from '../../../../../common';
import { FieldTypeIcon } from '../../../common/components/field_type_icon';
import { MultiSelectPicker, Option } from '../../../common/components/multi_select_picker';
import { jobTypeLabels } from '../../../common/util/field_types_utils';
export const DatavisualizerFieldTypeFilter: FC<{
export const DataVisualizerFieldTypeFilter: FC<{
indexedFieldTypes: JobFieldType[];
setVisibleFieldTypes(q: string[]): void;
visibleFieldTypes: string[];
}> = ({ indexedFieldTypes, setVisibleFieldTypes, visibleFieldTypes }) => {
const options: Option[] = useMemo(() => {
return indexedFieldTypes.map((indexedFieldName) => {
const item = JOB_FIELD_TYPES_OPTIONS[indexedFieldName];
const label = jobTypeLabels[indexedFieldName] ?? '';
return {
value: indexedFieldName,
name: (
<EuiFlexGroup>
<EuiFlexItem grow={true}> {item.name}</EuiFlexItem>
<EuiFlexItem grow={true}> {label}</EuiFlexItem>
{indexedFieldName && (
<EuiFlexItem grow={false}>
<FieldTypeIcon
type={indexedFieldName}
fieldName={item.name}
tooltipEnabled={false}
needsAria={true}
/>
<FieldTypeIcon type={indexedFieldName} tooltipEnabled={false} needsAria={true} />
</EuiFlexItem>
)}
</EuiFlexGroup>

View file

@ -0,0 +1,20 @@
.dvSearchPanel__controls {
flex-direction: row;
padding: $euiSizeS;
}
@include euiBreakpoint('xs', 's', 'm', 'l') {
.dvSearchPanel__container {
flex-direction: column;
}
.dvSearchBar {
min-width: #{'max(100%, 500px)'};
}
.dvSearchPanel__controls {
padding: 0;
}
// prevent margin -16 which scrunches the filter bar
.globalFilterGroup__wrapper-isVisible {
margin: 0 !important;
}
}

View file

@ -6,21 +6,22 @@
*/
import React, { FC, useEffect, useState } from 'react';
import { EuiCode, EuiFlexItem, EuiFlexGroup, EuiInputPopover } from '@elastic/eui';
import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Query, fromKueryExpression, luceneStringToDsl, toElasticsearchQuery } from '@kbn/es-query';
import { QueryStringInput } from '../../../../../../../../src/plugins/data/public';
import { Query, Filter } from '@kbn/es-query';
import { ShardSizeFilter } from './shard_size_select';
import { DataVisualizerFieldNamesFilter } from './field_name_filter';
import { DatavisualizerFieldTypeFilter } from './field_type_filter';
import { IndexPattern } from '../../../../../../../../src/plugins/data/common';
import { JobFieldType } from '../../../../../common/types';
import { DataVisualizerFieldTypeFilter } from './field_type_filter';
import {
ErrorMessage,
SEARCH_QUERY_LANGUAGE,
SearchQueryLanguage,
} from '../../types/combined_query';
IndexPattern,
IndexPatternField,
TimeRange,
} from '../../../../../../../../src/plugins/data/common';
import { JobFieldType } from '../../../../../common/types';
import { SearchQueryLanguage } from '../../types/combined_query';
import { useDataVisualizerKibana } from '../../../kibana_context';
import './_index.scss';
import { createMergedEsQuery } from '../../utils/saved_search_utils';
interface Props {
indexPattern: IndexPattern;
searchString: Query['query'];
@ -38,12 +39,15 @@ interface Props {
searchQuery,
searchString,
queryLanguage,
filters,
}: {
searchQuery: Query['query'];
searchString: Query['query'];
queryLanguage: SearchQueryLanguage;
filters: Filter[];
}): void;
showEmptyFields: boolean;
onAddFilter?: (field: IndexPatternField | string, value: string, type: '+' | '-') => void;
}
export const SearchPanel: FC<Props> = ({
@ -61,98 +65,109 @@ export const SearchPanel: FC<Props> = ({
setSearchParams,
showEmptyFields,
}) => {
const {
services: {
uiSettings,
notifications: { toasts },
data: {
query: queryManager,
ui: { SearchBar },
},
},
} = useDataVisualizerKibana();
// The internal state of the input query bar updated on every key stroke.
const [searchInput, setSearchInput] = useState<Query>({
query: searchString || '',
language: searchQueryLanguage,
});
const [errorMessage, setErrorMessage] = useState<ErrorMessage | undefined>(undefined);
useEffect(() => {
setSearchInput({
query: searchString || '',
language: searchQueryLanguage,
});
}, [searchQueryLanguage, searchString]);
}, [searchQueryLanguage, searchString, queryManager.filterManager]);
const searchHandler = (query: Query) => {
let filterQuery;
const searchHandler = ({ query, filters }: { query?: Query; filters?: Filter[] }) => {
const mergedQuery = query ?? searchInput;
const mergedFilters = filters ?? queryManager.filterManager.getFilters();
try {
if (query.language === SEARCH_QUERY_LANGUAGE.KUERY) {
filterQuery = toElasticsearchQuery(fromKueryExpression(query.query), indexPattern);
} else if (query.language === SEARCH_QUERY_LANGUAGE.LUCENE) {
filterQuery = luceneStringToDsl(query.query);
} else {
filterQuery = {};
if (mergedFilters) {
queryManager.filterManager.setFilters(mergedFilters);
}
const combinedQuery = createMergedEsQuery(
mergedQuery,
queryManager.filterManager.getFilters() ?? [],
indexPattern,
uiSettings
);
setSearchParams({
searchQuery: filterQuery,
searchString: query.query,
queryLanguage: query.language as SearchQueryLanguage,
searchQuery: combinedQuery,
searchString: mergedQuery.query,
queryLanguage: mergedQuery.language as SearchQueryLanguage,
filters: mergedFilters,
});
} catch (e) {
console.log('Invalid syntax', JSON.stringify(e, null, 2)); // eslint-disable-line no-console
setErrorMessage({ query: query.query as string, message: e.message });
toasts.addError(e, {
title: i18n.translate('xpack.dataVisualizer.searchPanel.invalidSyntax', {
defaultMessage: 'Invalid syntax',
}),
});
}
};
const searchChangeHandler = (query: Query) => setSearchInput(query);
return (
<EuiFlexGroup gutterSize="m" alignItems="center" data-test-subj="dataVisualizerSearchPanel">
<EuiFlexItem>
<EuiInputPopover
style={{ maxWidth: '100%' }}
closePopover={() => setErrorMessage(undefined)}
input={
<QueryStringInput
bubbleSubmitEvent={false}
query={searchInput}
indexPatterns={[indexPattern]}
onChange={searchChangeHandler}
onSubmit={searchHandler}
placeholder={i18n.translate(
'xpack.dataVisualizer.searchPanel.queryBarPlaceholderText',
{
defaultMessage: 'Search… (e.g. status:200 AND extension:"PHP")',
}
)}
disableAutoFocus={true}
dataTestSubj="dataVisualizerQueryInput"
languageSwitcherPopoverAnchorPosition="rightDown"
/>
<EuiFlexGroup
gutterSize="s"
alignItems="flexStart"
data-test-subj="dataVisualizerSearchPanel"
className={'dvSearchPanel__container'}
responsive={false}
>
<EuiFlexItem grow={9} className={'dvSearchBar'}>
<SearchBar
dataTestSubj="dataVisualizerQueryInput"
appName={'dataVisualizer'}
showFilterBar={true}
showDatePicker={false}
showQueryInput={true}
query={searchInput}
onQuerySubmit={(params: { dateRange: TimeRange; query?: Query | undefined }) =>
searchHandler({ query: params.query })
}
isOpen={errorMessage?.query === searchInput.query && errorMessage?.message !== ''}
>
<EuiCode>
{i18n.translate(
'xpack.dataVisualizer.searchPanel.invalidKuerySyntaxErrorMessageQueryBar',
{
defaultMessage: 'Invalid query',
}
)}
{': '}
{errorMessage?.message.split('\n')[0]}
</EuiCode>
</EuiInputPopover>
// @ts-expect-error onFiltersUpdated is a valid prop on SearchBar
onFiltersUpdated={(filters: Filter[]) => searchHandler({ filters })}
indexPatterns={[indexPattern]}
placeholder={i18n.translate('xpack.dataVisualizer.searchPanel.queryBarPlaceholderText', {
defaultMessage: 'Search… (e.g. status:200 AND extension:"PHP")',
})}
displayStyle={'inPage'}
isClearable={true}
customSubmitButton={<div />}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexItem grow={2} className={'dvSearchPanel__controls'}>
<ShardSizeFilter
samplerShardSize={samplerShardSize}
setSamplerShardSize={setSamplerShardSize}
/>
<DataVisualizerFieldNamesFilter
overallStats={overallStats}
setVisibleFieldNames={setVisibleFieldNames}
visibleFieldNames={visibleFieldNames}
showEmptyFields={showEmptyFields}
/>
<DataVisualizerFieldTypeFilter
indexedFieldTypes={indexedFieldTypes}
setVisibleFieldTypes={setVisibleFieldTypes}
visibleFieldTypes={visibleFieldTypes}
/>
</EuiFlexItem>
<DataVisualizerFieldNamesFilter
overallStats={overallStats}
setVisibleFieldNames={setVisibleFieldNames}
visibleFieldNames={visibleFieldNames}
showEmptyFields={showEmptyFields}
/>
<DatavisualizerFieldTypeFilter
indexedFieldTypes={indexedFieldTypes}
setVisibleFieldTypes={setVisibleFieldTypes}
visibleFieldTypes={visibleFieldTypes}
/>
</EuiFlexGroup>
);
};

View file

@ -49,6 +49,7 @@ export const DataVisualizerUrlStateContextProvider: FC<DataVisualizerUrlStateCon
},
} = useDataVisualizerKibana();
const history = useHistory();
const { search: searchString } = useLocation();
const [currentIndexPattern, setCurrentIndexPattern] = useState<IndexPattern | undefined>(
undefined
@ -56,7 +57,6 @@ export const DataVisualizerUrlStateContextProvider: FC<DataVisualizerUrlStateCon
const [currentSavedSearch, setCurrentSavedSearch] = useState<SimpleSavedObject<unknown> | null>(
null
);
const { search: searchString } = useLocation();
useEffect(() => {
const prevSearchString = searchString;

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export * from './locator';

View file

@ -0,0 +1,105 @@
/*
* 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 { IndexDataVisualizerLocatorDefinition } from './locator';
describe('Index data visualizer locator', () => {
const definition = new IndexDataVisualizerLocatorDefinition();
it('should generate valid URL for the Index Data Visualizer Viewer page with global settings', async () => {
const location = await definition.getLocation({
indexPatternId: '3da93760-e0af-11ea-9ad3-3bcfc330e42a',
timeRange: {
from: 'now-30m',
to: 'now',
},
refreshInterval: { pause: false, value: 300 },
});
expect(location).toMatchObject({
app: 'ml',
path: '/jobs/new_job/datavisualizer?index=3da93760-e0af-11ea-9ad3-3bcfc330e42a&_a=(DATA_VISUALIZER_INDEX_VIEWER:())&_g=(refreshInterval:(pause:!f,value:300),time:(from:now-30m,to:now))',
state: {},
});
});
it('should prioritize savedSearchId even when index pattern id is available', async () => {
const location = await definition.getLocation({
indexPatternId: '3da93760-e0af-11ea-9ad3-3bcfc330e42a',
savedSearchId: '45014020-dffa-11eb-b120-a105fbbe93b3',
});
expect(location).toMatchObject({
app: 'ml',
path: '/jobs/new_job/datavisualizer?savedSearchId=45014020-dffa-11eb-b120-a105fbbe93b3&_a=(DATA_VISUALIZER_INDEX_VIEWER:())&_g=()',
state: {},
});
});
it('should generate valid URL with field names and field types', async () => {
const location = await definition.getLocation({
indexPatternId: '3da93760-e0af-11ea-9ad3-3bcfc330e42a',
visibleFieldNames: ['@timestamp', 'responsetime'],
visibleFieldTypes: ['number'],
});
expect(location).toMatchObject({
app: 'ml',
path: "/jobs/new_job/datavisualizer?index=3da93760-e0af-11ea-9ad3-3bcfc330e42a&_a=(DATA_VISUALIZER_INDEX_VIEWER:(visibleFieldNames:!('@timestamp',responsetime),visibleFieldTypes:!(number)))&_g=()",
});
});
it('should generate valid URL with KQL query', async () => {
const location = await definition.getLocation({
indexPatternId: '3da93760-e0af-11ea-9ad3-3bcfc330e42a',
query: {
searchQuery: {
bool: {
should: [
{
match: {
region: 'ap-northwest-1',
},
},
],
minimum_should_match: 1,
},
},
searchString: 'region : ap-northwest-1',
searchQueryLanguage: 'kuery',
},
});
expect(location).toMatchObject({
app: 'ml',
path: "/jobs/new_job/datavisualizer?index=3da93760-e0af-11ea-9ad3-3bcfc330e42a&_a=(DATA_VISUALIZER_INDEX_VIEWER:(searchQuery:(bool:(minimum_should_match:1,should:!((match:(region:ap-northwest-1))))),searchQueryLanguage:kuery,searchString:'region : ap-northwest-1'))&_g=()",
state: {},
});
});
it('should generate valid URL with Lucene query', async () => {
const location = await definition.getLocation({
indexPatternId: '3da93760-e0af-11ea-9ad3-3bcfc330e42a',
query: {
searchQuery: {
query_string: {
query: 'region: ap-northwest-1',
analyze_wildcard: true,
},
},
searchString: 'region : ap-northwest-1',
searchQueryLanguage: 'lucene',
},
});
expect(location).toMatchObject({
app: 'ml',
path: "/jobs/new_job/datavisualizer?index=3da93760-e0af-11ea-9ad3-3bcfc330e42a&_a=(DATA_VISUALIZER_INDEX_VIEWER:(searchQuery:(query_string:(analyze_wildcard:!t,query:'region: ap-northwest-1')),searchQueryLanguage:lucene,searchString:'region : ap-northwest-1'))&_g=()",
state: {},
});
});
});

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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
// @ts-ignore
import { encode } from 'rison-node';
import { stringify } from 'query-string';
import { SerializableRecord } from '@kbn/utility-types';
import { RefreshInterval, TimeRange } from '../../../../../../../src/plugins/data/common';
import { LocatorDefinition, LocatorPublic } from '../../../../../../../src/plugins/share/common';
import { QueryState } from '../../../../../../../src/plugins/data/public';
import { Dictionary, isRisonSerializationRequired } from '../../common/util/url_state';
import { SearchQueryLanguage } from '../types/combined_query';
export const DATA_VISUALIZER_APP_LOCATOR = 'DATA_VISUALIZER_APP_LOCATOR';
export interface IndexDataVisualizerLocatorParams extends SerializableRecord {
/**
* Optionally set saved search ID.
*/
savedSearchId?: string;
/**
* Optionally set index pattern ID.
*/
indexPatternId?: string;
/**
* Optionally set the time range in the time picker.
*/
timeRange?: TimeRange;
/**
* Optionally set the refresh interval.
*/
refreshInterval?: RefreshInterval & SerializableRecord;
/**
* Optionally set a query.
*/
query?: {
searchQuery: SerializableRecord;
searchString: string | SerializableRecord;
searchQueryLanguage: SearchQueryLanguage;
};
/**
* If not given, will use the uiSettings configuration for `storeInSessionStorage`. useHash determines
* whether to hash the data in the url to avoid url length issues.
*/
useHash?: boolean;
/**
* Optionally set visible field names.
*/
visibleFieldNames?: string[];
/**
* Optionally set visible field types.
*/
visibleFieldTypes?: string[];
}
export type IndexDataVisualizerLocator = LocatorPublic<IndexDataVisualizerLocatorParams>;
export class IndexDataVisualizerLocatorDefinition
implements LocatorDefinition<IndexDataVisualizerLocatorParams>
{
public readonly id = DATA_VISUALIZER_APP_LOCATOR;
constructor() {}
public readonly getLocation = async (params: IndexDataVisualizerLocatorParams) => {
const {
indexPatternId,
query,
refreshInterval,
savedSearchId,
timeRange,
visibleFieldNames,
visibleFieldTypes,
} = params;
const appState: {
searchQuery?: { [key: string]: any };
searchQueryLanguage?: string;
searchString?: string | SerializableRecord;
visibleFieldNames?: string[];
visibleFieldTypes?: string[];
} = {};
const queryState: QueryState = {};
if (query) {
appState.searchQuery = query.searchQuery;
appState.searchString = query.searchString;
appState.searchQueryLanguage = query.searchQueryLanguage;
}
if (visibleFieldNames) appState.visibleFieldNames = visibleFieldNames;
if (visibleFieldTypes) appState.visibleFieldTypes = visibleFieldTypes;
if (timeRange) queryState.time = timeRange;
if (refreshInterval) queryState.refreshInterval = refreshInterval;
const urlState: Dictionary<any> = {
...(savedSearchId ? { savedSearchId } : { index: indexPatternId }),
_a: { DATA_VISUALIZER_INDEX_VIEWER: appState },
_g: queryState,
};
const parsedQueryString: Dictionary<any> = {};
Object.keys(urlState).forEach((a) => {
if (isRisonSerializationRequired(a)) {
parsedQueryString[a] = encode(urlState[a]);
} else {
parsedQueryString[a] = urlState[a];
}
});
const newLocationSearchString = stringify(parsedQueryString, {
sort: false,
encode: false,
});
const path = `/jobs/new_job/datavisualizer?${newLocationSearchString}`;
return {
app: 'ml',
path,
state: {},
};
};
}

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import type { Filter } from '@kbn/es-query';
import { Query } from '../../../../../../../src/plugins/data/common/query';
import { SearchQueryLanguage } from './combined_query';
@ -25,4 +26,5 @@ export interface DataVisualizerIndexBasedAppState extends Omit<ListingPageUrlSta
showDistributions?: boolean;
showAllFields?: boolean;
showEmptyFields?: boolean;
filters?: Filter[];
}

View file

@ -0,0 +1,313 @@
/*
* 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 {
getQueryFromSavedSearch,
createMergedEsQuery,
getEsQueryFromSavedSearch,
} from './saved_search_utils';
import type { SavedSearchSavedObject } from '../../../../common';
import type { SavedSearch } from '../../../../../../../src/plugins/discover/public';
import type { Filter, FilterStateStore } from '@kbn/es-query';
import { stubbedSavedObjectIndexPattern } from '../../../../../../../src/plugins/data/common/data_views/data_view.stub';
import { IndexPattern } from '../../../../../../../src/plugins/data/common';
import { fieldFormatsMock } from '../../../../../../../src/plugins/field_formats/common/mocks';
import { uiSettingsServiceMock } from 'src/core/public/mocks';
// helper function to create index patterns
function createMockDataView(id: string) {
const {
type,
version,
attributes: { timeFieldName, fields, title },
} = stubbedSavedObjectIndexPattern(id);
return new IndexPattern({
spec: {
id,
type,
version,
timeFieldName,
fields: JSON.parse(fields),
title,
runtimeFieldMap: {},
},
fieldFormats: fieldFormatsMock,
shortDotsEnable: false,
metaFields: [],
});
}
const mockDataView = createMockDataView('test-mock-data-view');
const mockUiSettings = uiSettingsServiceMock.createStartContract();
// @ts-expect-error We don't need the full object here
const luceneSavedSearchObj: SavedSearchSavedObject = {
attributes: {
title: 'farequote_filter_and_lucene',
columns: ['_source'],
sort: ['@timestamp', 'desc'],
kibanaSavedObjectMeta: {
searchSourceJSON:
'{"highlightAll":true,"version":true,"query":{"query":"responsetime:>50","language":"lucene"},"filter":[{"meta":{"index":"90a978e0-1c80-11ec-b1d7-f7e5cf21b9e0","negate":false,"disabled":false,"alias":null,"type":"phrase","key":"airline","value":"ASA","params":{"query":"ASA","type":"phrase"}},"query":{"match":{"airline":{"query":"ASA","type":"phrase"}}},"$state":{"store":"appState"}}],"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.index"}',
},
},
id: '93fc4d60-1c80-11ec-b1d7-f7e5cf21b9e0',
type: 'search',
};
// @ts-expect-error We don't need the full object here
const luceneInvalidSavedSearchObj: SavedSearchSavedObject = {
attributes: {
kibanaSavedObjectMeta: {
searchSourceJSON: null,
},
},
id: '93fc4d60-1c80-11ec-b1d7-f7e5cf21b9e0',
type: 'search',
};
const kqlSavedSearch: SavedSearch = {
title: 'farequote_filter_and_kuery',
description: '',
columns: ['_source'],
// @ts-expect-error We don't need the full object here
kibanaSavedObjectMeta: {
searchSourceJSON:
'{"highlightAll":true,"version":true,"query":{"query":"responsetime > 49","language":"kuery"},"filter":[{"meta":{"index":"90a978e0-1c80-11ec-b1d7-f7e5cf21b9e0","negate":false,"disabled":false,"alias":null,"type":"phrase","key":"airline","value":"ASA","params":{"query":"ASA","type":"phrase"}},"query":{"match":{"airline":{"query":"ASA","type":"phrase"}}},"$state":{"store":"appState"}}],"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.index"}',
},
};
describe('getQueryFromSavedSearch()', () => {
it('should return parsed searchSourceJSON with query and filter', () => {
expect(getQueryFromSavedSearch(luceneSavedSearchObj)).toEqual({
filter: [
{
$state: { store: 'appState' },
meta: {
alias: null,
disabled: false,
index: '90a978e0-1c80-11ec-b1d7-f7e5cf21b9e0',
key: 'airline',
negate: false,
params: { query: 'ASA', type: 'phrase' },
type: 'phrase',
value: 'ASA',
},
query: { match: { airline: { query: 'ASA', type: 'phrase' } } },
},
],
highlightAll: true,
indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.index',
query: { language: 'lucene', query: 'responsetime:>50' },
version: true,
});
expect(getQueryFromSavedSearch(kqlSavedSearch)).toEqual({
filter: [
{
$state: { store: 'appState' },
meta: {
alias: null,
disabled: false,
index: '90a978e0-1c80-11ec-b1d7-f7e5cf21b9e0',
key: 'airline',
negate: false,
params: { query: 'ASA', type: 'phrase' },
type: 'phrase',
value: 'ASA',
},
query: { match: { airline: { query: 'ASA', type: 'phrase' } } },
},
],
highlightAll: true,
indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.index',
query: { language: 'kuery', query: 'responsetime > 49' },
version: true,
});
});
it('should return undefined if invalid searchSourceJSON', () => {
expect(getQueryFromSavedSearch(luceneInvalidSavedSearchObj)).toEqual(undefined);
});
});
describe('createMergedEsQuery()', () => {
const luceneQuery = {
query: 'responsetime:>50',
language: 'lucene',
};
const kqlQuery = {
query: 'responsetime > 49',
language: 'kuery',
};
const mockFilters: Filter[] = [
{
meta: {
index: '90a978e0-1c80-11ec-b1d7-f7e5cf21b9e0',
negate: false,
disabled: false,
alias: null,
type: 'phrase',
key: 'airline',
params: {
query: 'ASA',
},
},
query: {
match: {
airline: {
query: 'ASA',
type: 'phrase',
},
},
},
$state: {
store: 'appState' as FilterStateStore,
},
},
];
it('return formatted ES bool query with both the original query and filters combined', () => {
expect(createMergedEsQuery(luceneQuery, mockFilters)).toEqual({
bool: {
filter: [{ match_phrase: { airline: { query: 'ASA' } } }],
must: [{ query_string: { query: 'responsetime:>50' } }],
must_not: [],
should: [],
},
});
expect(createMergedEsQuery(kqlQuery, mockFilters)).toEqual({
bool: {
filter: [{ match_phrase: { airline: { query: 'ASA' } } }],
minimum_should_match: 1,
must_not: [],
should: [{ range: { responsetime: { gt: '49' } } }],
},
});
});
it('return formatted ES bool query without filters ', () => {
expect(createMergedEsQuery(luceneQuery)).toEqual({
bool: {
filter: [],
must: [{ query_string: { query: 'responsetime:>50' } }],
must_not: [],
should: [],
},
});
expect(createMergedEsQuery(kqlQuery)).toEqual({
bool: {
filter: [],
minimum_should_match: 1,
must_not: [],
should: [{ range: { responsetime: { gt: '49' } } }],
},
});
});
});
describe('getEsQueryFromSavedSearch()', () => {
it('return undefined if saved search is not provided', () => {
expect(
getEsQueryFromSavedSearch({
indexPattern: mockDataView,
savedSearch: undefined,
uiSettings: mockUiSettings,
})
).toEqual(undefined);
});
it('return search data from saved search if neither query nor filter is provided ', () => {
expect(
getEsQueryFromSavedSearch({
indexPattern: mockDataView,
savedSearch: luceneSavedSearchObj,
uiSettings: mockUiSettings,
})
).toEqual({
queryLanguage: 'lucene',
searchQuery: {
bool: {
filter: [{ match_phrase: { airline: { query: 'ASA' } } }],
must: [{ query_string: { query: 'responsetime:>50' } }],
must_not: [],
should: [],
},
},
searchString: 'responsetime:>50',
});
});
it('should override original saved search with the provided query ', () => {
expect(
getEsQueryFromSavedSearch({
indexPattern: mockDataView,
savedSearch: luceneSavedSearchObj,
uiSettings: mockUiSettings,
query: {
query: 'responsetime:>100',
language: 'lucene',
},
})
).toEqual({
queryLanguage: 'lucene',
searchQuery: {
bool: {
filter: [{ match_phrase: { airline: { query: 'ASA' } } }],
must: [{ query_string: { query: 'responsetime:>100' } }],
must_not: [],
should: [],
},
},
searchString: 'responsetime:>100',
});
});
it('should override original saved search with the provided filters ', () => {
expect(
getEsQueryFromSavedSearch({
indexPattern: mockDataView,
savedSearch: luceneSavedSearchObj,
uiSettings: mockUiSettings,
query: {
query: 'responsetime:>100',
language: 'lucene',
},
filters: [
{
meta: {
index: '90a978e0-1c80-11ec-b1d7-f7e5cf21b9e0',
alias: null,
negate: true,
disabled: false,
type: 'phrase',
key: 'airline',
params: {
query: 'JZA',
},
},
query: {
match_phrase: {
airline: 'JZA',
},
},
$state: {
store: 'appState' as FilterStateStore,
},
},
],
})
).toEqual({
queryLanguage: 'lucene',
searchQuery: {
bool: {
filter: [],
must: [{ query_string: { query: 'responsetime:>100' } }],
must_not: [{ match_phrase: { airline: 'JZA' } }],
should: [],
},
},
searchString: 'responsetime:>100',
});
});
});

View file

@ -8,55 +8,155 @@
import { cloneDeep } from 'lodash';
import { IUiSettingsClient } from 'kibana/public';
import {
buildEsQuery,
buildQueryFromFilters,
decorateQuery,
fromKueryExpression,
luceneStringToDsl,
toElasticsearchQuery,
buildQueryFromFilters,
buildEsQuery,
Query,
Filter,
} from '@kbn/es-query';
import { estypes } from '@elastic/elasticsearch';
import { SavedSearchSavedObject } from '../../../../common/types';
import { isSavedSearchSavedObject, SavedSearchSavedObject } from '../../../../common/types';
import { IndexPattern } from '../../../../../../../src/plugins/data/common';
import { SEARCH_QUERY_LANGUAGE, SearchQueryLanguage } from '../types/combined_query';
import { getEsQueryConfig, Query } from '../../../../../../../src/plugins/data/public';
import { SavedSearch } from '../../../../../../../src/plugins/discover/public';
import { getEsQueryConfig } from '../../../../../../../src/plugins/data/common';
import { FilterManager } from '../../../../../../../src/plugins/data/public';
export function getQueryFromSavedSearch(savedSearch: SavedSearchSavedObject) {
const search = savedSearch.attributes.kibanaSavedObjectMeta as { searchSourceJSON: string };
return JSON.parse(search.searchSourceJSON) as {
query: Query;
filter: any[];
};
/**
* Parse the stringified searchSourceJSON
* from a saved search or saved search object
*/
export function getQueryFromSavedSearch(savedSearch: SavedSearchSavedObject | SavedSearch) {
const search = isSavedSearchSavedObject(savedSearch)
? savedSearch?.attributes?.kibanaSavedObjectMeta
: // @ts-expect-error kibanaSavedObjectMeta does exist
savedSearch?.kibanaSavedObjectMeta;
const parsed =
typeof search?.searchSourceJSON === 'string'
? (JSON.parse(search.searchSourceJSON) as {
query: Query;
filter: Filter[];
})
: undefined;
// Remove indexRefName because saved search might no longer be relevant
// if user modifies the query or filter
// after opening a saved search
if (parsed && Array.isArray(parsed.filter)) {
parsed.filter.forEach((f) => {
// @ts-expect-error indexRefName does appear in meta for newly created saved search
f.meta.indexRefName = undefined;
});
}
return parsed;
}
/**
* Extract query data from the saved search object.
* Create an Elasticsearch query that combines both lucene/kql query string and filters
* Should also form a valid query if only the query or filters is provided
*/
export function extractSearchData(
savedSearch: SavedSearchSavedObject | null,
currentIndexPattern: IndexPattern,
queryStringOptions: Record<string, any> | string
export function createMergedEsQuery(
query?: Query,
filters?: Filter[],
indexPattern?: IndexPattern,
uiSettings?: IUiSettingsClient
) {
if (!savedSearch) {
return undefined;
let combinedQuery: any = getDefaultQuery();
if (query && query.language === SEARCH_QUERY_LANGUAGE.KUERY) {
const ast = fromKueryExpression(query.query);
if (query.query !== '') {
combinedQuery = toElasticsearchQuery(ast, indexPattern);
}
const filterQuery = buildQueryFromFilters(filters, indexPattern);
if (Array.isArray(combinedQuery.bool.filter) === false) {
combinedQuery.bool.filter =
combinedQuery.bool.filter === undefined ? [] : [combinedQuery.bool.filter];
}
if (Array.isArray(combinedQuery.bool.must_not) === false) {
combinedQuery.bool.must_not =
combinedQuery.bool.must_not === undefined ? [] : [combinedQuery.bool.must_not];
}
combinedQuery.bool.filter = [...combinedQuery.bool.filter, ...filterQuery.filter];
combinedQuery.bool.must_not = [...combinedQuery.bool.must_not, ...filterQuery.must_not];
} else {
combinedQuery = buildEsQuery(
indexPattern,
query ? [query] : [],
filters ? filters : [],
uiSettings ? getEsQueryConfig(uiSettings) : undefined
);
}
return combinedQuery;
}
/**
* Extract query data from the saved search object
* with overrides from the provided query data and/or filters
*/
export function getEsQueryFromSavedSearch({
indexPattern,
uiSettings,
savedSearch,
query,
filters,
filterManager,
}: {
indexPattern: IndexPattern;
uiSettings: IUiSettingsClient;
savedSearch: SavedSearchSavedObject | SavedSearch | null | undefined;
query?: Query;
filters?: Filter[];
filterManager?: FilterManager;
}) {
if (!indexPattern || !savedSearch) return;
const savedSearchData = getQueryFromSavedSearch(savedSearch);
const userQuery = query;
const userFilters = filters;
// If no saved search available, use user's query and filters
if (!savedSearchData && userQuery) {
if (filterManager && userFilters) filterManager.setFilters(userFilters);
const combinedQuery = createMergedEsQuery(
userQuery,
Array.isArray(userFilters) ? userFilters : [],
indexPattern,
uiSettings
);
return {
searchQuery: combinedQuery,
searchString: userQuery.query,
queryLanguage: userQuery.language as SearchQueryLanguage,
};
}
const { query: extractedQuery } = getQueryFromSavedSearch(savedSearch);
const queryLanguage = extractedQuery.language as SearchQueryLanguage;
const qryString = extractedQuery.query;
let qry;
if (queryLanguage === SEARCH_QUERY_LANGUAGE.KUERY) {
const ast = fromKueryExpression(qryString);
qry = toElasticsearchQuery(ast, currentIndexPattern);
} else {
qry = luceneStringToDsl(qryString);
decorateQuery(qry, queryStringOptions);
// If saved search available, merge saved search with latest user query or filters differ from extracted saved search data
if (savedSearchData) {
const currentQuery = userQuery ?? savedSearchData?.query;
const currentFilters = userFilters ?? savedSearchData?.filter;
if (filterManager) filterManager.setFilters(currentFilters);
const combinedQuery = createMergedEsQuery(
currentQuery,
Array.isArray(currentFilters) ? currentFilters : [],
indexPattern,
uiSettings
);
return {
searchQuery: combinedQuery,
searchString: currentQuery.query,
queryLanguage: currentQuery.language as SearchQueryLanguage,
};
}
return {
searchQuery: qry,
searchString: qryString,
queryLanguage,
};
}
const DEFAULT_QUERY = {
@ -69,64 +169,6 @@ const DEFAULT_QUERY = {
},
};
export function getDefaultDatafeedQuery() {
export function getDefaultQuery() {
return cloneDeep(DEFAULT_QUERY);
}
export function createSearchItems(
kibanaConfig: IUiSettingsClient,
indexPattern: IndexPattern | undefined,
savedSearch: SavedSearchSavedObject | null
) {
// query is only used by the data visualizer as it needs
// a lucene query_string.
// Using a blank query will cause match_all:{} to be used
// when passed through luceneStringToDsl
let query: Query = {
query: '',
language: 'lucene',
};
let combinedQuery: estypes.QueryDslQueryContainer = getDefaultDatafeedQuery();
if (savedSearch !== null) {
const data = getQueryFromSavedSearch(savedSearch);
query = data.query;
const filter = data.filter;
const filters = Array.isArray(filter) ? filter : [];
if (query.language === SEARCH_QUERY_LANGUAGE.KUERY) {
const ast = fromKueryExpression(query.query);
if (query.query !== '') {
combinedQuery = toElasticsearchQuery(ast, indexPattern);
}
const filterQuery = buildQueryFromFilters(filters, indexPattern);
if (!combinedQuery.bool) {
throw new Error('Missing bool on query');
}
if (!Array.isArray(combinedQuery.bool.filter)) {
combinedQuery.bool.filter =
combinedQuery.bool.filter === undefined ? [] : [combinedQuery.bool.filter];
}
if (!Array.isArray(combinedQuery.bool.must_not)) {
combinedQuery.bool.must_not =
combinedQuery.bool.must_not === undefined ? [] : [combinedQuery.bool.must_not];
}
combinedQuery.bool.filter = [...combinedQuery.bool.filter, ...filterQuery.filter];
combinedQuery.bool.must_not = [...combinedQuery.bool.must_not, ...filterQuery.must_not];
} else {
const esQueryConfigs = getEsQueryConfig(kibanaConfig);
combinedQuery = buildEsQuery(indexPattern, [query], filters, esQueryConfigs);
}
}
return {
query,
combinedQuery,
};
}

View file

@ -48,7 +48,10 @@ export class DataVisualizerPlugin
DataVisualizerStartDependencies
>
{
public setup(core: CoreSetup, plugins: DataVisualizerSetupDependencies) {
public setup(
core: CoreSetup<DataVisualizerStartDependencies, DataVisualizerPluginStart>,
plugins: DataVisualizerSetupDependencies
) {
if (plugins.home) {
registerHomeAddData(plugins.home);
registerHomeFeatureCatalogue(plugins.home);