[ES|QL] Adds the ability to breakdown the histogram in Discover (#193820)

## Summary

Part of https://github.com/elastic/kibana/issues/186369

It enables the users to breakdown the histogram visualization in
Discover.


![meow](https://github.com/user-attachments/assets/d5fdaa41-0a69-4caf-9da2-1221dcfd5ce2)


### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Stratoula Kalafateli 2024-10-01 11:29:32 +02:00 committed by GitHub
parent 8776fe588d
commit dfe00f20dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 453 additions and 32 deletions

View file

@ -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(
<BreakdownFieldSelector
dataView={dataViewWithTimefieldMock}
breakdown={breakdown}
onBreakdownFieldChange={onBreakdownFieldChange}
esqlColumns={[
{
name: 'bytes',
meta: { type: 'number' },
id: 'bytes',
},
{
name: 'extension',
meta: { type: 'string' },
id: 'extension',
},
]}
/>
);
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(
<BreakdownFieldSelector
dataView={dataViewWithTimefieldMock}
breakdown={breakdown}
onBreakdownFieldChange={onBreakdownFieldChange}
esqlColumns={esqlColumns}
/>
);
act(() => {
screen.getByTestId('unifiedHistogramBreakdownSelectorButton').click();
});
act(() => {
screen.getByTitle('bytes').click();
});
expect(onBreakdownFieldChange).toHaveBeenCalledWith(selectedField);
});
});

View file

@ -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<NonNullable<ToolbarSelectorProps['onChange']>>(
(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 (

View file

@ -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}
/>
)}
</div>

View file

@ -147,6 +147,7 @@ export const UnifiedHistogramContainer = forwardRef<
query,
searchSessionId,
requestAdapter,
columns: containerProps.columns,
});
const handleVisContextChange: UnifiedHistogramLayoutProps['onVisContextChanged'] | undefined =

View file

@ -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,87 @@ 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,
currentSuggestionContext: undefined,
},
});
const { result } = renderHook(() =>
useStateProps({
stateService,
dataView: dataViewWithTimefieldMock,
query: { esql: 'FROM index | keep field1' },
requestAdapter: new RequestAdapter(),
searchSessionId: '123',
columns: undefined,
})
);
expect(result.current.chart).toStrictEqual({ hidden: false, timeInterval: 'auto' });
expect(result.current.breakdown).toBe(undefined);
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,
@ -236,11 +320,14 @@ describe('useStateProps', () => {
query: { esql: 'FROM index' },
requestAdapter: new RequestAdapter(),
searchSessionId: '123',
columns: esqlColumns,
})
);
expect(result.current.chart).toStrictEqual({ hidden: false, timeInterval: 'auto' });
expect(result.current.breakdown).toBe(undefined);
expect(result.current.isPlainRecord).toBe(true);
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', () => {
@ -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<typeof useStateProps>[0]) => useStateProps(props), {
initialProps,

View file

@ -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 {

View file

@ -374,6 +374,7 @@ export const UnifiedHistogramLayout = ({
lensAdapters={lensAdapters}
lensEmbeddableOutput$={lensEmbeddableOutput$}
withDefaultActions={withDefaultActions}
columns={columns}
/>
</InPortal>
<InPortal node={mainPanelNode}>

View file

@ -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);
});
});

View file

@ -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;

View file

@ -33,6 +33,7 @@
"@kbn/discover-utils",
"@kbn/visualization-utils",
"@kbn/search-types",
"@kbn/data-view-utils",
],
"exclude": [
"target/**/*",

View file

@ -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']);
});
});
});
}

View file

@ -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');

View file

@ -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,