diff --git a/x-pack/plugins/ml/common/util/group_color_utils.ts b/x-pack/plugins/ml/common/util/group_color_utils.ts index 92f5c6b2c134..710591927418 100644 --- a/x-pack/plugins/ml/common/util/group_color_utils.ts +++ b/x-pack/plugins/ml/common/util/group_color_utils.ts @@ -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; -} diff --git a/x-pack/plugins/ml/common/util/string_utils.test.ts b/x-pack/plugins/ml/common/util/string_utils.test.ts index 026c8e6110c9..8afc7e52c9fa 100644 --- a/x-pack/plugins/ml/common/util/string_utils.test.ts +++ b/x-pack/plugins/ml/common/util/string_utils.test.ts @@ -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); + }); + }); }); diff --git a/x-pack/plugins/ml/common/util/string_utils.ts b/x-pack/plugins/ml/common/util/string_utils.ts index bd4ca02bf93c..b4591fd2943e 100644 --- a/x-pack/plugins/ml/common/util/string_utils.ts +++ b/x-pack/plugins/ml/common/util/string_utils.ts @@ -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; +} diff --git a/x-pack/plugins/ml/public/application/components/data_grid/column_chart.scss b/x-pack/plugins/ml/public/application/components/data_grid/column_chart.scss new file mode 100644 index 000000000000..37d8871ab356 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/data_grid/column_chart.scss @@ -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; +} diff --git a/x-pack/plugins/ml/public/application/components/data_grid/column_chart.tsx b/x-pack/plugins/ml/public/application/components/data_grid/column_chart.tsx new file mode 100644 index 000000000000..00e2d5b14a96 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/data_grid/column_chart.tsx @@ -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 = ({ chartData, columnType, dataTestSubj }) => { + const { data, legendText, xScaleType } = useColumnChart(chartData, columnType); + + return ( +
+ {!isUnsupportedChartData(chartData) && data.length > 0 && ( +
+ + + d.datum.color} + data={data} + /> + +
+ )} +
+ {legendText} +
+
{columnType.id}
+
+ ); +}; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/common.ts b/x-pack/plugins/ml/public/application/components/data_grid/common.ts index 7d0559c21511..1f0fcb63f019 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/common.ts +++ b/x-pack/plugins/ml/public/application/components/data_grid/common.ts @@ -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 }, + }) + ); +}; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx b/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx index 618075a77d90..9af7a869e0e5 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx +++ b/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx @@ -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 }) => ( @@ -54,7 +57,9 @@ type Props = PropsWithHeader | PropsWithoutHeader; export const DataGrid: FC = memo( (props) => { const { - columns, + chartsVisible, + chartsButtonVisible, + columnsWithCharts, dataTestSubj, errorMessage, invalidSortingColumnns, @@ -70,9 +75,18 @@ export const DataGrid: FC = 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 = memo( )} - +
+ { + c.initialWidth = 165; + return c; + })} + columnVisibility={{ visibleColumns, setVisibleColumns }} + gridStyle={euiDataGridStyle} + rowCount={rowCount} + renderCellValue={renderCellValue} + sorting={{ columns: sortingColumns, onSort }} + toolbarVisibility={{ + ...euiDataGridToolbarSettings, + ...(chartsButtonVisible + ? { + additionalControls: ( + + {i18n.translate('xpack.ml.dataGrid.histogramButtonText', { + defaultMessage: 'Histogram charts', + })} + + ), + } + : {}), + }} + pagination={{ + ...pagination, + pageSizeOptions: [5, 10, 25], + onChangeItemsPerPage, + onChangePage, + }} + /> +
); }, @@ -186,7 +228,7 @@ export const DataGrid: FC = memo( function pickProps(props: Props) { return [ - props.columns, + props.columnsWithCharts, props.dataTestSubj, props.errorMessage, props.invalidSortingColumnns, diff --git a/x-pack/plugins/ml/public/application/components/data_grid/index.ts b/x-pack/plugins/ml/public/application/components/data_grid/index.ts index 2472878d1b0c..80bc6b861f74 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/index.ts +++ b/x-pack/plugins/ml/public/application/components/data_grid/index.ts @@ -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 { diff --git a/x-pack/plugins/ml/public/application/components/data_grid/types.ts b/x-pack/plugins/ml/public/application/components/data_grid/types.ts index 5fa038edf781..756f74c8f930 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/types.ts +++ b/x-pack/plugins/ml/public/application/components/data_grid/types.ts @@ -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; @@ -54,6 +56,9 @@ export interface SearchResponse7 extends SearchResponse { 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>; setErrorMessage: Dispatch>; setNoDataMessage: Dispatch>; setPagination: Dispatch>; @@ -94,5 +103,6 @@ export interface UseDataGridReturnType { sortingColumns: EuiDataGridSorting['columns']; status: INDEX_STATUS; tableItems: DataGridItem[]; + toggleChartVisibility: () => void; visibleColumns: ColumnId[]; } diff --git a/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.tsx b/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.tsx new file mode 100644 index 000000000000..6b207a999eb5 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.tsx @@ -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(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; +const getAggIntervals = async ( + indexPatternTitle: string, + esSearch: (payload: any) => Promise, + query: any, + columnTypes: EuiDataGridColumn[] +): Promise => { + 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); + + 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, + query: any, + columnTypes: EuiDataGridColumn[] +): Promise => { + 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); + + 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 ( + + + + {chartData.data[0] !== undefined && } + {chartData.data[1] !== undefined && } + + +
{chartData.data[0].key_as_string}{chartData.data[1].key_as_string}
+ ); + } + + 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, + }; +}; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/use_data_grid.ts b/x-pack/plugins/ml/public/application/components/data_grid/use_data_grid.tsx similarity index 79% rename from x-pack/plugins/ml/public/application/components/data_grid/use_data_grid.ts rename to x-pack/plugins/ml/public/application/components/data_grid/use_data_grid.tsx index 7843bf2ea801..47c84ce6cd66 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/use_data_grid.ts +++ b/x-pack/plugins/ml/public/application/components/data_grid/use_data_grid.tsx @@ -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([]); const [tableItems, setTableItems] = useState([]); const [pagination, setPagination] = useState(defaultPagination); const [sortingColumns, setSortingColumns] = useState([]); + 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 ? ( + + ) : 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, }; }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts index 0a64886c80a6..1b28875a624f 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts @@ -30,7 +30,7 @@ export interface EsDoc extends Record { _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([ diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx index e63756686a4b..2c3079e24cf1 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx @@ -87,7 +87,8 @@ export const ConfigurationStepForm: FC = ({ const indexData = useIndexData( currentIndexPattern, - savedSearchQuery !== undefined ? savedSearchQuery : jobConfigQuery + savedSearchQuery !== undefined ? savedSearchQuery : jobConfigQuery, + toastNotifications ); const indexPreviewProps = { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts index e8f25584201e..ee0e5c1955ea 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts @@ -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, }; }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/exploration_results_table.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/exploration_results_table.tsx index 105eb9f73804..941fbefd7808 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/exploration_results_table.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/exploration_results_table.tsx @@ -67,9 +67,20 @@ export const ExplorationResultsTable: FC = 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 = React.memo( - {(columns.length > 0 || searchQuery !== defaultSearchQuery) && ( + {(columnsWithCharts.length > 0 || searchQuery !== defaultSearchQuery) && ( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts index b8b5a16c84e8..796670f6a864 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts @@ -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, }; }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx index 917ab1b0ed1d..0b29b7f43bfc 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx @@ -53,7 +53,7 @@ export const OutlierExploration: FC = React.memo(({ jobId }) = const [searchQuery, setSearchQuery] = useState(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 = React.memo(({ jobId }) = )} - {(columns.length > 0 || searchQuery !== defaultSearchQuery) && indexPattern !== undefined && ( - <> - - - - - - - 0 || searchQuery !== defaultSearchQuery) && + indexPattern !== undefined && ( + <> + + + + + + + + + + + {columnsWithCharts.length > 0 && tableItems.length > 0 && ( + - - - - {columns.length > 0 && tableItems.length > 0 && ( - - )} - - )} + )} + + )} ); }); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts index 37ab67e2a33c..beb6836bf801 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts @@ -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, }; }; diff --git a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts index e1aa27df5e97..c821c183ad37 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts @@ -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, }; }; diff --git a/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts b/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts index 13544b80ed1b..b073dace3034 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts @@ -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, }; }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_preview_pane.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_preview_pane.tsx index e183712b390c..a917fc73ad8f 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_preview_pane.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_preview_pane.tsx @@ -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 = ({ 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 ( diff --git a/x-pack/plugins/transform/public/shared_imports.ts b/x-pack/plugins/transform/public/shared_imports.ts index ca3fb52cc02c..f50ae9274fa4 100644 --- a/x-pack/plugins/transform/public/shared_imports.ts +++ b/x-pack/plugins/transform/public/shared_imports.ts @@ -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, diff --git a/x-pack/test/functional/apps/transform/creation_index_pattern.ts b/x-pack/test/functional/apps/transform/creation_index_pattern.ts index 61c4776e2040..55b2f4a0220a 100644 --- a/x-pack/test/functional/apps/transform/creation_index_pattern.ts +++ b/x-pack/test/functional/apps/transform/creation_index_pattern.ts @@ -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 diff --git a/x-pack/test/functional/services/transform/wizard.ts b/x-pack/test/functional/services/transform/wizard.ts index 8b61e8c895e3..9cfdbadac8a3 100644 --- a/x-pack/test/functional/services/transform/wizard.ts +++ b/x-pack/test/functional/services/transform/wizard.ts @@ -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'); },