mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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.  ### 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:
parent
8776fe588d
commit
dfe00f20dd
13 changed files with 453 additions and 32 deletions
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -147,6 +147,7 @@ export const UnifiedHistogramContainer = forwardRef<
|
|||
query,
|
||||
searchSessionId,
|
||||
requestAdapter,
|
||||
columns: containerProps.columns,
|
||||
});
|
||||
|
||||
const handleVisContextChange: UnifiedHistogramLayoutProps['onVisContextChanged'] | undefined =
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -374,6 +374,7 @@ export const UnifiedHistogramLayout = ({
|
|||
lensAdapters={lensAdapters}
|
||||
lensEmbeddableOutput$={lensEmbeddableOutput$}
|
||||
withDefaultActions={withDefaultActions}
|
||||
columns={columns}
|
||||
/>
|
||||
</InPortal>
|
||||
<InPortal node={mainPanelNode}>
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -33,6 +33,7 @@
|
|||
"@kbn/discover-utils",
|
||||
"@kbn/visualization-utils",
|
||||
"@kbn/search-types",
|
||||
"@kbn/data-view-utils",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue