[Textbased languages] Render Lens suggestions in Discover (#151581)

## Summary

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

This PR adds the unified histogram on the text based mode in Discover
and depicts Lens suggestions to the users. The users can navigate from
it to Lens for further exploration and adding the viz into a dashboard.


![discover](https://user-images.githubusercontent.com/17003240/222077814-33422fe1-4dc1-4861-b9af-681062412b59.gif)

Some notes:
- Lens now exposes a new api to fetch the suggestions from a text based
query. Later it can also be used to return suggestions for the dataview
mode (the other part of the aforementioned issue)
- Lens visualizations have been updated to return the correct
previewIcon (the majority of them were using the empty icon and not the
icon that represents them). This icon appears on the visualization
selection dropdown
- When Lens suggests a table, then the chart is not displayed (Discover
already offers a table view)
- The legacy metric is excluded from the suggestions as it is not
compatible with the text based languages.
- The text based languages are treated a bit differently on the unified
histogram as they do not work with search sessions.
- This feature is on technical preview, so we can iterate more on that
on later esql milestones. This is the first iteration required for the
milestone 1.
- The ESQL search strategy has a default size of 1000
https://github.com/elastic/kibana/blob/main/src/plugins/data/common/search/expressions/essql.ts#L113
which means that this is the max rows that we retrieve. I am keeping the
default as is irrelevant from the PR. In ESQL this limitation doesn't
exist so I think we are fine.

**Flaky test runner**:
https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/1972

### 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]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [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] 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: Davis McPhee <davis.mcphee@elastic.co>
This commit is contained in:
Stratoula Kalafateli 2023-03-27 18:32:15 +03:00 committed by GitHub
parent a5bf13f453
commit dec52ef09d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
62 changed files with 1864 additions and 227 deletions

View file

@ -109,6 +109,7 @@ export const buildDataViewMock = ({
getFormatterForField: jest.fn(() => ({ convert: (value: unknown) => value })),
isTimeNanosBased: () => false,
isPersisted: () => true,
toSpec: () => ({}),
getTimeField: () => {
return dataViewFields.find((field) => field.name === timeFieldName);
},

View file

@ -57,6 +57,14 @@ export function createDiscoverServicesMock(): DiscoverServices {
},
}));
dataPlugin.dataViews = createDiscoverDataViewsMock();
expressionsPlugin.run = jest.fn(() =>
of({
partial: false,
result: {
rows: [],
},
})
) as unknown as typeof expressionsPlugin.run;
return {
core: coreMock.createStart(),
@ -152,7 +160,14 @@ export function createDiscoverServicesMock(): DiscoverServices {
savedObjectsTagging: {},
dataViews: dataPlugin.dataViews,
timefilter: dataPlugin.query.timefilter.timefilter,
lens: { EmbeddableComponent: jest.fn(() => null) },
lens: {
EmbeddableComponent: jest.fn(() => null),
stateHelperApi: jest.fn(() => {
return {
suggestions: jest.fn(),
};
}),
},
locator: {
useUrl: jest.fn(() => ''),
navigate: jest.fn(),

View file

@ -46,6 +46,7 @@ export const DiscoverHistogramLayout = ({
inspectorAdapters,
savedSearchFetch$: stateContainer.dataState.fetch$,
searchSessionId,
isPlainRecord,
...commonProps,
});

View file

@ -163,20 +163,6 @@ describe('Discover component', () => {
).not.toBeNull();
}, 10000);
test('sql query displays no chart toggle', async () => {
const container = document.createElement('div');
await mountComponent(
dataViewWithTimefieldMock,
false,
{ attachTo: container },
{ sql: 'SELECT * FROM test' },
true
);
expect(
container.querySelector('[data-test-subj="unifiedHistogramChartOptionsToggle"]')
).toBeNull();
});
test('the saved search title h1 gains focus on navigate', async () => {
const container = document.createElement('div');
document.body.appendChild(container);

View file

@ -72,12 +72,12 @@ export const DiscoverMainContent = ({
gutterSize="none"
responsive={false}
>
{!isPlainRecord && (
<EuiFlexItem grow={false}>
<EuiHorizontalRule margin="none" />
<EuiFlexItem grow={false}>
<EuiHorizontalRule margin="none" />
{!isPlainRecord && (
<DocumentViewModeToggle viewMode={viewMode} setDiscoverViewMode={setDiscoverViewMode} />
</EuiFlexItem>
)}
)}
</EuiFlexItem>
{dataState.error && (
<ErrorCallout
title={i18n.translate('discover.documentsErrorTitle', {

View file

@ -7,6 +7,7 @@
*/
import React, { ReactElement } from 'react';
import { AggregateQuery, Query } from '@kbn/es-query';
import { buildDataTableRecord } from '../../../../utils/build_data_record';
import { esHits } from '../../../../__mocks__/es_hits';
import { act, renderHook, WrapperComponent } from '@testing-library/react-hooks';
@ -38,11 +39,11 @@ import { checkHitCount, sendErrorTo } from '../../hooks/use_saved_search_message
import type { InspectorAdapters } from '../../hooks/use_inspector';
const mockData = dataPluginMock.createStartContract();
const mockQueryState = {
let mockQueryState = {
query: {
query: 'query',
language: 'kuery',
},
} as Query | AggregateQuery,
filters: [],
time: {
from: 'now-15m',
@ -112,6 +113,11 @@ describe('useDiscoverHistogram', () => {
foundDocuments: true,
}) as DataMain$,
savedSearchFetch$ = new Subject() as DataFetch$,
documents$ = new BehaviorSubject({
fetchStatus: FetchStatus.COMPLETE,
result: esHits.map((esHit) => buildDataTableRecord(esHit, dataViewWithTimefieldMock)),
}) as DataDocuments$,
isPlainRecord = false,
}: {
stateContainer?: DiscoverStateContainer;
searchSessionId?: string;
@ -119,12 +125,9 @@ describe('useDiscoverHistogram', () => {
totalHits$?: DataTotalHits$;
main$?: DataMain$;
savedSearchFetch$?: DataFetch$;
documents$?: DataDocuments$;
isPlainRecord?: boolean;
} = {}) => {
const documents$ = new BehaviorSubject({
fetchStatus: FetchStatus.COMPLETE,
result: esHits.map((esHit) => buildDataTableRecord(esHit, dataViewWithTimefieldMock)),
}) as DataDocuments$;
const availableFields$ = new BehaviorSubject({
fetchStatus: FetchStatus.COMPLETE,
fields: [] as string[],
@ -144,6 +147,7 @@ describe('useDiscoverHistogram', () => {
dataView: dataViewWithTimefieldMock,
inspectorAdapters,
searchSessionId,
isPlainRecord,
};
const Wrapper: WrapperComponent<UseDiscoverHistogramProps> = ({ children }) => (
@ -186,6 +190,7 @@ describe('useDiscoverHistogram', () => {
'timeRange',
'chartHidden',
'timeInterval',
'columns',
'breakdownField',
'searchSessionId',
'totalHitsStatus',
@ -196,6 +201,9 @@ describe('useDiscoverHistogram', () => {
});
describe('state', () => {
beforeEach(() => {
mockCheckHitCount.mockClear();
});
it('should subscribe to state changes', async () => {
const { hook } = await renderUseDiscoverHistogram();
const api = createMockUnifiedHistogramApi({ initialized: true });
@ -203,7 +211,7 @@ describe('useDiscoverHistogram', () => {
act(() => {
hook.result.current.setUnifiedHistogramApi(api);
});
expect(api.state$.subscribe).toHaveBeenCalledTimes(2);
expect(api.state$.subscribe).toHaveBeenCalledTimes(4);
});
it('should sync Unified Histogram state with the state container', async () => {
@ -217,6 +225,7 @@ describe('useDiscoverHistogram', () => {
breakdownField: 'test',
totalHitsStatus: UnifiedHistogramFetchStatus.loading,
totalHitsResult: undefined,
dataView: dataViewWithTimefieldMock,
} as unknown as UnifiedHistogramState;
const api = createMockUnifiedHistogramApi({ initialized: true });
api.state$ = new BehaviorSubject({ ...state, lensRequestAdapter });
@ -241,6 +250,7 @@ describe('useDiscoverHistogram', () => {
breakdownField: containerState.breakdownField,
totalHitsStatus: UnifiedHistogramFetchStatus.loading,
totalHitsResult: undefined,
dataView: dataViewWithTimefieldMock,
} as unknown as UnifiedHistogramState;
const api = createMockUnifiedHistogramApi({ initialized: true });
api.state$ = new BehaviorSubject(state);
@ -303,6 +313,7 @@ describe('useDiscoverHistogram', () => {
breakdownField: containerState.breakdownField,
totalHitsStatus: UnifiedHistogramFetchStatus.loading,
totalHitsResult: undefined,
dataView: dataViewWithTimefieldMock,
} as unknown as UnifiedHistogramState;
const api = createMockUnifiedHistogramApi({ initialized: true });
let params: Partial<UnifiedHistogramState> = {};
@ -347,7 +358,6 @@ describe('useDiscoverHistogram', () => {
});
it('should update total hits when the total hits state changes', async () => {
mockCheckHitCount.mockClear();
const totalHits$ = new BehaviorSubject({
fetchStatus: FetchStatus.LOADING,
result: undefined,
@ -366,6 +376,7 @@ describe('useDiscoverHistogram', () => {
breakdownField: containerState.breakdownField,
totalHitsStatus: UnifiedHistogramFetchStatus.loading,
totalHitsResult: undefined,
dataView: dataViewWithTimefieldMock,
} as unknown as UnifiedHistogramState;
const api = createMockUnifiedHistogramApi({ initialized: true });
api.state$ = new BehaviorSubject({
@ -384,7 +395,19 @@ describe('useDiscoverHistogram', () => {
});
it('should not update total hits when the total hits state changes to an error', async () => {
mockCheckHitCount.mockClear();
mockQueryState = {
query: {
query: 'query',
language: 'kuery',
} as Query | AggregateQuery,
filters: [],
time: {
from: 'now-15m',
to: 'now',
},
};
mockData.query.getState = () => mockQueryState;
const totalHits$ = new BehaviorSubject({
fetchStatus: FetchStatus.UNINITIALIZED,
result: undefined,
@ -399,6 +422,7 @@ describe('useDiscoverHistogram', () => {
breakdownField: containerState.breakdownField,
totalHitsStatus: UnifiedHistogramFetchStatus.loading,
totalHitsResult: undefined,
dataView: dataViewWithTimefieldMock,
} as unknown as UnifiedHistogramState;
const api = createMockUnifiedHistogramApi({ initialized: true });
api.state$ = new BehaviorSubject({

View file

@ -15,17 +15,24 @@ import {
UnifiedHistogramState,
} from '@kbn/unified-histogram-plugin/public';
import { isEqual } from 'lodash';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { distinctUntilChanged, map, Observable } from 'rxjs';
import { useCallback, useEffect, useRef, useMemo, useState } from 'react';
import { distinctUntilChanged, filter, map, Observable, skip } from 'rxjs';
import type { Suggestion } from '@kbn/lens-plugin/public';
import useLatest from 'react-use/lib/useLatest';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
import { getUiActions } from '../../../../kibana_services';
import { FetchStatus } from '../../../types';
import { useDataState } from '../../hooks/use_data_state';
import type { InspectorAdapters } from '../../hooks/use_inspector';
import type { DataFetch$, SavedSearchData } from '../../services/discover_data_state_container';
import type {
DataDocuments$,
DataFetch$,
SavedSearchData,
} from '../../services/discover_data_state_container';
import { checkHitCount, sendErrorTo } from '../../hooks/use_saved_search_messages';
import { useAppStateSelector } from '../../services/discover_app_state_container';
import type { DiscoverStateContainer } from '../../services/discover_state';
import { addLog } from '../../../../utils/add_log';
export interface UseDiscoverHistogramProps {
stateContainer: DiscoverStateContainer;
@ -34,6 +41,7 @@ export interface UseDiscoverHistogramProps {
inspectorAdapters: InspectorAdapters;
savedSearchFetch$: DataFetch$;
searchSessionId: string | undefined;
isPlainRecord: boolean;
}
export const useDiscoverHistogram = ({
@ -43,6 +51,7 @@ export const useDiscoverHistogram = ({
inspectorAdapters,
savedSearchFetch$,
searchSessionId,
isPlainRecord,
}: UseDiscoverHistogramProps) => {
const services = useDiscoverServices();
const timefilter = services.data.query.timefilter.timefilter;
@ -66,6 +75,7 @@ export const useDiscoverHistogram = ({
hideChart: chartHidden,
interval: timeInterval,
breakdownField,
columns,
} = stateContainer.appState.getState();
const { fetchStatus: totalHitsStatus, result: totalHitsResult } =
@ -85,6 +95,7 @@ export const useDiscoverHistogram = ({
timeRange,
chartHidden,
timeInterval,
columns,
breakdownField,
searchSessionId,
totalHitsStatus: totalHitsStatus.toString() as UnifiedHistogramFetchStatus,
@ -134,18 +145,8 @@ export const useDiscoverHistogram = ({
/**
* Update Unified Histgoram request params
*/
const {
query,
filters,
fromDate: from,
toDate: to,
} = useQuerySubscriber({ data: services.data });
const timeRange = useMemo(
() => (from && to ? { from, to } : timefilter.getTimeDefaults()),
[timefilter, from, to]
);
const { query, filters } = useQuerySubscriber({ data: services.data });
const timeRange = timefilter.getAbsoluteTime();
useEffect(() => {
unifiedHistogram?.setRequestParams({
@ -214,6 +215,25 @@ export const useDiscoverHistogram = ({
unifiedHistogram?.setBreakdownField(breakdownField);
}, [breakdownField, unifiedHistogram]);
/**
* Columns
*/
// Update the columns only when documents are fetched so the Lens suggestions
// don't constantly change when the user modifies the table columns
useEffect(() => {
const subscription = createDocumentsFetchedObservable(
stateContainer.dataState.data$.documents$
).subscribe(({ textBasedQueryColumns }) => {
const columns = textBasedQueryColumns?.map(({ name }) => name) ?? [];
unifiedHistogram?.setColumns(columns);
});
return () => {
subscription.unsubscribe();
};
}, [stateContainer.appState, stateContainer.dataState.data$.documents$, unifiedHistogram]);
/**
* Total hits
*/
@ -260,41 +280,81 @@ export const useDiscoverHistogram = ({
}, [
savedSearchData$.main$,
savedSearchData$.totalHits$,
services.data,
setTotalHitsError,
unifiedHistogram,
unifiedHistogram?.state$,
]);
/**
* Data fetching
*/
const skipRefetch = useRef<boolean>();
const skipDiscoverRefetch = useRef<boolean>();
const skipLensSuggestionRefetch = useRef<boolean>();
const usingLensSuggestion = useLatest(isPlainRecord && !hideChart);
// Skip refetching when showing the chart since Lens will
// automatically fetch when the chart is shown
useEffect(() => {
if (skipRefetch.current === undefined) {
skipRefetch.current = false;
if (skipDiscoverRefetch.current === undefined) {
skipDiscoverRefetch.current = false;
} else {
skipRefetch.current = !hideChart;
skipDiscoverRefetch.current = !hideChart;
}
}, [hideChart]);
// Trigger a unified histogram refetch when savedSearchFetch$ is triggered
useEffect(() => {
const subscription = savedSearchFetch$.subscribe(() => {
if (!skipRefetch.current) {
if (!skipDiscoverRefetch.current) {
addLog('Unified Histogram - Discover refetch');
unifiedHistogram?.refetch();
}
skipRefetch.current = false;
skipDiscoverRefetch.current = false;
});
return () => {
subscription.unsubscribe();
};
}, [savedSearchFetch$, unifiedHistogram]);
}, [savedSearchFetch$, unifiedHistogram, usingLensSuggestion]);
// Reload the chart when the current suggestion changes
const [currentSuggestion, setCurrentSuggestion] = useState<Suggestion>();
useEffect(() => {
if (!skipLensSuggestionRefetch.current && currentSuggestion && usingLensSuggestion.current) {
addLog('Unified Histogram - Lens suggestion refetch');
unifiedHistogram?.refetch();
}
skipLensSuggestionRefetch.current = false;
}, [currentSuggestion, unifiedHistogram, usingLensSuggestion]);
useEffect(() => {
const subscription = createCurrentSuggestionObservable(unifiedHistogram?.state$)?.subscribe(
setCurrentSuggestion
);
return () => {
subscription?.unsubscribe();
};
}, [unifiedHistogram]);
// When the data view or query changes, which will trigger a current suggestion change,
// skip the next refetch since we want to wait for the columns to update first, which
// doesn't happen until after the documents are fetched
useEffect(() => {
const subscription = createSkipFetchObservable(unifiedHistogram?.state$)?.subscribe(() => {
if (usingLensSuggestion.current) {
skipLensSuggestionRefetch.current = true;
skipDiscoverRefetch.current = true;
}
});
return () => {
subscription?.unsubscribe();
};
}, [unifiedHistogram?.state$, usingLensSuggestion]);
return { hideChart, setUnifiedHistogramApi };
};
@ -316,9 +376,31 @@ const createStateSyncObservable = (state$?: Observable<UnifiedHistogramState>) =
);
};
const createDocumentsFetchedObservable = (documents$: DataDocuments$) => {
return documents$.pipe(
distinctUntilChanged((prev, curr) => prev.fetchStatus === curr.fetchStatus),
filter(({ fetchStatus }) => fetchStatus === FetchStatus.COMPLETE)
);
};
const createTotalHitsObservable = (state$?: Observable<UnifiedHistogramState>) => {
return state$?.pipe(
map((state) => ({ status: state.totalHitsStatus, result: state.totalHitsResult })),
distinctUntilChanged((prev, curr) => prev.status === curr.status && prev.result === curr.result)
);
};
const createCurrentSuggestionObservable = (state$?: Observable<UnifiedHistogramState>) => {
return state$?.pipe(
map((state) => state.currentSuggestion),
distinctUntilChanged(isEqual)
);
};
const createSkipFetchObservable = (state$?: Observable<UnifiedHistogramState>) => {
return state$?.pipe(
map((state) => [state.dataView.id, state.query]),
distinctUntilChanged(isEqual),
skip(1)
);
};

View file

@ -262,12 +262,4 @@ describe('discover sidebar', function () {
const createDataViewButton = findTestSubject(compWithPickerInViewerMode, 'dataview-create-new');
expect(createDataViewButton.length).toBe(0);
});
it('should render the Visualize in Lens button in text based languages mode', async () => {
const compInViewerMode = await mountComponent(getCompProps(), {
query: { sql: 'SELECT * FROM test' },
});
const visualizeField = findTestSubject(compInViewerMode, 'textBased-visualize');
expect(visualizeField.length).toBe(1);
});
});

View file

@ -15,7 +15,6 @@ import {
EuiFlexItem,
EuiPageSideBar_Deprecated as EuiPageSideBar,
} from '@elastic/eui';
import { isOfAggregateQueryType } from '@kbn/es-query';
import { DataViewPicker } from '@kbn/unified-search-plugin/public';
import { type DataViewField, getFieldSubtypeMulti } from '@kbn/data-views-plugin/public';
import {
@ -25,14 +24,13 @@ import {
FieldListGroupedProps,
FieldsGroupNames,
GroupedFieldsParams,
triggerVisualizeActionsTextBasedLanguages,
useGroupedFields,
} from '@kbn/unified-field-list-plugin/public';
import { VIEW_MODE } from '../../../../../common/constants';
import { useAppStateSelector } from '../../services/discover_app_state_container';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
import { DiscoverField } from './discover_field';
import { FIELDS_LIMIT_SETTING, PLUGIN_ID } from '../../../../../common';
import { FIELDS_LIMIT_SETTING } from '../../../../../common';
import {
getSelectedFields,
shouldShowField,
@ -40,7 +38,6 @@ import {
INITIAL_SELECTED_FIELDS_RESULT,
} from './lib/group_fields';
import { DiscoverSidebarResponsiveProps } from './discover_sidebar_responsive';
import { getUiActions } from '../../../../kibana_services';
import { getRawRecordType } from '../../utils/get_raw_record_type';
import { RecordRawType } from '../../services/discover_data_state_container';
@ -127,7 +124,6 @@ export function DiscoverSidebarComponent({
const isPlainRecord = useAppStateSelector(
(state) => getRawRecordType(state.query) === RecordRawType.PLAIN
);
const query = useAppStateSelector((state) => state.query);
const showFieldStats = useMemo(() => viewMode === VIEW_MODE.DOCUMENT_LEVEL, [viewMode]);
const [selectedFieldsState, setSelectedFieldsState] = useState<SelectedFieldsResult>(
@ -194,17 +190,6 @@ export function DiscoverSidebarComponent({
]
);
const visualizeAggregateQuery = useCallback(() => {
const aggregateQuery = query && isOfAggregateQueryType(query) ? query : undefined;
triggerVisualizeActionsTextBasedLanguages(
getUiActions(),
columns,
PLUGIN_ID,
selectedDataView,
aggregateQuery
);
}, [columns, selectedDataView, query]);
const popularFieldsLimit = useMemo(() => uiSettings.get(FIELDS_LIMIT_SETTING), [uiSettings]);
const onSupportedFieldFilter: GroupedFieldsParams<DataViewField>['onSupportedFieldFilter'] =
useCallback(
@ -342,20 +327,6 @@ export function DiscoverSidebarComponent({
</EuiButton>
</EuiFlexItem>
)}
{isPlainRecord && (
<EuiFlexItem grow={false}>
<EuiButton
iconType="lensApp"
data-test-subj="textBased-visualize"
onClick={visualizeAggregateQuery}
size="s"
>
{i18n.translate('discover.textBasedLanguages.visualize.label', {
defaultMessage: 'Visualize in Lens',
})}
</EuiButton>
</EuiFlexItem>
)}
</FieldList>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -15,7 +15,11 @@ import {
import { RecordRawType } from '../services/discover_data_state_container';
export function getRawRecordType(query?: Query | AggregateQuery) {
if (query && isOfAggregateQueryType(query) && getAggregateQueryMode(query) === 'sql') {
if (
query &&
isOfAggregateQueryType(query) &&
(getAggregateQueryMode(query) === 'sql' || getAggregateQueryMode(query) === 'esql')
) {
return RecordRawType.PLAIN;
}

View file

@ -7,11 +7,6 @@
"id": "unifiedHistogram",
"server": false,
"browser": true,
"requiredBundles": [
"data",
"dataViews",
"embeddable",
"inspector"
]
"requiredBundles": ["data", "dataViews", "embeddable", "inspector", "expressions"]
}
}

View file

@ -8,8 +8,10 @@
import { EUI_CHARTS_THEME_LIGHT } from '@elastic/eui/dist/eui_charts_theme';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { expressionsPluginMock } from '@kbn/expressions-plugin/public/mocks';
import { fieldFormatsMock } from '@kbn/field-formats-plugin/common/mocks';
import type { UnifiedHistogramServices } from '../types';
import { allSuggestionsMock } from './suggestions';
const dataPlugin = dataPluginMock.createStartContract();
dataPlugin.query.filterManager.getFilters = jest.fn(() => []);
@ -28,11 +30,20 @@ export const unifiedHistogramServicesMock = {
useChartsTheme: jest.fn(() => EUI_CHARTS_THEME_LIGHT.theme),
useChartsBaseTheme: jest.fn(() => EUI_CHARTS_THEME_LIGHT.theme),
},
lens: { EmbeddableComponent: jest.fn(() => null), navigateToPrefilledEditor: jest.fn() },
lens: {
EmbeddableComponent: jest.fn(() => null),
navigateToPrefilledEditor: jest.fn(),
stateHelperApi: jest.fn(() => {
return {
suggestions: jest.fn(() => allSuggestionsMock),
};
}),
},
storage: {
get: jest.fn(),
set: jest.fn(),
remove: jest.fn(),
clear: jest.fn(),
},
expressions: expressionsPluginMock.createStartContract(),
} as unknown as UnifiedHistogramServices;

View file

@ -0,0 +1,292 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { Suggestion } from '@kbn/lens-plugin/public';
export const currentSuggestionMock = {
title: 'Heat map',
hide: false,
score: 0.6,
previewIcon: 'heatmap',
visualizationId: 'lnsHeatmap',
visualizationState: {
shape: 'heatmap',
layerId: '46aa21fa-b747-4543-bf90-0b40007c546d',
layerType: 'data',
legend: {
isVisible: true,
position: 'right',
type: 'heatmap_legend',
},
gridConfig: {
type: 'heatmap_grid',
isCellLabelVisible: false,
isYAxisLabelVisible: true,
isXAxisLabelVisible: true,
isYAxisTitleVisible: false,
isXAxisTitleVisible: false,
},
valueAccessor: '5b9b8b76-0836-4a12-b9c0-980c9900502f',
xAccessor: '81e332d6-ee37-42a8-a646-cea4fc75d2d3',
},
keptLayerIds: ['46aa21fa-b747-4543-bf90-0b40007c546d'],
datasourceState: {
layers: {
'46aa21fa-b747-4543-bf90-0b40007c546d': {
index: 'd3d7af60-4c81-11e8-b3d7-01146121b73d',
query: {
sql: 'SELECT Dest, AvgTicketPrice FROM "kibana_sample_data_flights"',
},
columns: [
{
columnId: '81e332d6-ee37-42a8-a646-cea4fc75d2d3',
fieldName: 'Dest',
meta: {
type: 'string',
},
},
{
columnId: '5b9b8b76-0836-4a12-b9c0-980c9900502f',
fieldName: 'AvgTicketPrice',
meta: {
type: 'number',
},
},
],
allColumns: [
{
columnId: '81e332d6-ee37-42a8-a646-cea4fc75d2d3',
fieldName: 'Dest',
meta: {
type: 'string',
},
},
{
columnId: '5b9b8b76-0836-4a12-b9c0-980c9900502f',
fieldName: 'AvgTicketPrice',
meta: {
type: 'number',
},
},
],
timeField: 'timestamp',
},
},
fieldList: [],
indexPatternRefs: [],
initialContext: {
dataViewSpec: {
id: 'd3d7af60-4c81-11e8-b3d7-01146121b73d',
version: 'WzM1ODA3LDFd',
title: 'kibana_sample_data_flights',
timeFieldName: 'timestamp',
sourceFilters: [],
fields: {
AvgTicketPrice: {
count: 0,
name: 'AvgTicketPrice',
type: 'number',
esTypes: ['float'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
format: {
id: 'number',
params: {
pattern: '$0,0.[00]',
},
},
shortDotsEnable: false,
isMapped: true,
},
Dest: {
count: 0,
name: 'Dest',
type: 'string',
esTypes: ['keyword'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
format: {
id: 'string',
},
shortDotsEnable: false,
isMapped: true,
},
timestamp: {
count: 0,
name: 'timestamp',
type: 'date',
esTypes: ['date'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
format: {
id: 'date',
},
shortDotsEnable: false,
isMapped: true,
},
},
allowNoIndex: false,
name: 'Kibana Sample Data Flights',
},
fieldName: '',
contextualFields: ['Dest', 'AvgTicketPrice'],
query: {
sql: 'SELECT Dest, AvgTicketPrice FROM "kibana_sample_data_flights"',
},
},
},
datasourceId: 'textBased',
columns: 2,
changeType: 'initial',
} as Suggestion;
export const allSuggestionsMock = [
currentSuggestionMock,
{
title: 'Donut',
score: 0.46,
visualizationId: 'lnsPie',
previewIcon: 'pie',
visualizationState: {
shape: 'donut',
layers: [
{
layerId: '2513a3d4-ad9d-48ea-bd58-8b6419ab97e6',
primaryGroups: ['923f0681-3fe1-4987-aa27-d9c91fb95fa6'],
metrics: ['b5f41c04-4bca-4abe-ae5c-b1d4d6fb00e0'],
numberDisplay: 'percent',
categoryDisplay: 'default',
legendDisplay: 'default',
nestedLegend: false,
layerType: 'data',
},
],
},
keptLayerIds: ['2513a3d4-ad9d-48ea-bd58-8b6419ab97e6'],
datasourceState: {
layers: {
'2513a3d4-ad9d-48ea-bd58-8b6419ab97e6': {
index: 'd3d7af60-4c81-11e8-b3d7-01146121b73d',
query: {
sql: 'SELECT Dest, AvgTicketPrice FROM "kibana_sample_data_flights"',
},
columns: [
{
columnId: '923f0681-3fe1-4987-aa27-d9c91fb95fa6',
fieldName: 'Dest',
meta: {
type: 'string',
},
},
{
columnId: 'b5f41c04-4bca-4abe-ae5c-b1d4d6fb00e0',
fieldName: 'AvgTicketPrice',
meta: {
type: 'number',
},
},
],
allColumns: [
{
columnId: '923f0681-3fe1-4987-aa27-d9c91fb95fa6',
fieldName: 'Dest',
meta: {
type: 'string',
},
},
{
columnId: 'b5f41c04-4bca-4abe-ae5c-b1d4d6fb00e0',
fieldName: 'AvgTicketPrice',
meta: {
type: 'number',
},
},
],
timeField: 'timestamp',
},
},
fieldList: [],
indexPatternRefs: [],
initialContext: {
dataViewSpec: {
id: 'd3d7af60-4c81-11e8-b3d7-01146121b73d',
version: 'WzM1ODA3LDFd',
title: 'kibana_sample_data_flights',
timeFieldName: 'timestamp',
sourceFilters: [],
fields: {
AvgTicketPrice: {
count: 0,
name: 'AvgTicketPrice',
type: 'number',
esTypes: ['float'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
format: {
id: 'number',
params: {
pattern: '$0,0.[00]',
},
},
shortDotsEnable: false,
isMapped: true,
},
Dest: {
count: 0,
name: 'Dest',
type: 'string',
esTypes: ['keyword'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
format: {
id: 'string',
},
shortDotsEnable: false,
isMapped: true,
},
timestamp: {
count: 0,
name: 'timestamp',
type: 'date',
esTypes: ['date'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
format: {
id: 'date',
},
shortDotsEnable: false,
isMapped: true,
},
},
typeMeta: {},
allowNoIndex: false,
name: 'Kibana Sample Data Flights',
},
fieldName: '',
contextualFields: ['Dest', 'AvgTicketPrice'],
query: {
sql: 'SELECT Dest, AvgTicketPrice FROM "kibana_sample_data_flights"',
},
},
},
datasourceId: 'textBased',
columns: 2,
changeType: 'unchanged',
} as Suggestion,
];

View file

@ -10,6 +10,7 @@ import React, { ReactElement } from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import type { DataView } from '@kbn/data-views-plugin/public';
import type { Suggestion } from '@kbn/lens-plugin/public';
import type { UnifiedHistogramFetchStatus } from '../types';
import { Chart } from './chart';
import type { ReactWrapper } from 'enzyme';
@ -20,6 +21,9 @@ import { HitsCounter } from '../hits_counter';
import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield';
import { dataViewMock } from '../__mocks__/data_view';
import { BreakdownFieldSelector } from './breakdown_field_selector';
import { SuggestionSelector } from './suggestion_selector';
import { currentSuggestionMock, allSuggestionsMock } from '../__mocks__/suggestions';
let mockUseEditVisualization: jest.Mock | undefined = jest.fn();
@ -34,6 +38,8 @@ async function mountComponent({
chartHidden = false,
appendHistogram,
dataView = dataViewWithTimefieldMock,
currentSuggestion,
allSuggestions,
}: {
noChart?: boolean;
noHits?: boolean;
@ -41,6 +47,8 @@ async function mountComponent({
chartHidden?: boolean;
appendHistogram?: ReactElement;
dataView?: DataView;
currentSuggestion?: Suggestion;
allSuggestions?: Suggestion[];
} = {}) {
(searchSourceInstanceMock.fetch$ as jest.Mock).mockImplementation(
jest.fn().mockReturnValue(of({ rawResponse: { hits: { total: noHits ? 0 : 2 } } }))
@ -74,6 +82,8 @@ async function mountComponent({
},
},
breakdown: noBreakdown ? undefined : { field: undefined },
currentSuggestion,
allSuggestions,
appendHistogram,
onResetChartHeight: jest.fn(),
onChartHiddenChange: jest.fn(),
@ -191,4 +201,26 @@ describe('Chart', () => {
const component = await mountComponent({ noBreakdown: true });
expect(component.find(BreakdownFieldSelector).exists()).toBeFalsy();
});
it('should render the Lens SuggestionsSelector when chart is visible and suggestions exist', async () => {
const component = await mountComponent({
currentSuggestion: currentSuggestionMock,
allSuggestions: allSuggestionsMock,
});
expect(component.find(SuggestionSelector).exists()).toBeTruthy();
});
it('should not render the Lens SuggestionsSelector when chart is hidden', async () => {
const component = await mountComponent({
chartHidden: true,
currentSuggestion: currentSuggestionMock,
allSuggestions: allSuggestionsMock,
});
expect(component.find(SuggestionSelector).exists()).toBeFalsy();
});
it('should not render the Lens SuggestionsSelector when chart is visible and suggestions are undefined', async () => {
const component = await mountComponent({ currentSuggestion: currentSuggestionMock });
expect(component.find(SuggestionSelector).exists()).toBeFalsy();
});
});

View file

@ -17,6 +17,7 @@ import {
EuiToolTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { Suggestion } from '@kbn/lens-plugin/public';
import { DataView, DataViewField, DataViewType } from '@kbn/data-views-plugin/public';
import type { LensEmbeddableInput } from '@kbn/lens-plugin/public';
import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
@ -36,6 +37,7 @@ import type {
UnifiedHistogramInputMessage,
} from '../types';
import { BreakdownFieldSelector } from './breakdown_field_selector';
import { SuggestionSelector } from './suggestion_selector';
import { useTotalHits } from './hooks/use_total_hits';
import { useRequestParams } from './hooks/use_request_params';
import { useChartStyles } from './hooks/use_chart_styles';
@ -50,6 +52,9 @@ export interface ChartProps {
dataView: DataView;
query?: Query | AggregateQuery;
filters?: Filter[];
isPlainRecord?: boolean;
currentSuggestion?: Suggestion;
allSuggestions?: Suggestion[];
timeRange?: TimeRange;
request?: UnifiedHistogramRequestContext;
hits?: UnifiedHistogramHitsContext;
@ -66,6 +71,7 @@ export interface ChartProps {
onChartHiddenChange?: (chartHidden: boolean) => void;
onTimeIntervalChange?: (timeInterval: string) => void;
onBreakdownFieldChange?: (breakdownField: DataViewField | undefined) => void;
onSuggestionChange?: (suggestion: Suggestion | undefined) => void;
onTotalHitsChange?: (status: UnifiedHistogramFetchStatus, result?: number | Error) => void;
onChartLoad?: (event: UnifiedHistogramChartLoadEvent) => void;
onFilter?: LensEmbeddableInput['onFilter'];
@ -85,6 +91,9 @@ export function Chart({
hits,
chart,
breakdown,
currentSuggestion,
allSuggestions,
isPlainRecord,
appendHitsCounter,
appendHistogram,
disableAutoFetching,
@ -95,6 +104,7 @@ export function Chart({
onResetChartHeight,
onChartHiddenChange,
onTimeIntervalChange,
onSuggestionChange,
onBreakdownFieldChange,
onTotalHitsChange,
onChartLoad,
@ -118,6 +128,7 @@ export function Chart({
onTimeIntervalChange,
closePopover: closeChartOptions,
onResetChartHeight,
isPlainRecord,
});
const chartVisible = !!(
@ -150,6 +161,7 @@ export function Chart({
filters,
query,
relativeTimeRange,
currentSuggestion,
disableAutoFetching,
input$,
beforeRefetch: updateTimeRange,
@ -166,6 +178,7 @@ export function Chart({
getTimeRange,
refetch$,
onTotalHitsChange,
isPlainRecord,
});
const {
@ -188,8 +201,17 @@ export function Chart({
dataView,
timeInterval: chart?.timeInterval,
breakdownField: breakdown?.field,
suggestion: currentSuggestion,
}),
[breakdown?.field, chart?.timeInterval, chart?.title, dataView, filters, query]
[
breakdown?.field,
chart?.timeInterval,
chart?.title,
currentSuggestion,
dataView,
filters,
query,
]
);
const getRelativeTimeRange = useMemo(
@ -244,6 +266,15 @@ export function Chart({
/>
</EuiFlexItem>
)}
{chartVisible && currentSuggestion && allSuggestions && allSuggestions?.length > 1 && (
<EuiFlexItem css={breakdownFieldSelectorItemCss}>
<SuggestionSelector
suggestions={allSuggestions}
activeSuggestion={currentSuggestion}
onSuggestionChange={onSuggestionChange}
/>
</EuiFlexItem>
)}
{onEditVisualization && (
<EuiFlexItem grow={false} css={chartToolButtonCss}>
<EuiToolTip
@ -315,6 +346,7 @@ export function Chart({
getTimeRange={getTimeRange}
refetch$={refetch$}
lensAttributes={lensAttributes}
isPlainRecord={isPlainRecord}
disableTriggers={disableTriggers}
disabledActions={disabledActions}
onTotalHitsChange={onTotalHitsChange}

View file

@ -35,6 +35,7 @@ const getMockLensAttributes = () =>
dataView: dataViewWithTimefieldMock,
timeInterval: 'auto',
breakdownField: dataViewWithTimefieldMock.getFieldByName('extension'),
suggestion: undefined,
});
function mountComponent() {

View file

@ -6,9 +6,9 @@
* Side Public License, v 1.
*/
import { useEuiTheme } from '@elastic/eui';
import { useEuiTheme, useResizeObserver } from '@elastic/eui';
import { css } from '@emotion/react';
import React, { useState } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import type { DataView } from '@kbn/data-views-plugin/public';
import type { DefaultInspectorAdapters } from '@kbn/expressions-plugin/common';
import type { IKibanaSearchResponse } from '@kbn/data-plugin/public';
@ -38,6 +38,7 @@ export interface HistogramProps {
request?: UnifiedHistogramRequestContext;
hits?: UnifiedHistogramHitsContext;
chart: UnifiedHistogramChartContext;
isPlainRecord?: boolean;
getTimeRange: () => TimeRange;
refetch$: Observable<UnifiedHistogramInputMessage>;
lensAttributes: TypedLensByValueInput['attributes'];
@ -55,6 +56,7 @@ export function Histogram({
request,
hits,
chart: { timeInterval },
isPlainRecord,
getTimeRange,
refetch$,
lensAttributes: attributes,
@ -66,12 +68,24 @@ export function Histogram({
onBrushEnd,
}: HistogramProps) {
const [bucketInterval, setBucketInterval] = useState<UnifiedHistogramBucketInterval>();
const [chartSize, setChartSize] = useState('100%');
const { timeRangeText, timeRangeDisplay } = useTimeRange({
uiSettings,
bucketInterval,
timeRange: getTimeRange(),
timeInterval,
isPlainRecord,
});
const chartRef = useRef<HTMLDivElement | null>(null);
const { height: containerHeight, width: containerWidth } = useResizeObserver(chartRef.current);
useEffect(() => {
if (attributes.visualizationType === 'lnsMetric') {
const size = containerHeight < containerWidth ? containerHeight : containerWidth;
setChartSize(`${size}px`);
} else {
setChartSize('100%');
}
}, [attributes, containerHeight, containerWidth]);
const onLoad = useStableCallback(
(isLoading: boolean, adapters: Partial<DefaultInspectorAdapters> | undefined) => {
@ -91,7 +105,10 @@ export function Histogram({
return;
}
const totalHits = adapters?.tables?.tables?.unifiedHistogram?.meta?.statistics?.totalCount;
const adapterTables = adapters?.tables?.tables;
const totalHits = isPlainRecord
? Object.values(adapterTables ?? {})?.[0]?.rows?.length
: adapterTables?.unifiedHistogram?.meta?.statistics?.totalCount;
onTotalHitsChange?.(
isLoading ? UnifiedHistogramFetchStatus.loading : UnifiedHistogramFetchStatus.complete,
@ -129,6 +146,13 @@ export function Histogram({
& > div {
height: 100%;
position: absolute;
width: 100%;
}
& .lnsExpressionRenderer {
width: ${chartSize};
margin: auto;
}
& .echLegend .echLegendList {
@ -145,7 +169,12 @@ export function Histogram({
return (
<>
<div data-test-subj="unifiedHistogramChart" data-time-range={timeRangeText} css={chartCss}>
<div
data-test-subj="unifiedHistogramChart"
data-time-range={timeRangeText}
css={chartCss}
ref={chartRef}
>
<lens.EmbeddableComponent
{...lensProps}
disableTriggers={disableTriggers}

View file

@ -85,4 +85,24 @@ describe('test useChartPanels', () => {
(resetChartHeightButton.onClick as Function)();
expect(onResetChartHeight).toBeCalled();
});
test('useChartsPanel when isPlainRecord', async () => {
const { result } = renderHook(() => {
return useChartPanels({
toggleHideChart: jest.fn(),
onTimeIntervalChange: jest.fn(),
closePopover: jest.fn(),
onResetChartHeight: jest.fn(),
isPlainRecord: true,
chart: {
hidden: true,
timeInterval: 'auto',
},
});
});
const panels: EuiContextMenuPanelDescriptor[] = result.current;
const panel0: EuiContextMenuPanelDescriptor = result.current[0];
expect(panels.length).toBe(1);
expect(panel0!.items).toHaveLength(1);
expect(panel0!.items![0].icon).toBe('eye');
});
});

View file

@ -20,12 +20,14 @@ export function useChartPanels({
onTimeIntervalChange,
closePopover,
onResetChartHeight,
isPlainRecord,
}: {
chart?: UnifiedHistogramChartContext;
toggleHideChart: () => void;
onTimeIntervalChange?: (timeInterval: string) => void;
closePopover: () => void;
onResetChartHeight?: () => void;
isPlainRecord?: boolean;
}) {
if (!chart) {
return [];
@ -71,16 +73,18 @@ export function useChartPanels({
});
}
mainPanelItems.push({
name: i18n.translate('unifiedHistogram.timeIntervalWithValue', {
defaultMessage: 'Time interval: {timeInterval}',
values: {
timeInterval: intervalDisplay,
},
}),
panel: 1,
'data-test-subj': 'unifiedHistogramTimeIntervalPanel',
});
if (!isPlainRecord) {
mainPanelItems.push({
name: i18n.translate('unifiedHistogram.timeIntervalWithValue', {
defaultMessage: 'Time interval: {timeInterval}',
values: {
timeInterval: intervalDisplay,
},
}),
panel: 1,
'data-test-subj': 'unifiedHistogramTimeIntervalPanel',
});
}
}
const panels: EuiContextMenuPanelDescriptor[] = [
@ -92,7 +96,7 @@ export function useChartPanels({
items: mainPanelItems,
},
];
if (!chart.hidden) {
if (!chart.hidden && !isPlainRecord) {
panels.push({
id: 1,
initialFocusedItemIndex: selectedOptionIdx > -1 ? selectedOptionIdx : 0,

View file

@ -11,6 +11,7 @@ import { act } from 'react-test-renderer';
import { Subject } from 'rxjs';
import type { UnifiedHistogramInputMessage } from '../../types';
import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield';
import { currentSuggestionMock } from '../../__mocks__/suggestions';
import { getLensAttributes } from '../utils/get_lens_attributes';
import { getLensProps, useLensProps } from './use_lens_props';
@ -29,6 +30,45 @@ describe('useLensProps', () => {
dataView: dataViewWithTimefieldMock,
timeInterval: 'auto',
breakdownField: dataViewWithTimefieldMock.getFieldByName('extension'),
suggestion: undefined,
});
const lensProps = renderHook(() => {
return useLensProps({
request: {
searchSessionId: 'id',
adapter: undefined,
},
getTimeRange,
refetch$,
attributes,
onLoad,
});
});
expect(lensProps.result.current).toEqual(
getLensProps({
searchSessionId: 'id',
getTimeRange,
attributes,
onLoad,
})
);
});
it('should return lens props for text based languages', () => {
const getTimeRange = jest.fn();
const refetch$ = new Subject<UnifiedHistogramInputMessage>();
const onLoad = jest.fn();
const attributes = getLensAttributes({
title: 'test',
filters: [],
query: {
language: 'kuery',
query: '',
},
dataView: dataViewWithTimefieldMock,
timeInterval: 'auto',
breakdownField: dataViewWithTimefieldMock.getFieldByName('extension'),
suggestion: currentSuggestionMock,
});
const lensProps = renderHook(() => {
return useLensProps({
@ -73,6 +113,7 @@ describe('useLensProps', () => {
dataView: dataViewWithTimefieldMock,
timeInterval: 'auto',
breakdownField: dataViewWithTimefieldMock.getFieldByName('extension'),
suggestion: undefined,
}),
onLoad,
};

View file

@ -8,6 +8,7 @@
import type { DataView } from '@kbn/data-views-plugin/common';
import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import type { Suggestion } from '@kbn/lens-plugin/public';
import { cloneDeep, isEqual } from 'lodash';
import { useEffect, useMemo, useRef } from 'react';
import { filter, share, tap } from 'rxjs';
@ -29,6 +30,7 @@ export const useRefetch = ({
filters,
query,
relativeTimeRange,
currentSuggestion,
disableAutoFetching,
input$,
beforeRefetch,
@ -42,6 +44,7 @@ export const useRefetch = ({
filters: Filter[];
query: Query | AggregateQuery;
relativeTimeRange: TimeRange;
currentSuggestion?: Suggestion;
disableAutoFetching?: boolean;
input$: UnifiedHistogramInput$;
beforeRefetch: () => void;
@ -67,6 +70,7 @@ export const useRefetch = ({
filters,
query,
relativeTimeRange,
currentSuggestion,
});
if (!isEqual(refetchDeps.current, newRefetchDeps)) {
@ -80,6 +84,7 @@ export const useRefetch = ({
breakdown,
chart,
chartVisible,
currentSuggestion,
dataView,
disableAutoFetching,
filters,
@ -111,6 +116,7 @@ const getRefetchDeps = ({
filters,
query,
relativeTimeRange,
currentSuggestion,
}: {
dataView: DataView;
request: UnifiedHistogramRequestContext | undefined;
@ -121,6 +127,7 @@ const getRefetchDeps = ({
filters: Filter[];
query: Query | AggregateQuery;
relativeTimeRange: TimeRange;
currentSuggestion?: Suggestion;
}) =>
cloneDeep([
dataView.id,
@ -133,4 +140,5 @@ const getRefetchDeps = ({
filters,
query,
relativeTimeRange,
currentSuggestion?.visualizationId,
]);

View file

@ -236,4 +236,35 @@ describe('useTimeRange', () => {
</EuiFlexGroup>
`);
});
it('should render time range display and no interval for text based languages', () => {
const { result } = renderHook(() =>
useTimeRange({
uiSettings,
bucketInterval,
timeRange,
timeInterval,
isPlainRecord: true,
})
);
expect(result.current.timeRangeDisplay).toMatchInlineSnapshot(`
<EuiText
css={
Object {
"map": undefined,
"name": "1vgo99t",
"next": undefined,
"styles": "
padding: 0 8px 0 8px;
",
"toString": [Function],
}
}
size="xs"
textAlign="center"
>
2022-11-17T00:00:00.000Z - 2022-11-17T12:00:00.000Z
</EuiText>
`);
});
});

View file

@ -20,11 +20,13 @@ export const useTimeRange = ({
bucketInterval,
timeRange: { from, to },
timeInterval,
isPlainRecord,
}: {
uiSettings: IUiSettingsClient;
bucketInterval?: UnifiedHistogramBucketInterval;
timeRange: TimeRange;
timeInterval?: string;
isPlainRecord?: boolean;
}) => {
const dateFormat = useMemo(() => uiSettings.get('dateFormat'), [uiSettings]);
@ -47,26 +49,28 @@ export const useTimeRange = ({
to: dateMath.parse(to, { roundUp: true }),
};
const intervalText = i18n.translate('unifiedHistogram.histogramTimeRangeIntervalDescription', {
defaultMessage: '(interval: {value})',
values: {
value: `${
timeInterval === 'auto'
? `${i18n.translate('unifiedHistogram.histogramTimeRangeIntervalAuto', {
defaultMessage: 'Auto',
})} - `
: ''
}${
bucketInterval?.description ??
i18n.translate('unifiedHistogram.histogramTimeRangeIntervalLoading', {
defaultMessage: 'Loading',
})
}`,
},
});
const intervalText = Boolean(isPlainRecord)
? ''
: i18n.translate('unifiedHistogram.histogramTimeRangeIntervalDescription', {
defaultMessage: '(interval: {value})',
values: {
value: `${
timeInterval === 'auto'
? `${i18n.translate('unifiedHistogram.histogramTimeRangeIntervalAuto', {
defaultMessage: 'Auto',
})} - `
: ''
}${
bucketInterval?.description ??
i18n.translate('unifiedHistogram.histogramTimeRangeIntervalLoading', {
defaultMessage: 'Loading',
})
}`,
},
});
return `${toMoment(timeRange.from)} - ${toMoment(timeRange.to)} ${intervalText}`;
}, [bucketInterval, from, timeInterval, to, toMoment]);
}, [bucketInterval?.description, from, isPlainRecord, timeInterval, to, toMoment]);
const { euiTheme } = useEuiTheme();
const timeRangeCss = css`

View file

@ -18,6 +18,7 @@ import { of, Subject, throwError } from 'rxjs';
import { waitFor } from '@testing-library/dom';
import { RequestAdapter } from '@kbn/inspector-plugin/common';
import { DataViewType, SearchSourceSearchOptions } from '@kbn/data-plugin/common';
import { expressionsPluginMock } from '@kbn/expressions-plugin/public/mocks';
jest.mock('react-use/lib/useDebounce', () => {
return jest.fn((...args) => {
@ -29,7 +30,20 @@ describe('useTotalHits', () => {
const timeRange = { from: 'now-15m', to: 'now' };
const refetch$: UnifiedHistogramInput$ = new Subject();
const getDeps = () => ({
services: { data: dataPluginMock.createStartContract() } as any,
services: {
data: dataPluginMock.createStartContract(),
expressions: {
...expressionsPluginMock.createStartContract(),
run: jest.fn(() =>
of({
partial: false,
result: {
rows: [{}, {}, {}],
},
})
),
},
} as any,
dataView: dataViewWithTimefieldMock,
request: undefined,
hits: {
@ -103,6 +117,22 @@ describe('useTotalHits', () => {
});
});
it('should fetch total hits if isPlainRecord is true', async () => {
const onTotalHitsChange = jest.fn();
const deps = {
...getDeps(),
isPlainRecord: true,
onTotalHitsChange,
query: { sql: 'select * from test' },
};
renderHook(() => useTotalHits(deps));
expect(onTotalHitsChange).toBeCalledTimes(1);
await waitFor(() => {
expect(deps.services.expressions.run).toBeCalledTimes(1);
expect(onTotalHitsChange).toBeCalledWith(UnifiedHistogramFetchStatus.complete, 3);
});
});
it('should not fetch total hits if chartVisible is true', async () => {
const onTotalHitsChange = jest.fn();
const fetchSpy = jest.spyOn(searchSourceInstanceMock, 'fetch$').mockClear();

View file

@ -6,13 +6,15 @@
* Side Public License, v 1.
*/
import { textBasedQueryStateToAstWithValidation } from '@kbn/data-plugin/common';
import { isCompleteResponse } from '@kbn/data-plugin/public';
import { DataView, DataViewType } from '@kbn/data-views-plugin/public';
import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import { Datatable, isExpressionValueError } from '@kbn/expressions-plugin/common';
import { i18n } from '@kbn/i18n';
import { MutableRefObject, useEffect, useRef } from 'react';
import useEffectOnce from 'react-use/lib/useEffectOnce';
import { catchError, filter, lastValueFrom, map, Observable, of } from 'rxjs';
import { catchError, filter, lastValueFrom, map, Observable, of, pluck } from 'rxjs';
import {
UnifiedHistogramFetchStatus,
UnifiedHistogramHitsContext,
@ -33,6 +35,7 @@ export const useTotalHits = ({
getTimeRange,
refetch$,
onTotalHitsChange,
isPlainRecord,
}: {
services: UnifiedHistogramServices;
dataView: DataView;
@ -44,6 +47,7 @@ export const useTotalHits = ({
getTimeRange: () => TimeRange;
refetch$: Observable<UnifiedHistogramInputMessage>;
onTotalHitsChange?: (status: UnifiedHistogramFetchStatus, result?: number | Error) => void;
isPlainRecord?: boolean;
}) => {
const abortController = useRef<AbortController>();
const fetch = useStableCallback(() => {
@ -58,6 +62,7 @@ export const useTotalHits = ({
query,
timeRange: getTimeRange(),
onTotalHitsChange,
isPlainRecord,
});
});
@ -70,16 +75,17 @@ export const useTotalHits = ({
};
const fetchTotalHits = async ({
services: { data },
services,
abortController,
dataView,
request,
hits,
chartVisible,
filters: originalFilters,
filters,
query,
timeRange,
onTotalHitsChange,
isPlainRecord,
}: {
services: UnifiedHistogramServices;
abortController: MutableRefObject<AbortController | undefined>;
@ -91,6 +97,7 @@ const fetchTotalHits = async ({
query: Query | AggregateQuery;
timeRange: TimeRange;
onTotalHitsChange?: (status: UnifiedHistogramFetchStatus, result?: number | Error) => void;
isPlainRecord?: boolean;
}) => {
abortController.current?.abort();
abortController.current = undefined;
@ -103,6 +110,53 @@ const fetchTotalHits = async ({
onTotalHitsChange?.(UnifiedHistogramFetchStatus.loading, hits.total);
const newAbortController = new AbortController();
abortController.current = newAbortController;
const response = isPlainRecord
? await fetchTotalHitsTextBased({
services,
abortController: newAbortController,
dataView,
request,
query,
timeRange,
})
: await fetchTotalHitsSearchSource({
services,
abortController: newAbortController,
dataView,
request,
filters,
query,
timeRange,
});
if (!response) {
return;
}
onTotalHitsChange?.(response.resultStatus, response.result);
};
const fetchTotalHitsSearchSource = async ({
services: { data },
abortController,
dataView,
request,
filters: originalFilters,
query,
timeRange,
}: {
services: UnifiedHistogramServices;
abortController: AbortController;
dataView: DataView;
request: UnifiedHistogramRequestContext | undefined;
filters: Filter[];
query: Query | AggregateQuery;
timeRange: TimeRange;
}) => {
const searchSource = data.search.searchSource.createEmpty();
searchSource
@ -131,8 +185,6 @@ const fetchTotalHits = async ({
searchSource.setField('filter', filters);
abortController.current = new AbortController();
// Let the consumer inspect the request if they want to track it
const inspector = request?.adapter
? {
@ -150,7 +202,7 @@ const fetchTotalHits = async ({
.fetch$({
inspector,
sessionId: request?.searchSessionId,
abortSignal: abortController.current.signal,
abortSignal: abortController.signal,
executionContext: {
description: 'fetch total hits',
},
@ -168,5 +220,63 @@ const fetchTotalHits = async ({
? UnifiedHistogramFetchStatus.error
: UnifiedHistogramFetchStatus.complete;
onTotalHitsChange?.(resultStatus, result);
return { resultStatus, result };
};
const fetchTotalHitsTextBased = async ({
services: { expressions },
abortController,
dataView,
request,
query,
timeRange,
}: {
services: UnifiedHistogramServices;
abortController: AbortController;
dataView: DataView;
request: UnifiedHistogramRequestContext | undefined;
query: Query | AggregateQuery;
timeRange: TimeRange;
}) => {
const ast = await textBasedQueryStateToAstWithValidation({
query,
time: timeRange,
dataView,
});
if (abortController.signal.aborted) {
return undefined;
}
if (!ast) {
return {
resultStatus: UnifiedHistogramFetchStatus.error,
result: new Error('Invalid text based query'),
};
}
const result = await lastValueFrom(
expressions
.run<null, Datatable>(ast, null, {
inspectorAdapters: { requests: request?.adapter },
searchSessionId: request?.searchSessionId,
executionContext: {
description: 'fetch total hits',
},
})
.pipe(pluck('result'))
);
if (abortController.signal.aborted) {
return undefined;
}
if (isExpressionValueError(result)) {
return {
resultStatus: UnifiedHistogramFetchStatus.error,
result: new Error(result.error.message),
};
}
return { resultStatus: UnifiedHistogramFetchStatus.complete, result: result.rows.length };
};

View file

@ -0,0 +1,67 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { EuiComboBox } from '@elastic/eui';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import React from 'react';
import { currentSuggestionMock, allSuggestionsMock } from '../__mocks__/suggestions';
import { SuggestionSelector } from './suggestion_selector';
describe('SuggestionSelector', () => {
it('should pass the suggestions charts titles to the EuiComboBox', () => {
const onSuggestionChange = jest.fn();
const wrapper = mountWithIntl(
<SuggestionSelector
suggestions={allSuggestionsMock}
activeSuggestion={currentSuggestionMock}
onSuggestionChange={onSuggestionChange}
/>
);
const comboBox = wrapper.find(EuiComboBox);
expect(comboBox.prop('options')).toEqual(
allSuggestionsMock.map((sug) => {
return {
label: sug.title,
value: sug.title,
};
})
);
});
it('should pass the current suggestion as selectedProps to the EuiComboBox', () => {
const onSuggestionChange = jest.fn();
const wrapper = mountWithIntl(
<SuggestionSelector
suggestions={allSuggestionsMock}
activeSuggestion={currentSuggestionMock}
onSuggestionChange={onSuggestionChange}
/>
);
const comboBox = wrapper.find(EuiComboBox);
expect(comboBox.prop('selectedOptions')).toEqual([
{ label: currentSuggestionMock.title, value: currentSuggestionMock.title },
]);
});
it('should call onSuggestionChange when the user selects another suggestion', () => {
const onSuggestionChange = jest.fn();
const wrapper = mountWithIntl(
<SuggestionSelector
suggestions={allSuggestionsMock}
activeSuggestion={currentSuggestionMock}
onSuggestionChange={onSuggestionChange}
/>
);
const comboBox = wrapper.find(EuiComboBox);
const selectedSuggestion = allSuggestionsMock.find((sug) => sug.title === 'Donut')!;
comboBox.prop('onChange')!([
{ label: selectedSuggestion.title, value: selectedSuggestion.title },
]);
expect(onSuggestionChange).toHaveBeenCalledWith(selectedSuggestion);
});
});

View file

@ -0,0 +1,112 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
EuiComboBox,
EuiFlexItem,
EuiToolTip,
useEuiTheme,
EuiFlexGroup,
EuiIcon,
} from '@elastic/eui';
import { css } from '@emotion/react';
import type { Suggestion } from '@kbn/lens-plugin/public';
import { i18n } from '@kbn/i18n';
import React, { useCallback, useState } from 'react';
export interface SuggestionSelectorProps {
suggestions: Suggestion[];
activeSuggestion?: Suggestion;
onSuggestionChange?: (sug: Suggestion | undefined) => void;
}
export const SuggestionSelector = ({
suggestions,
activeSuggestion,
onSuggestionChange,
}: SuggestionSelectorProps) => {
const suggestionOptions = suggestions.map((sug) => {
return {
label: sug.title,
value: sug.title,
};
});
const selectedSuggestion = activeSuggestion
? [
{
label: activeSuggestion.title,
value: activeSuggestion.title,
},
]
: [];
const onSelectionChange = useCallback(
(newOptions) => {
const suggestion = newOptions.length
? suggestions.find((current) => current.title === newOptions[0].value)
: activeSuggestion;
onSuggestionChange?.(suggestion);
},
[activeSuggestion, onSuggestionChange, suggestions]
);
const [suggestionsPopoverDisabled, setSuggestionaPopoverDisabled] = useState(false);
const disableFieldPopover = useCallback(() => setSuggestionaPopoverDisabled(true), []);
const enableFieldPopover = useCallback(
() => setTimeout(() => setSuggestionaPopoverDisabled(false)),
[]
);
const { euiTheme } = useEuiTheme();
const suggestionComboCss = css`
width: 100%;
max-width: ${euiTheme.base * 22}px;
`;
return (
<EuiToolTip
position="top"
content={suggestionsPopoverDisabled ? undefined : activeSuggestion?.title}
anchorProps={{ css: suggestionComboCss }}
>
<EuiComboBox
data-test-subj="unifiedHistogramSuggestionSelector"
prepend={i18n.translate('unifiedHistogram.suggestionSelectorLabel', {
defaultMessage: 'Visualization',
})}
placeholder={i18n.translate('unifiedHistogram.suggestionSelectorPlaceholder', {
defaultMessage: 'Select visualization',
})}
singleSelection={{ asPlainText: true }}
options={suggestionOptions}
selectedOptions={selectedSuggestion}
onChange={onSelectionChange}
compressed
fullWidth={true}
isClearable={false}
onFocus={disableFieldPopover}
onBlur={enableFieldPopover}
renderOption={(option) => {
const suggestion = suggestions.find((s) => {
return s.title === option.label;
});
return (
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem grow={null}>
<EuiIcon type={suggestion?.previewIcon ?? 'empty'} />
</EuiFlexItem>
<EuiFlexItem>{option.label}</EuiFlexItem>
</EuiFlexGroup>
);
}}
/>
</EuiToolTip>
);
};

View file

@ -10,6 +10,7 @@ import { getLensAttributes } from './get_lens_attributes';
import { AggregateQuery, Filter, FilterStateStore, Query } from '@kbn/es-query';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield';
import { currentSuggestionMock } from '../../__mocks__/suggestions';
describe('getLensAttributes', () => {
const dataView: DataView = dataViewWithTimefieldMock;
@ -45,7 +46,15 @@ describe('getLensAttributes', () => {
it('should return correct attributes', () => {
const breakdownField: DataViewField | undefined = undefined;
expect(
getLensAttributes({ title: 'test', filters, query, dataView, timeInterval, breakdownField })
getLensAttributes({
title: 'test',
filters,
query,
dataView,
timeInterval,
breakdownField,
suggestion: undefined,
})
).toMatchInlineSnapshot(`
Object {
"references": Array [
@ -185,7 +194,15 @@ describe('getLensAttributes', () => {
(f) => f.name === 'extension'
);
expect(
getLensAttributes({ title: 'test', filters, query, dataView, timeInterval, breakdownField })
getLensAttributes({
title: 'test',
filters,
query,
dataView,
timeInterval,
breakdownField,
suggestion: undefined,
})
).toMatchInlineSnapshot(`
Object {
"references": Array [
@ -343,7 +360,15 @@ describe('getLensAttributes', () => {
(f) => f.name === 'scripted'
);
expect(
getLensAttributes({ title: 'test', filters, query, dataView, timeInterval, breakdownField })
getLensAttributes({
title: 'test',
filters,
query,
dataView,
timeInterval,
breakdownField,
suggestion: undefined,
})
).toMatchInlineSnapshot(`
Object {
"references": Array [
@ -477,4 +502,209 @@ describe('getLensAttributes', () => {
}
`);
});
it('should return correct attributes for text based languages', () => {
expect(
getLensAttributes({
title: 'test',
filters,
query,
dataView,
timeInterval,
breakdownField: undefined,
suggestion: currentSuggestionMock,
})
).toMatchInlineSnapshot(`
Object {
"references": Array [
Object {
"id": "index-pattern-with-timefield-id",
"name": "indexpattern-datasource-current-indexpattern",
"type": "index-pattern",
},
Object {
"id": "index-pattern-with-timefield-id",
"name": "indexpattern-datasource-layer-unifiedHistogram",
"type": "index-pattern",
},
],
"state": Object {
"datasourceStates": Object {
"textBased": Object {
"fieldList": Array [],
"indexPatternRefs": Array [],
"initialContext": Object {
"contextualFields": Array [
"Dest",
"AvgTicketPrice",
],
"dataViewSpec": Object {
"allowNoIndex": false,
"fields": Object {
"AvgTicketPrice": Object {
"aggregatable": true,
"count": 0,
"esTypes": Array [
"float",
],
"format": Object {
"id": "number",
"params": Object {
"pattern": "$0,0.[00]",
},
},
"isMapped": true,
"name": "AvgTicketPrice",
"readFromDocValues": true,
"scripted": false,
"searchable": true,
"shortDotsEnable": false,
"type": "number",
},
"Dest": Object {
"aggregatable": true,
"count": 0,
"esTypes": Array [
"keyword",
],
"format": Object {
"id": "string",
},
"isMapped": true,
"name": "Dest",
"readFromDocValues": true,
"scripted": false,
"searchable": true,
"shortDotsEnable": false,
"type": "string",
},
"timestamp": Object {
"aggregatable": true,
"count": 0,
"esTypes": Array [
"date",
],
"format": Object {
"id": "date",
},
"isMapped": true,
"name": "timestamp",
"readFromDocValues": true,
"scripted": false,
"searchable": true,
"shortDotsEnable": false,
"type": "date",
},
},
"id": "d3d7af60-4c81-11e8-b3d7-01146121b73d",
"name": "Kibana Sample Data Flights",
"sourceFilters": Array [],
"timeFieldName": "timestamp",
"title": "kibana_sample_data_flights",
"version": "WzM1ODA3LDFd",
},
"fieldName": "",
"query": Object {
"sql": "SELECT Dest, AvgTicketPrice FROM \\"kibana_sample_data_flights\\"",
},
},
"layers": Object {
"46aa21fa-b747-4543-bf90-0b40007c546d": Object {
"allColumns": Array [
Object {
"columnId": "81e332d6-ee37-42a8-a646-cea4fc75d2d3",
"fieldName": "Dest",
"meta": Object {
"type": "string",
},
},
Object {
"columnId": "5b9b8b76-0836-4a12-b9c0-980c9900502f",
"fieldName": "AvgTicketPrice",
"meta": Object {
"type": "number",
},
},
],
"columns": Array [
Object {
"columnId": "81e332d6-ee37-42a8-a646-cea4fc75d2d3",
"fieldName": "Dest",
"meta": Object {
"type": "string",
},
},
Object {
"columnId": "5b9b8b76-0836-4a12-b9c0-980c9900502f",
"fieldName": "AvgTicketPrice",
"meta": Object {
"type": "number",
},
},
],
"index": "d3d7af60-4c81-11e8-b3d7-01146121b73d",
"query": Object {
"sql": "SELECT Dest, AvgTicketPrice FROM \\"kibana_sample_data_flights\\"",
},
"timeField": "timestamp",
},
},
},
},
"filters": Array [
Object {
"$state": Object {
"store": "appState",
},
"meta": Object {
"alias": null,
"disabled": false,
"index": "index-pattern-with-timefield-id",
"key": "extension",
"negate": false,
"params": Object {
"query": "js",
},
"type": "phrase",
},
"query": Object {
"match": Object {
"extension": Object {
"query": "js",
"type": "phrase",
},
},
},
},
],
"query": Object {
"language": "kuery",
"query": "extension : css",
},
"visualization": Object {
"gridConfig": Object {
"isCellLabelVisible": false,
"isXAxisLabelVisible": true,
"isXAxisTitleVisible": false,
"isYAxisLabelVisible": true,
"isYAxisTitleVisible": false,
"type": "heatmap_grid",
},
"layerId": "46aa21fa-b747-4543-bf90-0b40007c546d",
"layerType": "data",
"legend": Object {
"isVisible": true,
"position": "right",
"type": "heatmap_legend",
},
"shape": "heatmap",
"valueAccessor": "5b9b8b76-0836-4a12-b9c0-980c9900502f",
"xAccessor": "81e332d6-ee37-42a8-a646-cea4fc75d2d3",
},
},
"title": "test",
"visualizationType": "lnsHeatmap",
}
`);
});
});

View file

@ -15,6 +15,7 @@ import type {
GenericIndexPatternColumn,
TermsIndexPatternColumn,
TypedLensByValueInput,
Suggestion,
} from '@kbn/lens-plugin/public';
import { fieldSupportsBreakdown } from './field_supports_breakdown';
@ -25,6 +26,7 @@ export const getLensAttributes = ({
dataView,
timeInterval,
breakdownField,
suggestion,
}: {
title?: string;
filters: Filter[];
@ -32,6 +34,7 @@ export const getLensAttributes = ({
dataView: DataView;
timeInterval: string | undefined;
breakdownField: DataViewField | undefined;
suggestion: Suggestion | undefined;
}) => {
const showBreakdown = breakdownField && fieldSupportsBreakdown(breakdownField);
@ -103,35 +106,27 @@ export const getLensAttributes = ({
};
}
return {
title:
title ??
i18n.translate('unifiedHistogram.lensTitle', {
defaultMessage: 'Edit visualization',
}),
references: [
{
id: dataView.id ?? '',
name: 'indexpattern-datasource-current-indexpattern',
type: 'index-pattern',
},
{
id: dataView.id ?? '',
name: 'indexpattern-datasource-layer-unifiedHistogram',
type: 'index-pattern',
},
],
state: {
datasourceStates: {
formBased: {
layers: {
unifiedHistogram: { columnOrder, columns },
const suggestionDatasourceState = Object.assign({}, suggestion?.datasourceState);
const suggestionVisualizationState = Object.assign({}, suggestion?.visualizationState);
const datasourceStates =
suggestion && suggestion.datasourceState
? {
[suggestion.datasourceId!]: {
...suggestionDatasourceState,
},
},
},
filters,
query: 'language' in query ? query : { language: 'kuery', query: '' },
visualization: {
}
: {
formBased: {
layers: {
unifiedHistogram: { columnOrder, columns },
},
},
};
const visualization = suggestion
? {
...suggestionVisualizationState,
}
: {
layers: [
{
accessors: ['count_column'],
@ -173,8 +168,32 @@ export const getLensAttributes = ({
yLeft: true,
yRight: false,
},
};
return {
title:
title ??
i18n.translate('unifiedHistogram.lensTitle', {
defaultMessage: 'Edit visualization',
}),
references: [
{
id: dataView.id ?? '',
name: 'indexpattern-datasource-current-indexpattern',
type: 'index-pattern',
},
{
id: dataView.id ?? '',
name: 'indexpattern-datasource-layer-unifiedHistogram',
type: 'index-pattern',
},
],
state: {
datasourceStates,
filters,
query,
visualization,
},
visualizationType: 'lnsXY',
visualizationType: suggestion ? suggestion.visualizationId : 'lnsXY',
} as TypedLensByValueInput['attributes'];
};

View file

@ -31,6 +31,8 @@ describe('UnifiedHistogramContainer', () => {
topPanelHeight: 100,
totalHitsStatus: UnifiedHistogramFetchStatus.uninitialized,
totalHitsResult: undefined,
columns: [],
currentSuggestion: undefined,
};
it('should set ref', () => {
@ -61,7 +63,7 @@ describe('UnifiedHistogramContainer', () => {
const component = mountWithIntl(
<UnifiedHistogramContainer ref={setApi} resizeRef={{ current: null }} />
);
await new Promise((resolve) => setTimeout(resolve, 0));
await act(() => new Promise((resolve) => setTimeout(resolve, 0)));
expect(component.update().isEmptyRender()).toBe(false);
});
@ -80,6 +82,7 @@ describe('UnifiedHistogramContainer', () => {
});
}
});
await act(() => new Promise((resolve) => setTimeout(resolve, 0)));
expect(api?.initialized).toBe(true);
});
});

View file

@ -9,6 +9,7 @@
import React, { forwardRef, useImperativeHandle, useMemo, useState } from 'react';
import { Subject } from 'rxjs';
import { pick } from 'lodash';
import { LensSuggestionsApi } from '@kbn/lens-plugin/public';
import { UnifiedHistogramLayout, UnifiedHistogramLayoutProps } from '../layout';
import type { UnifiedHistogramInputMessage } from '../types';
import {
@ -19,6 +20,8 @@ import {
import { useStateProps } from './hooks/use_state_props';
import { useStateSelector } from './utils/use_state_selector';
import {
columnsSelector,
currentSuggestionSelector,
dataViewSelector,
filtersSelector,
querySelector,
@ -81,6 +84,7 @@ export type UnifiedHistogramInitializedApi = {
| 'setChartHidden'
| 'setTopPanelHeight'
| 'setBreakdownField'
| 'setColumns'
| 'setTimeInterval'
| 'setRequestParams'
| 'setTotalHits'
@ -98,6 +102,7 @@ export const UnifiedHistogramContainer = forwardRef<
const [initialized, setInitialized] = useState(false);
const [layoutProps, setLayoutProps] = useState<LayoutProps>();
const [stateService, setStateService] = useState<UnifiedHistogramStateService>();
const [lensSuggestionsApi, setLensSuggestionsApi] = useState<LensSuggestionsApi>();
const [input$] = useState(() => new Subject<UnifiedHistogramInputMessage>());
const api = useMemo<UnifiedHistogramApi>(
() => ({
@ -111,6 +116,12 @@ export const UnifiedHistogramContainer = forwardRef<
getRelativeTimeRange,
} = options;
// API helpers are loaded async from Lens
(async () => {
const apiHelper = await services.lens.stateHelperApi();
setLensSuggestionsApi(() => apiHelper.suggestions);
})();
setLayoutProps({
services,
disableAutoFetching,
@ -130,6 +141,7 @@ export const UnifiedHistogramContainer = forwardRef<
'setChartHidden',
'setTopPanelHeight',
'setBreakdownField',
'setColumns',
'setTimeInterval',
'setRequestParams',
'setTotalHits'
@ -146,10 +158,12 @@ export const UnifiedHistogramContainer = forwardRef<
const query = useStateSelector(stateService?.state$, querySelector);
const filters = useStateSelector(stateService?.state$, filtersSelector);
const timeRange = useStateSelector(stateService?.state$, timeRangeSelector);
const columns = useStateSelector(stateService?.state$, columnsSelector);
const currentSuggestion = useStateSelector(stateService?.state$, currentSuggestionSelector);
const topPanelHeight = useStateSelector(stateService?.state$, topPanelHeightSelector);
// Don't render anything until the container is initialized
if (!layoutProps || !dataView) {
if (!layoutProps || !dataView || !lensSuggestionsApi) {
return null;
}
@ -162,8 +176,11 @@ export const UnifiedHistogramContainer = forwardRef<
query={query}
filters={filters}
timeRange={timeRange}
columns={columns}
currentSuggestion={currentSuggestion}
topPanelHeight={topPanelHeight}
input$={input$}
lensSuggestionsApi={lensSuggestionsApi}
/>
);
});

View file

@ -8,11 +8,13 @@
import { DataView, DataViewField, DataViewType } from '@kbn/data-views-plugin/common';
import { RequestAdapter } from '@kbn/inspector-plugin/common';
import { Suggestion } from '@kbn/lens-plugin/public';
import { renderHook } from '@testing-library/react-hooks';
import { act } from 'react-test-renderer';
import { UnifiedHistogramFetchStatus } from '../../types';
import { dataViewMock } from '../../__mocks__/data_view';
import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield';
import { currentSuggestionMock } from '../../__mocks__/suggestions';
import { unifiedHistogramServicesMock } from '../../__mocks__/services';
import {
createStateService,
@ -36,6 +38,8 @@ describe('useStateProps', () => {
topPanelHeight: 100,
totalHitsStatus: UnifiedHistogramFetchStatus.uninitialized,
totalHitsResult: undefined,
columns: [],
currentSuggestion: undefined,
};
const getStateService = (options: Omit<UnifiedHistogramStateOptions, 'services'>) => {
@ -50,6 +54,7 @@ describe('useStateProps', () => {
jest.spyOn(stateService, 'setRequestParams');
jest.spyOn(stateService, 'setLensRequestAdapter');
jest.spyOn(stateService, 'setTotalHits');
jest.spyOn(stateService, 'setCurrentSuggestion');
return stateService;
};
@ -76,9 +81,11 @@ describe('useStateProps', () => {
"status": "uninitialized",
"total": undefined,
},
"isPlainRecord": false,
"onBreakdownFieldChange": [Function],
"onChartHiddenChange": [Function],
"onChartLoad": [Function],
"onSuggestionChange": [Function],
"onTimeIntervalChange": [Function],
"onTopPanelHeightChange": [Function],
"onTotalHitsChange": [Function],
@ -104,11 +111,19 @@ describe('useStateProps', () => {
expect(result.current).toMatchInlineSnapshot(`
Object {
"breakdown": undefined,
"chart": undefined,
"hits": undefined,
"chart": Object {
"hidden": false,
"timeInterval": "auto",
},
"hits": Object {
"status": "uninitialized",
"total": undefined,
},
"isPlainRecord": true,
"onBreakdownFieldChange": [Function],
"onChartHiddenChange": [Function],
"onChartLoad": [Function],
"onSuggestionChange": [Function],
"onTimeIntervalChange": [Function],
"onTopPanelHeightChange": [Function],
"onTotalHitsChange": [Function],
@ -126,6 +141,20 @@ describe('useStateProps', () => {
`);
});
it('should return the correct props when a text based language is used', () => {
const stateService = getStateService({
initialState: {
...initialState,
query: { sql: 'SELECT * FROM index' },
currentSuggestion: currentSuggestionMock,
},
});
const { result } = renderHook(() => useStateProps(stateService));
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 a rollup data view is used', () => {
const stateService = getStateService({
initialState: {
@ -145,9 +174,11 @@ describe('useStateProps', () => {
"status": "uninitialized",
"total": undefined,
},
"isPlainRecord": false,
"onBreakdownFieldChange": [Function],
"onChartHiddenChange": [Function],
"onChartLoad": [Function],
"onSuggestionChange": [Function],
"onTimeIntervalChange": [Function],
"onTopPanelHeightChange": [Function],
"onTotalHitsChange": [Function],
@ -178,9 +209,11 @@ describe('useStateProps', () => {
"status": "uninitialized",
"total": undefined,
},
"isPlainRecord": false,
"onBreakdownFieldChange": [Function],
"onChartHiddenChange": [Function],
"onChartLoad": [Function],
"onSuggestionChange": [Function],
"onTimeIntervalChange": [Function],
"onTopPanelHeightChange": [Function],
"onTotalHitsChange": [Function],
@ -208,6 +241,7 @@ describe('useStateProps', () => {
onChartHiddenChange,
onChartLoad,
onBreakdownFieldChange,
onSuggestionChange,
} = result.current;
act(() => {
onTopPanelHeightChange(200);
@ -237,6 +271,11 @@ describe('useStateProps', () => {
onBreakdownFieldChange({ name: 'field' } as DataViewField);
});
expect(stateService.setBreakdownField).toHaveBeenLastCalledWith('field');
act(() => {
onSuggestionChange({ title: 'Stacked Bar' } as Suggestion);
});
expect(stateService.setCurrentSuggestion).toHaveBeenLastCalledWith({ title: 'Stacked Bar' });
});
it('should clear lensRequestAdapter when chart is hidden', () => {

View file

@ -40,7 +40,11 @@ export const useStateProps = (stateService: UnifiedHistogramStateService | undef
*/
const isPlainRecord = useMemo(() => {
return query && isOfAggregateQueryType(query) && getAggregateQueryMode(query) === 'sql';
return (
query &&
isOfAggregateQueryType(query) &&
['sql', 'esql'].some((mode) => mode === getAggregateQueryMode(query))
);
}, [query]);
const isTimeBased = useMemo(() => {
@ -48,7 +52,7 @@ export const useStateProps = (stateService: UnifiedHistogramStateService | undef
}, [dataView]);
const hits = useMemo(() => {
if (isPlainRecord || totalHitsResult instanceof Error) {
if (totalHitsResult instanceof Error) {
return undefined;
}
@ -56,10 +60,10 @@ export const useStateProps = (stateService: UnifiedHistogramStateService | undef
status: totalHitsStatus,
total: totalHitsResult,
};
}, [isPlainRecord, totalHitsResult, totalHitsStatus]);
}, [totalHitsResult, totalHitsStatus]);
const chart = useMemo(() => {
if (isPlainRecord || !isTimeBased) {
if (!isTimeBased && !isPlainRecord) {
return undefined;
}
@ -136,6 +140,13 @@ export const useStateProps = (stateService: UnifiedHistogramStateService | undef
[stateService]
);
const onSuggestionChange = useCallback(
(suggestion) => {
stateService?.setCurrentSuggestion(suggestion);
},
[stateService]
);
/**
* Effects
*/
@ -152,11 +163,13 @@ export const useStateProps = (stateService: UnifiedHistogramStateService | undef
chart,
breakdown,
request,
isPlainRecord,
onTopPanelHeightChange,
onTimeIntervalChange,
onTotalHitsChange,
onChartHiddenChange,
onChartLoad,
onBreakdownFieldChange,
onSuggestionChange,
};
};

View file

@ -59,6 +59,8 @@ describe('UnifiedHistogramStateService', () => {
topPanelHeight: 100,
totalHitsStatus: UnifiedHistogramFetchStatus.uninitialized,
totalHitsResult: undefined,
columns: [],
currentSuggestion: undefined,
};
it('should initialize state with default values', () => {
@ -84,6 +86,9 @@ describe('UnifiedHistogramStateService', () => {
topPanelHeight: undefined,
totalHitsResult: undefined,
totalHitsStatus: UnifiedHistogramFetchStatus.uninitialized,
columns: [],
currentSuggestion: undefined,
allSuggestions: undefined,
});
});

View file

@ -9,6 +9,7 @@
import type { DataView } from '@kbn/data-views-plugin/common';
import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import type { RequestAdapter } from '@kbn/inspector-plugin/common';
import type { Suggestion } from '@kbn/lens-plugin/public';
import { BehaviorSubject, Observable } from 'rxjs';
import { UnifiedHistogramFetchStatus } from '../..';
import type { UnifiedHistogramServices } from '../../types';
@ -29,6 +30,14 @@ export interface UnifiedHistogramState {
* The current field used for the breakdown
*/
breakdownField: string | undefined;
/**
* The current selected columns
*/
columns: string[] | undefined;
/**
* The current Lens suggestion
*/
currentSuggestion: Suggestion | undefined;
/**
* Whether or not the chart is hidden
*/
@ -109,6 +118,14 @@ export interface UnifiedHistogramStateService {
* Sets the current chart hidden state
*/
setChartHidden: (chartHidden: boolean) => void;
/**
* Sets current Lens suggestion
*/
setCurrentSuggestion: (suggestion: Suggestion | undefined) => void;
/**
* Sets columns
*/
setColumns: (columns: string[] | undefined) => void;
/**
* Sets the current top panel height
*/
@ -163,7 +180,9 @@ export const createStateService = (
const state$ = new BehaviorSubject({
breakdownField: initialBreakdownField,
chartHidden: initialChartHidden,
columns: [],
filters: [],
currentSuggestion: undefined,
lensRequestAdapter: undefined,
query: services.data.query.queryString.getDefaultQuery(),
requestAdapter: undefined,
@ -210,6 +229,14 @@ export const createStateService = (
updateState({ breakdownField });
},
setCurrentSuggestion: (suggestion: Suggestion | undefined) => {
updateState({ currentSuggestion: suggestion });
},
setColumns: (columns: string[] | undefined) => {
updateState({ columns });
},
setTimeInterval: (timeInterval: string) => {
updateState({ timeInterval });
},

View file

@ -9,6 +9,7 @@
import type { UnifiedHistogramState } from '../services/state_service';
export const breakdownFieldSelector = (state: UnifiedHistogramState) => state.breakdownField;
export const columnsSelector = (state: UnifiedHistogramState) => state.columns;
export const chartHiddenSelector = (state: UnifiedHistogramState) => state.chartHidden;
export const dataViewSelector = (state: UnifiedHistogramState) => state.dataView;
export const filtersSelector = (state: UnifiedHistogramState) => state.filters;
@ -20,3 +21,4 @@ export const timeRangeSelector = (state: UnifiedHistogramState) => state.timeRan
export const topPanelHeightSelector = (state: UnifiedHistogramState) => state.topPanelHeight;
export const totalHitsResultSelector = (state: UnifiedHistogramState) => state.totalHitsResult;
export const totalHitsStatusSelector = (state: UnifiedHistogramState) => state.totalHitsStatus;
export const currentSuggestionSelector = (state: UnifiedHistogramState) => state.currentSuggestion;

View file

@ -0,0 +1,92 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { DataView } from '@kbn/data-views-plugin/common';
import { AggregateQuery, isOfAggregateQueryType, Query } from '@kbn/es-query';
import { LensSuggestionsApi, Suggestion } from '@kbn/lens-plugin/public';
import { isEqual } from 'lodash';
import { useEffect, useMemo, useRef, useState } from 'react';
export const useLensSuggestions = ({
dataView,
query,
originalSuggestion,
isPlainRecord,
columns,
lensSuggestionsApi,
onSuggestionChange,
}: {
dataView: DataView;
query?: Query | AggregateQuery;
originalSuggestion?: Suggestion;
isPlainRecord?: boolean;
columns?: string[];
lensSuggestionsApi: LensSuggestionsApi;
onSuggestionChange?: (suggestion: Suggestion | undefined) => void;
}) => {
const suggestions = useMemo(() => {
const context = {
dataViewSpec: dataView?.toSpec(),
fieldName: '',
contextualFields: columns,
query: query && isOfAggregateQueryType(query) ? query : undefined,
};
const lensSuggestions = isPlainRecord ? lensSuggestionsApi(context, dataView) : undefined;
const firstSuggestion = lensSuggestions?.length ? lensSuggestions[0] : undefined;
const restSuggestions = lensSuggestions?.filter((sug) => {
return !sug.hide && sug.visualizationId !== 'lnsLegacyMetric';
});
const firstSuggestionExists = restSuggestions?.find(
(sug) => sug.title === firstSuggestion?.title
);
if (firstSuggestion && !firstSuggestionExists) {
restSuggestions?.push(firstSuggestion);
}
return { firstSuggestion, restSuggestions };
}, [columns, dataView, isPlainRecord, lensSuggestionsApi, query]);
const [allSuggestions, setAllSuggestions] = useState(suggestions.restSuggestions);
const currentSuggestion = originalSuggestion ?? suggestions.firstSuggestion;
const suggestionDeps = useRef(getSuggestionDeps({ dataView, query, columns }));
useEffect(() => {
const newSuggestionsDeps = getSuggestionDeps({ dataView, query, columns });
if (!isEqual(suggestionDeps.current, newSuggestionsDeps)) {
setAllSuggestions(suggestions.restSuggestions);
onSuggestionChange?.(suggestions.firstSuggestion);
suggestionDeps.current = newSuggestionsDeps;
}
}, [
columns,
dataView,
onSuggestionChange,
query,
suggestions.firstSuggestion,
suggestions.restSuggestions,
]);
return {
allSuggestions,
currentSuggestion,
suggestionUnsupported:
isPlainRecord &&
(!currentSuggestion || currentSuggestion?.visualizationId === 'lnsDatatable'),
};
};
const getSuggestionDeps = ({
dataView,
query,
columns,
}: {
dataView: DataView;
query?: Query | AggregateQuery;
columns?: string[];
}) => [dataView.id, columns, query];

View file

@ -76,6 +76,7 @@ describe('Layout', () => {
from: '2020-05-14T11:05:13.590',
to: '2020-05-14T11:20:13.590',
}}
lensSuggestionsApi={jest.fn()}
{...rest}
/>
);

View file

@ -7,13 +7,13 @@
*/
import { EuiSpacer, useEuiTheme, useIsWithinBreakpoints } from '@elastic/eui';
import type { PropsWithChildren, ReactElement, RefObject } from 'react';
import { PropsWithChildren, ReactElement, RefObject } from 'react';
import React, { useMemo } from 'react';
import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal';
import { css } from '@emotion/css';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import type { LensEmbeddableInput } from '@kbn/lens-plugin/public';
import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import type { LensEmbeddableInput, LensSuggestionsApi, Suggestion } from '@kbn/lens-plugin/public';
import { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import { Chart } from '../chart';
import { Panels, PANELS_MODE } from '../panels';
import type {
@ -26,6 +26,7 @@ import type {
UnifiedHistogramChartLoadEvent,
UnifiedHistogramInput$,
} from '../types';
import { useLensSuggestions } from './hooks/use_lens_suggestions';
export interface UnifiedHistogramLayoutProps extends PropsWithChildren<unknown> {
/**
@ -48,10 +49,22 @@ export interface UnifiedHistogramLayoutProps extends PropsWithChildren<unknown>
* The current filters
*/
filters?: Filter[];
/**
* The current Lens suggestion
*/
currentSuggestion?: Suggestion;
/**
* Flag that indicates that a text based language is used
*/
isPlainRecord?: boolean;
/**
* The current time range
*/
timeRange?: TimeRange;
/**
* The current columns
*/
columns?: string[];
/**
* Context object for requests made by Unified Histogram components -- optional
*/
@ -96,6 +109,10 @@ export interface UnifiedHistogramLayoutProps extends PropsWithChildren<unknown>
* Input observable
*/
input$?: UnifiedHistogramInput$;
/**
* The Lens suggestions API
*/
lensSuggestionsApi: LensSuggestionsApi;
/**
* Callback to get the relative time range, useful when passing an absolute time range (e.g. for edit visualization button)
*/
@ -116,6 +133,10 @@ export interface UnifiedHistogramLayoutProps extends PropsWithChildren<unknown>
* Callback to update the breakdown field -- should set {@link UnifiedHistogramBreakdownContext.field} to breakdownField
*/
onBreakdownFieldChange?: (breakdownField: DataViewField | undefined) => void;
/**
* Callback to update the suggested chart
*/
onSuggestionChange?: (suggestion: Suggestion | undefined) => void;
/**
* Callback to update the total hits -- should set {@link UnifiedHistogramHitsContext.status} to status
* and {@link UnifiedHistogramHitsContext.total} to result
@ -141,10 +162,13 @@ export const UnifiedHistogramLayout = ({
dataView,
query,
filters,
currentSuggestion: originalSuggestion,
isPlainRecord,
timeRange,
columns,
request,
hits,
chart,
chart: originalChart,
breakdown,
resizeRef,
topPanelHeight,
@ -152,18 +176,32 @@ export const UnifiedHistogramLayout = ({
disableAutoFetching,
disableTriggers,
disabledActions,
lensSuggestionsApi,
input$,
getRelativeTimeRange,
onTopPanelHeightChange,
onChartHiddenChange,
onTimeIntervalChange,
onBreakdownFieldChange,
onSuggestionChange,
onTotalHitsChange,
onChartLoad,
onFilter,
onBrushEnd,
children,
}: UnifiedHistogramLayoutProps) => {
const { allSuggestions, currentSuggestion, suggestionUnsupported } = useLensSuggestions({
dataView,
query,
originalSuggestion,
isPlainRecord,
columns,
lensSuggestionsApi,
onSuggestionChange,
});
const chart = suggestionUnsupported ? undefined : originalChart;
const topPanelNode = useMemo(
() => createHtmlPortalNode({ attributes: { class: 'eui-fullHeight' } }),
[]
@ -214,6 +252,9 @@ export const UnifiedHistogramLayout = ({
timeRange={timeRange}
request={request}
hits={hits}
currentSuggestion={currentSuggestion}
allSuggestions={allSuggestions}
isPlainRecord={isPlainRecord}
chart={chart}
breakdown={breakdown}
appendHitsCounter={appendHitsCounter}
@ -227,6 +268,7 @@ export const UnifiedHistogramLayout = ({
onChartHiddenChange={onChartHiddenChange}
onTimeIntervalChange={onTimeIntervalChange}
onBreakdownFieldChange={onBreakdownFieldChange}
onSuggestionChange={onSuggestionChange}
onTotalHitsChange={onTotalHitsChange}
onChartLoad={onChartLoad}
onFilter={onFilter}

View file

@ -24,6 +24,7 @@ export const createMockUnifiedHistogramApi = (
setChartHidden: jest.fn(),
setTopPanelHeight: jest.fn(),
setBreakdownField: jest.fn(),
setColumns: jest.fn(),
setTimeInterval: jest.fn(),
setRequestParams: jest.fn(),
setTotalHits: jest.fn(),

View file

@ -17,6 +17,7 @@ import type { DefaultInspectorAdapters } from '@kbn/expressions-plugin/common';
import type { Subject } from 'rxjs';
import type { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import type { Storage } from '@kbn/kibana-utils-plugin/public';
import type { ExpressionsStart } from '@kbn/expressions-plugin/public';
/**
* The fetch status of a Unified Histogram request
@ -40,6 +41,7 @@ export interface UnifiedHistogramServices {
fieldFormats: FieldFormatsStart;
lens: LensPublicStart;
storage: Storage;
expressions: ExpressionsStart;
}
/**

View file

@ -66,8 +66,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await testSubjects.exists('showQueryBarMenu')).to.be(false);
expect(await testSubjects.exists('addFilter')).to.be(false);
expect(await testSubjects.exists('dscViewModeDocumentButton')).to.be(false);
// here Lens suggests a table so the chart is not rendered
expect(await testSubjects.exists('unifiedHistogramChart')).to.be(false);
expect(await testSubjects.exists('unifiedHistogramQueryHits')).to.be(false);
expect(await testSubjects.exists('unifiedHistogramQueryHits')).to.be(true);
expect(await testSubjects.exists('discoverAlertsButton')).to.be(false);
expect(await testSubjects.exists('shareTopNavButton')).to.be(false);
expect(await testSubjects.exists('docTableExpandToggleColumn')).to.be(false);
@ -87,7 +88,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await monacoEditor.setCodeEditorValue(testQuery);
await testSubjects.click('querySubmitButton');
await PageObjects.header.waitUntilLoadingHasFinished();
// here Lens suggests a heatmap so it is rendered
expect(await testSubjects.exists('unifiedHistogramChart')).to.be(true);
expect(await testSubjects.exists('heatmapChart')).to.be(true);
const cell = await dataGrid.getCellElement(0, 3);
expect(await cell.getVisibleText()).to.be('2269');
});

View file

@ -210,6 +210,10 @@ export class DiscoverPageObject extends FtrService {
await this.comboBox.set('unifiedHistogramBreakdownFieldSelector', field);
}
public async chooseLensChart(chart: string) {
await this.comboBox.set('unifiedHistogramSuggestionSelector', chart);
}
public async getHistogramLegendList() {
const unifiedHistogram = await this.testSubjects.find('unifiedHistogramChart');
const list = await unifiedHistogram.findAllByClassName('echLegendItem__label');

View file

@ -93,7 +93,7 @@ export const getRotatingNumberVisualization = ({
{
previewIcon: 'refresh',
score: 0.5,
title: `Rotating ${table.label}` || 'Rotating number',
title: table.label ? `Rotating ${table.label}` : 'Rotating number',
state: {
layerId: table.layerId,
color: state?.color || DEFAULT_COLOR,

View file

@ -32,6 +32,7 @@ export * from './visualizations/gauge';
export * from './datasources/form_based/form_based';
export { getTextBasedDatasource } from './datasources/text_based/text_based_languages';
export { createFormulaPublicApi } from './datasources/form_based/operations/definitions/formula/formula_public_api';
export * from './lens_suggestions_api';
export * from './datasources/text_based';
export * from './datasources/form_based';

View file

@ -441,7 +441,7 @@ describe('Textbased Data Source', () => {
},
},
],
index: 'foo',
index: '1',
query: {
sql: 'SELECT * FROM "foo"',
},

View file

@ -116,7 +116,7 @@ export function getTextBasedDatasource({
};
});
const index = context.dataViewSpec.title;
const index = context.dataViewSpec.id ?? context.dataViewSpec.title;
const query = context.query;
const updatedState = {
...state,
@ -127,6 +127,7 @@ export function getTextBasedDatasource({
query,
columns: newColumns ?? [],
allColumns: newColumns ?? [],
timeField: context.dataViewSpec.timeFieldName,
},
},
};

View file

@ -30,6 +30,7 @@ export type {
TableSuggestion,
Visualization,
VisualizationSuggestion,
Suggestion,
} from './types';
export type {
LegacyMetricState as MetricState,
@ -109,6 +110,6 @@ export type { ChartInfo } from './chart_info_api';
export { layerTypes } from '../common/layer_types';
export { LENS_EMBEDDABLE_TYPE } from '../common/constants';
export type { LensPublicStart, LensPublicSetup } from './plugin';
export type { LensPublicStart, LensPublicSetup, LensSuggestionsApi } from './plugin';
export const plugin = () => new LensPlugin();

View file

@ -0,0 +1,105 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
// import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import type { DataView } from '@kbn/data-views-plugin/public';
import { createMockVisualization, DatasourceMock, createMockDatasource } from './mocks';
import { DatasourceSuggestion } from './types';
import { suggestionsApi } from './lens_suggestions_api';
const generateSuggestion = (state = {}, layerId: string = 'first'): DatasourceSuggestion => ({
state,
table: {
columns: [],
isMultiRow: false,
layerId,
changeType: 'unchanged',
},
keptLayerIds: [layerId],
});
describe('suggestionsApi', () => {
let datasourceMap: Record<string, DatasourceMock>;
const mockVis = createMockVisualization();
beforeEach(() => {
datasourceMap = {
textBased: createMockDatasource('textBased'),
};
});
test('call the getDatasourceSuggestionsForVisualizeField for the text based query', async () => {
const dataView = { id: 'index1' } as unknown as DataView;
const visualizationMap = {
testVis: {
...mockVis,
},
};
datasourceMap.textBased.getDatasourceSuggestionsForVisualizeField.mockReturnValue([
generateSuggestion(),
]);
const context = {
dataViewSpec: {
id: 'index1',
title: 'index1',
name: 'DataView',
},
fieldName: '',
contextualFields: ['field1', 'field2'],
query: {
sql: 'SELECT field1, field2 FROM "index1"',
},
};
const suggestions = suggestionsApi({ context, dataView, datasourceMap, visualizationMap });
expect(datasourceMap.textBased.getDatasourceSuggestionsForVisualizeField).toHaveBeenCalledWith(
{ layers: {}, fieldList: [], indexPatternRefs: [], initialContext: context },
'index1',
'',
{ index1: { id: 'index1' } }
);
expect(datasourceMap.textBased.getDatasourceSuggestionsFromCurrentState).not.toHaveBeenCalled();
expect(suggestions?.length).toEqual(0);
});
test('returns the visualizations suggestions', async () => {
const dataView = { id: 'index1' } as unknown as DataView;
const visualizationMap = {
testVis: {
...mockVis,
getSuggestions: () => [
{
score: 0.2,
title: 'Test',
state: {},
previewIcon: 'empty',
},
{
score: 0.8,
title: 'Test2',
state: {},
previewIcon: 'empty',
},
],
},
};
datasourceMap.textBased.getDatasourceSuggestionsForVisualizeField.mockReturnValue([
generateSuggestion(),
]);
const context = {
dataViewSpec: {
id: 'index1',
title: 'index1',
name: 'DataView',
},
fieldName: '',
contextualFields: ['field1', 'field2'],
query: {
sql: 'SELECT field1, field2 FROM "index1"',
},
};
const suggestions = suggestionsApi({ context, dataView, datasourceMap, visualizationMap });
expect(datasourceMap.textBased.getDatasourceSuggestionsFromCurrentState).toHaveBeenCalled();
expect(suggestions?.length).toEqual(1);
});
});

View file

@ -0,0 +1,82 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { VisualizeFieldContext } from '@kbn/ui-actions-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import { getSuggestions } from './editor_frame_service/editor_frame/suggestion_helpers';
import type { DatasourceMap, VisualizationMap, VisualizeEditorContext } from './types';
import type { DataViewsState } from './state_management';
interface SuggestionsApi {
context: VisualizeFieldContext | VisualizeEditorContext;
dataView: DataView;
visualizationMap?: VisualizationMap;
datasourceMap?: DatasourceMap;
}
export const suggestionsApi = ({
context,
dataView,
datasourceMap,
visualizationMap,
}: SuggestionsApi) => {
if (!datasourceMap || !visualizationMap || !dataView.id) return undefined;
const datasourceStates = {
formBased: {
isLoading: false,
state: {
layers: {},
},
},
textBased: {
isLoading: false,
state: {
layers: {},
fieldList: [],
indexPatternRefs: [],
initialContext: context,
},
},
};
const currentDataViewId = dataView.id;
const dataViews = {
indexPatterns: {
[currentDataViewId]: dataView,
},
indexPatternRefs: [],
} as unknown as DataViewsState;
const initialVisualization = visualizationMap?.[Object.keys(visualizationMap)[0]] || null;
// find the active visualizations from the context
const suggestions = getSuggestions({
datasourceMap,
datasourceStates,
visualizationMap,
activeVisualization: initialVisualization,
visualizationState: undefined,
visualizeTriggerFieldContext: context,
dataViews,
});
if (!suggestions.length) return [];
const activeVisualization = suggestions[0];
// compute the rest suggestions depending on the active one
const newSuggestions = getSuggestions({
datasourceMap,
datasourceStates: {
textBased: {
isLoading: false,
state: activeVisualization.datasourceState,
},
},
visualizationMap,
activeVisualization: visualizationMap[activeVisualization.visualizationId],
visualizationState: activeVisualization.visualizationState,
dataViews,
});
return [activeVisualization, ...newSuggestions];
};

View file

@ -16,7 +16,7 @@ import { Storage } from '@kbn/kibana-utils-plugin/public';
import type { DataPublicPluginSetup, DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public';
import { CONTEXT_MENU_TRIGGER } from '@kbn/embeddable-plugin/public';
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import type { DataViewsPublicPluginStart, DataView } from '@kbn/data-views-plugin/public';
import type { DashboardStart } from '@kbn/dashboard-plugin/public';
import type { SpacesPluginStart } from '@kbn/spaces-plugin/public';
import type {
@ -44,6 +44,7 @@ import {
UiActionsStart,
ACTION_VISUALIZE_FIELD,
VISUALIZE_FIELD_TRIGGER,
VisualizeFieldContext,
} from '@kbn/ui-actions-plugin/public';
import {
VISUALIZE_EDITOR_TRIGGER,
@ -89,6 +90,8 @@ import type {
VisualizationType,
EditorFrameSetup,
LensTopNavMenuEntryGenerator,
VisualizeEditorContext,
Suggestion,
} from './types';
import { getLensAliasConfig } from './vis_type_alias';
import { createOpenInDiscoverAction } from './trigger_actions/open_in_discover_action';
@ -232,9 +235,15 @@ export interface LensPublicStart {
stateHelperApi: () => Promise<{
formula: FormulaPublicApi;
chartInfo: ChartInfoApi;
suggestions: LensSuggestionsApi;
}>;
}
export type LensSuggestionsApi = (
context: VisualizeFieldContext | VisualizeEditorContext,
dataViews: DataView
) => Suggestion[] | undefined;
export class LensPlugin {
private datatableVisualization: DatatableVisualizationType | undefined;
private editorFrameService: EditorFrameServiceType | undefined;
@ -585,13 +594,30 @@ export class LensPlugin {
},
stateHelperApi: async () => {
const { createFormulaPublicApi, createChartInfoApi } = await import('./async_services');
const { createFormulaPublicApi, createChartInfoApi, suggestionsApi } = await import(
'./async_services'
);
if (!this.editorFrameService) {
await this.initDependenciesForApi();
}
const [visualizationMap, datasourceMap] = await Promise.all([
this.editorFrameService!.loadVisualizations(),
this.editorFrameService!.loadDatasources(),
]);
return {
formula: createFormulaPublicApi(),
chartInfo: createChartInfoApi(startDependencies.dataViews, this.editorFrameService),
suggestions: (
context: VisualizeFieldContext | VisualizeEditorContext,
dataView: DataView
) => {
return suggestionsApi({
datasourceMap,
visualizationMap,
context,
dataView,
});
},
};
},
};

View file

@ -6,6 +6,7 @@
*/
import { getSuggestions } from './suggestions';
import { IconChartVerticalBullet, IconChartHorizontalBullet } from '@kbn/chart-icons';
import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import { GaugeShapes } from '@kbn/expression-gauge-plugin/common';
import { GaugeVisualizationState } from './constants';
@ -157,12 +158,12 @@ describe('shows suggestions', () => {
},
title: 'Gauge',
hide: true,
previewIcon: 'empty',
previewIcon: IconChartHorizontalBullet,
score: 0.5,
},
{
hide: true,
previewIcon: 'empty',
previewIcon: IconChartVerticalBullet,
title: 'Gauge',
score: 0.5,
state: {
@ -204,7 +205,7 @@ describe('shows suggestions', () => {
ticksPosition: 'auto',
layerId: 'first',
},
previewIcon: 'empty',
previewIcon: IconChartVerticalBullet,
title: 'Gauge',
hide: false, // shows suggestion when current is gauge
score: 0.5,

View file

@ -12,6 +12,7 @@ import {
GaugeTicksPositions,
GaugeLabelMajorModes,
} from '@kbn/expression-gauge-plugin/common';
import { IconChartHorizontalBullet, IconChartVerticalBullet } from '@kbn/chart-icons';
import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import type { TableSuggestion, Visualization } from '../../types';
import type { GaugeVisualizationState } from './constants';
@ -64,7 +65,8 @@ export const getSuggestions: Visualization<GaugeVisualizationState>['getSuggesti
title: i18n.translate('xpack.lens.gauge.gaugeLabel', {
defaultMessage: 'Gauge',
}),
previewIcon: 'empty',
previewIcon:
shape === GaugeShapes.VERTICAL_BULLET ? IconChartVerticalBullet : IconChartHorizontalBullet,
score: 0.5,
hide: !isGauge || state?.metricAccessor === undefined, // only display for gauges for beta
};
@ -73,6 +75,10 @@ export const getSuggestions: Visualization<GaugeVisualizationState>['getSuggesti
? [
{
...baseSuggestion,
previewIcon:
state?.shape === GaugeShapes.VERTICAL_BULLET
? IconChartHorizontalBullet
: IconChartVerticalBullet,
state: {
...baseSuggestion.state,
...state,
@ -93,6 +99,10 @@ export const getSuggestions: Visualization<GaugeVisualizationState>['getSuggesti
},
{
...baseSuggestion,
previewIcon:
state?.shape === GaugeShapes.VERTICAL_BULLET
? IconChartHorizontalBullet
: IconChartVerticalBullet,
state: {
...baseSuggestion.state,
metricAccessor: table.columns[0].columnId,

View file

@ -6,6 +6,7 @@
*/
import { Position } from '@elastic/charts';
import { IconChartHeatmap } from '@kbn/chart-icons';
import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import { getSuggestions } from './suggestions';
import type { HeatmapVisualizationState } from './types';
@ -298,7 +299,7 @@ describe('heatmap suggestions', () => {
},
title: 'Heat map',
hide: true,
previewIcon: 'empty',
previewIcon: IconChartHeatmap,
score: 0.3,
},
]);
@ -351,7 +352,7 @@ describe('heatmap suggestions', () => {
},
title: 'Heat map',
hide: true,
previewIcon: 'empty',
previewIcon: IconChartHeatmap,
score: 0,
},
]);
@ -404,7 +405,7 @@ describe('heatmap suggestions', () => {
},
title: 'Heat map',
hide: true,
previewIcon: 'empty',
previewIcon: IconChartHeatmap,
score: 0.3,
},
]);
@ -468,7 +469,7 @@ describe('heatmap suggestions', () => {
},
title: 'Heat map',
hide: true,
previewIcon: 'empty',
previewIcon: IconChartHeatmap,
score: 0.3,
},
]);
@ -534,7 +535,7 @@ describe('heatmap suggestions', () => {
},
title: 'Heat map',
hide: false,
previewIcon: 'empty',
previewIcon: IconChartHeatmap,
score: 0.6,
},
]);
@ -608,7 +609,7 @@ describe('heatmap suggestions', () => {
},
title: 'Heat map',
hide: false,
previewIcon: 'empty',
previewIcon: IconChartHeatmap,
score: 0.3,
},
]);
@ -682,7 +683,7 @@ describe('heatmap suggestions', () => {
},
title: 'Heat map',
hide: false,
previewIcon: 'empty',
previewIcon: IconChartHeatmap,
score: 0.9,
},
]);

View file

@ -8,6 +8,7 @@
import { partition } from 'lodash';
import { Position } from '@elastic/charts';
import { i18n } from '@kbn/i18n';
import { IconChartHeatmap } from '@kbn/chart-icons';
import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import type { Visualization } from '../../types';
import type { HeatmapVisualizationState } from './types';
@ -127,7 +128,7 @@ export const getSuggestions: Visualization<HeatmapVisualizationState>['getSugges
defaultMessage: 'Heat map',
}),
hide,
previewIcon: 'empty',
previewIcon: IconChartHeatmap,
score: Number(score.toFixed(1)),
},
];

View file

@ -1040,7 +1040,7 @@ describe('suggestions', () => {
Array [
Object {
"hide": false,
"previewIcon": "bullseye",
"previewIcon": [Function],
"score": 0.61,
"state": Object {
"layers": Array [
@ -1069,7 +1069,7 @@ describe('suggestions', () => {
"palette": undefined,
"shape": "mosaic",
},
"title": "As Mosaic",
"title": "Mosaic",
},
]
`);
@ -1149,7 +1149,7 @@ describe('suggestions', () => {
Array [
Object {
"hide": false,
"previewIcon": "bullseye",
"previewIcon": [Function],
"score": 0.46,
"state": Object {
"layers": Array [
@ -1175,7 +1175,7 @@ describe('suggestions', () => {
"palette": undefined,
"shape": "waffle",
},
"title": "As Waffle",
"title": "Waffle",
},
]
`);

View file

@ -120,7 +120,7 @@ export function suggestions({
const newShape = getNewShape(groups, subVisualizationId as PieVisualizationState['shape']);
const baseSuggestion: VisualizationSuggestion<PieVisualizationState> = {
title: i18n.translate('xpack.lens.pie.suggestionLabel', {
defaultMessage: 'As {chartName}',
defaultMessage: '{chartName}',
values: { chartName: PartitionChartsMeta[newShape].label },
description: 'chartName is already translated',
}),
@ -149,7 +149,7 @@ export function suggestions({
},
],
},
previewIcon: 'bullseye',
previewIcon: PartitionChartsMeta[newShape].icon,
// dont show suggestions for same type
hide:
table.changeType === 'reduced' ||
@ -161,7 +161,7 @@ export function suggestions({
results.push({
...baseSuggestion,
title: i18n.translate('xpack.lens.pie.suggestionLabel', {
defaultMessage: 'As {chartName}',
defaultMessage: '{chartName}',
values: {
chartName:
PartitionChartsMeta[
@ -185,7 +185,7 @@ export function suggestions({
) {
results.push({
title: i18n.translate('xpack.lens.pie.treemapSuggestionLabel', {
defaultMessage: 'As Treemap',
defaultMessage: 'Treemap',
}),
// Use a higher score when currently active, to prevent chart type switching
// on the user unintentionally
@ -218,7 +218,7 @@ export function suggestions({
},
],
},
previewIcon: 'bullseye',
previewIcon: PartitionChartsMeta.treemap.icon,
// hide treemap suggestions from bottom bar, but keep them for chart switcher
hide:
table.changeType === 'reduced' ||
@ -234,7 +234,7 @@ export function suggestions({
) {
results.push({
title: i18n.translate('xpack.lens.pie.mosaicSuggestionLabel', {
defaultMessage: 'As Mosaic',
defaultMessage: 'Mosaic',
}),
score: state?.shape === PieChartTypes.MOSAIC ? 0.7 : 0.5,
state: {
@ -266,7 +266,7 @@ export function suggestions({
},
],
},
previewIcon: 'bullseye',
previewIcon: PartitionChartsMeta.mosaic.icon,
hide:
groups.length !== 2 ||
table.changeType === 'reduced' ||
@ -281,7 +281,7 @@ export function suggestions({
) {
results.push({
title: i18n.translate('xpack.lens.pie.waffleSuggestionLabel', {
defaultMessage: 'As Waffle',
defaultMessage: 'Waffle',
}),
score: state?.shape === PieChartTypes.WAFFLE ? 0.7 : 0.4,
state: {
@ -310,7 +310,7 @@ export function suggestions({
},
],
},
previewIcon: 'bullseye',
previewIcon: PartitionChartsMeta.waffle.icon,
hide:
groups.length !== 1 ||
table.changeType === 'reduced' ||

View file

@ -2467,7 +2467,6 @@
"discover.sourceViewer.errorMessage": "Impossible de récupérer les données pour le moment. Actualisez l'onglet et réessayez.",
"discover.sourceViewer.errorMessageTitle": "Une erreur s'est produite.",
"discover.sourceViewer.refresh": "Actualiser",
"discover.textBasedLanguages.visualize.label": "Visualiser dans Lens",
"discover.toggleSidebarAriaLabel": "Activer/Désactiver la barre latérale",
"discover.topNav.openOptionsPopover.documentExplorerDisabledHint": "Saviez-vous que Discover possède un nouvel Explorateur de documents avec un meilleur tri des données, des colonnes redimensionnables et une vue en plein écran ? Vous pouvez modifier le mode d'affichage dans les Paramètres avancés.",
"discover.topNav.openOptionsPopover.documentExplorerEnabledHint": "Vous pouvez revenir à l'affichage Discover classique dans les Paramètres avancés.",

View file

@ -2467,7 +2467,6 @@
"discover.sourceViewer.errorMessage": "現在データを取得できませんでした。タブを更新して、再試行してください。",
"discover.sourceViewer.errorMessageTitle": "エラーが発生しました",
"discover.sourceViewer.refresh": "更新",
"discover.textBasedLanguages.visualize.label": "Lensで可視化",
"discover.toggleSidebarAriaLabel": "サイドバーを切り替える",
"discover.topNav.openOptionsPopover.documentExplorerDisabledHint": "Discoverの新しいドキュメントエクスプローラーでは、データの並べ替え、列のサイズ変更、全画面表示といった優れた機能をご利用いただけます。高度な設定で表示モードを変更できます。",
"discover.topNav.openOptionsPopover.documentExplorerEnabledHint": "高度な設定でクラシックDiscoverビューに戻すことができます。",

View file

@ -2467,7 +2467,6 @@
"discover.sourceViewer.errorMessage": "当前无法获取数据。请刷新选项卡以重试。",
"discover.sourceViewer.errorMessageTitle": "发生错误",
"discover.sourceViewer.refresh": "刷新",
"discover.textBasedLanguages.visualize.label": "在 Lens 中可视化",
"discover.toggleSidebarAriaLabel": "切换侧边栏",
"discover.topNav.openOptionsPopover.documentExplorerDisabledHint": "您知道吗Discover 有一种新的 Document Explorer可提供更好的数据排序、可调整大小的列及全屏视图。您可以在高级设置中更改视图模式。",
"discover.topNav.openOptionsPopover.documentExplorerEnabledHint": "您可以在高级设置中切换回经典 Discover 视图。",

View file

@ -130,7 +130,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
});
});
it('should visualize correctly text based language queries', async () => {
it('should visualize correctly text based language queries in Discover', async () => {
await PageObjects.discover.selectTextBaseLang('SQL');
await PageObjects.header.waitUntilLoadingHasFinished();
await monacoEditor.setCodeEditorValue(
@ -138,8 +138,24 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
);
await testSubjects.click('querySubmitButton');
await PageObjects.header.waitUntilLoadingHasFinished();
expect(await testSubjects.exists('unifiedHistogramChart')).to.be(true);
expect(await testSubjects.exists('heatmapChart')).to.be(true);
await testSubjects.click('textBased-visualize');
await PageObjects.discover.chooseLensChart('Donut');
await PageObjects.header.waitUntilLoadingHasFinished();
expect(await testSubjects.exists('partitionVisChart')).to.be(true);
});
it('should visualize correctly text based language queries in Lens', async () => {
await PageObjects.discover.selectTextBaseLang('SQL');
await PageObjects.header.waitUntilLoadingHasFinished();
await monacoEditor.setCodeEditorValue(
'SELECT extension, AVG("bytes") as average FROM "logstash-*" GROUP BY extension'
);
await testSubjects.click('querySubmitButton');
await PageObjects.header.waitUntilLoadingHasFinished();
await testSubjects.click('unifiedTextLangEditor-expand');
await testSubjects.click('unifiedHistogramEditVisualization');
await PageObjects.header.waitUntilLoadingHasFinished();
@ -157,8 +173,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
);
await testSubjects.click('querySubmitButton');
await PageObjects.header.waitUntilLoadingHasFinished();
await testSubjects.click('textBased-visualize');
await testSubjects.click('unifiedTextLangEditor-expand');
await testSubjects.click('unifiedHistogramEditVisualization');
await PageObjects.header.waitUntilLoadingHasFinished();

View file

@ -315,7 +315,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
await PageObjects.lens.save('twolayerchart');
await testSubjects.click('lnsSuggestion-asDonut > lnsSuggestion');
await testSubjects.click('lnsSuggestion-donut > lnsSuggestion');
expect(await PageObjects.lens.getLayerCount()).to.eql(1);
expect(await PageObjects.lens.getDimensionTriggerText('lnsPie_sliceByDimensionPanel')).to.eql(