From 00bc98057ea289d832ae299124a7962521901265 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 1 Oct 2024 22:46:57 +1000 Subject: [PATCH] [8.x] [ES|QL] Adds the ability to breakdown the histogram in Discover (#193820) (#194534) # Backport This will backport the following commits from `main` to `8.x`: - [[ES|QL] Adds the ability to breakdown the histogram in Discover (#193820)](https://github.com/elastic/kibana/pull/193820) ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) Co-authored-by: Stratoula Kalafateli --- .../chart/breakdown_field_selector.test.tsx | 109 +++++++++++++++++- .../public/chart/breakdown_field_selector.tsx | 33 ++++-- .../unified_histogram/public/chart/chart.tsx | 4 + .../public/container/container.tsx | 1 + .../container/hooks/use_state_props.test.ts | 98 +++++++++++++++- .../public/container/hooks/use_state_props.ts | 24 +++- .../public/layout/layout.tsx | 1 + .../lens_vis_service.suggestions.test.ts | 52 +++++++++ .../public/services/lens_vis_service.ts | 71 ++++++++++-- src/plugins/unified_histogram/tsconfig.json | 1 + .../apps/discover/esql/_esql_view.ts | 58 ++++++++++ .../apps/discover/group3/_lens_vis.ts | 20 +++- .../lens/public/embeddable/embeddable.tsx | 9 +- 13 files changed, 451 insertions(+), 30 deletions(-) diff --git a/src/plugins/unified_histogram/public/chart/breakdown_field_selector.test.tsx b/src/plugins/unified_histogram/public/chart/breakdown_field_selector.test.tsx index 5b39fd0482bb49..4342c00c988543 100644 --- a/src/plugins/unified_histogram/public/chart/breakdown_field_selector.test.tsx +++ b/src/plugins/unified_histogram/public/chart/breakdown_field_selector.test.tsx @@ -9,12 +9,15 @@ import { render, act, screen } from '@testing-library/react'; import React from 'react'; +import type { DatatableColumn } from '@kbn/expressions-plugin/common'; +import { convertDatatableColumnToDataViewFieldSpec } from '@kbn/data-view-utils'; +import { DataViewField } from '@kbn/data-views-plugin/common'; import { UnifiedHistogramBreakdownContext } from '../types'; import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield'; import { BreakdownFieldSelector } from './breakdown_field_selector'; describe('BreakdownFieldSelector', () => { - it('should render correctly', () => { + it('should render correctly for dataview fields', () => { const onBreakdownFieldChange = jest.fn(); const breakdown: UnifiedHistogramBreakdownContext = { field: undefined, @@ -63,6 +66,67 @@ describe('BreakdownFieldSelector', () => { `); }); + it('should render correctly for ES|QL columns', () => { + const onBreakdownFieldChange = jest.fn(); + const breakdown: UnifiedHistogramBreakdownContext = { + field: undefined, + }; + + render( + + ); + + const button = screen.getByTestId('unifiedHistogramBreakdownSelectorButton'); + expect(button.getAttribute('data-selected-value')).toBe(null); + + act(() => { + button.click(); + }); + + const options = screen.getAllByRole('option'); + expect( + options.map((option) => ({ + label: option.getAttribute('title'), + value: option.getAttribute('value'), + checked: option.getAttribute('aria-checked'), + })) + ).toMatchInlineSnapshot(` + Array [ + Object { + "checked": "true", + "label": "No breakdown", + "value": "__EMPTY_SELECTOR_OPTION__", + }, + Object { + "checked": "false", + "label": "bytes", + "value": "bytes", + }, + Object { + "checked": "false", + "label": "extension", + "value": "extension", + }, + ] + `); + }); + it('should mark the option as checked if breakdown.field is defined', () => { const onBreakdownFieldChange = jest.fn(); const field = dataViewWithTimefieldMock.fields.find((f) => f.name === 'extension')!; @@ -111,7 +175,7 @@ describe('BreakdownFieldSelector', () => { `); }); - it('should call onBreakdownFieldChange with the selected field when the user selects a field', () => { + it('should call onBreakdownFieldChange with the selected field when the user selects a dataview field', () => { const onBreakdownFieldChange = jest.fn(); const selectedField = dataViewWithTimefieldMock.fields.find((f) => f.name === 'bytes')!; const breakdown: UnifiedHistogramBreakdownContext = { @@ -135,4 +199,45 @@ describe('BreakdownFieldSelector', () => { expect(onBreakdownFieldChange).toHaveBeenCalledWith(selectedField); }); + + it('should call onBreakdownFieldChange with the selected field when the user selects an ES|QL field', () => { + const onBreakdownFieldChange = jest.fn(); + const esqlColumns = [ + { + name: 'bytes', + meta: { type: 'number' }, + id: 'bytes', + }, + { + name: 'extension', + meta: { type: 'string' }, + id: 'extension', + }, + ] as DatatableColumn[]; + const breakdownColumn = esqlColumns.find((c) => c.name === 'bytes')!; + const selectedField = new DataViewField( + convertDatatableColumnToDataViewFieldSpec(breakdownColumn) + ); + const breakdown: UnifiedHistogramBreakdownContext = { + field: undefined, + }; + render( + + ); + + act(() => { + screen.getByTestId('unifiedHistogramBreakdownSelectorButton').click(); + }); + + act(() => { + screen.getByTitle('bytes').click(); + }); + + expect(onBreakdownFieldChange).toHaveBeenCalledWith(selectedField); + }); }); diff --git a/src/plugins/unified_histogram/public/chart/breakdown_field_selector.tsx b/src/plugins/unified_histogram/public/chart/breakdown_field_selector.tsx index 7d29827a1389b7..b3c49e27c60115 100644 --- a/src/plugins/unified_histogram/public/chart/breakdown_field_selector.tsx +++ b/src/plugins/unified_histogram/public/chart/breakdown_field_selector.tsx @@ -11,7 +11,9 @@ import React, { useCallback, useMemo } from 'react'; import { EuiSelectableOption } from '@elastic/eui'; import { FieldIcon, getFieldIconProps, comboBoxFieldOptionMatcher } from '@kbn/field-utils'; import { css } from '@emotion/react'; -import type { DataView, DataViewField } from '@kbn/data-views-plugin/common'; +import { type DataView, DataViewField } from '@kbn/data-views-plugin/common'; +import type { DatatableColumn } from '@kbn/expressions-plugin/common'; +import { convertDatatableColumnToDataViewFieldSpec } from '@kbn/data-view-utils'; import { i18n } from '@kbn/i18n'; import { UnifiedHistogramBreakdownContext } from '../types'; import { fieldSupportsBreakdown } from '../utils/field_supports_breakdown'; @@ -25,17 +27,32 @@ import { export interface BreakdownFieldSelectorProps { dataView: DataView; breakdown: UnifiedHistogramBreakdownContext; + esqlColumns?: DatatableColumn[]; onBreakdownFieldChange?: (breakdownField: DataViewField | undefined) => void; } +const mapToDropdownFields = (dataView: DataView, esqlColumns?: DatatableColumn[]) => { + if (esqlColumns) { + return ( + esqlColumns + .map((column) => new DataViewField(convertDatatableColumnToDataViewFieldSpec(column))) + // filter out unsupported field types + .filter((field) => field.type !== 'unknown') + ); + } + + return dataView.fields.filter(fieldSupportsBreakdown); +}; + export const BreakdownFieldSelector = ({ dataView, breakdown, + esqlColumns, onBreakdownFieldChange, }: BreakdownFieldSelectorProps) => { + const fields = useMemo(() => mapToDropdownFields(dataView, esqlColumns), [dataView, esqlColumns]); const fieldOptions: SelectableEntry[] = useMemo(() => { - const options: SelectableEntry[] = dataView.fields - .filter(fieldSupportsBreakdown) + const options: SelectableEntry[] = fields .map((field) => ({ key: field.name, name: field.name, @@ -69,16 +86,16 @@ export const BreakdownFieldSelector = ({ }); return options; - }, [dataView, breakdown.field]); + }, [fields, breakdown?.field]); const onChange = useCallback>( (chosenOption) => { - const field = chosenOption?.value - ? dataView.fields.find((currentField) => currentField.name === chosenOption.value) + const breakdownField = chosenOption?.value + ? fields.find((currentField) => currentField.name === chosenOption.value) : undefined; - onBreakdownFieldChange?.(field); + onBreakdownFieldChange?.(breakdownField); }, - [dataView.fields, onBreakdownFieldChange] + [fields, onBreakdownFieldChange] ); return ( diff --git a/src/plugins/unified_histogram/public/chart/chart.tsx b/src/plugins/unified_histogram/public/chart/chart.tsx index 42046582734472..4fb1b9cbe6471a 100644 --- a/src/plugins/unified_histogram/public/chart/chart.tsx +++ b/src/plugins/unified_histogram/public/chart/chart.tsx @@ -19,6 +19,7 @@ import type { LensEmbeddableInput, LensEmbeddableOutput, } from '@kbn/lens-plugin/public'; +import type { DatatableColumn } from '@kbn/expressions-plugin/common'; import type { DataView, DataViewField } from '@kbn/data-views-plugin/public'; import type { TimeRange } from '@kbn/es-query'; import { Histogram } from './histogram'; @@ -79,6 +80,7 @@ export interface ChartProps { onFilter?: LensEmbeddableInput['onFilter']; onBrushEnd?: LensEmbeddableInput['onBrushEnd']; withDefaultActions: EmbeddableComponentProps['withDefaultActions']; + columns?: DatatableColumn[]; } const HistogramMemoized = memo(Histogram); @@ -114,6 +116,7 @@ export function Chart({ onBrushEnd, withDefaultActions, abortController, + columns, }: ChartProps) { const lensVisServiceCurrentSuggestionContext = useObservable( lensVisService.currentSuggestionContext$ @@ -312,6 +315,7 @@ export function Chart({ dataView={dataView} breakdown={breakdown} onBreakdownFieldChange={onBreakdownFieldChange} + esqlColumns={isPlainRecord ? columns : undefined} /> )} diff --git a/src/plugins/unified_histogram/public/container/container.tsx b/src/plugins/unified_histogram/public/container/container.tsx index a4231529a629be..15367ae51d9b52 100644 --- a/src/plugins/unified_histogram/public/container/container.tsx +++ b/src/plugins/unified_histogram/public/container/container.tsx @@ -147,6 +147,7 @@ export const UnifiedHistogramContainer = forwardRef< query, searchSessionId, requestAdapter, + columns: containerProps.columns, }); const handleVisContextChange: UnifiedHistogramLayoutProps['onVisContextChanged'] | undefined = diff --git a/src/plugins/unified_histogram/public/container/hooks/use_state_props.test.ts b/src/plugins/unified_histogram/public/container/hooks/use_state_props.test.ts index e109b5339e728e..44a36be34d1abc 100644 --- a/src/plugins/unified_histogram/public/container/hooks/use_state_props.test.ts +++ b/src/plugins/unified_histogram/public/container/hooks/use_state_props.test.ts @@ -11,6 +11,8 @@ import { DataView, DataViewField, DataViewType } from '@kbn/data-views-plugin/co import { RequestAdapter } from '@kbn/inspector-plugin/common'; import { renderHook } from '@testing-library/react-hooks'; import { act } from 'react-test-renderer'; +import type { DatatableColumn } from '@kbn/expressions-plugin/common'; +import { convertDatatableColumnToDataViewFieldSpec } from '@kbn/data-view-utils'; import { UnifiedHistogramFetchStatus, UnifiedHistogramSuggestionContext } from '../../types'; import { dataViewMock } from '../../__mocks__/data_view'; import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield'; @@ -60,6 +62,7 @@ describe('useStateProps', () => { query: { language: 'kuery', query: '' }, requestAdapter: new RequestAdapter(), searchSessionId: '123', + columns: undefined, }) ); expect(result.current).toMatchInlineSnapshot(` @@ -150,11 +153,14 @@ describe('useStateProps', () => { query: { esql: 'FROM index' }, requestAdapter: new RequestAdapter(), searchSessionId: '123', + columns: undefined, }) ); expect(result.current).toMatchInlineSnapshot(` Object { - "breakdown": undefined, + "breakdown": Object { + "field": undefined, + }, "chart": Object { "hidden": false, "timeInterval": "auto", @@ -220,9 +226,13 @@ describe('useStateProps', () => { }, } `); + + expect(result.current.chart).toStrictEqual({ hidden: false, timeInterval: 'auto' }); + expect(result.current.breakdown).toStrictEqual({ field: undefined }); + expect(result.current.isPlainRecord).toBe(true); }); - it('should return the correct props when a text based language is used', () => { + it('should return the correct props when an ES|QL query is used with transformational commands', () => { const stateService = getStateService({ initialState: { ...initialState, @@ -233,9 +243,10 @@ describe('useStateProps', () => { useStateProps({ stateService, dataView: dataViewWithTimefieldMock, - query: { esql: 'FROM index' }, + query: { esql: 'FROM index | keep field1' }, requestAdapter: new RequestAdapter(), searchSessionId: '123', + columns: undefined, }) ); expect(result.current.chart).toStrictEqual({ hidden: false, timeInterval: 'auto' }); @@ -243,6 +254,82 @@ describe('useStateProps', () => { expect(result.current.isPlainRecord).toBe(true); }); + it('should return the correct props when an ES|QL query is used with breakdown field', () => { + const breakdownField = 'extension'; + const esqlColumns = [ + { + name: 'bytes', + meta: { type: 'number' }, + id: 'bytes', + }, + { + name: 'extension', + meta: { type: 'string' }, + id: 'extension', + }, + ] as DatatableColumn[]; + const stateService = getStateService({ + initialState: { + ...initialState, + currentSuggestionContext: undefined, + breakdownField, + }, + }); + const { result } = renderHook(() => + useStateProps({ + stateService, + dataView: dataViewWithTimefieldMock, + query: { esql: 'FROM index' }, + requestAdapter: new RequestAdapter(), + searchSessionId: '123', + columns: esqlColumns, + }) + ); + + const breakdownColumn = esqlColumns.find((c) => c.name === breakdownField)!; + const selectedField = new DataViewField( + convertDatatableColumnToDataViewFieldSpec(breakdownColumn) + ); + expect(result.current.breakdown).toStrictEqual({ field: selectedField }); + }); + + it('should call the setBreakdown cb when an ES|QL query is used', () => { + const breakdownField = 'extension'; + const esqlColumns = [ + { + name: 'bytes', + meta: { type: 'number' }, + id: 'bytes', + }, + { + name: 'extension', + meta: { type: 'string' }, + id: 'extension', + }, + ] as DatatableColumn[]; + const stateService = getStateService({ + initialState: { + ...initialState, + currentSuggestionContext: undefined, + }, + }); + const { result } = renderHook(() => + useStateProps({ + stateService, + dataView: dataViewWithTimefieldMock, + query: { esql: 'FROM index' }, + requestAdapter: new RequestAdapter(), + searchSessionId: '123', + columns: esqlColumns, + }) + ); + const { onBreakdownFieldChange } = result.current; + act(() => { + onBreakdownFieldChange({ name: breakdownField } as DataViewField); + }); + expect(stateService.setBreakdownField).toHaveBeenLastCalledWith(breakdownField); + }); + it('should return the correct props when a rollup data view is used', () => { const stateService = getStateService({ initialState }); const { result } = renderHook(() => @@ -255,6 +342,7 @@ describe('useStateProps', () => { query: { language: 'kuery', query: '' }, requestAdapter: new RequestAdapter(), searchSessionId: '123', + columns: undefined, }) ); expect(result.current).toMatchInlineSnapshot(` @@ -333,6 +421,7 @@ describe('useStateProps', () => { query: { language: 'kuery', query: '' }, requestAdapter: new RequestAdapter(), searchSessionId: '123', + columns: undefined, }) ); expect(result.current).toMatchInlineSnapshot(` @@ -411,6 +500,7 @@ describe('useStateProps', () => { query: { language: 'kuery', query: '' }, requestAdapter: new RequestAdapter(), searchSessionId: '123', + columns: undefined, }) ); const { @@ -470,6 +560,7 @@ describe('useStateProps', () => { query: { language: 'kuery', query: '' }, requestAdapter: new RequestAdapter(), searchSessionId: '123', + columns: undefined, }) ); (stateService.setLensRequestAdapter as jest.Mock).mockClear(); @@ -489,6 +580,7 @@ describe('useStateProps', () => { query: { language: 'kuery', query: '' }, requestAdapter: new RequestAdapter(), searchSessionId: '123', + columns: undefined, }; const hook = renderHook((props: Parameters[0]) => useStateProps(props), { initialProps, diff --git a/src/plugins/unified_histogram/public/container/hooks/use_state_props.ts b/src/plugins/unified_histogram/public/container/hooks/use_state_props.ts index bb0e4acc81740a..fcc19fcd78a00c 100644 --- a/src/plugins/unified_histogram/public/container/hooks/use_state_props.ts +++ b/src/plugins/unified_histogram/public/container/hooks/use_state_props.ts @@ -9,7 +9,10 @@ import { DataView, DataViewField, DataViewType } from '@kbn/data-views-plugin/common'; import { AggregateQuery, isOfAggregateQueryType, Query } from '@kbn/es-query'; +import { hasTransformationalCommand } from '@kbn/esql-utils'; import type { RequestAdapter } from '@kbn/inspector-plugin/public'; +import type { DatatableColumn } from '@kbn/expressions-plugin/common'; +import { convertDatatableColumnToDataViewFieldSpec } from '@kbn/data-view-utils'; import { useCallback, useEffect, useMemo } from 'react'; import { UnifiedHistogramChartLoadEvent, @@ -34,12 +37,14 @@ export const useStateProps = ({ query, searchSessionId, requestAdapter, + columns, }: { stateService: UnifiedHistogramStateService | undefined; dataView: DataView; query: Query | AggregateQuery | undefined; searchSessionId: string | undefined; requestAdapter: RequestAdapter | undefined; + columns: DatatableColumn[] | undefined; }) => { const breakdownField = useStateSelector(stateService?.state$, breakdownFieldSelector); const chartHidden = useStateSelector(stateService?.state$, chartHiddenSelector); @@ -86,14 +91,29 @@ export const useStateProps = ({ }, [chartHidden, isPlainRecord, isTimeBased, timeInterval]); const breakdown = useMemo(() => { - if (isPlainRecord || !isTimeBased) { + if (!isTimeBased) { return undefined; } + // hide the breakdown field selector when the ES|QL query has a transformational command (STATS, KEEP etc) + if (query && isOfAggregateQueryType(query) && hasTransformationalCommand(query.esql)) { + return undefined; + } + + if (isPlainRecord) { + const breakdownColumn = columns?.find((column) => column.name === breakdownField); + const field = breakdownColumn + ? new DataViewField(convertDatatableColumnToDataViewFieldSpec(breakdownColumn)) + : undefined; + return { + field, + }; + } + return { field: breakdownField ? dataView?.getFieldByName(breakdownField) : undefined, }; - }, [breakdownField, dataView, isPlainRecord, isTimeBased]); + }, [isTimeBased, query, isPlainRecord, breakdownField, dataView, columns]); const request = useMemo(() => { return { diff --git a/src/plugins/unified_histogram/public/layout/layout.tsx b/src/plugins/unified_histogram/public/layout/layout.tsx index aac1cfe308c60c..3e34cf4ee69b35 100644 --- a/src/plugins/unified_histogram/public/layout/layout.tsx +++ b/src/plugins/unified_histogram/public/layout/layout.tsx @@ -374,6 +374,7 @@ export const UnifiedHistogramLayout = ({ lensAdapters={lensAdapters} lensEmbeddableOutput$={lensEmbeddableOutput$} withDefaultActions={withDefaultActions} + columns={columns} /> diff --git a/src/plugins/unified_histogram/public/services/lens_vis_service.suggestions.test.ts b/src/plugins/unified_histogram/public/services/lens_vis_service.suggestions.test.ts index f4128146c9f344..28819f7a5c54be 100644 --- a/src/plugins/unified_histogram/public/services/lens_vis_service.suggestions.test.ts +++ b/src/plugins/unified_histogram/public/services/lens_vis_service.suggestions.test.ts @@ -8,6 +8,7 @@ */ import type { AggregateQuery, Query } from '@kbn/es-query'; +import { DataViewField } from '@kbn/data-views-plugin/common'; import { deepMockedFields, buildDataViewMock } from '@kbn/discover-utils/src/__mocks__'; import { allSuggestionsMock } from '../__mocks__/suggestions'; import { getLensVisMock } from '../__mocks__/lens_vis'; @@ -195,4 +196,55 @@ describe('LensVisService suggestions', () => { expect(lensVis.currentSuggestionContext?.type).toBe(UnifiedHistogramSuggestionType.unsupported); expect(lensVis.currentSuggestionContext?.suggestion).not.toBeDefined(); }); + + test('should return histogramSuggestion if no suggestions returned by the api with the breakdown field if it is given', async () => { + const lensVis = await getLensVisMock({ + filters: [], + query: { esql: 'from the-data-view | limit 100' }, + dataView: dataViewMock, + timeInterval: 'auto', + timeRange: { + from: '2023-09-03T08:00:00.000Z', + to: '2023-09-04T08:56:28.274Z', + }, + breakdownField: { name: 'var0' } as DataViewField, + columns: [ + { + id: 'var0', + name: 'var0', + meta: { + type: 'number', + }, + }, + ], + isPlainRecord: true, + allSuggestions: [], + hasHistogramSuggestionForESQL: true, + }); + + expect(lensVis.currentSuggestionContext?.type).toBe( + UnifiedHistogramSuggestionType.histogramForESQL + ); + expect(lensVis.currentSuggestionContext?.suggestion).toBeDefined(); + expect(lensVis.currentSuggestionContext?.suggestion?.visualizationState).toHaveProperty( + 'layers', + [ + { + layerId: '662552df-2cdc-4539-bf3b-73b9f827252c', + seriesType: 'bar_stacked', + xAccessor: '@timestamp every 30 second', + accessors: ['results'], + layerType: 'data', + splitAccessor: 'var0', + }, + ] + ); + + const histogramQuery = { + esql: `from the-data-view | limit 100 +| EVAL timestamp=DATE_TRUNC(30 minute, @timestamp) | stats results = count(*) by timestamp, \`var0\` | sort \`var0\` asc | rename timestamp as \`@timestamp every 30 minute\``, + }; + + expect(lensVis.visContext?.attributes.state.query).toStrictEqual(histogramQuery); + }); }); diff --git a/src/plugins/unified_histogram/public/services/lens_vis_service.ts b/src/plugins/unified_histogram/public/services/lens_vis_service.ts index caadc3506ccbe3..eccfd663b2557f 100644 --- a/src/plugins/unified_histogram/public/services/lens_vis_service.ts +++ b/src/plugins/unified_histogram/public/services/lens_vis_service.ts @@ -245,7 +245,10 @@ export class LensVisService { if (queryParams.isPlainRecord) { // appends an ES|QL histogram - const histogramSuggestionForESQL = this.getHistogramSuggestionForESQL({ queryParams }); + const histogramSuggestionForESQL = this.getHistogramSuggestionForESQL({ + queryParams, + breakdownField, + }); if (histogramSuggestionForESQL) { availableSuggestionsWithType.push({ suggestion: histogramSuggestionForESQL, @@ -452,16 +455,27 @@ export class LensVisService { private getHistogramSuggestionForESQL = ({ queryParams, + breakdownField, }: { queryParams: QueryParams; + breakdownField?: DataViewField; }): Suggestion | undefined => { - const { dataView, query, timeRange } = queryParams; + const { dataView, query, timeRange, columns } = queryParams; + const breakdownColumn = breakdownField?.name + ? columns?.find((column) => column.name === breakdownField.name) + : undefined; if (dataView.isTimeBased() && query && isOfAggregateQueryType(query) && timeRange) { const isOnHistogramMode = shouldDisplayHistogram(query); if (!isOnHistogramMode) return undefined; const interval = computeInterval(timeRange, this.services.data); - const esqlQuery = this.getESQLHistogramQuery({ dataView, query, timeRange, interval }); + const esqlQuery = this.getESQLHistogramQuery({ + dataView, + query, + timeRange, + interval, + breakdownColumn, + }); const context = { dataViewSpec: dataView?.toSpec(), fieldName: '', @@ -485,9 +499,38 @@ export class LensVisService { esql: esqlQuery, }, }; + + if (breakdownColumn) { + context.textBasedColumns.push(breakdownColumn); + } const suggestions = this.lensSuggestionsApi(context, dataView, ['lnsDatatable']) ?? []; if (suggestions.length) { - return suggestions[0]; + const suggestion = suggestions[0]; + const suggestionVisualizationState = Object.assign({}, suggestion?.visualizationState); + // the suggestions api will suggest a numeric column as a metric and not as a breakdown, + // so we need to adjust it here + if ( + breakdownColumn && + breakdownColumn.meta?.type === 'number' && + suggestion && + 'layers' in suggestionVisualizationState && + Array.isArray(suggestionVisualizationState.layers) + ) { + return { + ...suggestion, + visualizationState: { + ...(suggestionVisualizationState ?? {}), + layers: suggestionVisualizationState.layers.map((layer) => { + return { + ...layer, + accessors: ['results'], + splitAccessor: breakdownColumn.name, + }; + }), + }, + }; + } + return suggestion; } } @@ -499,18 +542,23 @@ export class LensVisService { timeRange, query, interval, + breakdownColumn, }: { dataView: DataView; timeRange: TimeRange; query: AggregateQuery; interval?: string; + breakdownColumn?: DatatableColumn; }): string => { const queryInterval = interval ?? computeInterval(timeRange, this.services.data); const language = getAggregateQueryMode(query); const safeQuery = removeDropCommandsFromESQLQuery(query[language]); + const breakdown = breakdownColumn + ? `, \`${breakdownColumn.name}\` | sort \`${breakdownColumn.name}\` asc` + : ''; return appendToESQLQuery( safeQuery, - `| EVAL timestamp=DATE_TRUNC(${queryInterval}, ${dataView.timeFieldName}) | stats results = count(*) by timestamp | rename timestamp as \`${dataView.timeFieldName} every ${queryInterval}\`` + `| EVAL timestamp=DATE_TRUNC(${queryInterval}, ${dataView.timeFieldName}) | stats results = count(*) by timestamp${breakdown} | rename timestamp as \`${dataView.timeFieldName} every ${queryInterval}\`` ); }; @@ -548,7 +596,7 @@ export class LensVisService { externalVisContextStatus: UnifiedHistogramExternalVisContextStatus; visContext: UnifiedHistogramVisContext | undefined; } => { - const { dataView, query, filters, timeRange } = queryParams; + const { dataView, query, filters, timeRange, columns } = queryParams; const { type: suggestionType, suggestion } = currentSuggestionContext; if (!suggestion || !suggestion.datasourceId || !query || !filters) { @@ -563,13 +611,20 @@ export class LensVisService { dataViewId: dataView.id, timeField: dataView.timeFieldName, timeInterval: isTextBased ? undefined : timeInterval, - breakdownField: isTextBased ? undefined : breakdownField?.name, + breakdownField: breakdownField?.name, }; const currentQuery = suggestionType === UnifiedHistogramSuggestionType.histogramForESQL && isTextBased && timeRange ? { - esql: this.getESQLHistogramQuery({ dataView, query, timeRange }), + esql: this.getESQLHistogramQuery({ + dataView, + query, + timeRange, + breakdownColumn: breakdownField?.name + ? columns?.find((column) => column.name === breakdownField.name) + : undefined, + }), } : query; diff --git a/src/plugins/unified_histogram/tsconfig.json b/src/plugins/unified_histogram/tsconfig.json index 2f54a5d33797a9..d14adf53889b92 100644 --- a/src/plugins/unified_histogram/tsconfig.json +++ b/src/plugins/unified_histogram/tsconfig.json @@ -33,6 +33,7 @@ "@kbn/discover-utils", "@kbn/visualization-utils", "@kbn/search-types", + "@kbn/data-view-utils", ], "exclude": [ "target/**/*", diff --git a/test/functional/apps/discover/esql/_esql_view.ts b/test/functional/apps/discover/esql/_esql_view.ts index 01660925db7998..760827816db964 100644 --- a/test/functional/apps/discover/esql/_esql_view.ts +++ b/test/functional/apps/discover/esql/_esql_view.ts @@ -675,5 +675,63 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); }); }); + + describe('histogram breakdown', () => { + before(async () => { + await common.navigateToApp('discover'); + await timePicker.setDefaultAbsoluteRange(); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + }); + + it('should choose breakdown field', async () => { + await discover.selectTextBaseLang(); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + + const testQuery = 'from logstash-*'; + await monacoEditor.setCodeEditorValue(testQuery); + await testSubjects.click('querySubmitButton'); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + + await discover.chooseBreakdownField('extension'); + await header.waitUntilLoadingHasFinished(); + const list = await discover.getHistogramLegendList(); + expect(list).to.eql(['css', 'gif', 'jpg', 'php', 'png']); + }); + + it('should add filter using histogram legend values', async () => { + await discover.clickLegendFilter('png', '+'); + await header.waitUntilLoadingHasFinished(); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + await unifiedFieldList.waitUntilSidebarHasLoaded(); + + const editorValue = await monacoEditor.getCodeEditorValue(); + expect(editorValue).to.eql(`from logstash-*\n| WHERE \`extension\`=="png"`); + }); + + it('should save breakdown field in saved search', async () => { + // revert the filter + const testQuery = 'from logstash-*'; + await monacoEditor.setCodeEditorValue(testQuery); + await testSubjects.click('querySubmitButton'); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + + await discover.saveSearch('esql view with breakdown'); + + await discover.clickNewSearchButton(); + await header.waitUntilLoadingHasFinished(); + const prevList = await discover.getHistogramLegendList(); + expect(prevList).to.eql([]); + + await discover.loadSavedSearch('esql view with breakdown'); + await header.waitUntilLoadingHasFinished(); + const list = await discover.getHistogramLegendList(); + expect(list).to.eql(['css', 'gif', 'jpg', 'php', 'png']); + }); + }); }); } diff --git a/test/functional/apps/discover/group3/_lens_vis.ts b/test/functional/apps/discover/group3/_lens_vis.ts index b5907a97bb5abc..1bd6f8099fd221 100644 --- a/test/functional/apps/discover/group3/_lens_vis.ts +++ b/test/functional/apps/discover/group3/_lens_vis.ts @@ -56,7 +56,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await discover.getHitCount()).to.be(totalCount); } - async function checkESQLHistogramVis(timespan: string, totalCount: string) { + async function checkESQLHistogramVis( + timespan: string, + totalCount: string, + hasTransformationalCommand = false + ) { await header.waitUntilLoadingHasFinished(); await discover.waitUntilSearchingHasFinished(); @@ -64,7 +68,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.existOrFail('unifiedHistogramSaveVisualization'); await testSubjects.existOrFail('unifiedHistogramEditFlyoutVisualization'); await testSubjects.missingOrFail('unifiedHistogramEditVisualization'); - await testSubjects.missingOrFail('unifiedHistogramBreakdownSelectorButton'); + if (hasTransformationalCommand) { + await testSubjects.missingOrFail('unifiedHistogramBreakdownSelectorButton'); + } else { + await testSubjects.existOrFail('unifiedHistogramBreakdownSelectorButton'); + } await testSubjects.missingOrFail('unifiedHistogramTimeIntervalSelectorButton'); expect(await discover.getChartTimespan()).to.be(timespan); expect(await discover.getHitCount()).to.be(totalCount); @@ -310,7 +318,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await header.waitUntilLoadingHasFinished(); await discover.waitUntilSearchingHasFinished(); - await checkESQLHistogramVis(defaultTimespanESQL, '5'); + await checkESQLHistogramVis(defaultTimespanESQL, '5', true); await discover.chooseLensSuggestion('pie'); await testSubjects.existOrFail('unsavedChangesBadge'); @@ -359,7 +367,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await header.waitUntilLoadingHasFinished(); await discover.waitUntilSearchingHasFinished(); - await checkESQLHistogramVis(defaultTimespanESQL, '5'); + await checkESQLHistogramVis(defaultTimespanESQL, '5', true); await discover.chooseLensSuggestion('pie'); await testSubjects.existOrFail('unsavedChangesBadge'); @@ -412,7 +420,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await header.waitUntilLoadingHasFinished(); await discover.waitUntilSearchingHasFinished(); - await checkESQLHistogramVis(defaultTimespanESQL, '5'); + await checkESQLHistogramVis(defaultTimespanESQL, '5', true); await discover.chooseLensSuggestion('pie'); await testSubjects.existOrFail('unsavedChangesBadge'); @@ -456,7 +464,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await header.waitUntilLoadingHasFinished(); await discover.waitUntilSearchingHasFinished(); - await checkESQLHistogramVis(defaultTimespanESQL, '5'); + await checkESQLHistogramVis(defaultTimespanESQL, '5', true); await discover.chooseLensSuggestion('pie'); await discover.saveSearch('testCustomESQLVis'); diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.tsx index 51bcbb4fed635e..2d8dad7571c1a0 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable.tsx @@ -24,6 +24,7 @@ import { getAggregateQueryMode, ExecutionContextSearch, getLanguageDisplayName, + isOfAggregateQueryType, } from '@kbn/es-query'; import type { PaletteOutput } from '@kbn/coloring'; import { @@ -1406,7 +1407,13 @@ export class Embeddable } else if (isLensTableRowContextMenuClickEvent(event)) { eventHandler = this.input.onTableRowClick; } - const esqlQuery = this.isTextBasedLanguage() ? this.savedVis?.state.query : undefined; + // if the embeddable is located in an app where there is the Unified search bar with the ES|QL editor, then use this query + // otherwise use the query from the saved object + let esqlQuery: AggregateQuery | Query | undefined; + if (this.isTextBasedLanguage()) { + const query = this.deps.data.query.queryString.getQuery(); + esqlQuery = isOfAggregateQueryType(query) ? query : this.savedVis?.state.query; + } eventHandler?.({ ...event.data,