[ML] Data Grid Histograms (#68359)

Adds support for histogram charts to data grid columns.
- Adds a toggle button to the data grid's header to enabled/disable column charts.
- When enabled, the charts get rendered as part of the data grid header.
- Histogram charts will get rendered for fields based on date, number, string and boolean.
This commit is contained in:
Walter Rafelsberger 2020-06-19 08:39:50 +02:00 committed by GitHub
parent 639d1e0829
commit a489e5f0b1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 996 additions and 95 deletions

View file

@ -6,6 +6,8 @@
import euiVars from '@elastic/eui/dist/eui_theme_dark.json';
import { stringHash } from './string_utils';
const COLORS = [
euiVars.euiColorVis0,
euiVars.euiColorVis1,
@ -33,17 +35,3 @@ export function tabColor(name: string): string {
return colorMap[name];
}
}
function stringHash(str: string): number {
let hash = 0;
let chr = 0;
if (str.length === 0) {
return hash;
}
for (let i = 0; i < str.length; i++) {
chr = str.charCodeAt(i);
hash = (hash << 5) - hash + chr; // eslint-disable-line no-bitwise
hash |= 0; // eslint-disable-line no-bitwise
}
return hash < 0 ? hash * -2 : hash;
}

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { renderTemplate, getMedianStringLength } from './string_utils';
import { renderTemplate, getMedianStringLength, stringHash } from './string_utils';
const strings: string[] = [
'foo',
@ -46,4 +46,12 @@ describe('ML - string utils', () => {
expect(result).toBe(0);
});
});
describe('stringHash', () => {
test('should return a unique number based off a string', () => {
const hash1 = stringHash('the-string-1');
const hash2 = stringHash('the-string-2');
expect(hash1).not.toBe(hash2);
});
});
});

View file

@ -22,3 +22,20 @@ export function getMedianStringLength(strings: string[]) {
const sortedStringLengths = strings.map((s) => s.length).sort((a, b) => a - b);
return sortedStringLengths[Math.floor(sortedStringLengths.length / 2)] || 0;
}
/**
* Creates a deterministic number based hash out of a string.
*/
export function stringHash(str: string): number {
let hash = 0;
let chr = 0;
if (str.length === 0) {
return hash;
}
for (let i = 0; i < str.length; i++) {
chr = str.charCodeAt(i);
hash = (hash << 5) - hash + chr; // eslint-disable-line no-bitwise
hash |= 0; // eslint-disable-line no-bitwise
}
return hash < 0 ? hash * -2 : hash;
}

View file

@ -0,0 +1,31 @@
.mlDataGridChart__histogram {
width: 100%;
height: $euiSizeXL + $euiSizeXXL;
}
.mlDataGridChart__legend {
@include euiTextTruncate;
@include euiFontSizeXS;
color: $euiColorMediumShade;
display: block;
overflow-x: hidden;
margin: $euiSizeXS 0px 0px 0px;
font-style: italic;
font-weight: normal;
text-align: left;
}
.mlDataGridChart__legend--numeric {
text-align: right;
}
.mlDataGridChart__legendBoolean {
width: 100%;
td { text-align: center }
}
/* Override to align column header to bottom of cell when no chart is available */
.mlDataGrid .euiDataGridHeaderCell__content {
margin-top: auto;
}

View file

@ -0,0 +1,73 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC } from 'react';
import classNames from 'classnames';
import { BarSeries, Chart, Settings } from '@elastic/charts';
import { EuiDataGridColumn } from '@elastic/eui';
import './column_chart.scss';
import { isUnsupportedChartData, useColumnChart, ChartData } from './use_column_chart';
interface Props {
chartData: ChartData;
columnType: EuiDataGridColumn;
dataTestSubj: string;
}
export const ColumnChart: FC<Props> = ({ chartData, columnType, dataTestSubj }) => {
const { data, legendText, xScaleType } = useColumnChart(chartData, columnType);
return (
<div data-test-subj={dataTestSubj}>
{!isUnsupportedChartData(chartData) && data.length > 0 && (
<div className="mlDataGridChart__histogram" data-test-subj={`${dataTestSubj}-histogram`}>
<Chart>
<Settings
theme={{
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 },
}}
/>
<BarSeries
id="histogram"
name="count"
xScaleType={xScaleType}
yScaleType="linear"
xAccessor="key"
yAccessors={['doc_count']}
styleAccessor={(d) => d.datum.color}
data={data}
/>
</Chart>
</div>
)}
<div
className={classNames('mlDataGridChart__legend', {
'mlDataGridChart__legend--numeric': columnType.schema === 'number',
})}
data-test-subj={`${dataTestSubj}-legend`}
>
{legendText}
</div>
<div data-test-subj={`${dataTestSubj}-id`}>{columnType.id}</div>
</div>
);
};

View file

@ -13,6 +13,10 @@ import {
EuiDataGridStyle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { CoreSetup } from 'src/core/public';
import {
IndexPattern,
IFieldType,
@ -20,6 +24,8 @@ import {
KBN_FIELD_TYPES,
} from '../../../../../../../src/plugins/data/public';
import { extractErrorMessage } from '../../../../common/util/errors';
import {
BASIC_NUMERICAL_TYPES,
EXTENDED_NUMERICAL_TYPES,
@ -37,7 +43,7 @@ import { mlFieldFormatService } from '../../services/field_format_service';
import { DataGridItem, IndexPagination, RenderCellValue } from './types';
export const INIT_MAX_COLUMNS = 20;
export const INIT_MAX_COLUMNS = 10;
export const euiDataGridStyle: EuiDataGridStyle = {
border: 'all',
@ -102,6 +108,8 @@ export const getDataGridSchemasFromFieldTypes = (fieldTypes: FieldTypes, results
case 'boolean':
schema = 'boolean';
break;
case 'text':
schema = NON_AGGREGATABLE;
}
if (
@ -122,7 +130,10 @@ export const getDataGridSchemasFromFieldTypes = (fieldTypes: FieldTypes, results
});
};
export const getDataGridSchemaFromKibanaFieldType = (field: IFieldType | undefined) => {
export const NON_AGGREGATABLE = 'non-aggregatable';
export const getDataGridSchemaFromKibanaFieldType = (
field: IFieldType | undefined
): string | undefined => {
// Built-in values are ['boolean', 'currency', 'datetime', 'numeric', 'json']
// To fall back to the default string schema it needs to be undefined.
let schema;
@ -143,6 +154,10 @@ export const getDataGridSchemaFromKibanaFieldType = (field: IFieldType | undefin
break;
}
if (schema === undefined && field?.aggregatable === false) {
return NON_AGGREGATABLE;
}
return schema;
};
@ -289,3 +304,17 @@ export const multiColumnSortFactory = (sortingColumns: EuiDataGridSorting['colum
return sortFn;
};
export const showDataGridColumnChartErrorMessageToast = (
e: any,
toastNotifications: CoreSetup['notifications']['toasts']
) => {
const error = extractErrorMessage(e);
toastNotifications.addDanger(
i18n.translate('xpack.ml.dataGrid.columnChart.ErrorMessageToast', {
defaultMessage: 'An error occurred fetching the histogram charts data: {error}',
values: { error: error !== '' ? error : e },
})
);
};

View file

@ -10,6 +10,7 @@ import React, { memo, useEffect, FC } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiButtonEmpty,
EuiButtonIcon,
EuiCallOut,
EuiCodeBlock,
@ -27,6 +28,8 @@ import { INDEX_STATUS } from '../../data_frame_analytics/common';
import { euiDataGridStyle, euiDataGridToolbarSettings } from './common';
import { UseIndexDataReturnType } from './types';
// TODO Fix row hovering + bar highlighting
// import { hoveredRow$ } from './column_chart';
export const DataGridTitle: FC<{ title: string }> = ({ title }) => (
<EuiTitle size="xs">
@ -54,7 +57,9 @@ type Props = PropsWithHeader | PropsWithoutHeader;
export const DataGrid: FC<Props> = memo(
(props) => {
const {
columns,
chartsVisible,
chartsButtonVisible,
columnsWithCharts,
dataTestSubj,
errorMessage,
invalidSortingColumnns,
@ -70,9 +75,18 @@ export const DataGrid: FC<Props> = memo(
status,
tableItems: data,
toastNotifications,
toggleChartVisibility,
visibleColumns,
} = props;
// TODO Fix row hovering + bar highlighting
// const getRowProps = (item: any) => {
// return {
// onMouseOver: () => hoveredRow$.next(item),
// onMouseLeave: () => hoveredRow$.next(null),
// };
// };
useEffect(() => {
if (invalidSortingColumnns.length > 0) {
invalidSortingColumnns.forEach((columnId) => {
@ -162,22 +176,50 @@ export const DataGrid: FC<Props> = memo(
<EuiSpacer size="m" />
</div>
)}
<EuiDataGrid
aria-label={isWithHeader(props) ? props.title : ''}
columns={columns}
columnVisibility={{ visibleColumns, setVisibleColumns }}
gridStyle={euiDataGridStyle}
rowCount={rowCount}
renderCellValue={renderCellValue}
sorting={{ columns: sortingColumns, onSort }}
toolbarVisibility={euiDataGridToolbarSettings}
pagination={{
...pagination,
pageSizeOptions: [5, 10, 25],
onChangeItemsPerPage,
onChangePage,
}}
/>
<div className="mlDataGrid">
<EuiDataGrid
aria-label={isWithHeader(props) ? props.title : ''}
columns={columnsWithCharts.map((c) => {
c.initialWidth = 165;
return c;
})}
columnVisibility={{ visibleColumns, setVisibleColumns }}
gridStyle={euiDataGridStyle}
rowCount={rowCount}
renderCellValue={renderCellValue}
sorting={{ columns: sortingColumns, onSort }}
toolbarVisibility={{
...euiDataGridToolbarSettings,
...(chartsButtonVisible
? {
additionalControls: (
<EuiButtonEmpty
aria-checked={chartsVisible}
className={`euiDataGrid__controlBtn${
chartsVisible ? ' euiDataGrid__controlBtn--active' : ''
}`}
data-test-subj={`${dataTestSubj}HistogramButton`}
size="xs"
iconType="visBarVertical"
color="text"
onClick={toggleChartVisibility}
>
{i18n.translate('xpack.ml.dataGrid.histogramButtonText', {
defaultMessage: 'Histogram charts',
})}
</EuiButtonEmpty>
),
}
: {}),
}}
pagination={{
...pagination,
pageSizeOptions: [5, 10, 25],
onChangeItemsPerPage,
onChangePage,
}}
/>
</div>
</div>
);
},
@ -186,7 +228,7 @@ export const DataGrid: FC<Props> = memo(
function pickProps(props: Props) {
return [
props.columns,
props.columnsWithCharts,
props.dataTestSubj,
props.errorMessage,
props.invalidSortingColumnns,

View file

@ -9,8 +9,10 @@ export {
getDataGridSchemaFromKibanaFieldType,
getFieldsFromKibanaIndexPattern,
multiColumnSortFactory,
showDataGridColumnChartErrorMessageToast,
useRenderCellValue,
} from './common';
export { fetchChartsData, ChartData } from './use_column_chart';
export { useDataGrid } from './use_data_grid';
export { DataGrid } from './data_grid';
export {

View file

@ -13,6 +13,8 @@ import { Dictionary } from '../../../../common/types/common';
import { INDEX_STATUS } from '../../data_frame_analytics/common/analytics';
import { ChartData } from './use_column_chart';
export type ColumnId = string;
export type DataGridItem = Record<string, any>;
@ -54,6 +56,9 @@ export interface SearchResponse7 extends SearchResponse<any> {
export interface UseIndexDataReturnType
extends Pick<
UseDataGridReturnType,
| 'chartsVisible'
| 'chartsButtonVisible'
| 'columnsWithCharts'
| 'errorMessage'
| 'invalidSortingColumnns'
| 'noDataMessage'
@ -67,13 +72,16 @@ export interface UseIndexDataReturnType
| 'sortingColumns'
| 'status'
| 'tableItems'
| 'toggleChartVisibility'
| 'visibleColumns'
> {
columns: EuiDataGridColumn[];
renderCellValue: RenderCellValue;
}
export interface UseDataGridReturnType {
chartsVisible: boolean;
chartsButtonVisible: boolean;
columnsWithCharts: EuiDataGridColumn[];
errorMessage: string;
invalidSortingColumnns: ColumnId[];
noDataMessage: string;
@ -83,6 +91,7 @@ export interface UseDataGridReturnType {
pagination: IndexPagination;
resetPagination: () => void;
rowCount: number;
setColumnCharts: Dispatch<SetStateAction<ChartData[]>>;
setErrorMessage: Dispatch<SetStateAction<string>>;
setNoDataMessage: Dispatch<SetStateAction<string>>;
setPagination: Dispatch<SetStateAction<IndexPagination>>;
@ -94,5 +103,6 @@ export interface UseDataGridReturnType {
sortingColumns: EuiDataGridSorting['columns'];
status: INDEX_STATUS;
tableItems: DataGridItem[];
toggleChartVisibility: () => void;
visibleColumns: ColumnId[];
}

View file

@ -0,0 +1,432 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import moment from 'moment';
import { BehaviorSubject } from 'rxjs';
import React from 'react';
import { useObservable } from 'react-use';
import { euiPaletteColorBlind, EuiDataGridColumn } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { KBN_FIELD_TYPES } from '../../../../../../../src/plugins/data/public';
import { stringHash } from '../../../../common/util/string_utils';
import { NON_AGGREGATABLE } from './common';
export const hoveredRow$ = new BehaviorSubject<any | null>(null);
const BAR_COLOR = euiPaletteColorBlind()[0];
const BAR_COLOR_BLUR = euiPaletteColorBlind({ rotations: 2 })[10];
const MAX_CHART_COLUMNS = 20;
type XScaleType = 'ordinal' | 'time' | 'linear' | undefined;
const getXScaleType = (kbnFieldType: KBN_FIELD_TYPES | undefined): XScaleType => {
switch (kbnFieldType) {
case KBN_FIELD_TYPES.BOOLEAN:
case KBN_FIELD_TYPES.IP:
case KBN_FIELD_TYPES.STRING:
return 'ordinal';
case KBN_FIELD_TYPES.DATE:
return 'time';
case KBN_FIELD_TYPES.NUMBER:
return 'linear';
}
};
const getFieldType = (schema: EuiDataGridColumn['schema']): KBN_FIELD_TYPES | undefined => {
if (schema === NON_AGGREGATABLE) {
return undefined;
}
let fieldType: KBN_FIELD_TYPES;
switch (schema) {
case 'datetime':
fieldType = KBN_FIELD_TYPES.DATE;
break;
case 'numeric':
fieldType = KBN_FIELD_TYPES.NUMBER;
break;
case 'boolean':
fieldType = KBN_FIELD_TYPES.BOOLEAN;
break;
case 'json':
fieldType = KBN_FIELD_TYPES.OBJECT;
break;
default:
fieldType = KBN_FIELD_TYPES.STRING;
}
return fieldType;
};
interface NumericColumnStats {
interval: number;
min: number;
max: number;
}
type NumericColumnStatsMap = Record<string, NumericColumnStats>;
const getAggIntervals = async (
indexPatternTitle: string,
esSearch: (payload: any) => Promise<any>,
query: any,
columnTypes: EuiDataGridColumn[]
): Promise<NumericColumnStatsMap> => {
const numericColumns = columnTypes.filter((cT) => {
const fieldType = getFieldType(cT.schema);
return fieldType === KBN_FIELD_TYPES.NUMBER || fieldType === KBN_FIELD_TYPES.DATE;
});
if (numericColumns.length === 0) {
return {};
}
const minMaxAggs = numericColumns.reduce((aggs, c) => {
const id = stringHash(c.id);
aggs[id] = {
stats: {
field: c.id,
},
};
return aggs;
}, {} as Record<string, object>);
const respStats = await esSearch({
index: indexPatternTitle,
size: 0,
body: {
query,
aggs: minMaxAggs,
size: 0,
},
});
return Object.keys(respStats.aggregations).reduce((p, aggName) => {
const stats = [respStats.aggregations[aggName].min, respStats.aggregations[aggName].max];
if (!stats.includes(null)) {
const delta = respStats.aggregations[aggName].max - respStats.aggregations[aggName].min;
let aggInterval = 1;
if (delta > MAX_CHART_COLUMNS) {
aggInterval = Math.round(delta / MAX_CHART_COLUMNS);
}
if (delta <= 1) {
aggInterval = delta / MAX_CHART_COLUMNS;
}
p[aggName] = { interval: aggInterval, min: stats[0], max: stats[1] };
}
return p;
}, {} as NumericColumnStatsMap);
};
interface AggHistogram {
histogram: {
field: string;
interval: number;
};
}
interface AggCardinality {
cardinality: {
field: string;
};
}
interface AggTerms {
terms: {
field: string;
size: number;
};
}
type ChartRequestAgg = AggHistogram | AggCardinality | AggTerms;
export const fetchChartsData = async (
indexPatternTitle: string,
esSearch: (payload: any) => Promise<any>,
query: any,
columnTypes: EuiDataGridColumn[]
): Promise<ChartData[]> => {
const aggIntervals = await getAggIntervals(indexPatternTitle, esSearch, query, columnTypes);
const chartDataAggs = columnTypes.reduce((aggs, c) => {
const fieldType = getFieldType(c.schema);
const id = stringHash(c.id);
if (fieldType === KBN_FIELD_TYPES.NUMBER || fieldType === KBN_FIELD_TYPES.DATE) {
if (aggIntervals[id] !== undefined) {
aggs[`${id}_histogram`] = {
histogram: {
field: c.id,
interval: aggIntervals[id].interval !== 0 ? aggIntervals[id].interval : 1,
},
};
}
} else if (fieldType === KBN_FIELD_TYPES.STRING || fieldType === KBN_FIELD_TYPES.BOOLEAN) {
if (fieldType === KBN_FIELD_TYPES.STRING) {
aggs[`${id}_cardinality`] = {
cardinality: {
field: c.id,
},
};
}
aggs[`${id}_terms`] = {
terms: {
field: c.id,
size: MAX_CHART_COLUMNS,
},
};
}
return aggs;
}, {} as Record<string, ChartRequestAgg>);
if (Object.keys(chartDataAggs).length === 0) {
return [];
}
const respChartsData = await esSearch({
index: indexPatternTitle,
size: 0,
body: {
query,
aggs: chartDataAggs,
size: 0,
},
});
const chartsData: ChartData[] = columnTypes.map(
(c): ChartData => {
const fieldType = getFieldType(c.schema);
const id = stringHash(c.id);
if (fieldType === KBN_FIELD_TYPES.NUMBER || fieldType === KBN_FIELD_TYPES.DATE) {
if (aggIntervals[id] === undefined) {
return {
type: 'numeric',
data: [],
interval: 0,
stats: [0, 0],
id: c.id,
};
}
return {
data: respChartsData.aggregations[`${id}_histogram`].buckets,
interval: aggIntervals[id].interval,
stats: [aggIntervals[id].min, aggIntervals[id].max],
type: 'numeric',
id: c.id,
};
} else if (fieldType === KBN_FIELD_TYPES.STRING || fieldType === KBN_FIELD_TYPES.BOOLEAN) {
return {
type: fieldType === KBN_FIELD_TYPES.STRING ? 'ordinal' : 'boolean',
cardinality:
fieldType === KBN_FIELD_TYPES.STRING
? respChartsData.aggregations[`${id}_cardinality`].value
: 2,
data: respChartsData.aggregations[`${id}_terms`].buckets,
id: c.id,
};
}
return {
type: 'unsupported',
id: c.id,
};
}
);
return chartsData;
};
interface NumericDataItem {
key: number;
key_as_string?: string;
doc_count: number;
}
interface NumericChartData {
data: NumericDataItem[];
id: string;
interval: number;
stats: [number, number];
type: 'numeric';
}
export const isNumericChartData = (arg: any): arg is NumericChartData => {
return (
arg.hasOwnProperty('data') &&
arg.hasOwnProperty('id') &&
arg.hasOwnProperty('interval') &&
arg.hasOwnProperty('stats') &&
arg.hasOwnProperty('type')
);
};
interface OrdinalDataItem {
key: string;
key_as_string?: string;
doc_count: number;
}
interface OrdinalChartData {
type: 'ordinal' | 'boolean';
cardinality: number;
data: OrdinalDataItem[];
id: string;
}
export const isOrdinalChartData = (arg: any): arg is OrdinalChartData => {
return (
arg.hasOwnProperty('data') &&
arg.hasOwnProperty('cardinality') &&
arg.hasOwnProperty('id') &&
arg.hasOwnProperty('type')
);
};
interface UnsupportedChartData {
id: string;
type: 'unsupported';
}
export const isUnsupportedChartData = (arg: any): arg is UnsupportedChartData => {
return arg.hasOwnProperty('type') && arg.type === 'unsupported';
};
type ChartDataItem = NumericDataItem | OrdinalDataItem;
export type ChartData = NumericChartData | OrdinalChartData | UnsupportedChartData;
type LegendText = string | JSX.Element;
const getLegendText = (chartData: ChartData): LegendText => {
if (chartData.type === 'unsupported') {
return i18n.translate('xpack.ml.dataGridChart.histogramNotAvailable', {
defaultMessage: 'Chart not supported.',
});
}
if (chartData.data.length === 0) {
return i18n.translate('xpack.ml.dataGridChart.notEnoughData', {
defaultMessage: `0 documents contain field.`,
});
}
if (chartData.type === 'boolean') {
return (
<table className="mlDataGridChart__legendBoolean">
<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>}
</tr>
</tbody>
</table>
);
}
if (isOrdinalChartData(chartData) && chartData.cardinality <= MAX_CHART_COLUMNS) {
return i18n.translate('xpack.ml.dataGridChart.singleCategoryLegend', {
defaultMessage: `{cardinality, plural, one {# category} other {# categories}}`,
values: { cardinality: chartData.cardinality },
});
}
if (isOrdinalChartData(chartData) && chartData.cardinality > MAX_CHART_COLUMNS) {
return i18n.translate('xpack.ml.dataGridChart.topCategoriesLegend', {
defaultMessage: `top {MAX_CHART_COLUMNS} of {cardinality} categories`,
values: { cardinality: chartData.cardinality, MAX_CHART_COLUMNS },
});
}
if (isNumericChartData(chartData)) {
const fromValue = Math.round(chartData.stats[0] * 100) / 100;
const toValue = Math.round(chartData.stats[1] * 100) / 100;
return fromValue !== toValue ? `${fromValue} - ${toValue}` : '' + fromValue;
}
return '';
};
interface ColumnChart {
data: ChartDataItem[];
legendText: LegendText;
xScaleType: XScaleType;
}
export const useColumnChart = (
chartData: ChartData,
columnType: EuiDataGridColumn
): ColumnChart => {
const fieldType = getFieldType(columnType.schema);
const hoveredRow = useObservable(hoveredRow$);
const xScaleType = getXScaleType(fieldType);
const getColor = (d: ChartDataItem) => {
if (hoveredRow === undefined || hoveredRow === null) {
return BAR_COLOR;
}
if (
isOrdinalChartData(chartData) &&
xScaleType === 'ordinal' &&
hoveredRow._source[columnType.id] === d.key
) {
return BAR_COLOR;
}
if (
isNumericChartData(chartData) &&
xScaleType === 'linear' &&
hoveredRow._source[columnType.id] >= +d.key &&
hoveredRow._source[columnType.id] < +d.key + chartData.interval
) {
return BAR_COLOR;
}
if (
isNumericChartData(chartData) &&
xScaleType === 'time' &&
moment(hoveredRow._source[columnType.id]).unix() * 1000 >= +d.key &&
moment(hoveredRow._source[columnType.id]).unix() * 1000 < +d.key + chartData.interval
) {
return BAR_COLOR;
}
return BAR_COLOR_BLUR;
};
let data: ChartDataItem[] = [];
// 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) => ({
...d,
color: getColor(d),
}));
} else if (isNumericChartData(chartData)) {
data = chartData.data.map((d: NumericDataItem) => ({
...d,
color: getColor(d),
}));
}
return {
data,
legendText: getLegendText(chartData),
xScaleType,
};
};

View file

@ -4,12 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { EuiDataGridSorting, EuiDataGridColumn } from '@elastic/eui';
import { INDEX_STATUS } from '../../data_frame_analytics/common';
import { ColumnChart } from './column_chart';
import { INIT_MAX_COLUMNS } from './common';
import {
ColumnId,
@ -20,6 +21,7 @@ import {
OnSort,
UseDataGridReturnType,
} from './types';
import { ChartData } from './use_column_chart';
export const useDataGrid = (
columns: EuiDataGridColumn[],
@ -33,9 +35,15 @@ export const useDataGrid = (
const [errorMessage, setErrorMessage] = useState('');
const [status, setStatus] = useState(INDEX_STATUS.UNUSED);
const [rowCount, setRowCount] = useState(0);
const [columnCharts, setColumnCharts] = useState<ChartData[]>([]);
const [tableItems, setTableItems] = useState<DataGridItem[]>([]);
const [pagination, setPagination] = useState(defaultPagination);
const [sortingColumns, setSortingColumns] = useState<EuiDataGridSorting['columns']>([]);
const [chartsVisible, setChartsVisible] = useState(false);
const toggleChartVisibility = () => {
setChartsVisible(!chartsVisible);
};
const onChangeItemsPerPage: OnChangeItemsPerPage = useCallback((pageSize) => {
setPagination((p) => {
@ -87,6 +95,23 @@ export const useDataGrid = (
);
return {
chartsVisible,
chartsButtonVisible: true,
columnsWithCharts: columns.map((c, index) => {
const chartData = columnCharts.find((cd) => cd.id === c.id);
return {
...c,
display:
chartData !== undefined && chartsVisible === true ? (
<ColumnChart
chartData={chartData}
columnType={c}
dataTestSubj={`mlDataGridChart-${index}`}
/>
) : undefined,
};
}),
errorMessage,
invalidSortingColumnns,
noDataMessage,
@ -96,6 +121,7 @@ export const useDataGrid = (
pagination,
resetPagination,
rowCount,
setColumnCharts,
setErrorMessage,
setNoDataMessage,
setPagination,
@ -107,6 +133,7 @@ export const useDataGrid = (
sortingColumns,
status,
tableItems,
toggleChartVisibility,
visibleColumns,
};
};

View file

@ -30,7 +30,7 @@ export interface EsDoc extends Record<string, any> {
_source: EsDocSource;
}
export const MAX_COLUMNS = 20;
export const MAX_COLUMNS = 10;
export const DEFAULT_REGRESSION_COLUMNS = 8;
export const BASIC_NUMERICAL_TYPES = new Set([

View file

@ -87,7 +87,8 @@ export const ConfigurationStepForm: FC<CreateAnalyticsStepProps> = ({
const indexData = useIndexData(
currentIndexPattern,
savedSearchQuery !== undefined ? savedSearchQuery : jobConfigQuery
savedSearchQuery !== undefined ? savedSearchQuery : jobConfigQuery,
toastNotifications
);
const indexPreviewProps = {

View file

@ -6,10 +6,16 @@
import { useEffect } from 'react';
import { EuiDataGridColumn } from '@elastic/eui';
import { CoreSetup } from 'src/core/public';
import { IndexPattern } from '../../../../../../../../../src/plugins/data/public';
import {
fetchChartsData,
getDataGridSchemaFromKibanaFieldType,
getFieldsFromKibanaIndexPattern,
showDataGridColumnChartErrorMessageToast,
useDataGrid,
useRenderCellValue,
EsSorting,
@ -22,15 +28,19 @@ import { ml } from '../../../../services/ml_api_service';
type IndexSearchResponse = SearchResponse7;
export const useIndexData = (indexPattern: IndexPattern, query: any): UseIndexDataReturnType => {
export const useIndexData = (
indexPattern: IndexPattern,
query: any,
toastNotifications: CoreSetup['notifications']['toasts']
): UseIndexDataReturnType => {
const indexPatternFields = getFieldsFromKibanaIndexPattern(indexPattern);
// EuiDataGrid State
const columns = [
const columns: EuiDataGridColumn[] = [
...indexPatternFields.map((id) => {
const field = indexPattern.fields.getByName(id);
const schema = getDataGridSchemaFromKibanaFieldType(field);
return { id, schema };
return { id, schema, isExpandable: schema !== 'boolean' };
}),
];
@ -93,11 +103,36 @@ export const useIndexData = (indexPattern: IndexPattern, query: any): UseIndexDa
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [indexPattern.title, JSON.stringify([query, pagination, sortingColumns])]);
const fetchColumnChartsData = async function () {
try {
const columnChartsData = await fetchChartsData(
indexPattern.title,
ml.esSearch,
query,
columns.filter((cT) => dataGrid.visibleColumns.includes(cT.id))
);
dataGrid.setColumnCharts(columnChartsData);
} catch (e) {
showDataGridColumnChartErrorMessageToast(e, toastNotifications);
}
};
useEffect(() => {
if (dataGrid.chartsVisible) {
fetchColumnChartsData();
}
// custom comparison
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
dataGrid.chartsVisible,
indexPattern.title,
JSON.stringify([query, dataGrid.visibleColumns]),
]);
const renderCellValue = useRenderCellValue(indexPattern, pagination, tableItems);
return {
...dataGrid,
columns,
renderCellValue,
};
};

View file

@ -67,9 +67,20 @@ export const ExplorationResultsTable: FC<Props> = React.memo(
setEvaluateSearchQuery(searchQuery);
}, [JSON.stringify(searchQuery)]);
const classificationData = useExplorationResults(indexPattern, jobConfig, searchQuery);
const docFieldsCount = classificationData.columns.length;
const { columns, errorMessage, status, tableItems, visibleColumns } = classificationData;
const classificationData = useExplorationResults(
indexPattern,
jobConfig,
searchQuery,
getToastNotifications()
);
const docFieldsCount = classificationData.columnsWithCharts.length;
const {
columnsWithCharts,
errorMessage,
status,
tableItems,
visibleColumns,
} = classificationData;
if (jobConfig === undefined || classificationData === undefined) {
return null;
@ -140,7 +151,7 @@ export const ExplorationResultsTable: FC<Props> = React.memo(
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
{(columns.length > 0 || searchQuery !== defaultSearchQuery) && (
{(columnsWithCharts.length > 0 || searchQuery !== defaultSearchQuery) && (
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<EuiSpacer size="s" />

View file

@ -8,15 +8,20 @@ import { useEffect } from 'react';
import { EuiDataGridColumn } from '@elastic/eui';
import { CoreSetup } from 'src/core/public';
import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public';
import {
fetchChartsData,
getDataGridSchemasFromFieldTypes,
showDataGridColumnChartErrorMessageToast,
useDataGrid,
useRenderCellValue,
UseIndexDataReturnType,
} from '../../../../../components/data_grid';
import { SavedSearchQuery } from '../../../../../contexts/ml';
import { ml } from '../../../../../services/ml_api_service';
import { getIndexData, getIndexFields, DataFrameAnalyticsConfig } from '../../../../common';
import {
@ -29,7 +34,8 @@ import { sortExplorationResultsFields, ML__ID_COPY } from '../../../../common/fi
export const useExplorationResults = (
indexPattern: IndexPattern | undefined,
jobConfig: DataFrameAnalyticsConfig | undefined,
searchQuery: SavedSearchQuery
searchQuery: SavedSearchQuery,
toastNotifications: CoreSetup['notifications']['toasts']
): UseIndexDataReturnType => {
const needsDestIndexFields =
indexPattern !== undefined && indexPattern.title === jobConfig?.source.index[0];
@ -66,6 +72,34 @@ export const useExplorationResults = (
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [jobConfig && jobConfig.id, dataGrid.pagination, searchQuery, dataGrid.sortingColumns]);
const fetchColumnChartsData = async function () {
try {
if (jobConfig !== undefined) {
const columnChartsData = await fetchChartsData(
jobConfig.dest.index,
ml.esSearch,
searchQuery,
columns.filter((cT) => dataGrid.visibleColumns.includes(cT.id))
);
dataGrid.setColumnCharts(columnChartsData);
}
} catch (e) {
showDataGridColumnChartErrorMessageToast(e, toastNotifications);
}
};
useEffect(() => {
if (dataGrid.chartsVisible) {
fetchColumnChartsData();
}
// custom comparison
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
dataGrid.chartsVisible,
jobConfig?.dest.index,
JSON.stringify([searchQuery, dataGrid.visibleColumns]),
]);
const renderCellValue = useRenderCellValue(
indexPattern,
dataGrid.pagination,
@ -75,7 +109,6 @@ export const useExplorationResults = (
return {
...dataGrid,
columns,
renderCellValue,
};
};

View file

@ -53,7 +53,7 @@ export const OutlierExploration: FC<ExplorationProps> = React.memo(({ jobId }) =
const [searchQuery, setSearchQuery] = useState<SavedSearchQuery>(defaultSearchQuery);
const outlierData = useOutlierData(indexPattern, jobConfig, searchQuery);
const { columns, errorMessage, status, tableItems } = outlierData;
const { columnsWithCharts, errorMessage, status, tableItems } = outlierData;
// if it's a searchBar syntax error leave the table visible so they can try again
if (status === INDEX_STATUS.ERROR && !errorMessage.includes('failed to create query')) {
@ -98,35 +98,36 @@ export const OutlierExploration: FC<ExplorationProps> = React.memo(({ jobId }) =
)}
</EuiFlexGroup>
<EuiHorizontalRule margin="xs" />
{(columns.length > 0 || searchQuery !== defaultSearchQuery) && indexPattern !== undefined && (
<>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<ExplorationQueryBar indexPattern={indexPattern} setSearchQuery={setSearchQuery} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSpacer size="s" />
<ColorRangeLegend
colorRange={colorRange}
title={i18n.translate(
'xpack.ml.dataframe.analytics.exploration.colorRangeLegendTitle',
{
defaultMessage: 'Feature influence score',
}
)}
{(columnsWithCharts.length > 0 || searchQuery !== defaultSearchQuery) &&
indexPattern !== undefined && (
<>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<ExplorationQueryBar indexPattern={indexPattern} setSearchQuery={setSearchQuery} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSpacer size="s" />
<ColorRangeLegend
colorRange={colorRange}
title={i18n.translate(
'xpack.ml.dataframe.analytics.exploration.colorRangeLegendTitle',
{
defaultMessage: 'Feature influence score',
}
)}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
{columnsWithCharts.length > 0 && tableItems.length > 0 && (
<DataGrid
{...outlierData}
dataTestSubj="mlExplorationDataGrid"
toastNotifications={getToastNotifications()}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
{columns.length > 0 && tableItems.length > 0 && (
<DataGrid
{...outlierData}
dataTestSubj="mlExplorationDataGrid"
toastNotifications={getToastNotifications()}
/>
)}
</>
)}
)}
</>
)}
</EuiPanel>
);
});

View file

@ -16,12 +16,16 @@ import {
COLOR_RANGE_SCALE,
} from '../../../../../components/color_range_legend';
import {
fetchChartsData,
getDataGridSchemasFromFieldTypes,
showDataGridColumnChartErrorMessageToast,
useDataGrid,
useRenderCellValue,
UseIndexDataReturnType,
} from '../../../../../components/data_grid';
import { SavedSearchQuery } from '../../../../../contexts/ml';
import { ml } from '../../../../../services/ml_api_service';
import { getToastNotifications } from '../../../../../util/dependency_cache';
import { getIndexData, getIndexFields, DataFrameAnalyticsConfig } from '../../../../common';
import { DEFAULT_RESULTS_FIELD, FEATURE_INFLUENCE } from '../../../../common/constants';
@ -75,6 +79,34 @@ export const useOutlierData = (
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [jobConfig && jobConfig.id, dataGrid.pagination, searchQuery, dataGrid.sortingColumns]);
const fetchColumnChartsData = async function () {
try {
if (jobConfig !== undefined) {
const columnChartsData = await fetchChartsData(
jobConfig.dest.index,
ml.esSearch,
searchQuery,
columns.filter((cT) => dataGrid.visibleColumns.includes(cT.id))
);
dataGrid.setColumnCharts(columnChartsData);
}
} catch (e) {
showDataGridColumnChartErrorMessageToast(e, getToastNotifications());
}
};
useEffect(() => {
if (dataGrid.chartsVisible) {
fetchColumnChartsData();
}
// custom comparison
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
dataGrid.chartsVisible,
jobConfig?.dest.index,
JSON.stringify([searchQuery, dataGrid.visibleColumns]),
]);
const colorRange = useColorRange(
COLOR_RANGE.BLUE,
COLOR_RANGE_SCALE.INFLUENCER,
@ -115,7 +147,6 @@ export const useOutlierData = (
return {
...dataGrid,
columns,
renderCellValue,
};
};

View file

@ -6,10 +6,14 @@
import { useEffect } from 'react';
import { EuiDataGridColumn } from '@elastic/eui';
import {
fetchChartsData,
getDataGridSchemaFromKibanaFieldType,
getFieldsFromKibanaIndexPattern,
getErrorMessage,
showDataGridColumnChartErrorMessageToast,
useDataGrid,
useRenderCellValue,
EsSorting,
@ -23,6 +27,8 @@ import { isDefaultQuery, matchAllQuery, PivotQuery } from '../common';
import { SearchItems } from './use_search_items';
import { useApi } from './use_api';
import { useToastNotifications } from '../app_dependencies';
type IndexSearchResponse = SearchResponse7;
export const useIndexData = (
@ -30,11 +36,12 @@ export const useIndexData = (
query: PivotQuery
): UseIndexDataReturnType => {
const api = useApi();
const toastNotifications = useToastNotifications();
const indexPatternFields = getFieldsFromKibanaIndexPattern(indexPattern);
// EuiDataGrid State
const columns = [
const columns: EuiDataGridColumn[] = [
...indexPatternFields.map((id) => {
const field = indexPattern.fields.getByName(id);
const schema = getDataGridSchemaFromKibanaFieldType(field);
@ -45,8 +52,10 @@ export const useIndexData = (
const dataGrid = useDataGrid(columns);
const {
chartsVisible,
pagination,
resetPagination,
setColumnCharts,
setErrorMessage,
setRowCount,
setStatus,
@ -61,7 +70,7 @@ export const useIndexData = (
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(query)]);
const getIndexData = async function () {
const fetchDataGridData = async function () {
setErrorMessage('');
setStatus(INDEX_STATUS.LOADING);
@ -92,20 +101,43 @@ export const useIndexData = (
} catch (e) {
setErrorMessage(getErrorMessage(e));
setStatus(INDEX_STATUS.ERROR);
return;
}
};
const fetchColumnChartsData = async function () {
try {
const columnChartsData = await fetchChartsData(
indexPattern.title,
api.esSearch,
isDefaultQuery(query) ? matchAllQuery : query,
columns.filter((cT) => dataGrid.visibleColumns.includes(cT.id))
);
setColumnCharts(columnChartsData);
} catch (e) {
showDataGridColumnChartErrorMessageToast(e, toastNotifications);
}
};
useEffect(() => {
getIndexData();
fetchDataGridData();
// custom comparison
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [indexPattern.title, JSON.stringify([query, pagination, sortingColumns])]);
useEffect(() => {
if (chartsVisible) {
fetchColumnChartsData();
}
// custom comparison
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chartsVisible, indexPattern.title, JSON.stringify([query, dataGrid.visibleColumns])]);
const renderCellValue = useRenderCellValue(indexPattern, pagination, tableItems);
return {
...dataGrid,
columns,
renderCellValue,
};
};

View file

@ -7,6 +7,8 @@
import moment from 'moment-timezone';
import { useEffect, useMemo, useState } from 'react';
import { EuiDataGridColumn } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ES_FIELD_TYPES } from '../../../../../../src/plugins/data/common';
@ -76,7 +78,7 @@ export const usePivotData = (
const api = useApi();
const aggsArr = useMemo(() => dictionaryToArray(aggs), [aggs]);
const groupByArr = dictionaryToArray(groupBy);
const groupByArr = useMemo(() => dictionaryToArray(groupBy), [groupBy]);
// Filters mapping properties of type `object`, which get returned for nested field parents.
const columnKeys = Object.keys(previewMappings.properties).filter(
@ -85,7 +87,7 @@ export const usePivotData = (
columnKeys.sort(sortColumns(groupByArr));
// EuiDataGrid State
const columns = columnKeys.map((id) => {
const columns: EuiDataGridColumn[] = columnKeys.map((id) => {
const field = previewMappings.properties[id];
// Built-in values are ['boolean', 'currency', 'datetime', 'numeric', 'json']
@ -195,8 +197,7 @@ export const usePivotData = (
}, [
indexPatternTitle,
aggsArr,
JSON.stringify(groupByArr),
JSON.stringify(query),
JSON.stringify([groupByArr, query]),
/* eslint-enable react-hooks/exhaustive-deps */
]);
@ -251,7 +252,7 @@ export const usePivotData = (
return {
...dataGrid,
columns,
chartsButtonVisible: false,
renderCellValue,
};
};

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC } from 'react';
import React, { useMemo, FC } from 'react';
import { DataGrid } from '../../../../../shared_imports';
@ -24,14 +24,22 @@ interface ExpandedRowPreviewPaneProps {
export const ExpandedRowPreviewPane: FC<ExpandedRowPreviewPaneProps> = ({ transformConfig }) => {
const toastNotifications = useToastNotifications();
const { aggList, groupByList, searchQuery } = applyTransformConfigToDefineState(
getDefaultStepDefineState({} as SearchItems),
transformConfig
const { aggList, groupByList, searchQuery } = useMemo(
() =>
applyTransformConfigToDefineState(
getDefaultStepDefineState({} as SearchItems),
transformConfig
),
[transformConfig]
);
const pivotQuery = getPivotQuery(searchQuery);
const pivotQuery = useMemo(() => getPivotQuery(searchQuery), [searchQuery]);
const indexPatternTitle = Array.isArray(transformConfig.source.index)
? transformConfig.source.index.join(',')
: transformConfig.source.index;
const pivotPreviewProps = usePivotData(indexPatternTitle, pivotQuery, aggList, groupByList);
return (

View file

@ -14,13 +14,16 @@ export {
} from '../../../../src/plugins/es_ui_shared/public';
export {
fetchChartsData,
getErrorMessage,
extractErrorMessage,
getDataGridSchemaFromKibanaFieldType,
getFieldsFromKibanaIndexPattern,
multiColumnSortFactory,
showDataGridColumnChartErrorMessageToast,
useDataGrid,
useRenderCellValue,
ChartData,
DataGrid,
EsSorting,
RenderCellValue,

View file

@ -147,9 +147,25 @@ export default function ({ getService }: FtrProviderContext) {
progress: '100',
},
indexPreview: {
columns: 20,
columns: 10,
rows: 5,
},
histogramCharts: [
{ chartAvailable: false, id: 'category', legend: 'Chart not supported.' },
{ chartAvailable: true, id: 'currency', legend: '1 category' },
{
chartAvailable: false,
id: 'customer_birth_date',
legend: '0 documents contain field.',
},
{ chartAvailable: false, id: 'customer_first_name', legend: 'Chart not supported.' },
{ chartAvailable: false, id: 'customer_full_name', legend: 'Chart not supported.' },
{ chartAvailable: true, id: 'customer_gender', legend: '2 categories' },
{ chartAvailable: true, id: 'customer_id', legend: 'top 20 of 46 categories' },
{ chartAvailable: false, id: 'customer_last_name', legend: 'Chart not supported.' },
{ chartAvailable: true, id: 'customer_phone', legend: '1 category' },
{ chartAvailable: true, id: 'day_of_week', legend: '7 categories' },
],
},
},
{
@ -229,9 +245,10 @@ export default function ({ getService }: FtrProviderContext) {
progress: '100',
},
indexPreview: {
columns: 20,
columns: 10,
rows: 5,
},
histogramCharts: [],
},
},
];
@ -289,6 +306,16 @@ export default function ({ getService }: FtrProviderContext) {
await transform.wizard.assertAdvancedQueryEditorSwitchCheckState(false);
});
it('enables the index preview histogram charts', async () => {
await transform.wizard.enableIndexPreviewHistogramCharts();
});
it('displays the index preview histogram charts', async () => {
await transform.wizard.assertIndexPreviewHistogramCharts(
testData.expected.histogramCharts
);
});
it('adds the group by entries', async () => {
for (const [index, entry] of testData.groupByEntries.entries()) {
await transform.wizard.assertGroupByInputExists();
@ -323,6 +350,7 @@ export default function ({ getService }: FtrProviderContext) {
});
it('shows the pivot preview', async () => {
await transform.wizard.assertPivotPreviewChartHistogramButtonMissing();
await transform.wizard.assertPivotPreviewColumnValues(
testData.expected.pivotPreview.column,
testData.expected.pivotPreview.values

View file

@ -76,6 +76,12 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) {
await testSubjects.existOrFail(selector);
},
async assertPivotPreviewChartHistogramButtonMissing() {
// the button should not exist because histogram charts
// for the pivot preview are not supported yet
await testSubjects.missingOrFail('transformPivotPreviewHistogramButton');
},
async parseEuiDataGrid(tableSubj: string) {
const table = await testSubjects.find(`~${tableSubj}`);
const $ = await table.parseDomContent();
@ -155,6 +161,58 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) {
await this.assertPivotPreviewExists('empty');
},
async assertIndexPreviewHistogramChartButtonExists() {
await testSubjects.existOrFail('transformIndexPreviewHistogramButton');
},
async enableIndexPreviewHistogramCharts() {
await this.assertIndexPreviewHistogramChartButtonCheckState(false);
await testSubjects.click('transformIndexPreviewHistogramButton');
await this.assertIndexPreviewHistogramChartButtonCheckState(true);
},
async assertIndexPreviewHistogramChartButtonCheckState(expectedCheckState: boolean) {
const actualCheckState =
(await testSubjects.getAttribute(
'transformIndexPreviewHistogramButton',
'aria-checked'
)) === 'true';
expect(actualCheckState).to.eql(
expectedCheckState,
`Chart histogram button check state should be '${expectedCheckState}' (got '${actualCheckState}')`
);
},
async assertIndexPreviewHistogramCharts(
expectedHistogramCharts: Array<{ chartAvailable: boolean; id: string; legend: string }>
) {
// For each chart, get the content of each header cell and assert
// the legend text and column id and if the chart should be present or not.
await retry.tryForTime(5000, async () => {
for (const [index, expected] of expectedHistogramCharts.entries()) {
await testSubjects.existOrFail(`mlDataGridChart-${index}`);
if (expected.chartAvailable) {
await testSubjects.existOrFail(`mlDataGridChart-${index}-histogram`);
} else {
await testSubjects.missingOrFail(`mlDataGridChart-${index}-histogram`);
}
const actualLegend = await testSubjects.getVisibleText(`mlDataGridChart-${index}-legend`);
expect(actualLegend).to.eql(
expected.legend,
`Legend text for column '${index}' should be '${expected.legend}' (got '${actualLegend}')`
);
const actualId = await testSubjects.getVisibleText(`mlDataGridChart-${index}-id`);
expect(actualId).to.eql(
expected.id,
`Id text for column '${index}' should be '${expected.id}' (got '${actualId}')`
);
}
});
},
async assertQueryInputExists() {
await testSubjects.existOrFail('transformQueryInput');
},