diff --git a/packages/kbn-visualization-utils/src/get_lens_attributes.test.ts b/packages/kbn-visualization-utils/src/get_lens_attributes.test.ts index 94e0b8e7529268..8b0a22c63d005b 100644 --- a/packages/kbn-visualization-utils/src/get_lens_attributes.test.ts +++ b/packages/kbn-visualization-utils/src/get_lens_attributes.test.ts @@ -20,6 +20,7 @@ describe('getLensAttributesFromSuggestion', () => { timeFieldName: '@timestamp', isPersisted: () => false, toSpec: () => ({}), + toMinimalSpec: () => ({}), } as unknown as DataView; const query: AggregateQuery = { esql: 'from foo | limit 10' }; diff --git a/packages/kbn-visualization-utils/src/get_lens_attributes.ts b/packages/kbn-visualization-utils/src/get_lens_attributes.ts index 38a2dd29b841ea..3a62c7bee736f2 100644 --- a/packages/kbn-visualization-utils/src/get_lens_attributes.ts +++ b/packages/kbn-visualization-utils/src/get_lens_attributes.ts @@ -20,7 +20,17 @@ export const getLensAttributesFromSuggestion = ({ query: Query | AggregateQuery; suggestion: Suggestion | undefined; dataView?: DataView; -}) => { +}): { + references: Array<{ name: string; id: string; type: string }>; + visualizationType: string; + state: { + visualization: {}; + datasourceStates: Record; + query: Query | AggregateQuery; + filters: Filter[]; + }; + title: string; +} => { const suggestionDatasourceState = Object.assign({}, suggestion?.datasourceState); const suggestionVisualizationState = Object.assign({}, suggestion?.visualizationState); const datasourceStates = @@ -35,11 +45,11 @@ export const getLensAttributesFromSuggestion = ({ }; const visualization = suggestionVisualizationState; const attributes = { - title: suggestion - ? suggestion.title - : i18n.translate('visualizationUtils.config.suggestion.title', { - defaultMessage: 'New suggestion', - }), + title: + suggestion?.title ?? + i18n.translate('visualizationUtils.config.suggestion.title', { + defaultMessage: 'New suggestion', + }), references: [ { id: dataView?.id ?? '', @@ -55,7 +65,7 @@ export const getLensAttributesFromSuggestion = ({ ...(dataView && dataView.id && !dataView.isPersisted() && { - adHocDataViews: { [dataView.id]: dataView.toSpec(false) }, + adHocDataViews: { [dataView.id]: dataView.toMinimalSpec() }, }), }, visualizationType: suggestion ? suggestion.visualizationId : 'lnsXY', diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index a466bda78df4f4..10d6d4d7056d9e 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -135,7 +135,7 @@ describe('checking migration metadata changes on all registered SO types', () => "risk-engine-configuration": "aea0c371a462e6d07c3ceb3aff11891b47feb09d", "rules-settings": "892a2918ebaeba809a612b8d97cec0b07c800b5f", "sample-data-telemetry": "37441b12f5b0159c2d6d5138a494c9f440e950b5", - "search": "cf69e2bf8ae25c10af21887cd6effc4a9ea73064", + "search": "7598e4a701ddcaa5e3f44f22e797618a48595e6f", "search-session": "b2fcd840e12a45039ada50b1355faeafa39876d1", "search-telemetry": "b568601618744720b5662946d3103e3fb75fe8ee", "security-rule": "07abb4d7e707d91675ec0495c73816394c7b521f", diff --git a/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.tsx b/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.tsx index 79f4e9e74cc963..6b53ea87690172 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.tsx @@ -10,7 +10,7 @@ import React, { useCallback, useMemo } from 'react'; import { UnifiedHistogramContainer } from '@kbn/unified-histogram-plugin/public'; import { css } from '@emotion/react'; import useObservable from 'react-use/lib/useObservable'; -import { Datatable } from '@kbn/expressions-plugin/common'; +import type { Datatable } from '@kbn/expressions-plugin/common'; import { useDiscoverHistogram } from './use_discover_histogram'; import { type DiscoverMainContentProps, DiscoverMainContent } from './discover_main_content'; import { useAppStateSelector } from '../../services/discover_app_state_container'; diff --git a/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.ts b/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.ts index b8bfed44563a5e..5617b724df490b 100644 --- a/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.ts +++ b/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.ts @@ -8,12 +8,15 @@ import { useQuerySubscriber } from '@kbn/unified-field-list/src/hooks/use_query_subscriber'; import { + canImportVisContext, UnifiedHistogramApi, + UnifiedHistogramExternalVisContextStatus, UnifiedHistogramFetchStatus, UnifiedHistogramState, + UnifiedHistogramVisContext, } from '@kbn/unified-histogram-plugin/public'; import { isEqual } from 'lodash'; -import { useCallback, useEffect, useRef, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { debounceTime, distinctUntilChanged, @@ -26,6 +29,9 @@ import { } from 'rxjs'; import useObservable from 'react-use/lib/useObservable'; import type { RequestAdapter } from '@kbn/inspector-plugin/common'; +import type { DatatableColumn } from '@kbn/expressions-plugin/common'; +import type { SavedSearch } from '@kbn/saved-search-plugin/common'; +import type { Filter } from '@kbn/es-query'; import { useDiscoverCustomization } from '../../../../customizations'; import { useDiscoverServices } from '../../../../hooks/use_discover_services'; import { FetchStatus } from '../../../types'; @@ -35,7 +41,11 @@ import type { DiscoverStateContainer } from '../../services/discover_state'; import { addLog } from '../../../../utils/add_log'; import { useInternalStateSelector } from '../../services/discover_internal_state_container'; import type { DiscoverAppState } from '../../services/discover_app_state_container'; -import { RecordRawType } from '../../services/discover_data_state_container'; +import { DataDocumentsMsg, RecordRawType } from '../../services/discover_data_state_container'; +import { useSavedSearch } from '../../services/discover_state_provider'; + +const EMPTY_TEXT_BASED_COLUMNS: DatatableColumn[] = []; +const EMPTY_FILTERS: Filter[] = []; export interface UseDiscoverHistogramProps { stateContainer: DiscoverStateContainer; @@ -52,6 +62,7 @@ export const useDiscoverHistogram = ({ }: UseDiscoverHistogramProps) => { const services = useDiscoverServices(); const savedSearchData$ = stateContainer.dataState.data$; + const savedSearchState = useSavedSearch(); /** * API initialization @@ -219,15 +230,18 @@ export const useDiscoverHistogram = ({ [stateContainer] ); + const [initialTextBasedProps] = useState(() => + getUnifiedHistogramPropsForTextBased({ + documentsValue: savedSearchData$.documents$.getValue(), + savedSearch: stateContainer.savedSearchState.getState(), + }) + ); + const { dataView: textBasedDataView, query: textBasedQuery, - columns, - } = useObservable(textBasedFetchComplete$, { - dataView: stateContainer.internalState.getState().dataView!, - query: stateContainer.appState.getState().query, - columns: savedSearchData$.documents$.getValue().textBasedQueryColumns ?? [], - }); + columns: textBasedColumns, + } = useObservable(textBasedFetchComplete$, initialTextBasedProps); useEffect(() => { if (!isPlainRecord) { @@ -316,14 +330,53 @@ export const useDiscoverHistogram = ({ const histogramCustomization = useDiscoverCustomization('unified_histogram'); - const filtersMemoized = useMemo( - () => [...(filters ?? []), ...customFilters], - [filters, customFilters] - ); + const filtersMemoized = useMemo(() => { + const allFilters = [...(filters ?? []), ...customFilters]; + return allFilters.length ? allFilters : EMPTY_FILTERS; + }, [filters, customFilters]); // eslint-disable-next-line react-hooks/exhaustive-deps const timeRangeMemoized = useMemo(() => timeRange, [timeRange?.from, timeRange?.to]); + const onVisContextChanged = useCallback( + ( + nextVisContext: UnifiedHistogramVisContext | undefined, + externalVisContextStatus: UnifiedHistogramExternalVisContextStatus + ) => { + switch (externalVisContextStatus) { + case UnifiedHistogramExternalVisContextStatus.manuallyCustomized: + // if user customized the visualization manually + // (only this action should trigger Unsaved changes badge) + stateContainer.savedSearchState.updateVisContext({ + nextVisContext, + }); + stateContainer.internalState.transitions.setOverriddenVisContextAfterInvalidation( + undefined + ); + break; + case UnifiedHistogramExternalVisContextStatus.automaticallyOverridden: + // if the visualization was invalidated as incompatible and rebuilt + // (it will be used later for saving the visualization via Save button) + stateContainer.internalState.transitions.setOverriddenVisContextAfterInvalidation( + nextVisContext + ); + break; + case UnifiedHistogramExternalVisContextStatus.automaticallyCreated: + case UnifiedHistogramExternalVisContextStatus.applied: + // clearing the value in the internal state so we don't use it during saved search saving + stateContainer.internalState.transitions.setOverriddenVisContextAfterInvalidation( + undefined + ); + break; + case UnifiedHistogramExternalVisContextStatus.unknown: + // using `{}` to overwrite the value inside the saved search SO during saving + stateContainer.internalState.transitions.setOverriddenVisContextAfterInvalidation({}); + break; + } + }, + [stateContainer] + ); + return { ref, getCreationOptions, @@ -333,12 +386,18 @@ export const useDiscoverHistogram = ({ filters: filtersMemoized, timeRange: timeRangeMemoized, relativeTimeRange, - columns, + columns: isPlainRecord ? textBasedColumns : undefined, onFilter: histogramCustomization?.onFilter, onBrushEnd: histogramCustomization?.onBrushEnd, withDefaultActions: histogramCustomization?.withDefaultActions, disabledActions: histogramCustomization?.disabledActions, isChartLoading: isSuggestionLoading, + // visContext should be in sync with current query + externalVisContext: + isPlainRecord && canImportVisContext(savedSearchState?.visContext) + ? savedSearchState?.visContext + : undefined, + onVisContextChanged: isPlainRecord ? onVisContextChanged : undefined, }; }; @@ -412,12 +471,13 @@ const createAppStateObservable = (state$: Observable) => { const createFetchCompleteObservable = (stateContainer: DiscoverStateContainer) => { return stateContainer.dataState.data$.documents$.pipe( distinctUntilChanged((prev, curr) => prev.fetchStatus === curr.fetchStatus), - filter(({ fetchStatus }) => fetchStatus === FetchStatus.COMPLETE), - map(({ textBasedQueryColumns }) => ({ - dataView: stateContainer.internalState.getState().dataView!, - query: stateContainer.appState.getState().query!, - columns: textBasedQueryColumns ?? [], - })) + filter(({ fetchStatus }) => [FetchStatus.COMPLETE, FetchStatus.ERROR].includes(fetchStatus)), + map((documentsValue) => { + return getUnifiedHistogramPropsForTextBased({ + documentsValue, + savedSearch: stateContainer.savedSearchState.getState(), + }); + }) ); }; @@ -430,7 +490,27 @@ const createTotalHitsObservable = (state$?: Observable) = const createCurrentSuggestionObservable = (state$: Observable) => { return state$.pipe( - map((state) => state.currentSuggestion), + map((state) => state.currentSuggestionContext), distinctUntilChanged(isEqual) ); }; + +function getUnifiedHistogramPropsForTextBased({ + documentsValue, + savedSearch, +}: { + documentsValue: DataDocumentsMsg | undefined; + savedSearch: SavedSearch; +}) { + const columns = documentsValue?.textBasedQueryColumns || EMPTY_TEXT_BASED_COLUMNS; + + const nextProps = { + dataView: savedSearch.searchSource.getField('index')!, + query: savedSearch.searchSource.getField('query'), + columns, + }; + + addLog('[UnifiedHistogram] delayed next props for text-based', nextProps); + + return nextProps; +} diff --git a/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_badges.tsx b/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_badges.tsx index 37daf11478bfcd..30d58a58e1882e 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_badges.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_badges.tsx @@ -45,7 +45,13 @@ export const getTopNavBadges = ({ if (hasUnsavedChanges && !defaultBadges?.unsavedChangesBadge?.disabled) { entries.push({ data: getTopNavUnsavedChangesBadge({ - onRevert: stateContainer.actions.undoSavedSearchChanges, + onRevert: async () => { + const lensEditFlyoutCancelButton = document.getElementById('lnsCancelEditOnFlyFlyout'); + if (lensEditFlyoutCancelButton) { + lensEditFlyoutCancelButton.click?.(); + } + await stateContainer.actions.undoSavedSearchChanges(); + }, onSave: services.capabilities.discover.save && !isManaged ? async () => { diff --git a/src/plugins/discover/public/application/main/components/top_nav/on_save_search.tsx b/src/plugins/discover/public/application/main/components/top_nav/on_save_search.tsx index 84c056f60ad015..f22d07b4d4d898 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/on_save_search.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/on_save_search.tsx @@ -93,6 +93,9 @@ export async function onSaveSearch({ }) { const { uiSettings, savedObjectsTagging } = services; const dataView = state.internalState.getState().dataView; + const overriddenVisContextAfterInvalidation = + state.internalState.getState().overriddenVisContextAfterInvalidation; + const onSave = async ({ newTitle, newCopyOnSave, @@ -116,6 +119,7 @@ export async function onSaveSearch({ const currentSampleSize = savedSearch.sampleSize; const currentDescription = savedSearch.description; const currentTags = savedSearch.tags; + const currentVisContext = savedSearch.visContext; savedSearch.title = newTitle; savedSearch.description = newDescription; savedSearch.timeRestore = newTimeRestore; @@ -134,6 +138,11 @@ export async function onSaveSearch({ if (savedObjectsTagging) { savedSearch.tags = newTags; } + + if (overriddenVisContextAfterInvalidation) { + savedSearch.visContext = overriddenVisContextAfterInvalidation; + } + const saveOptions: SaveSavedSearchOptions = { onTitleDuplicate, copyOnSave: newCopyOnSave, @@ -159,10 +168,12 @@ export async function onSaveSearch({ savedSearch.rowsPerPage = currentRowsPerPage; savedSearch.sampleSize = currentSampleSize; savedSearch.description = currentDescription; + savedSearch.visContext = currentVisContext; if (savedObjectsTagging) { savedSearch.tags = currentTags; } } else { + state.internalState.transitions.resetOnSavedSearchChange(); state.appState.resetInitialState(); } onSaveCb?.(); diff --git a/src/plugins/discover/public/application/main/services/discover_internal_state_container.ts b/src/plugins/discover/public/application/main/services/discover_internal_state_container.ts index 5825e4d2cef6c4..4b26822bf04a50 100644 --- a/src/plugins/discover/public/application/main/services/discover_internal_state_container.ts +++ b/src/plugins/discover/public/application/main/services/discover_internal_state_container.ts @@ -11,9 +11,10 @@ import { createStateContainerReactHelpers, ReduxLikeStateContainer, } from '@kbn/kibana-utils-plugin/common'; -import { DataView, DataViewListItem } from '@kbn/data-views-plugin/common'; -import { Filter } from '@kbn/es-query'; +import type { DataView, DataViewListItem } from '@kbn/data-views-plugin/common'; +import type { Filter } from '@kbn/es-query'; import type { DataTableRecord } from '@kbn/discover-utils/types'; +import type { UnifiedHistogramVisContext } from '@kbn/unified-histogram-plugin/public'; export interface InternalState { dataView: DataView | undefined; @@ -22,6 +23,7 @@ export interface InternalState { adHocDataViews: DataView[]; expandedDoc: DataTableRecord | undefined; customFilters: Filter[]; + overriddenVisContextAfterInvalidation: UnifiedHistogramVisContext | {} | undefined; // it will be used during saved search saving } export interface InternalStateTransitions { @@ -40,6 +42,12 @@ export interface InternalStateTransitions { state: InternalState ) => (dataView: DataTableRecord | undefined) => InternalState; setCustomFilters: (state: InternalState) => (customFilters: Filter[]) => InternalState; + setOverriddenVisContextAfterInvalidation: ( + state: InternalState + ) => ( + overriddenVisContextAfterInvalidation: UnifiedHistogramVisContext | {} | undefined + ) => InternalState; + resetOnSavedSearchChange: (state: InternalState) => () => InternalState; } export type DiscoverInternalStateContainer = ReduxLikeStateContainer< @@ -59,6 +67,7 @@ export function getInternalStateContainer() { savedDataViews: [], expandedDoc: undefined, customFilters: [], + overriddenVisContextAfterInvalidation: undefined, }, { setDataView: (prevState: InternalState) => (nextDataView: DataView) => ({ @@ -112,6 +121,16 @@ export function getInternalStateContainer() { ...prevState, customFilters, }), + setOverriddenVisContextAfterInvalidation: + (prevState: InternalState) => + (overriddenVisContextAfterInvalidation: UnifiedHistogramVisContext | {} | undefined) => ({ + ...prevState, + overriddenVisContextAfterInvalidation, + }), + resetOnSavedSearchChange: (prevState: InternalState) => () => ({ + ...prevState, + overriddenVisContextAfterInvalidation: undefined, + }), }, {}, { freeze: (state) => state } diff --git a/src/plugins/discover/public/application/main/services/discover_saved_search_container.ts b/src/plugins/discover/public/application/main/services/discover_saved_search_container.ts index e1544ffffbe4c7..76ffca54430178 100644 --- a/src/plugins/discover/public/application/main/services/discover_saved_search_container.ts +++ b/src/plugins/discover/public/application/main/services/discover_saved_search_container.ts @@ -12,6 +12,7 @@ import { cloneDeep } from 'lodash'; import { COMPARE_ALL_OPTIONS, FilterCompareOptions } from '@kbn/es-query'; import type { SearchSourceFields } from '@kbn/data-plugin/common'; import type { DataView } from '@kbn/data-views-plugin/common'; +import type { UnifiedHistogramVisContext } from '@kbn/unified-histogram-plugin/public'; import { SavedObjectSaveOpts } from '@kbn/saved-objects-plugin/public'; import { isEqual, isFunction } from 'lodash'; import { restoreStateFromSavedSearch } from '../../../services/saved_searches/restore_from_saved_search'; @@ -120,6 +121,11 @@ export interface DiscoverSavedSearchContainer { * @param params */ updateWithFilterManagerFilters: () => SavedSearch; + /** + * Updates the current value of visContext in saved search + * @param params + */ + updateVisContext: (params: { nextVisContext: UnifiedHistogramVisContext | undefined }) => void; } export function getSavedSearchContainer({ @@ -239,6 +245,22 @@ export function getSavedSearchContainer({ addLog('[savedSearch] updateWithTimeRange done', nextSavedSearch); }; + const updateVisContext = ({ + nextVisContext, + }: { + nextVisContext: UnifiedHistogramVisContext | undefined; + }) => { + const previousSavedSearch = getState(); + const nextSavedSearch: SavedSearch = { + ...previousSavedSearch, + visContext: nextVisContext, + }; + + assignNextSavedSearch({ nextSavedSearch }); + + addLog('[savedSearch] updateVisContext done', nextSavedSearch); + }; + const load = async (id: string, dataView: DataView | undefined): Promise => { addLog('[savedSearch] load', { id, dataView }); @@ -268,6 +290,7 @@ export function getSavedSearchContainer({ update, updateTimeRange, updateWithFilterManagerFilters, + updateVisContext, }; } diff --git a/src/plugins/discover/public/application/main/services/discover_state.ts b/src/plugins/discover/public/application/main/services/discover_state.ts index 1bab2e0328af8a..94a0a80c54fd9c 100644 --- a/src/plugins/discover/public/application/main/services/discover_state.ts +++ b/src/plugins/discover/public/application/main/services/discover_state.ts @@ -496,6 +496,7 @@ export function getDiscoverStateContainer({ }); } + internalStateContainer.transitions.resetOnSavedSearchChange(); await appStateContainer.replaceUrlState(newAppState); return nextSavedSearch; }; diff --git a/src/plugins/discover/public/application/main/services/load_saved_search.ts b/src/plugins/discover/public/application/main/services/load_saved_search.ts index d5a5be0935d8cb..ac9e6f60526d17 100644 --- a/src/plugins/discover/public/application/main/services/load_saved_search.ts +++ b/src/plugins/discover/public/application/main/services/load_saved_search.ts @@ -53,6 +53,7 @@ export const loadSavedSearch = async ( globalStateContainer, services, } = deps; + const appStateExists = !appStateContainer.isEmptyURL(); const appState = appStateExists ? appStateContainer.getState() : initialAppState; @@ -124,6 +125,8 @@ export const loadSavedSearch = async ( nextSavedSearch = savedSearchContainer.updateWithFilterManagerFilters(); } + internalStateContainer.transitions.resetOnSavedSearchChange(); + return nextSavedSearch; }; diff --git a/src/plugins/discover/public/application/main/utils/fetch_all.test.ts b/src/plugins/discover/public/application/main/utils/fetch_all.test.ts index d9c96e9bce0e9b..13aaedeeb6e9ec 100644 --- a/src/plugins/discover/public/application/main/utils/fetch_all.test.ts +++ b/src/plugins/discover/public/application/main/utils/fetch_all.test.ts @@ -77,6 +77,7 @@ describe('test fetchAll', () => { adHocDataViews: [], expandedDoc: undefined, customFilters: [], + overriddenVisContextAfterInvalidation: undefined, }), searchSessionId: '123', initialFetchStatus: FetchStatus.UNINITIALIZED, @@ -275,6 +276,7 @@ describe('test fetchAll', () => { adHocDataViews: [], expandedDoc: undefined, customFilters: [], + overriddenVisContextAfterInvalidation: undefined, }), }; fetchAll(subjects, false, deps); @@ -401,6 +403,7 @@ describe('test fetchAll', () => { adHocDataViews: [], expandedDoc: undefined, customFilters: [], + overriddenVisContextAfterInvalidation: undefined, }), }; fetchAll(subjects, false, deps); diff --git a/src/plugins/saved_search/common/content_management/v1/cm_services.ts b/src/plugins/saved_search/common/content_management/v1/cm_services.ts index 24319df7d43ac7..bc9d18b21e5b77 100644 --- a/src/plugins/saved_search/common/content_management/v1/cm_services.ts +++ b/src/plugins/saved_search/common/content_management/v1/cm_services.ts @@ -69,6 +69,25 @@ const savedSearchAttributesSchema = schema.object( }) ), breakdownField: schema.maybe(schema.string()), + visContext: schema.maybe( + schema.oneOf([ + // existing value + schema.object({ + // unified histogram state + suggestionType: schema.string(), + requestData: schema.object({ + dataViewId: schema.maybe(schema.string()), + timeField: schema.maybe(schema.string()), + timeInterval: schema.maybe(schema.string()), + breakdownField: schema.maybe(schema.string()), + }), + // lens attributes + attributes: schema.recordOf(schema.string(), schema.any()), + }), + // cleared previous value + schema.object({}), + ]) + ), version: schema.maybe(schema.number()), }, { unknowns: 'forbid' } diff --git a/src/plugins/saved_search/common/saved_searches_utils.ts b/src/plugins/saved_search/common/saved_searches_utils.ts index b71819e96e2107..d8a1dbcd4cafad 100644 --- a/src/plugins/saved_search/common/saved_searches_utils.ts +++ b/src/plugins/saved_search/common/saved_searches_utils.ts @@ -36,5 +36,6 @@ export const fromSavedSearchAttributes = ( rowsPerPage: attributes.rowsPerPage, sampleSize: attributes.sampleSize, breakdownField: attributes.breakdownField, + visContext: attributes.visContext, managed, }); diff --git a/src/plugins/saved_search/common/service/get_saved_searches.test.ts b/src/plugins/saved_search/common/service/get_saved_searches.test.ts index be971f1469ade5..ea9403fda64760 100644 --- a/src/plugins/saved_search/common/service/get_saved_searches.test.ts +++ b/src/plugins/saved_search/common/service/get_saved_searches.test.ts @@ -148,6 +148,7 @@ describe('getSavedSearch', () => { "title": "test1", "usesAdHocDataView": undefined, "viewMode": undefined, + "visContext": undefined, } `); }); @@ -256,6 +257,7 @@ describe('getSavedSearch', () => { "title": "test2", "usesAdHocDataView": undefined, "viewMode": undefined, + "visContext": undefined, } `); }); diff --git a/src/plugins/saved_search/common/service/saved_searches_utils.test.ts b/src/plugins/saved_search/common/service/saved_searches_utils.test.ts index 716d9db855a027..3972f38caa5b54 100644 --- a/src/plugins/saved_search/common/service/saved_searches_utils.test.ts +++ b/src/plugins/saved_search/common/service/saved_searches_utils.test.ts @@ -91,6 +91,7 @@ describe('saved_searches_utils', () => { "title": "saved search", "usesAdHocDataView": false, "viewMode": undefined, + "visContext": undefined, } `); }); @@ -143,6 +144,7 @@ describe('saved_searches_utils', () => { "title": "title", "usesAdHocDataView": false, "viewMode": undefined, + "visContext": undefined, } `); }); diff --git a/src/plugins/saved_search/common/service/saved_searches_utils.ts b/src/plugins/saved_search/common/service/saved_searches_utils.ts index ce3da85a2d3bd3..11a848f8baaf82 100644 --- a/src/plugins/saved_search/common/service/saved_searches_utils.ts +++ b/src/plugins/saved_search/common/service/saved_searches_utils.ts @@ -50,4 +50,5 @@ export const toSavedSearchAttributes = ( rowsPerPage: savedSearch.rowsPerPage, sampleSize: savedSearch.sampleSize, breakdownField: savedSearch.breakdownField, + visContext: savedSearch.visContext, }); diff --git a/src/plugins/saved_search/common/types.ts b/src/plugins/saved_search/common/types.ts index d58f8c1cec7fcd..34ada26b0c1a47 100644 --- a/src/plugins/saved_search/common/types.ts +++ b/src/plugins/saved_search/common/types.ts @@ -20,6 +20,20 @@ export interface DiscoverGridSettingsColumn extends SerializableRecord { width?: number; } +export type VisContextUnmapped = + | { + // UnifiedHistogramVisContext (can't be referenced here directly due to circular dependency) + attributes: unknown; + requestData: { + dataViewId?: string; + timeField?: string; + timeInterval?: string; + breakdownField?: string; + }; + suggestionType: string; + } + | {}; // cleared value + /** @internal **/ export interface SavedSearchAttributes { title: string; @@ -45,6 +59,7 @@ export interface SavedSearchAttributes { rowsPerPage?: number; sampleSize?: number; breakdownField?: string; + visContext?: VisContextUnmapped; } /** @internal **/ @@ -76,6 +91,8 @@ export interface SavedSearch { rowsPerPage?: number; sampleSize?: number; breakdownField?: string; + visContext?: VisContextUnmapped; + // Whether or not this saved search is managed by the system managed: boolean; references?: SavedObjectReference[]; diff --git a/src/plugins/saved_search/public/services/saved_searches/saved_search_attribute_service.test.ts b/src/plugins/saved_search/public/services/saved_searches/saved_search_attribute_service.test.ts index 81b7e68cae319d..ae1e457fc7d4bb 100644 --- a/src/plugins/saved_search/public/services/saved_searches/saved_search_attribute_service.test.ts +++ b/src/plugins/saved_search/public/services/saved_searches/saved_search_attribute_service.test.ts @@ -242,6 +242,7 @@ describe('getSavedSearchAttributeService', () => { "title": "saved-search-title", "usesAdHocDataView": undefined, "viewMode": undefined, + "visContext": undefined, } `); }); diff --git a/src/plugins/saved_search/server/content_management/saved_search_storage.ts b/src/plugins/saved_search/server/content_management/saved_search_storage.ts index 53fe82eb6e1e44..d3ff8633637d22 100644 --- a/src/plugins/saved_search/server/content_management/saved_search_storage.ts +++ b/src/plugins/saved_search/server/content_management/saved_search_storage.ts @@ -45,6 +45,7 @@ export class SavedSearchStorage extends SOContentStorage { 'rowsPerPage', 'breakdownField', 'sampleSize', + 'visContext', ], logger, throwOnResultValidationError, diff --git a/src/plugins/saved_search/server/saved_objects/schema.ts b/src/plugins/saved_search/server/saved_objects/schema.ts index 851a14417b400a..fb0308915fe72b 100644 --- a/src/plugins/saved_search/server/saved_objects/schema.ts +++ b/src/plugins/saved_search/server/saved_objects/schema.ts @@ -97,3 +97,25 @@ export const SCHEMA_SEARCH_MODEL_VERSION_1 = SCHEMA_SEARCH_BASE.extends({ export const SCHEMA_SEARCH_MODEL_VERSION_2 = SCHEMA_SEARCH_MODEL_VERSION_1.extends({ headerRowHeight: schema.maybe(schema.number()), }); + +export const SCHEMA_SEARCH_MODEL_VERSION_3 = SCHEMA_SEARCH_MODEL_VERSION_2.extends({ + visContext: schema.maybe( + schema.oneOf([ + // existing value + schema.object({ + // unified histogram state + suggestionType: schema.string(), + requestData: schema.object({ + dataViewId: schema.maybe(schema.string()), + timeField: schema.maybe(schema.string()), + timeInterval: schema.maybe(schema.string()), + breakdownField: schema.maybe(schema.string()), + }), + // lens attributes + attributes: schema.recordOf(schema.string(), schema.any()), + }), + // cleared previous value + schema.object({}), + ]) + ), +}); diff --git a/src/plugins/saved_search/server/saved_objects/search.ts b/src/plugins/saved_search/server/saved_objects/search.ts index a913e513e897f3..6c6a9bb81c1edc 100644 --- a/src/plugins/saved_search/server/saved_objects/search.ts +++ b/src/plugins/saved_search/server/saved_objects/search.ts @@ -14,6 +14,7 @@ import { SCHEMA_SEARCH_V8_8_0, SCHEMA_SEARCH_MODEL_VERSION_1, SCHEMA_SEARCH_MODEL_VERSION_2, + SCHEMA_SEARCH_MODEL_VERSION_3, } from './schema'; export function getSavedSearchObjectType( @@ -54,6 +55,13 @@ export function getSavedSearchObjectType( create: SCHEMA_SEARCH_MODEL_VERSION_2, }, }, + 3: { + changes: [], + schemas: { + forwardCompatibility: SCHEMA_SEARCH_MODEL_VERSION_3.extends({}, { unknowns: 'ignore' }), + create: SCHEMA_SEARCH_MODEL_VERSION_3, + }, + }, }, mappings: { dynamic: false, diff --git a/src/plugins/unified_histogram/public/__mocks__/data_view.ts b/src/plugins/unified_histogram/public/__mocks__/data_view.ts index ffc429c1aa8877..62184359c5abdd 100644 --- a/src/plugins/unified_histogram/public/__mocks__/data_view.ts +++ b/src/plugins/unified_histogram/public/__mocks__/data_view.ts @@ -84,13 +84,16 @@ export const buildDataViewMock = ({ return dataViewFields; }; + const indexPattern = `${name}-title`; + const dataView = { id: `${name}-id`, - title: `${name}-title`, + title: indexPattern, name, metaFields: ['_index', '_score'], fields: dataViewFields, getName: () => name, + getIndexPattern: () => indexPattern, getComputedFields: () => ({ docvalueFields: [], scriptFields: {} }), getSourceFiltering: () => ({}), getFieldByName: jest.fn((fieldName: string) => dataViewFields.getByName(fieldName)), @@ -103,6 +106,7 @@ export const buildDataViewMock = ({ return dataViewFields.find((field) => field.name === timeFieldName); }, toSpec: () => ({}), + toMinimalSpec: () => ({}), } as unknown as DataView; dataView.isTimeBased = () => !!timeFieldName; diff --git a/src/plugins/unified_histogram/public/__mocks__/data_view_with_timefield.ts b/src/plugins/unified_histogram/public/__mocks__/data_view_with_timefield.ts index 3868ed2c70af5e..2075b28c92226c 100644 --- a/src/plugins/unified_histogram/public/__mocks__/data_view_with_timefield.ts +++ b/src/plugins/unified_histogram/public/__mocks__/data_view_with_timefield.ts @@ -64,3 +64,9 @@ export const dataViewWithTimefieldMock = buildDataViewMock({ fields, timeFieldName: 'timestamp', }); + +export const dataViewWithAtTimefieldMock = buildDataViewMock({ + name: 'index-pattern-with-@timefield', + fields, + timeFieldName: '@timestamp', +}); diff --git a/src/plugins/unified_histogram/public/__mocks__/lens_vis.ts b/src/plugins/unified_histogram/public/__mocks__/lens_vis.ts new file mode 100644 index 00000000000000..9ac64493806fea --- /dev/null +++ b/src/plugins/unified_histogram/public/__mocks__/lens_vis.ts @@ -0,0 +1,97 @@ +/* + * 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 { DataViewField } from '@kbn/data-views-plugin/common'; +import type { Datatable, DatatableColumn } from '@kbn/expressions-plugin/common'; +import type { Suggestion } from '@kbn/lens-plugin/public'; +import type { TimeRange } from '@kbn/data-plugin/common'; +import { LensVisService, type QueryParams } from '../services/lens_vis_service'; +import { unifiedHistogramServicesMock } from './services'; +import { histogramESQLSuggestionMock } from './suggestions'; +import { UnifiedHistogramSuggestionContext, UnifiedHistogramVisContext } from '../types'; + +const TIME_RANGE: TimeRange = { + from: '2022-11-17T00:00:00.000Z', + to: '2022-11-17T12:00:00.000Z', +}; + +export const getLensVisMock = async ({ + filters, + query, + columns, + isPlainRecord, + timeInterval, + timeRange, + breakdownField, + dataView, + allSuggestions, + hasHistogramSuggestionForESQL, + table, +}: { + filters: QueryParams['filters']; + query: QueryParams['query']; + dataView: QueryParams['dataView']; + columns: DatatableColumn[]; + isPlainRecord: boolean; + timeInterval: string; + timeRange?: TimeRange | null; + breakdownField: DataViewField | undefined; + allSuggestions?: Suggestion[]; + hasHistogramSuggestionForESQL?: boolean; + table?: Datatable; +}): Promise<{ + lensService: LensVisService; + visContext: UnifiedHistogramVisContext | undefined; + currentSuggestionContext: UnifiedHistogramSuggestionContext | undefined; +}> => { + const lensApi = await unifiedHistogramServicesMock.lens.stateHelperApi(); + const lensService = new LensVisService({ + services: unifiedHistogramServicesMock, + lensSuggestionsApi: allSuggestions + ? (...params) => { + const context = params[0]; + if ('query' in context && context.query === query) { + return allSuggestions; + } + return hasHistogramSuggestionForESQL ? [histogramESQLSuggestionMock] : []; + } + : lensApi.suggestions, + }); + + let visContext: UnifiedHistogramVisContext | undefined; + lensService.visContext$.subscribe((nextAttributesContext) => { + visContext = nextAttributesContext; + }); + + let currentSuggestionContext: UnifiedHistogramSuggestionContext | undefined; + lensService.currentSuggestionContext$.subscribe((nextSuggestionContext) => { + currentSuggestionContext = nextSuggestionContext; + }); + + lensService.update({ + queryParams: { + query, + filters, + dataView, + timeRange: timeRange ?? TIME_RANGE, + columns, + isPlainRecord, + }, + timeInterval, + breakdownField, + externalVisContext: undefined, + table, + onSuggestionContextChange: () => {}, + }); + + return { + lensService, + visContext, + currentSuggestionContext, + }; +}; diff --git a/src/plugins/unified_histogram/public/__mocks__/services.tsx b/src/plugins/unified_histogram/public/__mocks__/services.tsx index b7efb79941412f..ddfbc9eecc4053 100644 --- a/src/plugins/unified_histogram/public/__mocks__/services.tsx +++ b/src/plugins/unified_histogram/public/__mocks__/services.tsx @@ -6,6 +6,8 @@ * Side Public License, v 1. */ import React from 'react'; +import { of } from 'rxjs'; +import { calculateBounds } from '@kbn/data-plugin/common'; 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'; @@ -15,6 +17,11 @@ import { allSuggestionsMock } from './suggestions'; const dataPlugin = dataPluginMock.createStartContract(); dataPlugin.query.filterManager.getFilters = jest.fn(() => []); +dataPlugin.query.timefilter.timefilter = { + ...dataPlugin.query.timefilter.timefilter, + calculateBounds: jest.fn((timeRange) => calculateBounds(timeRange)), +}; + export const unifiedHistogramServicesMock = { data: dataPlugin, fieldFormats: fieldFormatsMock, @@ -43,7 +50,17 @@ export const unifiedHistogramServicesMock = { remove: jest.fn(), clear: jest.fn(), }, - expressions: expressionsPluginMock.createStartContract(), + expressions: { + ...expressionsPluginMock.createStartContract(), + run: jest.fn(() => + of({ + partial: false, + result: { + rows: [{}, {}, {}], + }, + }) + ), + }, capabilities: { dashboard: { showWriteControls: true, diff --git a/src/plugins/unified_histogram/public/__mocks__/suggestions.ts b/src/plugins/unified_histogram/public/__mocks__/suggestions.ts index 9e3a00d3960476..9da1fb2fc43170 100644 --- a/src/plugins/unified_histogram/public/__mocks__/suggestions.ts +++ b/src/plugins/unified_histogram/public/__mocks__/suggestions.ts @@ -133,6 +133,91 @@ export const currentSuggestionMock = { changeType: 'initial', } as Suggestion; +export const histogramESQLSuggestionMock = { + title: 'Bar vertical stacked', + score: 0.16666666666666666, + hide: false, + incomplete: false, + visualizationId: 'lnsXY', + visualizationState: { + legend: { + isVisible: true, + position: 'right', + }, + valueLabels: 'hide', + fittingFunction: 'None', + axisTitlesVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + tickLabelsVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + labelsOrientation: { + x: 0, + yLeft: 0, + yRight: 0, + }, + gridlinesVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + preferredSeriesType: 'bar_stacked', + layers: [ + { + layerId: '662552df-2cdc-4539-bf3b-73b9f827252c', + seriesType: 'bar_stacked', + xAccessor: '@timestamp every 30 second', + accessors: ['results'], + layerType: 'data', + }, + ], + }, + keptLayerIds: ['662552df-2cdc-4539-bf3b-73b9f827252c'], + datasourceState: { + layers: { + '662552df-2cdc-4539-bf3b-73b9f827252c': { + index: 'e3465e67bdeced2befff9f9dca7ecf9c48504cad68a10efd881f4c7dd5ade28a', + query: { + esql: 'from kibana_sample_data_logs | limit 10 | EVAL timestamp=DATE_TRUNC(30 second, @timestamp) | stats results = count(*) by timestamp | rename timestamp as `@timestamp every 30 second`', + }, + columns: [ + { + columnId: '@timestamp every 30 second', + fieldName: '@timestamp every 30 second', + meta: { + type: 'date', + }, + }, + { + columnId: 'results', + fieldName: 'results', + meta: { + type: 'number', + }, + inMetricDimension: true, + }, + ], + timeField: '@timestamp', + }, + }, + indexPatternRefs: [ + { + id: 'e3465e67bdeced2befff9f9dca7ecf9c48504cad68a10efd881f4c7dd5ade28a', + title: 'kibana_sample_data_logs', + timeField: '@timestamp', + }, + ], + }, + datasourceId: 'textBased', + columns: 2, + changeType: 'unchanged', +} as Suggestion; + export const allSuggestionsMock = [ currentSuggestionMock, { diff --git a/src/plugins/unified_histogram/public/__mocks__/table.ts b/src/plugins/unified_histogram/public/__mocks__/table.ts new file mode 100644 index 00000000000000..9aa28fdd5ed4cd --- /dev/null +++ b/src/plugins/unified_histogram/public/__mocks__/table.ts @@ -0,0 +1,49 @@ +/* + * 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 { Datatable } from '@kbn/expressions-plugin/common'; + +export const tableQueryMock = { + esql: 'from logstash | stats avg(bytes) by extension.keyword', +}; + +export const tableMock = { + type: 'datatable', + rows: [ + { + 'avg(bytes)': 3850, + 'extension.keyword': '', + }, + { + 'avg(bytes)': 5393.5, + 'extension.keyword': 'css', + }, + { + 'avg(bytes)': 3252, + 'extension.keyword': 'deb', + }, + ], + columns: [ + { + id: 'avg(bytes)', + name: 'avg(bytes)', + meta: { + type: 'number', + }, + isNull: false, + }, + { + id: 'extension.keyword', + name: 'extension.keyword', + meta: { + type: 'string', + }, + isNull: false, + }, + ], +} as Datatable; diff --git a/src/plugins/unified_histogram/public/chart/breakdown_field_selector.tsx b/src/plugins/unified_histogram/public/chart/breakdown_field_selector.tsx index 78df66f50873ef..5fbae47f631090 100644 --- a/src/plugins/unified_histogram/public/chart/breakdown_field_selector.tsx +++ b/src/plugins/unified_histogram/public/chart/breakdown_field_selector.tsx @@ -13,7 +13,7 @@ import { css } from '@emotion/react'; import type { DataView, DataViewField } from '@kbn/data-views-plugin/common'; import { i18n } from '@kbn/i18n'; import { UnifiedHistogramBreakdownContext } from '../types'; -import { fieldSupportsBreakdown } from './utils/field_supports_breakdown'; +import { fieldSupportsBreakdown } from '../utils/field_supports_breakdown'; import { ToolbarSelector, ToolbarSelectorProps, diff --git a/src/plugins/unified_histogram/public/chart/chart.test.tsx b/src/plugins/unified_histogram/public/chart/chart.test.tsx index 474da6bce5bf76..05f4e1a2b079a1 100644 --- a/src/plugins/unified_histogram/public/chart/chart.test.tsx +++ b/src/plugins/unified_histogram/public/chart/chart.test.tsx @@ -13,9 +13,10 @@ import type { Capabilities } from '@kbn/core/public'; 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 { Chart, type ChartProps } from './chart'; import type { ReactWrapper } from 'enzyme'; import { unifiedHistogramServicesMock } from '../__mocks__/services'; +import { getLensVisMock } from '../__mocks__/lens_vis'; import { searchSourceInstanceMock } from '@kbn/data-plugin/common/search/search_source/mocks'; import { of } from 'rxjs'; import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield'; @@ -23,8 +24,7 @@ import { dataViewMock } from '../__mocks__/data_view'; import { BreakdownFieldSelector } from './breakdown_field_selector'; import { SuggestionSelector } from './suggestion_selector'; import { checkChartAvailability } from './check_chart_availability'; - -import { currentSuggestionMock, allSuggestionsMock } from '../__mocks__/suggestions'; +import { allSuggestionsMock } from '../__mocks__/suggestions'; let mockUseEditVisualization: jest.Mock | undefined = jest.fn(); @@ -40,11 +40,11 @@ async function mountComponent({ chartHidden = false, appendHistogram, dataView = dataViewWithTimefieldMock, - currentSuggestion, allSuggestions, isPlainRecord, hasDashboardPermissions, isChartLoading, + hasHistogramSuggestionForESQL, }: { customToggle?: ReactElement; noChart?: boolean; @@ -53,11 +53,11 @@ async function mountComponent({ chartHidden?: boolean; appendHistogram?: ReactElement; dataView?: DataView; - currentSuggestion?: Suggestion; allSuggestions?: Suggestion[]; isPlainRecord?: boolean; hasDashboardPermissions?: boolean; isChartLoading?: boolean; + hasHistogramSuggestionForESQL?: boolean; } = {}) { (searchSourceInstanceMock.fetch$ as jest.Mock).mockImplementation( jest.fn().mockReturnValue(of({ rawResponse: { hits: { total: noHits ? 0 : 2 } } })) @@ -85,25 +85,46 @@ async function mountComponent({ }, }; - const props = { - dataView, - query: { - language: 'kuery', - query: '', - }, + const requestParams = { + query: isPlainRecord + ? { esql: 'from logs | limit 10' } + : { + language: 'kuery', + query: '', + }, filters: [], - timeRange: { from: '2020-05-14T11:05:13.590', to: '2020-05-14T11:20:13.590' }, + relativeTimeRange: { from: '2020-05-14T11:05:13.590', to: '2020-05-14T11:20:13.590' }, + getTimeRange: () => ({ from: '2020-05-14T11:05:13.590', to: '2020-05-14T11:20:13.590' }), + updateTimeRange: () => {}, + }; + + const lensVisService = ( + await getLensVisMock({ + query: requestParams.query, + filters: requestParams.filters, + isPlainRecord: Boolean(isPlainRecord), + timeInterval: 'auto', + dataView, + breakdownField: undefined, + columns: [], + allSuggestions, + hasHistogramSuggestionForESQL, + }) + ).lensService; + + const props: ChartProps = { + lensVisService, + dataView, + requestParams, services, hits: noHits ? undefined : { status: 'complete' as UnifiedHistogramFetchStatus, - number: 2, + total: 2, }, chart, breakdown: noBreakdown ? undefined : { field: undefined }, - currentSuggestion, - allSuggestions, isChartLoading: Boolean(isChartLoading), isPlainRecord, appendHistogram, @@ -248,7 +269,7 @@ describe('Chart', () => { it('should render the Lens SuggestionsSelector when chart is visible and suggestions exist', async () => { const component = await mountComponent({ - currentSuggestion: currentSuggestionMock, + isPlainRecord: true, allSuggestions: allSuggestionsMock, }); expect(component.find(SuggestionSelector).exists()).toBeTruthy(); @@ -256,7 +277,6 @@ describe('Chart', () => { it('should render the edit on the fly button when chart is visible and suggestions exist', async () => { const component = await mountComponent({ - currentSuggestion: currentSuggestionMock, allSuggestions: allSuggestionsMock, isPlainRecord: true, }); @@ -267,8 +287,8 @@ describe('Chart', () => { it('should not render the edit on the fly button when chart is visible and suggestions dont exist', async () => { const component = await mountComponent({ - currentSuggestion: undefined, - allSuggestions: undefined, + allSuggestions: [], + hasHistogramSuggestionForESQL: false, isPlainRecord: true, }); expect( @@ -278,8 +298,8 @@ describe('Chart', () => { it('should render the save button when chart is visible and suggestions exist', async () => { const component = await mountComponent({ - currentSuggestion: currentSuggestionMock, allSuggestions: allSuggestionsMock, + isPlainRecord: true, }); expect( component.find('[data-test-subj="unifiedHistogramSaveVisualization"]').exists() @@ -288,7 +308,6 @@ describe('Chart', () => { it('should not render the save button when the dashboard save by value permissions are false', async () => { const component = await mountComponent({ - currentSuggestion: currentSuggestionMock, allSuggestions: allSuggestionsMock, hasDashboardPermissions: false, }); @@ -300,14 +319,13 @@ describe('Chart', () => { 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 }); + const component = await mountComponent({}); expect(component.find(SuggestionSelector).exists()).toBeFalsy(); }); }); diff --git a/src/plugins/unified_histogram/public/chart/chart.tsx b/src/plugins/unified_histogram/public/chart/chart.tsx index cfc096e8f197f6..7c93e8bf5254da 100644 --- a/src/plugins/unified_histogram/public/chart/chart.tsx +++ b/src/plugins/unified_histogram/public/chart/chart.tsx @@ -6,45 +6,47 @@ * Side Public License, v 1. */ -import React, { ReactElement, useMemo, useState, useEffect, useCallback, memo } from 'react'; +import React, { memo, ReactElement, useCallback, useEffect, useMemo, useState } from 'react'; import type { Observable } from 'rxjs'; +import { Subject } from 'rxjs'; +import useObservable from 'react-use/lib/useObservable'; import { IconButtonGroup, type IconButtonGroupProps } from '@kbn/shared-ux-button-toolbar'; import { EuiFlexGroup, EuiFlexItem, EuiProgress } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { EmbeddableComponentProps, - Suggestion, + LensEmbeddableInput, LensEmbeddableOutput, + Suggestion, } from '@kbn/lens-plugin/public'; 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 { Subject } from 'rxjs'; -import type { LensAttributes } from '@kbn/lens-embeddable-utils'; -import type { TextBasedPersistedState } from '@kbn/lens-plugin/public/datasources/text_based/types'; +import type { TimeRange } from '@kbn/es-query'; import { Histogram } from './histogram'; import type { + UnifiedHistogramSuggestionContext, UnifiedHistogramBreakdownContext, UnifiedHistogramChartContext, + UnifiedHistogramChartLoadEvent, UnifiedHistogramFetchStatus, UnifiedHistogramHitsContext, - UnifiedHistogramChartLoadEvent, - UnifiedHistogramRequestContext, - UnifiedHistogramServices, UnifiedHistogramInput$, UnifiedHistogramInputMessage, + UnifiedHistogramRequestContext, + UnifiedHistogramServices, } from '../types'; +import { UnifiedHistogramSuggestionType } from '../types'; import { BreakdownFieldSelector } from './breakdown_field_selector'; import { SuggestionSelector } from './suggestion_selector'; import { TimeIntervalSelector } from './time_interval_selector'; import { useTotalHits } from './hooks/use_total_hits'; -import { useRequestParams } from './hooks/use_request_params'; import { useChartStyles } from './hooks/use_chart_styles'; import { useChartActions } from './hooks/use_chart_actions'; import { ChartConfigPanel } from './chart_config_panel'; -import { getLensAttributes } from './utils/get_lens_attributes'; import { useRefetch } from './hooks/use_refetch'; import { useEditVisualization } from './hooks/use_edit_visualization'; +import { LensVisService } from '../services/lens_vis_service'; +import type { UseRequestParamsResult } from '../hooks/use_request_params'; +import { removeTablesFromLensAttributes } from '../utils/lens_vis_from_table'; export interface ChartProps { abortController?: AbortController; @@ -53,12 +55,9 @@ export interface ChartProps { className?: string; services: UnifiedHistogramServices; dataView: DataView; - query?: Query | AggregateQuery; - filters?: Filter[]; + requestParams: UseRequestParamsResult; isPlainRecord?: boolean; - currentSuggestion?: Suggestion; - allSuggestions?: Suggestion[]; - timeRange?: TimeRange; + lensVisService: LensVisService; relativeTimeRange?: TimeRange; request?: UnifiedHistogramRequestContext; hits?: UnifiedHistogramHitsContext; @@ -72,13 +71,10 @@ export interface ChartProps { input$?: UnifiedHistogramInput$; lensAdapters?: UnifiedHistogramChartLoadEvent['adapters']; lensEmbeddableOutput$?: Observable; - isOnHistogramMode?: boolean; - histogramQuery?: AggregateQuery; isChartLoading?: boolean; 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']; @@ -93,16 +89,13 @@ export function Chart({ className, services, dataView, - query: originalQuery, - filters: originalFilters, - timeRange: originalTimeRange, + requestParams, relativeTimeRange: originalRelativeTimeRange, request, hits, chart, breakdown, - currentSuggestion, - allSuggestions, + lensVisService, isPlainRecord, renderCustomChartToggleActions, appendHistogram, @@ -112,12 +105,9 @@ export function Chart({ input$: originalInput$, lensAdapters, lensEmbeddableOutput$, - isOnHistogramMode, - histogramQuery, isChartLoading, onChartHiddenChange, onTimeIntervalChange, - onSuggestionChange, onBreakdownFieldChange, onTotalHitsChange, onChartLoad, @@ -126,6 +116,13 @@ export function Chart({ withDefaultActions, abortController, }: ChartProps) { + const lensVisServiceCurrentSuggestionContext = useObservable( + lensVisService.currentSuggestionContext$ + ); + const visContext = useObservable(lensVisService.visContext$); + const allSuggestions = useObservable(lensVisService.allSuggestions$); + const currentSuggestion = lensVisServiceCurrentSuggestionContext?.suggestion; + const [isSaveModalVisible, setIsSaveModalVisible] = useState(false); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); const { chartRef, toggleHideChart } = useChartActions({ @@ -133,19 +130,15 @@ export function Chart({ onChartHiddenChange, }); - const chartVisible = isChartAvailable && !!chart && !chart.hidden; + const chartVisible = + isChartAvailable && !!chart && !chart.hidden && !!visContext && !!visContext?.attributes; const input$ = useMemo( () => originalInput$ ?? new Subject(), [originalInput$] ); - const { filters, query, getTimeRange, updateTimeRange, relativeTimeRange } = useRequestParams({ - services, - query: originalQuery, - filters: originalFilters, - timeRange: originalTimeRange, - }); + const { filters, query, getTimeRange, updateTimeRange, relativeTimeRange } = requestParams; const refetch$ = useRefetch({ dataView, @@ -179,34 +172,24 @@ export function Chart({ const { chartToolbarCss, histogramCss } = useChartStyles(chartVisible); - const lensAttributesContext = useMemo( - () => - getLensAttributes({ - title: chart?.title, - filters, - query: histogramQuery ?? query, - dataView, - timeInterval: chart?.timeInterval, - breakdownField: breakdown?.field, - suggestion: currentSuggestion, - }), - [ - breakdown?.field, - chart?.timeInterval, - chart?.title, - currentSuggestion, - dataView, - filters, - query, - histogramQuery, - ] + const onSuggestionContextEdit = useCallback( + (editedSuggestionContext: UnifiedHistogramSuggestionContext | undefined) => { + lensVisService.onSuggestionEdited({ + editedSuggestionContext, + }); + }, + [lensVisService] ); const onSuggestionSelectorChange = useCallback( - (s: Suggestion | undefined) => { - onSuggestionChange?.(s); + (suggestion: Suggestion | undefined) => { + setIsFlyoutVisible(false); + onSuggestionContextEdit({ + suggestion, + type: UnifiedHistogramSuggestionType.lensSuggestion, + }); }, - [onSuggestionChange] + [onSuggestionContextEdit, setIsFlyoutVisible] ); useEffect(() => { @@ -221,7 +204,7 @@ export function Chart({ services, dataView, relativeTimeRange: originalRelativeTimeRange ?? relativeTimeRange, - lensAttributes: lensAttributesContext.attributes, + lensAttributes: visContext?.attributes, isPlainRecord, }); @@ -234,9 +217,22 @@ export function Chart({ } const LensSaveModalComponent = services.lens.SaveModalComponent; + const hasLensSuggestions = Boolean( + isPlainRecord && + lensVisServiceCurrentSuggestionContext?.type === UnifiedHistogramSuggestionType.lensSuggestion + ); + + const canCustomizeVisualization = + isPlainRecord && + currentSuggestion && + [ + UnifiedHistogramSuggestionType.lensSuggestion, + UnifiedHistogramSuggestionType.histogramForESQL, + ].includes(lensVisServiceCurrentSuggestionContext?.type); + + const canEditVisualizationOnTheFly = canCustomizeVisualization && chartVisible; const canSaveVisualization = - chartVisible && currentSuggestion && services.capabilities.dashboard?.showWriteControls; - const canEditVisualizationOnTheFly = currentSuggestion && chartVisible; + canEditVisualizationOnTheFly && services.capabilities.dashboard?.showWriteControls; const actions: IconButtonGroupProps['buttons'] = []; @@ -260,6 +256,7 @@ export function Chart({ onClick: onEditVisualization, }); } + if (canSaveVisualization) { actions.push({ label: i18n.translate('unifiedHistogram.saveVisualizationButton', { @@ -271,37 +268,6 @@ export function Chart({ }); } - const removeTables = (attributes: LensAttributes) => { - if (!attributes.state.datasourceStates.textBased) { - return attributes; - } - const layers = attributes.state.datasourceStates.textBased?.layers; - - const newState = { - ...attributes, - state: { - ...attributes.state, - datasourceStates: { - ...attributes.state.datasourceStates, - textBased: { - ...(attributes.state.datasourceStates.textBased || {}), - layers: {} as TextBasedPersistedState['layers'], - }, - }, - }, - }; - - if (layers) { - for (const key of Object.keys(layers)) { - const newLayer = { ...layers[key] }; - delete newLayer.table; - newState.state.datasourceStates.textBased!.layers[key] = newLayer; - } - } - - return newState; - }; - return ( )} - {canSaveVisualization && isSaveModalVisible && lensAttributesContext.attributes && ( + {canSaveVisualization && isSaveModalVisible && visContext.attributes && ( {}} onClose={() => setIsSaveModalVisible(false)} isSaveable={false} /> )} - {isFlyoutVisible && ( + {isFlyoutVisible && !!visContext && !!lensVisServiceCurrentSuggestionContext && ( )} diff --git a/src/plugins/unified_histogram/public/chart/chart_config_panel.test.tsx b/src/plugins/unified_histogram/public/chart/chart_config_panel.test.tsx index 5238fc0ac12bb4..4f4eaa9faf6cc3 100644 --- a/src/plugins/unified_histogram/public/chart/chart_config_panel.test.tsx +++ b/src/plugins/unified_histogram/public/chart/chart_config_panel.test.tsx @@ -13,9 +13,11 @@ import { act } from 'react-dom/test-utils'; import { setTimeout } from 'timers/promises'; import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield'; import { unifiedHistogramServicesMock } from '../__mocks__/services'; +import { currentSuggestionMock } from '../__mocks__/suggestions'; import { lensAdaptersMock } from '../__mocks__/lens_adapters'; import { ChartConfigPanel } from './chart_config_panel'; -import type { LensAttributesContext } from './utils/get_lens_attributes'; +import type { UnifiedHistogramVisContext } from '../types'; +import { UnifiedHistogramSuggestionType } from '../types'; describe('ChartConfigPanel', () => { it('should return a jsx element to edit the visualization', async () => { @@ -28,16 +30,21 @@ describe('ChartConfigPanel', () => { {...{ services: unifiedHistogramServicesMock, dataView: dataViewWithTimefieldMock, - lensAttributesContext: { + visContext: { attributes: lensAttributes, - } as unknown as LensAttributesContext, + } as unknown as UnifiedHistogramVisContext, isFlyoutVisible: true, setIsFlyoutVisible: jest.fn(), + onSuggestionContextChange: jest.fn(), isPlainRecord: true, lensAdapters: lensAdaptersMock, query: { esql: 'from test', }, + currentSuggestionContext: { + suggestion: currentSuggestionMock, + type: UnifiedHistogramSuggestionType.lensSuggestion, + }, }} /> ); @@ -55,12 +62,17 @@ describe('ChartConfigPanel', () => { {...{ services: unifiedHistogramServicesMock, dataView: dataViewWithTimefieldMock, - lensAttributesContext: { + visContext: { attributes: lensAttributes, - } as unknown as LensAttributesContext, + } as unknown as UnifiedHistogramVisContext, isFlyoutVisible: true, setIsFlyoutVisible: jest.fn(), + onSuggestionContextChange: jest.fn(), isPlainRecord: false, + currentSuggestionContext: { + suggestion: currentSuggestionMock, + type: UnifiedHistogramSuggestionType.histogramForDataView, + }, }} /> ); diff --git a/src/plugins/unified_histogram/public/chart/chart_config_panel.tsx b/src/plugins/unified_histogram/public/chart/chart_config_panel.tsx index 314226525296e6..654d4e9ab93ab1 100644 --- a/src/plugins/unified_histogram/public/chart/chart_config_panel.tsx +++ b/src/plugins/unified_histogram/public/chart/chart_config_panel.tsx @@ -12,31 +12,35 @@ import { isEqual } from 'lodash'; import type { LensEmbeddableOutput, Suggestion } from '@kbn/lens-plugin/public'; import type { Datatable } from '@kbn/expressions-plugin/common'; -import type { UnifiedHistogramServices, UnifiedHistogramChartLoadEvent } from '../types'; -import type { LensAttributesContext } from './utils/get_lens_attributes'; +import type { + UnifiedHistogramServices, + UnifiedHistogramChartLoadEvent, + UnifiedHistogramVisContext, + UnifiedHistogramSuggestionContext, +} from '../types'; export function ChartConfigPanel({ services, - lensAttributesContext, + visContext, lensAdapters, lensEmbeddableOutput$, - currentSuggestion, + currentSuggestionContext, isFlyoutVisible, setIsFlyoutVisible, isPlainRecord, query, - onSuggestionChange, + onSuggestionContextChange, }: { services: UnifiedHistogramServices; - lensAttributesContext: LensAttributesContext; + visContext: UnifiedHistogramVisContext; isFlyoutVisible: boolean; setIsFlyoutVisible: (flag: boolean) => void; lensAdapters?: UnifiedHistogramChartLoadEvent['adapters']; lensEmbeddableOutput$?: Observable; - currentSuggestion?: Suggestion; + currentSuggestionContext: UnifiedHistogramSuggestionContext; isPlainRecord?: boolean; query?: Query | AggregateQuery; - onSuggestionChange?: (suggestion: Suggestion | undefined) => void; + onSuggestionContextChange: (suggestion: UnifiedHistogramSuggestionContext | undefined) => void; }) { const [editLensConfigPanel, setEditLensConfigPanel] = useState(null); const previousSuggestion = useRef(undefined); @@ -44,16 +48,21 @@ export function ChartConfigPanel({ const previousQuery = useRef(undefined); const updateSuggestion = useCallback( (datasourceState, visualizationState) => { - const updatedSuggestion = { - ...currentSuggestion, + const updatedSuggestion: Suggestion = { + ...currentSuggestionContext?.suggestion, ...(datasourceState && { datasourceState }), ...(visualizationState && { visualizationState }), - } as Suggestion; - onSuggestionChange?.(updatedSuggestion); + }; + onSuggestionContextChange({ + ...currentSuggestionContext, + suggestion: updatedSuggestion, + }); }, - [currentSuggestion, onSuggestionChange] + [currentSuggestionContext, onSuggestionContextChange] ); + const currentSuggestion = currentSuggestionContext.suggestion; + useEffect(() => { const tablesAdapters = lensAdapters?.tables?.tables; const dataHasChanged = @@ -64,7 +73,7 @@ export function ChartConfigPanel({ const Component = await services.lens.EditLensConfigPanelApi(); const panel = ( - getLensAttributes({ - title: 'test', - filters: [], - query: { - language: 'kuery', - query: '', - }, - dataView: dataViewWithTimefieldMock, - timeInterval: 'auto', - breakdownField: dataViewWithTimefieldMock.getFieldByName('extension'), - suggestion: undefined, - }); +const getMockLensAttributes = async () => { + const query = { + language: 'kuery', + query: '', + }; + return ( + await getLensVisMock({ + filters: [], + query, + columns: [], + isPlainRecord: false, + dataView: dataViewWithTimefieldMock, + timeInterval: 'auto', + breakdownField: dataViewWithTimefieldMock.getFieldByName('extension'), + }) + ).visContext; +}; -function mountComponent(isPlainRecord = false, hasLensSuggestions = false) { +async function mountComponent(isPlainRecord = false, hasLensSuggestions = false) { const services = unifiedHistogramServicesMock; services.data.query.timefilter.timefilter.getAbsoluteTime = () => { return { from: '2020-05-14T11:05:13.590', to: '2020-05-14T11:20:13.590' }; @@ -69,7 +72,7 @@ function mountComponent(isPlainRecord = false, hasLensSuggestions = false) { to: '2020-05-14T11:20:13.590', }), refetch$, - lensAttributesContext: getMockLensAttributes(), + visContext: (await getMockLensAttributes())!, onTotalHitsChange: jest.fn(), onChartLoad: jest.fn(), withDefaultActions: undefined, @@ -82,20 +85,20 @@ function mountComponent(isPlainRecord = false, hasLensSuggestions = false) { } describe('Histogram', () => { - it('renders correctly', () => { - const { component } = mountComponent(); + it('renders correctly', async () => { + const { component } = await mountComponent(); expect(component.find('[data-test-subj="unifiedHistogramChart"]').exists()).toBe(true); }); it('should only update lens.EmbeddableComponent props when refetch$ is triggered', async () => { - const { component, props } = mountComponent(); + const { component, props } = await mountComponent(); const embeddable = unifiedHistogramServicesMock.lens.EmbeddableComponent; expect(component.find(embeddable).exists()).toBe(true); let lensProps = component.find(embeddable).props(); const originalProps = getLensProps({ searchSessionId: props.request.searchSessionId, getTimeRange: props.getTimeRange, - attributes: getMockLensAttributes().attributes, + attributes: (await getMockLensAttributes())!.attributes, onLoad: lensProps.onLoad, }); expect(lensProps).toMatchObject(expect.objectContaining(originalProps)); @@ -113,7 +116,7 @@ describe('Histogram', () => { }); it('should execute onLoad correctly', async () => { - const { component, props } = mountComponent(); + const { component, props } = await mountComponent(); const embeddable = unifiedHistogramServicesMock.lens.EmbeddableComponent; const onLoad = component.find(embeddable).props().onLoad; const adapters = createDefaultInspectorAdapters(); @@ -193,7 +196,7 @@ describe('Histogram', () => { }); it('should execute onLoad correctly when the request has a failure status', async () => { - const { component, props } = mountComponent(); + const { component, props } = await mountComponent(); const embeddable = unifiedHistogramServicesMock.lens.EmbeddableComponent; const onLoad = component.find(embeddable).props().onLoad; const adapters = createDefaultInspectorAdapters(); @@ -209,7 +212,7 @@ describe('Histogram', () => { }); it('should execute onLoad correctly when the response has shard failures', async () => { - const { component, props } = mountComponent(); + const { component, props } = await mountComponent(); const embeddable = unifiedHistogramServicesMock.lens.EmbeddableComponent; const onLoad = component.find(embeddable).props().onLoad; const adapters = createDefaultInspectorAdapters(); @@ -242,7 +245,7 @@ describe('Histogram', () => { }); it('should execute onLoad correctly for textbased language and no Lens suggestions', async () => { - const { component, props } = mountComponent(true, false); + const { component, props } = await mountComponent(true, false); const embeddable = unifiedHistogramServicesMock.lens.EmbeddableComponent; const onLoad = component.find(embeddable).props().onLoad; const adapters = createDefaultInspectorAdapters(); @@ -278,7 +281,7 @@ describe('Histogram', () => { }); it('should execute onLoad correctly for textbased language and Lens suggestions', async () => { - const { component, props } = mountComponent(true, true); + const { component, props } = await mountComponent(true, true); const embeddable = unifiedHistogramServicesMock.lens.EmbeddableComponent; const onLoad = component.find(embeddable).props().onLoad; const adapters = createDefaultInspectorAdapters(); diff --git a/src/plugins/unified_histogram/public/chart/histogram.tsx b/src/plugins/unified_histogram/public/chart/histogram.tsx index 70d406b7f9be8a..8a65426e4a9a2b 100644 --- a/src/plugins/unified_histogram/public/chart/histogram.tsx +++ b/src/plugins/unified_histogram/public/chart/histogram.tsx @@ -30,12 +30,12 @@ import { UnifiedHistogramRequestContext, UnifiedHistogramServices, UnifiedHistogramInputMessage, + UnifiedHistogramVisContext, } from '../types'; import { buildBucketInterval } from './utils/build_bucket_interval'; import { useTimeRange } from './hooks/use_time_range'; -import { useStableCallback } from './hooks/use_stable_callback'; +import { useStableCallback } from '../hooks/use_stable_callback'; import { useLensProps } from './hooks/use_lens_props'; -import type { LensAttributesContext } from './utils/get_lens_attributes'; export interface HistogramProps { abortController?: AbortController; @@ -48,7 +48,7 @@ export interface HistogramProps { hasLensSuggestions: boolean; getTimeRange: () => TimeRange; refetch$: Observable; - lensAttributesContext: LensAttributesContext; + visContext: UnifiedHistogramVisContext; disableTriggers?: LensEmbeddableInput['disableTriggers']; disabledActions?: LensEmbeddableInput['disabledActions']; onTotalHitsChange?: (status: UnifiedHistogramFetchStatus, result?: number | Error) => void; @@ -95,7 +95,7 @@ export function Histogram({ hasLensSuggestions, getTimeRange, refetch$, - lensAttributesContext: attributesContext, + visContext, disableTriggers, disabledActions, onTotalHitsChange, @@ -117,7 +117,7 @@ export function Histogram({ }); const chartRef = useRef(null); const { height: containerHeight, width: containerWidth } = useResizeObserver(chartRef.current); - const { attributes } = attributesContext; + const { attributes } = visContext; useEffect(() => { if (attributes.visualizationType === 'lnsMetric') { @@ -178,7 +178,7 @@ export function Histogram({ request, getTimeRange, refetch$, - attributesContext, + visContext, onLoad, }); diff --git a/src/plugins/unified_histogram/public/chart/hooks/use_edit_visualization.ts b/src/plugins/unified_histogram/public/chart/hooks/use_edit_visualization.ts index b02732bfcbfc96..8b70e08684971b 100644 --- a/src/plugins/unified_histogram/public/chart/hooks/use_edit_visualization.ts +++ b/src/plugins/unified_histogram/public/chart/hooks/use_edit_visualization.ts @@ -26,7 +26,7 @@ export const useEditVisualization = ({ services: UnifiedHistogramServices; dataView: DataView; relativeTimeRange?: TimeRange; - lensAttributes: TypedLensByValueInput['attributes']; + lensAttributes?: TypedLensByValueInput['attributes']; isPlainRecord?: boolean; }) => { const [canVisualize, setCanVisualize] = useState(false); @@ -51,7 +51,7 @@ export const useEditVisualization = ({ }, [dataView, isPlainRecord, services.uiActions]); const onEditVisualization = useMemo(() => { - if (!canVisualize) { + if (!canVisualize || !lensAttributes) { return undefined; } diff --git a/src/plugins/unified_histogram/public/chart/hooks/use_lens_props.test.ts b/src/plugins/unified_histogram/public/chart/hooks/use_lens_props.test.ts index 36b4e5c8f4e4d6..de483cbdb63ece 100644 --- a/src/plugins/unified_histogram/public/chart/hooks/use_lens_props.test.ts +++ b/src/plugins/unified_histogram/public/chart/hooks/use_lens_props.test.ts @@ -11,27 +11,29 @@ 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 { getLensVisMock } from '../../__mocks__/lens_vis'; import { getLensProps, useLensProps } from './use_lens_props'; describe('useLensProps', () => { - it('should return lens props', () => { + it('should return lens props', async () => { const getTimeRange = jest.fn(); const refetch$ = new Subject(); const onLoad = jest.fn(); - const attributesContext = getLensAttributes({ - title: 'test', - filters: [], - query: { - language: 'kuery', - query: '', - }, - dataView: dataViewWithTimefieldMock, - timeInterval: 'auto', - breakdownField: dataViewWithTimefieldMock.getFieldByName('extension'), - suggestion: undefined, - }); + const query = { + language: 'kuery', + query: '', + }; + const attributesContext = ( + await getLensVisMock({ + filters: [], + query, + columns: [], + isPlainRecord: false, + dataView: dataViewWithTimefieldMock, + timeInterval: 'auto', + breakdownField: dataViewWithTimefieldMock.getFieldByName('extension'), + }) + ).visContext; const lensProps = renderHook(() => { return useLensProps({ request: { @@ -40,7 +42,7 @@ describe('useLensProps', () => { }, getTimeRange, refetch$, - attributesContext, + visContext: attributesContext!, onLoad, }); }); @@ -48,28 +50,31 @@ describe('useLensProps', () => { getLensProps({ searchSessionId: 'id', getTimeRange, - attributes: attributesContext.attributes, + attributes: attributesContext!.attributes, onLoad, }) ); }); - it('should return lens props for text based languages', () => { + it('should return lens props for text based languages', async () => { const getTimeRange = jest.fn(); const refetch$ = new Subject(); const onLoad = jest.fn(); - const attributesContext = getLensAttributes({ - title: 'test', - filters: [], - query: { - language: 'kuery', - query: '', - }, - dataView: dataViewWithTimefieldMock, - timeInterval: 'auto', - breakdownField: dataViewWithTimefieldMock.getFieldByName('extension'), - suggestion: currentSuggestionMock, - }); + const query = { + language: 'kuery', + query: '', + }; + const attributesContext = ( + await getLensVisMock({ + filters: [], + query, + columns: [], + isPlainRecord: false, + dataView: dataViewWithTimefieldMock, + timeInterval: 'auto', + breakdownField: dataViewWithTimefieldMock.getFieldByName('extension'), + }) + ).visContext; const lensProps = renderHook(() => { return useLensProps({ request: { @@ -78,7 +83,7 @@ describe('useLensProps', () => { }, getTimeRange, refetch$, - attributesContext, + visContext: attributesContext!, onLoad, }); }); @@ -86,16 +91,31 @@ describe('useLensProps', () => { getLensProps({ searchSessionId: 'id', getTimeRange, - attributes: attributesContext.attributes, + attributes: attributesContext!.attributes, onLoad, }) ); }); - it('should only update lens props when refetch$ is triggered', () => { + it('should only update lens props when refetch$ is triggered', async () => { const getTimeRange = jest.fn(); const refetch$ = new Subject(); const onLoad = jest.fn(); + const query = { + language: 'kuery', + query: '', + }; + const attributesContext = ( + await getLensVisMock({ + filters: [], + query, + columns: [], + isPlainRecord: false, + dataView: dataViewWithTimefieldMock, + timeInterval: 'auto', + breakdownField: dataViewWithTimefieldMock.getFieldByName('extension'), + }) + ).visContext; const lensProps = { request: { searchSessionId: '123', @@ -103,18 +123,7 @@ describe('useLensProps', () => { }, getTimeRange, refetch$, - attributesContext: getLensAttributes({ - title: 'test', - filters: [], - query: { - language: 'kuery', - query: '', - }, - dataView: dataViewWithTimefieldMock, - timeInterval: 'auto', - breakdownField: dataViewWithTimefieldMock.getFieldByName('extension'), - suggestion: undefined, - }), + visContext: attributesContext!, onLoad, }; const hook = renderHook( diff --git a/src/plugins/unified_histogram/public/chart/hooks/use_lens_props.ts b/src/plugins/unified_histogram/public/chart/hooks/use_lens_props.ts index 29827a46dd7050..8c4d1ec9b16a7c 100644 --- a/src/plugins/unified_histogram/public/chart/hooks/use_lens_props.ts +++ b/src/plugins/unified_histogram/public/chart/hooks/use_lens_props.ts @@ -12,25 +12,28 @@ import type { DefaultInspectorAdapters } from '@kbn/expressions-plugin/common'; import type { TypedLensByValueInput } from '@kbn/lens-plugin/public'; import { useCallback, useEffect, useState } from 'react'; import type { Observable } from 'rxjs'; -import type { UnifiedHistogramInputMessage, UnifiedHistogramRequestContext } from '../../types'; -import type { LensAttributesContext } from '../utils/get_lens_attributes'; -import { useStableCallback } from './use_stable_callback'; +import type { + UnifiedHistogramInputMessage, + UnifiedHistogramRequestContext, + UnifiedHistogramVisContext, +} from '../../types'; +import { useStableCallback } from '../../hooks/use_stable_callback'; export const useLensProps = ({ request, getTimeRange, refetch$, - attributesContext, + visContext, onLoad, }: { request?: UnifiedHistogramRequestContext; getTimeRange: () => TimeRange; refetch$: Observable; - attributesContext: LensAttributesContext; + visContext: UnifiedHistogramVisContext; onLoad: (isLoading: boolean, adapters: Partial | undefined) => void; }) => { const buildLensProps = useCallback(() => { - const { attributes, requestData } = attributesContext; + const { attributes, requestData } = visContext; return { requestData: JSON.stringify(requestData), lensProps: getLensProps({ @@ -40,7 +43,7 @@ export const useLensProps = ({ onLoad, }), }; - }, [attributesContext, getTimeRange, onLoad, request?.searchSessionId]); + }, [visContext, getTimeRange, onLoad, request?.searchSessionId]); const [lensPropsContext, setLensPropsContext] = useState(buildLensProps()); const updateLensPropsContext = useStableCallback(() => setLensPropsContext(buildLensProps())); diff --git a/src/plugins/unified_histogram/public/chart/hooks/use_time_range.test.tsx b/src/plugins/unified_histogram/public/chart/hooks/use_time_range.test.tsx index 1e86bf5d9614e7..e37e8fdf44c8fc 100644 --- a/src/plugins/unified_histogram/public/chart/hooks/use_time_range.test.tsx +++ b/src/plugins/unified_histogram/public/chart/hooks/use_time_range.test.tsx @@ -264,7 +264,7 @@ describe('useTimeRange', () => { size="xs" textAlign="center" > - 2022-11-17T00:00:00.000Z - 2022-11-17T12:00:00.000Z + 2022-11-17T00:00:00.000Z - 2022-11-17T12:00:00.000Z `); }); diff --git a/src/plugins/unified_histogram/public/chart/hooks/use_time_range.tsx b/src/plugins/unified_histogram/public/chart/hooks/use_time_range.tsx index 791d332a3a89fb..f04b18de28f619 100644 --- a/src/plugins/unified_histogram/public/chart/hooks/use_time_range.tsx +++ b/src/plugins/unified_histogram/public/chart/hooks/use_time_range.tsx @@ -75,7 +75,7 @@ export const useTimeRange = ({ }, }); - return `${toMoment(timeRange.from)} - ${toMoment(timeRange.to)} ${intervalText}`; + return `${toMoment(timeRange.from)} - ${toMoment(timeRange.to)} ${intervalText}`.trim(); }, [bucketInterval?.description, from, isPlainRecord, timeField, timeInterval, to, toMoment]); const { euiTheme } = useEuiTheme(); diff --git a/src/plugins/unified_histogram/public/chart/hooks/use_total_hits.ts b/src/plugins/unified_histogram/public/chart/hooks/use_total_hits.ts index dfd14df6f452bb..038847db561503 100644 --- a/src/plugins/unified_histogram/public/chart/hooks/use_total_hits.ts +++ b/src/plugins/unified_histogram/public/chart/hooks/use_total_hits.ts @@ -19,7 +19,7 @@ import { UnifiedHistogramRequestContext, UnifiedHistogramServices, } from '../../types'; -import { useStableCallback } from './use_stable_callback'; +import { useStableCallback } from '../../hooks/use_stable_callback'; export const useTotalHits = ({ services, diff --git a/src/plugins/unified_histogram/public/chart/suggestion_selector.tsx b/src/plugins/unified_histogram/public/chart/suggestion_selector.tsx index cad20279bfdf01..82a7cc4d814c21 100644 --- a/src/plugins/unified_histogram/public/chart/suggestion_selector.tsx +++ b/src/plugins/unified_histogram/public/chart/suggestion_selector.tsx @@ -19,6 +19,10 @@ import type { Suggestion } from '@kbn/lens-plugin/public'; import { i18n } from '@kbn/i18n'; import React, { useCallback, useState } from 'react'; +const unfamiliarSuggestionTitle = i18n.translate('unifiedHistogram.lensUnfamiliarVisSubtypeTitle', { + defaultMessage: 'Customized', +}); + export interface SuggestionSelectorProps { suggestions: Suggestion[]; activeSuggestion?: Suggestion; @@ -30,21 +34,37 @@ export const SuggestionSelector = ({ activeSuggestion, onSuggestionChange, }: SuggestionSelectorProps) => { - const suggestionOptions = suggestions.map((sug) => { + const isUnfamiliarSuggestion = activeSuggestion && !activeSuggestion.previewIcon; + const activeSuggestionTitle = isUnfamiliarSuggestion + ? unfamiliarSuggestionTitle + : activeSuggestion?.title; + + let suggestionOptions = suggestions.map((sug) => { return { label: sug.title, value: sug.title, }; }); - const selectedSuggestion = activeSuggestion - ? [ - { - label: activeSuggestion.title, - value: activeSuggestion.title, - }, - ] - : []; + const selectedSuggestion = + activeSuggestion && activeSuggestionTitle + ? [ + { + label: activeSuggestionTitle, + value: activeSuggestionTitle, + }, + ] + : []; + + if (isUnfamiliarSuggestion && activeSuggestionTitle) { + suggestionOptions = [ + ...suggestionOptions, + { + label: activeSuggestionTitle, + value: activeSuggestionTitle, + }, + ]; + } const onSelectionChange = useCallback( (newOptions) => { @@ -80,7 +100,15 @@ export const SuggestionSelector = ({ > } + prepend={ + + } placeholder={i18n.translate('unifiedHistogram.suggestionSelectorPlaceholder', { defaultMessage: 'Select visualization', })} @@ -100,7 +128,13 @@ export const SuggestionSelector = ({ return ( - + {option.label} @@ -110,3 +144,25 @@ export const SuggestionSelector = ({ ); }; + +function getSuggestionIconWithFallback({ + suggestion, + suggestions, + activeSuggestion, +}: { + suggestion: Suggestion | undefined; + suggestions: Suggestion[]; + activeSuggestion?: Suggestion; +}) { + if (!suggestion) { + const similarKnownSuggestionWithIcon = suggestions.find( + (s) => s.title === activeSuggestion?.title && s.previewIcon + ); + + if (similarKnownSuggestionWithIcon?.previewIcon) { + return similarKnownSuggestionWithIcon.previewIcon; + } + } + + return suggestion?.previewIcon ?? 'lensApp'; +} diff --git a/src/plugins/unified_histogram/public/chart/utils/get_lens_attributes.ts b/src/plugins/unified_histogram/public/chart/utils/get_lens_attributes.ts deleted file mode 100644 index b5c9bca754ac5b..00000000000000 --- a/src/plugins/unified_histogram/public/chart/utils/get_lens_attributes.ts +++ /dev/null @@ -1,233 +0,0 @@ -/* - * 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 { DataView, DataViewField } from '@kbn/data-views-plugin/public'; -import type { AggregateQuery, Filter, Query } from '@kbn/es-query'; -import { i18n } from '@kbn/i18n'; -import type { - CountIndexPatternColumn, - DateHistogramIndexPatternColumn, - GenericIndexPatternColumn, - TermsIndexPatternColumn, - TypedLensByValueInput, - Suggestion, -} from '@kbn/lens-plugin/public'; -import { LegendSize } from '@kbn/visualizations-plugin/public'; -import { XYConfiguration } from '@kbn/visualizations-plugin/common'; -import { fieldSupportsBreakdown } from './field_supports_breakdown'; - -export interface LensRequestData { - dataViewId?: string; - timeField?: string; - timeInterval?: string; - breakdownField?: string; -} - -export interface LensAttributesContext { - attributes: TypedLensByValueInput['attributes']; - requestData: LensRequestData; -} - -export const getLensAttributes = ({ - title, - filters, - query, - dataView, - timeInterval, - breakdownField, - suggestion, -}: { - title?: string; - filters: Filter[]; - query: Query | AggregateQuery; - dataView: DataView; - timeInterval: string | undefined; - breakdownField: DataViewField | undefined; - suggestion: Suggestion | undefined; -}): LensAttributesContext => { - const showBreakdown = breakdownField && fieldSupportsBreakdown(breakdownField); - - let columnOrder = ['date_column', 'count_column']; - - if (showBreakdown) { - columnOrder = ['breakdown_column', ...columnOrder]; - } - - let columns: Record = { - date_column: { - dataType: 'date', - isBucketed: true, - label: dataView.timeFieldName ?? '', - operationType: 'date_histogram', - scale: 'interval', - sourceField: dataView.timeFieldName, - params: { - interval: timeInterval ?? 'auto', - }, - } as DateHistogramIndexPatternColumn, - count_column: { - dataType: 'number', - isBucketed: false, - label: i18n.translate('unifiedHistogram.countColumnLabel', { - defaultMessage: 'Count of records', - }), - operationType: 'count', - scale: 'ratio', - sourceField: '___records___', - params: { - format: { - id: 'number', - params: { - decimals: 0, - }, - }, - }, - } as CountIndexPatternColumn, - }; - - if (showBreakdown) { - columns = { - ...columns, - breakdown_column: { - dataType: 'string', - isBucketed: true, - label: i18n.translate('unifiedHistogram.breakdownColumnLabel', { - defaultMessage: 'Top 3 values of {fieldName}', - values: { fieldName: breakdownField?.displayName }, - }), - operationType: 'terms', - scale: 'ordinal', - sourceField: breakdownField.name, - params: { - size: 3, - orderBy: { - type: 'column', - columnId: 'count_column', - }, - orderDirection: 'desc', - otherBucket: true, - missingBucket: false, - parentFormat: { - id: 'terms', - }, - }, - } as TermsIndexPatternColumn, - }; - } - - const suggestionDatasourceState = Object.assign({}, suggestion?.datasourceState); - const suggestionVisualizationState = Object.assign({}, suggestion?.visualizationState); - const datasourceStates = - suggestion && suggestion.datasourceState - ? { - [suggestion.datasourceId!]: { - ...suggestionDatasourceState, - }, - } - : { - formBased: { - layers: { - unifiedHistogram: { columnOrder, columns }, - }, - }, - }; - const visualization = suggestion - ? { - ...suggestionVisualizationState, - } - : ({ - layers: [ - { - accessors: ['count_column'], - layerId: 'unifiedHistogram', - layerType: 'data', - seriesType: 'bar_stacked', - xAccessor: 'date_column', - ...(showBreakdown - ? { splitAccessor: 'breakdown_column' } - : { - yConfig: [ - { - forAccessor: 'count_column', - }, - ], - }), - }, - ], - legend: { - isVisible: true, - position: 'right', - legendSize: LegendSize.EXTRA_LARGE, - shouldTruncate: false, - }, - preferredSeriesType: 'bar_stacked', - valueLabels: 'hide', - fittingFunction: 'None', - minBarHeight: 2, - showCurrentTimeMarker: true, - axisTitlesVisibilitySettings: { - x: false, - yLeft: false, - yRight: false, - }, - gridlinesVisibilitySettings: { - x: true, - yLeft: true, - yRight: false, - }, - tickLabelsVisibilitySettings: { - x: true, - yLeft: true, - yRight: false, - }, - } as XYConfiguration); - const attributes = { - title: - title ?? - suggestion?.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, - ...(dataView && - dataView.id && - !dataView.isPersisted() && { - adHocDataViews: { - [dataView.id]: dataView.toSpec(false), - }, - }), - }, - visualizationType: suggestion ? suggestion.visualizationId : 'lnsXY', - } as TypedLensByValueInput['attributes']; - - return { - attributes, - requestData: { - dataViewId: dataView.id, - timeField: dataView.timeFieldName, - timeInterval, - breakdownField: breakdownField?.name, - }, - }; -}; diff --git a/src/plugins/unified_histogram/public/container/container.tsx b/src/plugins/unified_histogram/public/container/container.tsx index 3756b5da94e7b2..ef18a2ba992e0e 100644 --- a/src/plugins/unified_histogram/public/container/container.tsx +++ b/src/plugins/unified_histogram/public/container/container.tsx @@ -6,14 +6,18 @@ * Side Public License, v 1. */ -import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react'; +import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'; import { Subject } from 'rxjs'; import { pick } from 'lodash'; import useMount from 'react-use/lib/useMount'; import { LensSuggestionsApi } from '@kbn/lens-plugin/public'; -import type { Datatable } from '@kbn/expressions-plugin/common'; import { UnifiedHistogramLayout, UnifiedHistogramLayoutProps } from '../layout'; -import type { UnifiedHistogramInputMessage, UnifiedHistogramRequestContext } from '../types'; +import { + UnifiedHistogramExternalVisContextStatus, + UnifiedHistogramInputMessage, + UnifiedHistogramRequestContext, + UnifiedHistogramVisContext, +} from '../types'; import { createStateService, UnifiedHistogramStateOptions, @@ -21,7 +25,8 @@ import { } from './services/state_service'; import { useStateProps } from './hooks/use_state_props'; import { useStateSelector } from './utils/use_state_selector'; -import { topPanelHeightSelector, currentSuggestionSelector } from './utils/state_selectors'; +import { topPanelHeightSelector } from './utils/state_selectors'; +import { exportVisContext } from '../utils/external_vis_context'; type LayoutProps = Pick< UnifiedHistogramLayoutProps, @@ -44,7 +49,10 @@ export type UnifiedHistogramContainerProps = { searchSessionId?: UnifiedHistogramRequestContext['searchSessionId']; requestAdapter?: UnifiedHistogramRequestContext['adapter']; isChartLoading?: boolean; - table?: Datatable; + onVisContextChanged?: ( + nextVisContext: UnifiedHistogramVisContext | undefined, + externalVisContextStatus: UnifiedHistogramExternalVisContextStatus + ) => void; } & Pick< UnifiedHistogramLayoutProps, | 'services' @@ -55,11 +63,13 @@ export type UnifiedHistogramContainerProps = { | 'timeRange' | 'relativeTimeRange' | 'columns' + | 'table' | 'container' | 'renderCustomChartToggleActions' | 'children' | 'onBrushEnd' | 'onFilter' + | 'externalVisContext' | 'withDefaultActions' | 'disabledActions' | 'abortController' @@ -86,7 +96,7 @@ export type UnifiedHistogramApi = { export const UnifiedHistogramContainer = forwardRef< UnifiedHistogramApi, UnifiedHistogramContainerProps ->((containerProps, ref) => { +>(({ onVisContextChanged, ...containerProps }, ref) => { const [layoutProps, setLayoutProps] = useState(); const [stateService, setStateService] = useState(); const [lensSuggestionsApi, setLensSuggestionsApi] = useState(); @@ -129,7 +139,6 @@ export const UnifiedHistogramContainer = forwardRef< }); }, [input$, stateService]); const { dataView, query, searchSessionId, requestAdapter, isChartLoading } = containerProps; - const currentSuggestion = useStateSelector(stateService?.state$, currentSuggestionSelector); const topPanelHeight = useStateSelector(stateService?.state$, topPanelHeightSelector); const stateProps = useStateProps({ stateService, @@ -139,6 +148,19 @@ export const UnifiedHistogramContainer = forwardRef< requestAdapter, }); + const handleVisContextChange: UnifiedHistogramLayoutProps['onVisContextChanged'] | undefined = + useMemo(() => { + if (!onVisContextChanged) { + return undefined; + } + + return (visContext, externalVisContextStatus) => { + const minifiedVisContext = exportVisContext(visContext); + + onVisContextChanged(minifiedVisContext, externalVisContextStatus); + }; + }, [onVisContextChanged]); + // Don't render anything until the container is initialized if (!layoutProps || !lensSuggestionsApi || !api) { return null; @@ -149,7 +171,7 @@ export const UnifiedHistogramContainer = forwardRef< {...containerProps} {...layoutProps} {...stateProps} - currentSuggestion={currentSuggestion} + onVisContextChanged={handleVisContextChange} isChartLoading={Boolean(isChartLoading)} topPanelHeight={topPanelHeight} input$={input$} diff --git a/src/plugins/unified_histogram/public/container/hooks/use_state_props.test.ts b/src/plugins/unified_histogram/public/container/hooks/use_state_props.test.ts index 15c1ef83a4b8cb..44a216178f6d5a 100644 --- a/src/plugins/unified_histogram/public/container/hooks/use_state_props.test.ts +++ b/src/plugins/unified_histogram/public/container/hooks/use_state_props.test.ts @@ -13,7 +13,6 @@ 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 { lensAdaptersMock } from '../../__mocks__/lens_adapters'; import { unifiedHistogramServicesMock } from '../../__mocks__/services'; import { @@ -33,7 +32,7 @@ describe('useStateProps', () => { topPanelHeight: 100, totalHitsStatus: UnifiedHistogramFetchStatus.uninitialized, totalHitsResult: undefined, - currentSuggestion: undefined, + currentSuggestionContext: undefined, }; const getStateService = (options: Omit) => { @@ -47,7 +46,7 @@ describe('useStateProps', () => { jest.spyOn(stateService, 'setTimeInterval'); jest.spyOn(stateService, 'setLensRequestAdapter'); jest.spyOn(stateService, 'setTotalHits'); - jest.spyOn(stateService, 'setCurrentSuggestion'); + jest.spyOn(stateService, 'setCurrentSuggestionContext'); return stateService; }; @@ -122,7 +121,7 @@ describe('useStateProps', () => { "onBreakdownFieldChange": [Function], "onChartHiddenChange": [Function], "onChartLoad": [Function], - "onSuggestionChange": [Function], + "onSuggestionContextChange": [Function], "onTimeIntervalChange": [Function], "onTopPanelHeightChange": [Function], "onTotalHitsChange": [Function], @@ -203,7 +202,7 @@ describe('useStateProps', () => { "onBreakdownFieldChange": [Function], "onChartHiddenChange": [Function], "onChartLoad": [Function], - "onSuggestionChange": [Function], + "onSuggestionContextChange": [Function], "onTimeIntervalChange": [Function], "onTopPanelHeightChange": [Function], "onTotalHitsChange": [Function], @@ -226,7 +225,7 @@ describe('useStateProps', () => { const stateService = getStateService({ initialState: { ...initialState, - currentSuggestion: currentSuggestionMock, + currentSuggestionContext: undefined, }, }); const { result } = renderHook(() => @@ -305,7 +304,7 @@ describe('useStateProps', () => { "onBreakdownFieldChange": [Function], "onChartHiddenChange": [Function], "onChartLoad": [Function], - "onSuggestionChange": [Function], + "onSuggestionContextChange": [Function], "onTimeIntervalChange": [Function], "onTopPanelHeightChange": [Function], "onTotalHitsChange": [Function], @@ -383,7 +382,7 @@ describe('useStateProps', () => { "onBreakdownFieldChange": [Function], "onChartHiddenChange": [Function], "onChartLoad": [Function], - "onSuggestionChange": [Function], + "onSuggestionContextChange": [Function], "onTimeIntervalChange": [Function], "onTopPanelHeightChange": [Function], "onTotalHitsChange": [Function], @@ -420,7 +419,7 @@ describe('useStateProps', () => { onChartHiddenChange, onChartLoad, onBreakdownFieldChange, - onSuggestionChange, + onSuggestionContextChange, } = result.current; act(() => { onTopPanelHeightChange(200); @@ -452,9 +451,11 @@ describe('useStateProps', () => { expect(stateService.setBreakdownField).toHaveBeenLastCalledWith('field'); act(() => { - onSuggestionChange({ title: 'Stacked Bar' } as Suggestion); + onSuggestionContextChange({ title: 'Stacked Bar' } as Suggestion); + }); + expect(stateService.setCurrentSuggestionContext).toHaveBeenLastCalledWith({ + title: 'Stacked Bar', }); - expect(stateService.setCurrentSuggestion).toHaveBeenLastCalledWith({ title: 'Stacked Bar' }); }); it('should clear lensRequestAdapter when chart is hidden', () => { diff --git a/src/plugins/unified_histogram/public/container/hooks/use_state_props.ts b/src/plugins/unified_histogram/public/container/hooks/use_state_props.ts index d78afc50c15f57..7afdb029fd3cc1 100644 --- a/src/plugins/unified_histogram/public/container/hooks/use_state_props.ts +++ b/src/plugins/unified_histogram/public/container/hooks/use_state_props.ts @@ -158,9 +158,9 @@ export const useStateProps = ({ [stateService] ); - const onSuggestionChange = useCallback( - (suggestion) => { - stateService?.setCurrentSuggestion(suggestion); + const onSuggestionContextChange = useCallback( + (suggestionContext) => { + stateService?.setCurrentSuggestionContext(suggestionContext); }, [stateService] ); @@ -190,6 +190,6 @@ export const useStateProps = ({ onChartHiddenChange, onChartLoad, onBreakdownFieldChange, - onSuggestionChange, + onSuggestionContextChange, }; }; diff --git a/src/plugins/unified_histogram/public/container/services/state_service.test.ts b/src/plugins/unified_histogram/public/container/services/state_service.test.ts index 40304a967243ab..6249c3e4238772 100644 --- a/src/plugins/unified_histogram/public/container/services/state_service.test.ts +++ b/src/plugins/unified_histogram/public/container/services/state_service.test.ts @@ -52,7 +52,7 @@ describe('UnifiedHistogramStateService', () => { topPanelHeight: 100, totalHitsStatus: UnifiedHistogramFetchStatus.uninitialized, totalHitsResult: undefined, - currentSuggestion: undefined, + currentSuggestionContext: undefined, }; it('should initialize state with default values', () => { @@ -67,8 +67,7 @@ describe('UnifiedHistogramStateService', () => { topPanelHeight: undefined, totalHitsResult: undefined, totalHitsStatus: UnifiedHistogramFetchStatus.uninitialized, - currentSuggestion: undefined, - allSuggestions: undefined, + currentSuggestionContext: undefined, }); }); diff --git a/src/plugins/unified_histogram/public/container/services/state_service.ts b/src/plugins/unified_histogram/public/container/services/state_service.ts index 1a79389e2bc6f4..dd70dc646c9fb5 100644 --- a/src/plugins/unified_histogram/public/container/services/state_service.ts +++ b/src/plugins/unified_histogram/public/container/services/state_service.ts @@ -7,7 +7,7 @@ */ import type { RequestAdapter } from '@kbn/inspector-plugin/common'; -import type { LensEmbeddableOutput, Suggestion } from '@kbn/lens-plugin/public'; +import type { LensEmbeddableOutput } from '@kbn/lens-plugin/public'; import { BehaviorSubject, Observable } from 'rxjs'; import { UnifiedHistogramFetchStatus } from '../..'; import type { UnifiedHistogramServices, UnifiedHistogramChartLoadEvent } from '../../types'; @@ -19,6 +19,7 @@ import { setChartHidden, setTopPanelHeight, } from '../utils/local_storage_utils'; +import type { UnifiedHistogramSuggestionContext } from '../../types'; /** * The current state of the container @@ -31,7 +32,7 @@ export interface UnifiedHistogramState { /** * The current Lens suggestion */ - currentSuggestion: Suggestion | undefined; + currentSuggestionContext: UnifiedHistogramSuggestionContext | undefined; /** * Whether or not the chart is hidden */ @@ -99,7 +100,9 @@ export interface UnifiedHistogramStateService { /** * Sets current Lens suggestion */ - setCurrentSuggestion: (suggestion: Suggestion | undefined) => void; + setCurrentSuggestionContext: ( + suggestionContext: UnifiedHistogramSuggestionContext | undefined + ) => void; /** * Sets the current top panel height */ @@ -150,7 +153,7 @@ export const createStateService = ( const state$ = new BehaviorSubject({ breakdownField: initialBreakdownField, chartHidden: initialChartHidden, - currentSuggestion: undefined, + currentSuggestionContext: undefined, lensRequestAdapter: undefined, timeInterval: 'auto', topPanelHeight: initialTopPanelHeight, @@ -193,9 +196,12 @@ export const createStateService = ( updateState({ breakdownField }); }, - setCurrentSuggestion: (suggestion: Suggestion | undefined) => { - updateState({ currentSuggestion: suggestion }); + setCurrentSuggestionContext: ( + suggestionContext: UnifiedHistogramSuggestionContext | undefined + ) => { + updateState({ currentSuggestionContext: suggestionContext }); }, + setTimeInterval: (timeInterval: string) => { updateState({ timeInterval }); }, diff --git a/src/plugins/unified_histogram/public/container/utils/state_selectors.ts b/src/plugins/unified_histogram/public/container/utils/state_selectors.ts index f0707cdbe747ed..9c2c98b1aeae43 100644 --- a/src/plugins/unified_histogram/public/container/utils/state_selectors.ts +++ b/src/plugins/unified_histogram/public/container/utils/state_selectors.ts @@ -14,7 +14,6 @@ export const timeIntervalSelector = (state: UnifiedHistogramState) => state.time 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; export const lensAdaptersSelector = (state: UnifiedHistogramState) => state.lensAdapters; export const lensEmbeddableOutputSelector$ = (state: UnifiedHistogramState) => state.lensEmbeddableOutput$; diff --git a/src/plugins/unified_histogram/public/chart/hooks/use_request_params.test.ts b/src/plugins/unified_histogram/public/hooks/use_request_params.test.ts similarity index 95% rename from src/plugins/unified_histogram/public/chart/hooks/use_request_params.test.ts rename to src/plugins/unified_histogram/public/hooks/use_request_params.test.ts index f3889d1de6a42b..c49bcd4ce195bb 100644 --- a/src/plugins/unified_histogram/public/chart/hooks/use_request_params.test.ts +++ b/src/plugins/unified_histogram/public/hooks/use_request_params.test.ts @@ -7,7 +7,7 @@ */ import { renderHook } from '@testing-library/react-hooks'; -import { unifiedHistogramServicesMock } from '../../__mocks__/services'; +import { unifiedHistogramServicesMock } from '../__mocks__/services'; const getUseRequestParams = async () => { jest.doMock('@kbn/data-plugin/common', () => { diff --git a/src/plugins/unified_histogram/public/chart/hooks/use_request_params.tsx b/src/plugins/unified_histogram/public/hooks/use_request_params.tsx similarity index 85% rename from src/plugins/unified_histogram/public/chart/hooks/use_request_params.tsx rename to src/plugins/unified_histogram/public/hooks/use_request_params.tsx index c5ea702f898f00..dfa58629903efc 100644 --- a/src/plugins/unified_histogram/public/chart/hooks/use_request_params.tsx +++ b/src/plugins/unified_histogram/public/hooks/use_request_params.tsx @@ -9,9 +9,17 @@ import { getAbsoluteTimeRange } from '@kbn/data-plugin/common'; import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; import { useCallback, useMemo, useRef } from 'react'; -import type { UnifiedHistogramServices } from '../../types'; +import type { UnifiedHistogramServices } from '../types'; import { useStableCallback } from './use_stable_callback'; +export interface UseRequestParamsResult { + query: Query | AggregateQuery; + filters: Filter[]; + relativeTimeRange: TimeRange; + getTimeRange: () => TimeRange; + updateTimeRange: () => void; +} + export const useRequestParams = ({ services, query: originalQuery, @@ -22,7 +30,7 @@ export const useRequestParams = ({ query?: Query | AggregateQuery; filters?: Filter[]; timeRange?: TimeRange; -}) => { +}): UseRequestParamsResult => { const { data } = services; const filters = useMemo(() => originalFilters ?? [], [originalFilters]); diff --git a/src/plugins/unified_histogram/public/chart/hooks/use_stable_callback.test.ts b/src/plugins/unified_histogram/public/hooks/use_stable_callback.test.ts similarity index 100% rename from src/plugins/unified_histogram/public/chart/hooks/use_stable_callback.test.ts rename to src/plugins/unified_histogram/public/hooks/use_stable_callback.test.ts diff --git a/src/plugins/unified_histogram/public/chart/hooks/use_stable_callback.ts b/src/plugins/unified_histogram/public/hooks/use_stable_callback.ts similarity index 100% rename from src/plugins/unified_histogram/public/chart/hooks/use_stable_callback.ts rename to src/plugins/unified_histogram/public/hooks/use_stable_callback.ts diff --git a/src/plugins/unified_histogram/public/index.ts b/src/plugins/unified_histogram/public/index.ts index 5b32836bfb258f..08f79f7e2ee941 100644 --- a/src/plugins/unified_histogram/public/index.ts +++ b/src/plugins/unified_histogram/public/index.ts @@ -28,7 +28,9 @@ export type { UnifiedHistogramServices, UnifiedHistogramChartLoadEvent, UnifiedHistogramAdapters, + UnifiedHistogramVisContext, } from './types'; -export { UnifiedHistogramFetchStatus } from './types'; +export { UnifiedHistogramFetchStatus, UnifiedHistogramExternalVisContextStatus } from './types'; +export { canImportVisContext } from './utils/external_vis_context'; export const plugin = () => new UnifiedHistogramPublicPlugin(); diff --git a/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.test.ts b/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.test.ts deleted file mode 100644 index f74cc8a3c5925f..00000000000000 --- a/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -/* - * 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 { renderHook } from '@testing-library/react-hooks'; -import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; -import { calculateBounds } from '@kbn/data-plugin/public'; -import { deepMockedFields, buildDataViewMock } from '@kbn/discover-utils/src/__mocks__'; -import { allSuggestionsMock } from '../../__mocks__/suggestions'; -import { useLensSuggestions } from './use_lens_suggestions'; - -describe('useLensSuggestions', () => { - const dataMock = dataPluginMock.createStartContract(); - dataMock.query.timefilter.timefilter.calculateBounds = (timeRange) => { - return calculateBounds(timeRange); - }; - const dataViewMock = buildDataViewMock({ - name: 'the-data-view', - fields: deepMockedFields, - timeFieldName: '@timestamp', - }); - - test('should return empty suggestions for non aggregate query', async () => { - const { result } = renderHook(() => { - return useLensSuggestions({ - dataView: dataViewMock, - query: undefined, - isPlainRecord: false, - data: dataMock, - lensSuggestionsApi: jest.fn(), - }); - }); - const current = result.current; - expect(current).toStrictEqual({ - allSuggestions: [], - currentSuggestion: undefined, - isOnHistogramMode: false, - histogramQuery: undefined, - suggestionUnsupported: false, - }); - }); - - test('should return suggestions for aggregate query', async () => { - const { result } = renderHook(() => { - return useLensSuggestions({ - dataView: dataViewMock, - query: { esql: 'from the-data-view | stats maxB = max(bytes)' }, - isPlainRecord: true, - columns: [ - { - id: 'var0', - name: 'var0', - meta: { - type: 'number', - }, - }, - ], - data: dataMock, - lensSuggestionsApi: jest.fn(() => allSuggestionsMock), - }); - }); - const current = result.current; - expect(current).toStrictEqual({ - allSuggestions: allSuggestionsMock, - currentSuggestion: allSuggestionsMock[0], - isOnHistogramMode: false, - histogramQuery: undefined, - suggestionUnsupported: false, - }); - }); - - test('should return suggestionUnsupported if no timerange is provided and no suggestions returned by the api', async () => { - const { result } = renderHook(() => { - return useLensSuggestions({ - dataView: dataViewMock, - query: { esql: 'from the-data-view | stats maxB = max(bytes)' }, - isPlainRecord: true, - columns: [ - { - id: 'var0', - name: 'var0', - meta: { - type: 'number', - }, - }, - ], - data: dataMock, - lensSuggestionsApi: jest.fn(), - }); - }); - const current = result.current; - expect(current).toStrictEqual({ - allSuggestions: [], - currentSuggestion: undefined, - isOnHistogramMode: false, - histogramQuery: undefined, - suggestionUnsupported: true, - }); - }); - - test('should return histogramSuggestion if no suggestions returned by the api', async () => { - const firstMockReturn = undefined; - const secondMockReturn = allSuggestionsMock; - const lensSuggestionsApi = jest - .fn() - .mockReturnValueOnce(firstMockReturn) // will return to firstMockReturn object firstly - .mockReturnValueOnce(secondMockReturn); // will return to secondMockReturn object secondly - - const { result } = renderHook(() => { - return useLensSuggestions({ - dataView: dataViewMock, - query: { esql: 'from the-data-view | limit 100' }, - isPlainRecord: true, - columns: [ - { - id: 'var0', - name: 'var0', - meta: { - type: 'number', - }, - }, - ], - data: dataMock, - lensSuggestionsApi, - timeRange: { - from: '2023-09-03T08:00:00.000Z', - to: '2023-09-04T08:56:28.274Z', - }, - }); - }); - const current = result.current; - expect(current).toStrictEqual({ - allSuggestions: [], - currentSuggestion: allSuggestionsMock[0], - isOnHistogramMode: true, - histogramQuery: { - esql: 'from the-data-view | limit 100 | EVAL timestamp=DATE_TRUNC(30 minute, @timestamp) | stats results = count(*) by timestamp | rename timestamp as `@timestamp every 30 minute`', - }, - suggestionUnsupported: false, - }); - }); - - test('should return histogramSuggestion even if the ESQL query contains a DROP @timestamp statement', async () => { - const firstMockReturn = undefined; - const secondMockReturn = allSuggestionsMock; - const lensSuggestionsApi = jest - .fn() - .mockReturnValueOnce(firstMockReturn) // will return to firstMockReturn object firstly - .mockReturnValueOnce(secondMockReturn); // will return to secondMockReturn object secondly - - renderHook(() => { - return useLensSuggestions({ - dataView: dataViewMock, - query: { esql: 'from the-data-view | DROP @timestamp | limit 100' }, - isPlainRecord: true, - columns: [ - { - id: 'var0', - name: 'var0', - meta: { - type: 'number', - }, - }, - ], - data: dataMock, - lensSuggestionsApi, - timeRange: { - from: '2023-09-03T08:00:00.000Z', - to: '2023-09-04T08:56:28.274Z', - }, - }); - }); - expect(lensSuggestionsApi).toHaveBeenLastCalledWith( - expect.objectContaining({ - query: { esql: expect.stringMatching('from the-data-view | limit 100 ') }, - }), - expect.anything(), - ['lnsDatatable'] - ); - }); - - test('should not return histogramSuggestion if no suggestions returned by the api and transformational commands', async () => { - const firstMockReturn = undefined; - const secondMockReturn = allSuggestionsMock; - const lensSuggestionsApi = jest - .fn() - .mockReturnValueOnce(firstMockReturn) // will return to firstMockReturn object firstly - .mockReturnValueOnce(secondMockReturn); // will return to secondMockReturn object secondly - - const { result } = renderHook(() => { - return useLensSuggestions({ - dataView: dataViewMock, - query: { esql: 'from the-data-view | limit 100 | keep @timestamp' }, - isPlainRecord: true, - columns: [ - { - id: 'var0', - name: 'var0', - meta: { - type: 'number', - }, - }, - ], - data: dataMock, - lensSuggestionsApi, - timeRange: { - from: '2023-09-03T08:00:00.000Z', - to: '2023-09-04T08:56:28.274Z', - }, - }); - }); - const current = result.current; - expect(current).toStrictEqual({ - allSuggestions: [], - currentSuggestion: undefined, - isOnHistogramMode: false, - histogramQuery: undefined, - suggestionUnsupported: true, - }); - }); -}); diff --git a/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.ts b/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.ts deleted file mode 100644 index c45a8c1d701a6a..00000000000000 --- a/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.ts +++ /dev/null @@ -1,152 +0,0 @@ -/* - * 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 type { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import { removeDropCommandsFromESQLQuery } from '@kbn/esql-utils'; -import { - AggregateQuery, - isOfAggregateQueryType, - getAggregateQueryMode, - Query, - TimeRange, -} from '@kbn/es-query'; -import type { Datatable, DatatableColumn } from '@kbn/expressions-plugin/common'; -import { LensSuggestionsApi, Suggestion } from '@kbn/lens-plugin/public'; -import { isEqual } from 'lodash'; -import { useEffect, useMemo, useRef, useState } from 'react'; -import { computeInterval } from './compute_interval'; -import { shouldDisplayHistogram } from '../helpers'; - -export const useLensSuggestions = ({ - dataView, - query, - originalSuggestion, - isPlainRecord, - columns, - data, - timeRange, - lensSuggestionsApi, - onSuggestionChange, -}: { - dataView: DataView; - query?: Query | AggregateQuery; - originalSuggestion?: Suggestion; - isPlainRecord?: boolean; - columns?: DatatableColumn[]; - data: DataPublicPluginStart; - timeRange?: TimeRange; - lensSuggestionsApi: LensSuggestionsApi; - onSuggestionChange?: (suggestion: Suggestion | undefined) => void; - table?: Datatable; -}) => { - const suggestions = useMemo(() => { - const context = { - dataViewSpec: dataView?.toSpec(), - fieldName: '', - textBasedColumns: columns, - query: query && isOfAggregateQueryType(query) ? query : undefined, - }; - const allSuggestions = isPlainRecord - ? lensSuggestionsApi(context, dataView, ['lnsDatatable']) ?? [] - : []; - - const [firstSuggestion] = allSuggestions; - - return { firstSuggestion, allSuggestions }; - }, [dataView, columns, query, isPlainRecord, lensSuggestionsApi]); - - const [allSuggestions, setAllSuggestions] = useState(suggestions.allSuggestions); - const currentSuggestion = originalSuggestion || suggestions.firstSuggestion; - - const suggestionDeps = useRef(getSuggestionDeps({ dataView, query, columns })); - const histogramQuery = useRef(); - const histogramSuggestion = useMemo(() => { - if ( - !currentSuggestion && - dataView.isTimeBased() && - query && - isOfAggregateQueryType(query) && - getAggregateQueryMode(query) === 'esql' && - timeRange - ) { - const isOnHistogramMode = shouldDisplayHistogram(query); - if (!isOnHistogramMode) return undefined; - - const interval = computeInterval(timeRange, data); - const language = getAggregateQueryMode(query); - const safeQuery = removeDropCommandsFromESQLQuery(query[language]); - const esqlQuery = `${safeQuery} | EVAL timestamp=DATE_TRUNC(${interval}, ${dataView.timeFieldName}) | stats results = count(*) by timestamp | rename timestamp as \`${dataView.timeFieldName} every ${interval}\``; - const context = { - dataViewSpec: dataView?.toSpec(), - fieldName: '', - textBasedColumns: [ - { - id: `${dataView.timeFieldName} every ${interval}`, - name: `${dataView.timeFieldName} every ${interval}`, - meta: { - type: 'date', - }, - }, - { - id: 'results', - name: 'results', - meta: { - type: 'number', - }, - }, - ] as DatatableColumn[], - query: { - esql: esqlQuery, - }, - }; - const sug = lensSuggestionsApi(context, dataView, ['lnsDatatable']) ?? []; - if (sug.length) { - histogramQuery.current = { esql: esqlQuery }; - return sug[0]; - } - } - histogramQuery.current = undefined; - return undefined; - }, [currentSuggestion, dataView, query, timeRange, data, lensSuggestionsApi]); - - useEffect(() => { - const newSuggestionsDeps = getSuggestionDeps({ dataView, query, columns }); - - if (!isEqual(suggestionDeps.current, newSuggestionsDeps)) { - setAllSuggestions(suggestions.allSuggestions); - onSuggestionChange?.(suggestions.firstSuggestion); - - suggestionDeps.current = newSuggestionsDeps; - } - }, [ - columns, - dataView, - onSuggestionChange, - query, - suggestions.firstSuggestion, - suggestions.allSuggestions, - ]); - - return { - allSuggestions, - currentSuggestion: histogramSuggestion ?? currentSuggestion, - suggestionUnsupported: !currentSuggestion && !histogramSuggestion && isPlainRecord, - isOnHistogramMode: Boolean(histogramSuggestion), - histogramQuery: histogramQuery.current ? histogramQuery.current : undefined, - }; -}; - -const getSuggestionDeps = ({ - dataView, - query, - columns, -}: { - dataView: DataView; - query?: Query | AggregateQuery; - columns?: DatatableColumn[]; -}) => [dataView.id, columns, query]; diff --git a/src/plugins/unified_histogram/public/layout/layout.test.tsx b/src/plugins/unified_histogram/public/layout/layout.test.tsx index a10df63e7c328c..dcb96b093cac75 100644 --- a/src/plugins/unified_histogram/public/layout/layout.test.tsx +++ b/src/plugins/unified_histogram/public/layout/layout.test.tsx @@ -76,6 +76,7 @@ describe('Layout', () => { to: '2020-05-14T11:20:13.590', }} lensSuggestionsApi={jest.fn()} + onSuggestionContextChange={jest.fn()} isChartLoading={false} {...rest} /> diff --git a/src/plugins/unified_histogram/public/layout/layout.tsx b/src/plugins/unified_histogram/public/layout/layout.tsx index aaeb67b15b1018..5ceae61e13a9ea 100644 --- a/src/plugins/unified_histogram/public/layout/layout.tsx +++ b/src/plugins/unified_histogram/public/layout/layout.tsx @@ -7,8 +7,9 @@ */ import { EuiSpacer, useEuiTheme, useIsWithinBreakpoints } from '@elastic/eui'; -import React, { PropsWithChildren, ReactElement, useMemo, useState } from 'react'; +import React, { PropsWithChildren, ReactElement, useEffect, useMemo, useState } from 'react'; import { Observable } from 'rxjs'; +import useObservable from 'react-use/lib/useObservable'; import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal'; import { css } from '@emotion/css'; import type { Datatable, DatatableColumn } from '@kbn/expressions-plugin/common'; @@ -18,28 +19,30 @@ import type { LensEmbeddableInput, LensEmbeddableOutput, LensSuggestionsApi, - Suggestion, } from '@kbn/lens-plugin/public'; -import { AggregateQuery, Filter, isOfAggregateQueryType, Query, TimeRange } from '@kbn/es-query'; +import { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; import { ResizableLayout, - ResizableLayoutMode, ResizableLayoutDirection, + ResizableLayoutMode, } from '@kbn/resizable-layout'; -import { TextBasedPersistedState } from '@kbn/lens-plugin/public/datasources/text_based/types'; import { Chart, checkChartAvailability } from '../chart'; -import type { - UnifiedHistogramChartContext, - UnifiedHistogramServices, - UnifiedHistogramHitsContext, +import { + UnifiedHistogramVisContext, UnifiedHistogramBreakdownContext, - UnifiedHistogramFetchStatus, - UnifiedHistogramRequestContext, + UnifiedHistogramChartContext, UnifiedHistogramChartLoadEvent, + UnifiedHistogramFetchStatus, + UnifiedHistogramHitsContext, UnifiedHistogramInput$, + UnifiedHistogramRequestContext, + UnifiedHistogramServices, + UnifiedHistogramSuggestionContext, + UnifiedHistogramExternalVisContextStatus, } from '../types'; -import { useLensSuggestions } from './hooks/use_lens_suggestions'; -import { shouldDisplayHistogram } from './helpers'; +import { UnifiedHistogramSuggestionType } from '../types'; +import { LensVisService } from '../services/lens_vis_service'; +import { useRequestParams } from '../hooks/use_request_params'; const ChartMemoized = React.memo(Chart); @@ -67,9 +70,9 @@ export interface UnifiedHistogramLayoutProps extends PropsWithChildren */ filters?: Filter[]; /** - * The current Lens suggestion + * The external custom Lens vis */ - currentSuggestion?: Suggestion; + externalVisContext?: UnifiedHistogramVisContext; /** * Flag that indicates that a text based language is used */ @@ -159,7 +162,16 @@ export interface UnifiedHistogramLayoutProps extends PropsWithChildren /** * Callback to update the suggested chart */ - onSuggestionChange?: (suggestion: Suggestion | undefined) => void; + onSuggestionContextChange: ( + suggestionContext: UnifiedHistogramSuggestionContext | undefined + ) => void; + /** + * Callback to notify about the change in Lens attributes + */ + onVisContextChanged?: ( + visContext: UnifiedHistogramVisContext | undefined, + externalVisContextStatus: UnifiedHistogramExternalVisContextStatus + ) => void; /** * Callback to update the total hits -- should set {@link UnifiedHistogramHitsContext.status} to status * and {@link UnifiedHistogramHitsContext.total} to result @@ -190,12 +202,12 @@ export const UnifiedHistogramLayout = ({ className, services, dataView, - query, - filters, - currentSuggestion: originalSuggestion, + query: originalQuery, + filters: originalFilters, + externalVisContext, isChartLoading, isPlainRecord, - timeRange, + timeRange: originalTimeRange, relativeTimeRange, columns, request, @@ -217,7 +229,8 @@ export const UnifiedHistogramLayout = ({ onChartHiddenChange, onTimeIntervalChange, onBreakdownFieldChange, - onSuggestionChange, + onSuggestionContextChange, + onVisContextChanged, onTotalHitsChange, onChartLoad, onFilter, @@ -226,55 +239,75 @@ export const UnifiedHistogramLayout = ({ withDefaultActions, abortController, }: UnifiedHistogramLayoutProps) => { - const { - allSuggestions, - currentSuggestion, - suggestionUnsupported, - isOnHistogramMode, - histogramQuery, - } = useLensSuggestions({ - dataView, - query, - originalSuggestion, - isPlainRecord, - columns, - timeRange, - data: services.data, - lensSuggestionsApi, - onSuggestionChange, - }); + const columnsMap = useMemo(() => { + if (!columns?.length) { + return undefined; + } - // apply table to current suggestion - const usedSuggestion = useMemo(() => { - if ( - currentSuggestion && - table && - query && - isOfAggregateQueryType(query) && - !shouldDisplayHistogram(query) - ) { - const { layers } = currentSuggestion.datasourceState as TextBasedPersistedState; + return columns.reduce((acc, column) => { + acc[column.id] = column; + return acc; + }, {} as Record); + }, [columns]); - const newState = { - ...currentSuggestion, - datasourceState: { - ...(currentSuggestion.datasourceState as TextBasedPersistedState), - layers: {} as Record, - }, - }; + const requestParams = useRequestParams({ + services, + query: originalQuery, + filters: originalFilters, + timeRange: originalTimeRange, + }); - for (const key of Object.keys(layers)) { - const newLayer = { ...layers[key], table }; - newState.datasourceState.layers[key] = newLayer; - } + const [lensVisService] = useState(() => new LensVisService({ services, lensSuggestionsApi })); + const lensVisServiceCurrentSuggestionContext = useObservable( + lensVisService.currentSuggestionContext$ + ); - return newState; - } else { - return currentSuggestion; + const originalChartTimeInterval = originalChart?.timeInterval; + useEffect(() => { + if (isChartLoading) { + return; } - }, [currentSuggestion, query, table]); - const chart = suggestionUnsupported ? undefined : originalChart; + lensVisService.update({ + externalVisContext, + queryParams: { + dataView, + query: requestParams.query, + filters: requestParams.filters, + timeRange: originalTimeRange, + isPlainRecord, + columns, + columnsMap, + }, + timeInterval: originalChartTimeInterval, + breakdownField: breakdown?.field, + table, + onSuggestionContextChange, + onVisContextChanged: isPlainRecord ? onVisContextChanged : undefined, + }); + }, [ + lensVisService, + dataView, + requestParams.query, + requestParams.filters, + originalTimeRange, + originalChartTimeInterval, + isPlainRecord, + columns, + columnsMap, + breakdown, + externalVisContext, + onSuggestionContextChange, + onVisContextChanged, + isChartLoading, + table, + ]); + + const chart = + !lensVisServiceCurrentSuggestionContext?.type || + lensVisServiceCurrentSuggestionContext.type === UnifiedHistogramSuggestionType.unsupported + ? undefined + : originalChart; const isChartAvailable = checkChartAvailability({ chart, dataView, isPlainRecord }); const [topPanelNode] = useState(() => @@ -315,15 +348,12 @@ export const UnifiedHistogramLayout = ({ className={chartClassName} services={services} dataView={dataView} - query={query} - filters={filters} - timeRange={timeRange} + requestParams={requestParams} relativeTimeRange={relativeTimeRange} request={request} hits={hits} - currentSuggestion={usedSuggestion} + lensVisService={lensVisService} isChartLoading={isChartLoading} - allSuggestions={allSuggestions} isPlainRecord={isPlainRecord} chart={chart} breakdown={breakdown} @@ -336,15 +366,12 @@ export const UnifiedHistogramLayout = ({ onChartHiddenChange={onChartHiddenChange} onTimeIntervalChange={onTimeIntervalChange} onBreakdownFieldChange={onBreakdownFieldChange} - onSuggestionChange={onSuggestionChange} onTotalHitsChange={onTotalHitsChange} onChartLoad={onChartLoad} onFilter={onFilter} onBrushEnd={onBrushEnd} lensAdapters={lensAdapters} lensEmbeddableOutput$={lensEmbeddableOutput$} - isOnHistogramMode={isOnHistogramMode} - histogramQuery={histogramQuery} withDefaultActions={withDefaultActions} /> diff --git a/src/plugins/unified_histogram/public/chart/utils/get_lens_attributes.test.ts b/src/plugins/unified_histogram/public/services/lens_vis_service.attributes.test.ts similarity index 87% rename from src/plugins/unified_histogram/public/chart/utils/get_lens_attributes.test.ts rename to src/plugins/unified_histogram/public/services/lens_vis_service.attributes.test.ts index 3c049649d5c206..780069747a64af 100644 --- a/src/plugins/unified_histogram/public/chart/utils/get_lens_attributes.test.ts +++ b/src/plugins/unified_histogram/public/services/lens_vis_service.attributes.test.ts @@ -6,13 +6,16 @@ * Side Public License, v 1. */ -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'; +import { + dataViewWithTimefieldMock, + dataViewWithAtTimefieldMock, +} from '../__mocks__/data_view_with_timefield'; +import { currentSuggestionMock } from '../__mocks__/suggestions'; +import { getLensVisMock } from '../__mocks__/lens_vis'; -describe('getLensAttributes', () => { +describe('LensVisService attributes', () => { const dataView: DataView = dataViewWithTimefieldMock; const filters: Filter[] = [ { @@ -41,29 +44,25 @@ describe('getLensAttributes', () => { }, ]; const query: Query | AggregateQuery = { language: 'kuery', query: 'extension : css' }; + const queryEsql: Query | AggregateQuery = { esql: 'from logstash-* | limit 10' }; const timeInterval = 'auto'; - it('should return correct attributes', () => { + it('should return correct attributes', async () => { const breakdownField: DataViewField | undefined = undefined; - expect( - getLensAttributes({ - title: 'test', - filters, - query, - dataView, - timeInterval, - breakdownField, - suggestion: undefined, - }) - ).toMatchInlineSnapshot(` + const lensVis = await getLensVisMock({ + filters, + query, + dataView, + timeInterval, + breakdownField, + columns: [], + isPlainRecord: false, + }); + + expect(lensVis.visContext).toMatchInlineSnapshot(` Object { "attributes": 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", @@ -187,7 +186,7 @@ describe('getLensAttributes', () => { "valueLabels": "hide", }, }, - "title": "test", + "title": "Edit visualization", "visualizationType": "lnsXY", }, "requestData": Object { @@ -196,33 +195,28 @@ describe('getLensAttributes', () => { "timeField": "timestamp", "timeInterval": "auto", }, + "suggestionType": "histogramForDataView", } `); }); - it('should return correct attributes with breakdown field', () => { + it('should return correct attributes with breakdown field', async () => { const breakdownField: DataViewField | undefined = dataView.fields.find( (f) => f.name === 'extension' ); - expect( - getLensAttributes({ - title: 'test', - filters, - query, - dataView, - timeInterval, - breakdownField, - suggestion: undefined, - }) - ).toMatchInlineSnapshot(` + const lensVis = await getLensVisMock({ + filters, + query, + dataView, + timeInterval, + breakdownField, + columns: [], + isPlainRecord: false, + }); + expect(lensVis.visContext).toMatchInlineSnapshot(` Object { "attributes": 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", @@ -364,7 +358,7 @@ describe('getLensAttributes', () => { "valueLabels": "hide", }, }, - "title": "test", + "title": "Edit visualization", "visualizationType": "lnsXY", }, "requestData": Object { @@ -373,33 +367,28 @@ describe('getLensAttributes', () => { "timeField": "timestamp", "timeInterval": "auto", }, + "suggestionType": "histogramForDataView", } `); }); - it('should return correct attributes with unsupported breakdown field', () => { + it('should return correct attributes with unsupported breakdown field', async () => { const breakdownField: DataViewField | undefined = dataView.fields.find( (f) => f.name === 'scripted' ); - expect( - getLensAttributes({ - title: 'test', - filters, - query, - dataView, - timeInterval, - breakdownField, - suggestion: undefined, - }) - ).toMatchInlineSnapshot(` + const lensVis = await getLensVisMock({ + filters, + query, + dataView, + timeInterval, + breakdownField, + columns: [], + isPlainRecord: false, + }); + expect(lensVis.visContext).toMatchInlineSnapshot(` Object { "attributes": 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", @@ -523,7 +512,7 @@ describe('getLensAttributes', () => { "valueLabels": "hide", }, }, - "title": "test", + "title": "Edit visualization", "visualizationType": "lnsXY", }, "requestData": Object { @@ -532,33 +521,28 @@ describe('getLensAttributes', () => { "timeField": "timestamp", "timeInterval": "auto", }, + "suggestionType": "histogramForDataView", } `); }); - it('should return correct attributes for text based languages', () => { - expect( - getLensAttributes({ - title: 'test', - filters, - query, - dataView, - timeInterval, - breakdownField: undefined, - suggestion: currentSuggestionMock, - }) - ).toMatchInlineSnapshot(` + it('should return correct attributes for text based languages', async () => { + const lensVis = await getLensVisMock({ + filters, + query: queryEsql, + dataView, + timeInterval, + breakdownField: undefined, + columns: [], + isPlainRecord: true, + }); + expect(lensVis.visContext).toMatchInlineSnapshot(` Object { "attributes": 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", + "name": "textBasedLanguages-datasource-layer-suggestion", "type": "index-pattern", }, ], @@ -695,8 +679,7 @@ describe('getLensAttributes', () => { }, ], "query": Object { - "language": "kuery", - "query": "extension : css", + "esql": "from logstash-* | limit 10", }, "visualization": Object { "gridConfig": Object { @@ -719,34 +702,35 @@ describe('getLensAttributes', () => { "xAccessor": "81e332d6-ee37-42a8-a646-cea4fc75d2d3", }, }, - "title": "test", + "title": "Heat map", "visualizationType": "lnsHeatmap", }, "requestData": Object { "breakdownField": undefined, "dataViewId": "index-pattern-with-timefield-id", "timeField": "timestamp", - "timeInterval": "auto", + "timeInterval": undefined, }, + "suggestionType": "lensSuggestion", } `); }); - it('should return correct attributes for text based languages with adhoc dataview', () => { + it('should return correct attributes for text based languages with adhoc dataview', async () => { const adHocDataview = { ...dataView, isPersisted: () => false, } as DataView; - const lensAttrs = getLensAttributes({ - title: 'test', + const lensVis = await getLensVisMock({ filters, - query, + query: queryEsql, dataView: adHocDataview, timeInterval, breakdownField: undefined, - suggestion: currentSuggestionMock, + columns: [], + isPlainRecord: true, }); - expect(lensAttrs.attributes).toEqual({ + expect(lensVis.visContext?.attributes).toEqual({ state: expect.objectContaining({ adHocDataViews: { 'index-pattern-with-timefield-id': {}, @@ -755,31 +739,43 @@ describe('getLensAttributes', () => { references: [ { id: 'index-pattern-with-timefield-id', - name: 'indexpattern-datasource-current-indexpattern', - type: 'index-pattern', - }, - { - id: 'index-pattern-with-timefield-id', - name: 'indexpattern-datasource-layer-unifiedHistogram', + name: 'textBasedLanguages-datasource-layer-suggestion', type: 'index-pattern', }, ], - title: 'test', + title: 'Heat map', visualizationType: 'lnsHeatmap', }); }); - it('should return suggestion title if no title is given', () => { - expect( - getLensAttributes({ - title: undefined, - filters, - query, - dataView, - timeInterval, - breakdownField: undefined, - suggestion: currentSuggestionMock, - }).attributes.title - ).toBe(currentSuggestionMock.title); + it('should return suggestion title', async () => { + const lensVis = await getLensVisMock({ + filters, + query: queryEsql, + dataView, + timeInterval, + breakdownField: undefined, + columns: [], + isPlainRecord: true, + }); + expect(lensVis.visContext?.attributes.title).toBe(currentSuggestionMock.title); + }); + + it('should use the correct histogram query when no suggestion passed', async () => { + const histogramQuery = { + esql: 'from logstash-* | limit 10 | EVAL timestamp=DATE_TRUNC(10 minute, @timestamp) | stats results = count(*) by timestamp | rename timestamp as `@timestamp every 10 minute`', + }; + const lensVis = await getLensVisMock({ + filters, + query: queryEsql, + dataView: dataViewWithAtTimefieldMock, + timeInterval, + breakdownField: undefined, + columns: [], + isPlainRecord: true, + allSuggestions: [], // none available + hasHistogramSuggestionForESQL: true, + }); + expect(lensVis.visContext?.attributes.state.query).toStrictEqual(histogramQuery); }); }); diff --git a/src/plugins/unified_histogram/public/services/lens_vis_service.suggestions.test.ts b/src/plugins/unified_histogram/public/services/lens_vis_service.suggestions.test.ts new file mode 100644 index 00000000000000..7993f933a8054c --- /dev/null +++ b/src/plugins/unified_histogram/public/services/lens_vis_service.suggestions.test.ts @@ -0,0 +1,194 @@ +/* + * 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 { AggregateQuery, Query } from '@kbn/es-query'; +import { deepMockedFields, buildDataViewMock } from '@kbn/discover-utils/src/__mocks__'; +import { allSuggestionsMock } from '../__mocks__/suggestions'; +import { getLensVisMock } from '../__mocks__/lens_vis'; +import { UnifiedHistogramSuggestionType } from '../types'; + +describe('LensVisService suggestions', () => { + const dataViewMock = buildDataViewMock({ + name: 'the-data-view', + fields: deepMockedFields, + timeFieldName: '@timestamp', + }); + + test('should use a histogram fallback if suggestions are empty for non aggregate query', async () => { + const query: Query | AggregateQuery = { language: 'kuery', query: 'extension : css' }; + const lensVis = await getLensVisMock({ + filters: [], + query, + dataView: dataViewMock, + timeInterval: 'auto', + breakdownField: undefined, + columns: [], + isPlainRecord: false, + allSuggestions: [], + }); + + expect(lensVis.currentSuggestionContext?.type).toBe( + UnifiedHistogramSuggestionType.histogramForDataView + ); + expect(lensVis.currentSuggestionContext?.suggestion).toBeDefined(); + }); + + test('should return suggestions for aggregate query', async () => { + const lensVis = await getLensVisMock({ + filters: [], + query: { esql: 'from the-data-view | stats maxB = max(bytes)' }, + dataView: dataViewMock, + timeInterval: 'auto', + breakdownField: undefined, + columns: [ + { + id: 'var0', + name: 'var0', + meta: { + type: 'number', + }, + }, + ], + isPlainRecord: true, + allSuggestions: allSuggestionsMock, + }); + + expect(lensVis.currentSuggestionContext?.type).toBe( + UnifiedHistogramSuggestionType.lensSuggestion + ); + expect(lensVis.currentSuggestionContext?.suggestion).toBe(allSuggestionsMock[0]); + }); + + test('should return suggestionUnsupported if no timerange is provided and no suggestions returned by the api', async () => { + const lensVis = await getLensVisMock({ + filters: [], + query: { esql: 'from the-data-view | stats maxB = max(bytes)' }, + dataView: dataViewMock, + timeInterval: 'auto', + timeRange: null, + breakdownField: undefined, + columns: [ + { + id: 'var0', + name: 'var0', + meta: { + type: 'number', + }, + }, + ], + isPlainRecord: true, + allSuggestions: [], + hasHistogramSuggestionForESQL: false, + }); + + expect(lensVis.currentSuggestionContext?.type).toBe(UnifiedHistogramSuggestionType.unsupported); + expect(lensVis.currentSuggestionContext?.suggestion).not.toBeDefined(); + }); + + test('should return histogramSuggestion if no suggestions returned by the api', async () => { + const lensVis = await getLensVisMock({ + filters: [], + query: { esql: 'from the-data-view | limit 100' }, + dataView: dataViewMock, + timeInterval: 'auto', + timeRange: { + from: '2023-09-03T08:00:00.000Z', + to: '2023-09-04T08:56:28.274Z', + }, + breakdownField: undefined, + columns: [ + { + id: 'var0', + name: 'var0', + meta: { + type: 'number', + }, + }, + ], + isPlainRecord: true, + allSuggestions: [], + hasHistogramSuggestionForESQL: true, + }); + + expect(lensVis.currentSuggestionContext?.type).toBe( + UnifiedHistogramSuggestionType.histogramForESQL + ); + expect(lensVis.currentSuggestionContext?.suggestion).toBeDefined(); + + const histogramQuery = { + esql: 'from the-data-view | limit 100 | EVAL timestamp=DATE_TRUNC(30 minute, @timestamp) | stats results = count(*) by timestamp | rename timestamp as `@timestamp every 30 minute`', + }; + + expect(lensVis.visContext?.attributes.state.query).toStrictEqual(histogramQuery); + }); + + test('should return histogramSuggestion even if the ESQL query contains a DROP @timestamp statement', async () => { + const lensVis = await getLensVisMock({ + filters: [], + query: { esql: 'from the-data-view | DROP @timestamp | limit 100' }, + dataView: dataViewMock, + timeInterval: 'auto', + timeRange: { + from: '2023-09-03T08:00:00.000Z', + to: '2023-09-04T08:56:28.274Z', + }, + breakdownField: undefined, + columns: [ + { + id: 'var0', + name: 'var0', + meta: { + type: 'number', + }, + }, + ], + isPlainRecord: true, + allSuggestions: [], + hasHistogramSuggestionForESQL: true, + }); + + expect(lensVis.currentSuggestionContext?.type).toBe( + UnifiedHistogramSuggestionType.histogramForESQL + ); + expect(lensVis.currentSuggestionContext?.suggestion).toBeDefined(); + + const histogramQuery = { + esql: 'from the-data-view | limit 100 | EVAL timestamp=DATE_TRUNC(30 minute, @timestamp) | stats results = count(*) by timestamp | rename timestamp as `@timestamp every 30 minute`', + }; + + expect(lensVis.visContext?.attributes.state.query).toStrictEqual(histogramQuery); + }); + + test('should not return histogramSuggestion if no suggestions returned by the api and transformational commands', async () => { + const lensVis = await getLensVisMock({ + filters: [], + query: { esql: 'from the-data-view | limit 100 | keep @timestamp' }, + dataView: dataViewMock, + timeInterval: 'auto', + timeRange: { + from: '2023-09-03T08:00:00.000Z', + to: '2023-09-04T08:56:28.274Z', + }, + breakdownField: undefined, + columns: [ + { + id: 'var0', + name: 'var0', + meta: { + type: 'number', + }, + }, + ], + isPlainRecord: true, + allSuggestions: [], + hasHistogramSuggestionForESQL: true, + }); + + expect(lensVis.currentSuggestionContext?.type).toBe(UnifiedHistogramSuggestionType.unsupported); + expect(lensVis.currentSuggestionContext?.suggestion).not.toBeDefined(); + }); +}); diff --git a/src/plugins/unified_histogram/public/services/lens_vis_service.ts b/src/plugins/unified_histogram/public/services/lens_vis_service.ts new file mode 100644 index 00000000000000..7b1cf7cdaf55e4 --- /dev/null +++ b/src/plugins/unified_histogram/public/services/lens_vis_service.ts @@ -0,0 +1,754 @@ +/* + * 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 { BehaviorSubject, distinctUntilChanged, map, Observable } from 'rxjs'; +import { isEqual } from 'lodash'; +import { removeDropCommandsFromESQLQuery } from '@kbn/esql-utils'; +import type { DataView, DataViewField } from '@kbn/data-views-plugin/common'; +import type { + CountIndexPatternColumn, + DateHistogramIndexPatternColumn, + GenericIndexPatternColumn, + LensSuggestionsApi, + Suggestion, + TermsIndexPatternColumn, + TypedLensByValueInput, +} from '@kbn/lens-plugin/public'; +import type { AggregateQuery, Query, TimeRange } from '@kbn/es-query'; +import { Filter, getAggregateQueryMode, isOfAggregateQueryType } from '@kbn/es-query'; +import { i18n } from '@kbn/i18n'; +import { getLensAttributesFromSuggestion } from '@kbn/visualization-utils'; +import { LegendSize } from '@kbn/visualizations-plugin/public'; +import { XYConfiguration } from '@kbn/visualizations-plugin/common'; +import type { Datatable, DatatableColumn } from '@kbn/expressions-plugin/common'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import { + UnifiedHistogramExternalVisContextStatus, + UnifiedHistogramSuggestionContext, + UnifiedHistogramSuggestionType, + UnifiedHistogramVisContext, +} from '../types'; +import { isSuggestionShapeAndVisContextCompatible } from '../utils/external_vis_context'; +import { computeInterval } from '../utils/compute_interval'; +import { fieldSupportsBreakdown } from '../utils/field_supports_breakdown'; +import { shouldDisplayHistogram } from '../layout/helpers'; +import { enrichLensAttributesWithTablesData } from '../utils/lens_vis_from_table'; + +const UNIFIED_HISTOGRAM_LAYER_ID = 'unifiedHistogram'; + +const stateSelectorFactory = + (state$: Observable) => + (selector: (state: S) => R, equalityFn?: (arg0: R, arg1: R) => boolean) => + state$.pipe(map(selector), distinctUntilChanged(equalityFn)); + +export enum LensVisServiceStatus { + 'initial' = 'initial', + 'completed' = 'completed', +} + +interface LensVisServiceState { + status: LensVisServiceStatus; + allSuggestions: Suggestion[] | undefined; + currentSuggestionContext: UnifiedHistogramSuggestionContext; + visContext: UnifiedHistogramVisContext | undefined; +} + +export interface QueryParams { + dataView: DataView; + query?: Query | AggregateQuery; + filters: Filter[] | undefined; + isPlainRecord?: boolean; + columns?: DatatableColumn[]; + columnsMap?: Record; + timeRange?: TimeRange; +} + +interface Services { + data: DataPublicPluginStart; +} + +interface LensVisServiceParams { + services: Services; + lensSuggestionsApi: LensSuggestionsApi; +} + +export class LensVisService { + private state$: BehaviorSubject; + private services: Services; + private lensSuggestionsApi: LensSuggestionsApi; + status$: Observable; + currentSuggestionContext$: Observable; + allSuggestions$: Observable; + visContext$: Observable; + prevUpdateContext: + | { + queryParams: QueryParams; + timeInterval: string | undefined; + breakdownField: DataViewField | undefined; + table: Datatable | undefined; + onSuggestionContextChange: ( + suggestionContext: UnifiedHistogramSuggestionContext | undefined + ) => void; + onVisContextChanged?: ( + visContext: UnifiedHistogramVisContext | undefined, + externalVisContextStatus: UnifiedHistogramExternalVisContextStatus + ) => void; + } + | undefined; + + constructor({ services, lensSuggestionsApi }: LensVisServiceParams) { + this.services = services; + this.lensSuggestionsApi = lensSuggestionsApi; + + this.state$ = new BehaviorSubject({ + status: LensVisServiceStatus.initial, + allSuggestions: undefined, + currentSuggestionContext: { + suggestion: undefined, + type: UnifiedHistogramSuggestionType.unsupported, + }, + visContext: undefined, + }); + + const stateSelector = stateSelectorFactory(this.state$); + this.status$ = stateSelector((state) => state.status); + this.allSuggestions$ = stateSelector((state) => state.allSuggestions); + this.currentSuggestionContext$ = stateSelector( + (state) => state.currentSuggestionContext, + isEqual + ); + this.visContext$ = stateSelector((state) => state.visContext, isEqual); + this.prevUpdateContext = undefined; + } + + update = ({ + externalVisContext, + queryParams, + timeInterval, + breakdownField, + table, + onSuggestionContextChange, + onVisContextChanged, + }: { + externalVisContext: UnifiedHistogramVisContext | undefined; + queryParams: QueryParams; + timeInterval: string | undefined; + breakdownField: DataViewField | undefined; + table?: Datatable; + onSuggestionContextChange: ( + suggestionContext: UnifiedHistogramSuggestionContext | undefined + ) => void; + onVisContextChanged?: ( + visContext: UnifiedHistogramVisContext | undefined, + externalVisContextStatus: UnifiedHistogramExternalVisContextStatus + ) => void; + }) => { + const allSuggestions = this.getAllSuggestions({ queryParams }); + + const suggestionState = this.getCurrentSuggestionState({ + externalVisContext, + queryParams, + allSuggestions, + timeInterval, + breakdownField, + }); + + const lensAttributesState = this.getLensAttributesState({ + currentSuggestionContext: suggestionState.currentSuggestionContext, + externalVisContext, + queryParams, + timeInterval, + breakdownField, + table, + }); + + onSuggestionContextChange(suggestionState.currentSuggestionContext); + onVisContextChanged?.( + lensAttributesState.visContext, + lensAttributesState.externalVisContextStatus + ); + + this.state$.next({ + status: LensVisServiceStatus.completed, + allSuggestions, + currentSuggestionContext: suggestionState.currentSuggestionContext, + visContext: lensAttributesState.visContext, + }); + + this.prevUpdateContext = { + queryParams, + timeInterval, + breakdownField, + table, + onSuggestionContextChange, + onVisContextChanged, + }; + }; + + onSuggestionEdited = ({ + editedSuggestionContext, + }: { + editedSuggestionContext: UnifiedHistogramSuggestionContext | undefined; + }): UnifiedHistogramVisContext | undefined => { + if (!editedSuggestionContext || !this.prevUpdateContext) { + return; + } + + const { queryParams, timeInterval, breakdownField, table, onVisContextChanged } = + this.prevUpdateContext; + + const lensAttributesState = this.getLensAttributesState({ + currentSuggestionContext: editedSuggestionContext, + externalVisContext: undefined, + queryParams, + timeInterval, + breakdownField, + table, + }); + + onVisContextChanged?.( + lensAttributesState.visContext, + UnifiedHistogramExternalVisContextStatus.manuallyCustomized + ); + }; + + private getCurrentSuggestionState = ({ + allSuggestions, + externalVisContext, + queryParams, + timeInterval, + breakdownField, + }: { + allSuggestions: Suggestion[]; + externalVisContext: UnifiedHistogramVisContext | undefined; + queryParams: QueryParams; + timeInterval: string | undefined; + breakdownField: DataViewField | undefined; + }): { + currentSuggestionContext: UnifiedHistogramSuggestionContext; + } => { + let type = UnifiedHistogramSuggestionType.unsupported; + let currentSuggestion: Suggestion | undefined; + + // takes lens suggestions if provided + const availableSuggestionsWithType = allSuggestions.map((lensSuggestion) => ({ + suggestion: lensSuggestion, + type: UnifiedHistogramSuggestionType.lensSuggestion, + })); + + if (queryParams.isPlainRecord) { + // appends an ES|QL histogram + const histogramSuggestionForESQL = this.getHistogramSuggestionForESQL({ queryParams }); + if (histogramSuggestionForESQL) { + availableSuggestionsWithType.push({ + suggestion: histogramSuggestionForESQL, + type: UnifiedHistogramSuggestionType.histogramForESQL, + }); + } + } else { + // appends histogram for the data view mode + const histogramSuggestionForDataView = this.getDefaultHistogramSuggestion({ + queryParams, + timeInterval, + breakdownField, + }); + if (histogramSuggestionForDataView) { + availableSuggestionsWithType.push({ + suggestion: histogramSuggestionForDataView, + type: UnifiedHistogramSuggestionType.histogramForDataView, + }); + } + } + + if (externalVisContext) { + // externalVisContext can be based on an unfamiliar suggestion, but it was saved somehow, so try to restore it too + const derivedSuggestion = deriveLensSuggestionFromLensAttributes({ + externalVisContext, + queryParams, + }); + + if ( + derivedSuggestion && + // it should be in a group of available lens suggestions + // for example, Pie is a subtype of Donut charts + allSuggestions.find((s) => s.visualizationId === derivedSuggestion.visualizationId) + ) { + availableSuggestionsWithType.push({ + suggestion: derivedSuggestion, + type: UnifiedHistogramSuggestionType.lensSuggestion, + }); + } + } + + if (externalVisContext) { + // try to find a suggestion that is compatible with the external vis context + const matchingItem = availableSuggestionsWithType.find((item) => + isSuggestionShapeAndVisContextCompatible(item.suggestion, externalVisContext) + ); + + if (matchingItem) { + currentSuggestion = matchingItem.suggestion; + type = matchingItem.type; + } + } + + if (!currentSuggestion && availableSuggestionsWithType.length) { + // otherwise pick any first available suggestion + currentSuggestion = availableSuggestionsWithType[0].suggestion; + type = availableSuggestionsWithType[0].type; + } + + return { + currentSuggestionContext: { + type: Boolean(currentSuggestion) ? type : UnifiedHistogramSuggestionType.unsupported, + suggestion: currentSuggestion, + }, + }; + }; + + private getDefaultHistogramSuggestion = ({ + queryParams, + timeInterval, + breakdownField, + }: { + queryParams: QueryParams; + timeInterval: string | undefined; + breakdownField: DataViewField | undefined; + }): Suggestion => { + const { dataView } = queryParams; + const showBreakdown = breakdownField && fieldSupportsBreakdown(breakdownField); + + let columnOrder = ['date_column', 'count_column']; + + if (showBreakdown) { + columnOrder = ['breakdown_column', ...columnOrder]; + } + + let columns: Record = { + date_column: { + dataType: 'date', + isBucketed: true, + label: dataView.timeFieldName ?? '', + operationType: 'date_histogram', + scale: 'interval', + sourceField: dataView.timeFieldName, + params: { + interval: timeInterval ?? 'auto', + }, + } as DateHistogramIndexPatternColumn, + count_column: { + dataType: 'number', + isBucketed: false, + label: i18n.translate('unifiedHistogram.countColumnLabel', { + defaultMessage: 'Count of records', + }), + operationType: 'count', + scale: 'ratio', + sourceField: '___records___', + params: { + format: { + id: 'number', + params: { + decimals: 0, + }, + }, + }, + } as CountIndexPatternColumn, + }; + + if (showBreakdown) { + columns = { + ...columns, + breakdown_column: { + dataType: 'string', + isBucketed: true, + label: i18n.translate('unifiedHistogram.breakdownColumnLabel', { + defaultMessage: 'Top 3 values of {fieldName}', + values: { fieldName: breakdownField?.displayName }, + }), + operationType: 'terms', + scale: 'ordinal', + sourceField: breakdownField.name, + params: { + size: 3, + orderBy: { + type: 'column', + columnId: 'count_column', + }, + orderDirection: 'desc', + otherBucket: true, + missingBucket: false, + parentFormat: { + id: 'terms', + }, + }, + } as TermsIndexPatternColumn, + }; + } + + const datasourceState = { + layers: { + [UNIFIED_HISTOGRAM_LAYER_ID]: { columnOrder, columns }, + }, + }; + + const visualizationState = { + layers: [ + { + accessors: ['count_column'], + layerId: UNIFIED_HISTOGRAM_LAYER_ID, + layerType: 'data', + seriesType: 'bar_stacked', + xAccessor: 'date_column', + ...(showBreakdown + ? { splitAccessor: 'breakdown_column' } + : { + yConfig: [ + { + forAccessor: 'count_column', + }, + ], + }), + }, + ], + legend: { + isVisible: true, + position: 'right', + legendSize: LegendSize.EXTRA_LARGE, + shouldTruncate: false, + }, + preferredSeriesType: 'bar_stacked', + valueLabels: 'hide', + fittingFunction: 'None', + minBarHeight: 2, + showCurrentTimeMarker: true, + axisTitlesVisibilitySettings: { + x: false, + yLeft: false, + yRight: false, + }, + gridlinesVisibilitySettings: { + x: true, + yLeft: true, + yRight: false, + }, + tickLabelsVisibilitySettings: { + x: true, + yLeft: true, + yRight: false, + }, + } as XYConfiguration; + + return { + visualizationId: 'lnsXY', + visualizationState, + datasourceState, + datasourceId: 'formBased', + columns: Object.keys(columns).length, + } as Suggestion; + }; + + private getHistogramSuggestionForESQL = ({ + queryParams, + }: { + queryParams: QueryParams; + }): Suggestion | undefined => { + const { dataView, query, timeRange } = queryParams; + if ( + dataView.isTimeBased() && + query && + isOfAggregateQueryType(query) && + getAggregateQueryMode(query) === 'esql' && + timeRange + ) { + const isOnHistogramMode = shouldDisplayHistogram(query); + if (!isOnHistogramMode) return undefined; + + const interval = computeInterval(timeRange, this.services.data); + const esqlQuery = this.getESQLHistogramQuery({ dataView, query, timeRange, interval }); + const context = { + dataViewSpec: dataView?.toSpec(), + fieldName: '', + textBasedColumns: [ + { + id: `${dataView.timeFieldName} every ${interval}`, + name: `${dataView.timeFieldName} every ${interval}`, + meta: { + type: 'date', + }, + }, + { + id: 'results', + name: 'results', + meta: { + type: 'number', + }, + }, + ] as DatatableColumn[], + query: { + esql: esqlQuery, + }, + }; + const suggestions = this.lensSuggestionsApi(context, dataView, ['lnsDatatable']) ?? []; + if (suggestions.length) { + return suggestions[0]; + } + } + + return undefined; + }; + + private getESQLHistogramQuery = ({ + dataView, + timeRange, + query, + interval, + }: { + dataView: DataView; + timeRange: TimeRange; + query: AggregateQuery; + interval?: string; + }): string => { + const queryInterval = interval ?? computeInterval(timeRange, this.services.data); + const language = getAggregateQueryMode(query); + const safeQuery = removeDropCommandsFromESQLQuery(query[language]); + return `${safeQuery} | EVAL timestamp=DATE_TRUNC(${queryInterval}, ${dataView.timeFieldName}) | stats results = count(*) by timestamp | rename timestamp as \`${dataView.timeFieldName} every ${queryInterval}\``; + }; + + private getAllSuggestions = ({ queryParams }: { queryParams: QueryParams }): Suggestion[] => { + const { dataView, columns, query, isPlainRecord } = queryParams; + + const context = { + dataViewSpec: dataView?.toSpec(), + fieldName: '', + textBasedColumns: columns, + query: query && isOfAggregateQueryType(query) ? query : undefined, + }; + const allSuggestions = isPlainRecord + ? this.lensSuggestionsApi(context, dataView, ['lnsDatatable']) ?? [] + : []; + + return allSuggestions; + }; + + private getLensAttributesState = ({ + currentSuggestionContext, + externalVisContext, + queryParams, + timeInterval, + breakdownField, + table, + }: { + currentSuggestionContext: UnifiedHistogramSuggestionContext; + externalVisContext: UnifiedHistogramVisContext | undefined; + queryParams: QueryParams; + timeInterval: string | undefined; + breakdownField: DataViewField | undefined; + table: Datatable | undefined; + }): { + externalVisContextStatus: UnifiedHistogramExternalVisContextStatus; + visContext: UnifiedHistogramVisContext | undefined; + } => { + const { dataView, query, filters, timeRange } = queryParams; + const { type: suggestionType, suggestion } = currentSuggestionContext; + + if (!suggestion || !suggestion.datasourceId || !query || !filters) { + return { + externalVisContextStatus: UnifiedHistogramExternalVisContextStatus.unknown, + visContext: undefined, + }; + } + + const isTextBased = isOfAggregateQueryType(query); + const requestData = { + dataViewId: dataView.id, + timeField: dataView.timeFieldName, + timeInterval: isTextBased ? undefined : timeInterval, + breakdownField: isTextBased ? undefined : breakdownField?.name, + }; + + const currentQuery = + suggestionType === UnifiedHistogramSuggestionType.histogramForESQL && isTextBased && timeRange + ? { + esql: this.getESQLHistogramQuery({ dataView, query, timeRange }), + } + : query; + + let externalVisContextStatus: UnifiedHistogramExternalVisContextStatus; + let visContext: UnifiedHistogramVisContext | undefined; + + if (externalVisContext?.attributes) { + if ( + isEqual(currentQuery, externalVisContext.attributes?.state?.query) && + areSuggestionAndVisContextAndQueryParamsStillCompatible({ + suggestionType, + suggestion, + externalVisContext, + queryParams, + requestData, + }) + ) { + // using the external lens attributes + visContext = externalVisContext; + externalVisContextStatus = UnifiedHistogramExternalVisContextStatus.applied; + } else { + // external vis is not compatible with the current suggestion + externalVisContextStatus = UnifiedHistogramExternalVisContextStatus.automaticallyOverridden; + } + } else { + externalVisContextStatus = UnifiedHistogramExternalVisContextStatus.automaticallyCreated; + } + + if (!visContext) { + const attributes = getLensAttributesFromSuggestion({ + query: currentQuery, + filters, + suggestion, + dataView, + }) as TypedLensByValueInput['attributes']; + + if (suggestionType === UnifiedHistogramSuggestionType.histogramForDataView) { + attributes.title = i18n.translate('unifiedHistogram.lensTitle', { + defaultMessage: 'Edit visualization', + }); + attributes.references = [ + { + id: dataView.id ?? '', + name: `indexpattern-datasource-layer-${UNIFIED_HISTOGRAM_LAYER_ID}`, + type: 'index-pattern', + }, + ]; + } + + visContext = { + attributes, + requestData, + suggestionType, + }; + } + + if ( + table && // already fetched data + query && + isTextBased && + suggestionType === UnifiedHistogramSuggestionType.lensSuggestion && + visContext?.attributes + ) { + visContext = { + ...visContext, + attributes: enrichLensAttributesWithTablesData({ + attributes: visContext.attributes, + table, + }), + }; + } + + return { + externalVisContextStatus, + visContext, + }; + }; +} + +function deriveLensSuggestionFromLensAttributes({ + externalVisContext, + queryParams, +}: { + externalVisContext: UnifiedHistogramVisContext | undefined; + queryParams: QueryParams; +}): Suggestion | undefined { + if (!externalVisContext || !queryParams.isPlainRecord) { + return undefined; + } + + try { + if (externalVisContext.suggestionType === UnifiedHistogramSuggestionType.lensSuggestion) { + // should be based on same query + if (!isEqual(externalVisContext.attributes?.state?.query, queryParams.query)) { + return undefined; + } + + // it should be one of 'formBased'/'textBased' and have value + const datasourceId: 'formBased' | 'textBased' | undefined = [ + 'formBased' as const, + 'textBased' as const, + ].find((key) => Boolean(externalVisContext.attributes.state.datasourceStates[key])); + + if (!datasourceId) { + return undefined; + } + + const datasourceState = externalVisContext.attributes.state.datasourceStates[datasourceId]; + + // should be based on same columns + if ( + !datasourceState?.layers || + Object.values(datasourceState?.layers).some( + (layer) => + isEqual(layer.query, queryParams.query) && + layer.columns?.some( + // unknown column + (c: { fieldName: string }) => !queryParams.columnsMap?.[c.fieldName] + ) + ) + ) { + return undefined; + } + + return { + title: externalVisContext.attributes.title, + visualizationId: externalVisContext.attributes.visualizationType, + visualizationState: externalVisContext.attributes.state.visualization, + datasourceState, + datasourceId, + } as Suggestion; + } + } catch { + return undefined; + } + + return undefined; +} + +function areSuggestionAndVisContextAndQueryParamsStillCompatible({ + suggestionType, + suggestion, + externalVisContext, + queryParams, + requestData, +}: { + suggestionType: UnifiedHistogramSuggestionType; + suggestion: Suggestion; + externalVisContext: UnifiedHistogramVisContext; + queryParams: QueryParams; + requestData: UnifiedHistogramVisContext['requestData']; +}): boolean { + // requestData should match + if ( + (Object.keys(requestData) as Array).some( + (key) => !isEqual(requestData[key], externalVisContext.requestData[key]) + ) + ) { + return false; + } + + if ( + queryParams.isPlainRecord && + suggestionType === UnifiedHistogramSuggestionType.lensSuggestion && + !deriveLensSuggestionFromLensAttributes({ externalVisContext, queryParams }) + ) { + // can't retrieve back a suggestion with matching query and known columns + return false; + } + + return ( + suggestionType === externalVisContext.suggestionType && + // vis shape should match + isSuggestionShapeAndVisContextCompatible(suggestion, externalVisContext) + ); +} diff --git a/src/plugins/unified_histogram/public/types.ts b/src/plugins/unified_histogram/public/types.ts index 3ba27f7c5b26e9..d19c4481f202f3 100644 --- a/src/plugins/unified_histogram/public/types.ts +++ b/src/plugins/unified_histogram/public/types.ts @@ -9,7 +9,12 @@ import type { IUiSettingsClient, Capabilities } from '@kbn/core/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; -import type { LensEmbeddableOutput, LensPublicStart } from '@kbn/lens-plugin/public'; +import type { + LensEmbeddableOutput, + LensPublicStart, + TypedLensByValueInput, + Suggestion, +} from '@kbn/lens-plugin/public'; import type { DataViewField } from '@kbn/data-views-plugin/public'; import type { RequestAdapter } from '@kbn/inspector-plugin/public'; import type { DefaultInspectorAdapters } from '@kbn/expressions-plugin/common'; @@ -111,10 +116,6 @@ export interface UnifiedHistogramChartContext { * Controls the time interval of the chart */ timeInterval?: string; - /** - * The chart title -- sets the title property on the Lens chart input - */ - title?: string; } /** @@ -143,3 +144,39 @@ export type UnifiedHistogramInputMessage = UnifiedHistogramRefetchMessage; * Unified histogram input observable */ export type UnifiedHistogramInput$ = Subject; + +export enum UnifiedHistogramExternalVisContextStatus { + unknown = 'unknown', + applied = 'applied', + automaticallyCreated = 'automaticallyCreated', + automaticallyOverridden = 'automaticallyOverridden', + manuallyCustomized = 'manuallyCustomized', +} + +export enum UnifiedHistogramSuggestionType { + unsupported = 'unsupported', + lensSuggestion = 'lensSuggestion', + histogramForESQL = 'histogramForESQL', + histogramForDataView = 'histogramForDataView', +} + +export interface UnifiedHistogramSuggestionContext { + suggestion: Suggestion | undefined; + type: UnifiedHistogramSuggestionType; +} + +export interface LensRequestData { + dataViewId?: string; + timeField?: string; + timeInterval?: string; + breakdownField?: string; +} + +/** + * Unified Histogram type for recreating a stored Lens vis + */ +export interface UnifiedHistogramVisContext { + attributes: TypedLensByValueInput['attributes']; + requestData: LensRequestData; + suggestionType: UnifiedHistogramSuggestionType; +} diff --git a/src/plugins/unified_histogram/public/utils/__snapshots__/external_vis_context.test.ts.snap b/src/plugins/unified_histogram/public/utils/__snapshots__/external_vis_context.test.ts.snap new file mode 100644 index 00000000000000..fb4014f9697009 --- /dev/null +++ b/src/plugins/unified_histogram/public/utils/__snapshots__/external_vis_context.test.ts.snap @@ -0,0 +1,342 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`external_vis_context exportVisContext should work correctly 1`] = ` +Object { + "attributes": Object { + "references": Array [ + Object { + "id": "index-pattern-with-timefield-id", + "name": "textBasedLanguages-datasource-layer-suggestion", + "type": "index-pattern", + }, + ], + "state": Object { + "datasourceStates": Object { + "textBased": Object { + "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 { + "esql": "FROM \\"kibana_sample_data_flights\\"", + }, + }, + "layers": Object { + "46aa21fa-b747-4543-bf90-0b40007c546d": Object { + "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 { + "esql": "FROM kibana_sample_data_flights | keep Dest, AvgTicketPrice", + }, + "table": Object { + "columns": Array [ + Object { + "id": "avg(bytes)", + "isNull": false, + "meta": Object { + "type": "number", + }, + "name": "avg(bytes)", + }, + Object { + "id": "extension.keyword", + "isNull": false, + "meta": Object { + "type": "string", + }, + "name": "extension.keyword", + }, + ], + "rows": Array [ + Object { + "avg(bytes)": 3850, + "extension.keyword": "", + }, + Object { + "avg(bytes)": 5393.5, + "extension.keyword": "css", + }, + Object { + "avg(bytes)": 3252, + "extension.keyword": "deb", + }, + ], + "type": "datatable", + }, + "timeField": "timestamp", + }, + }, + }, + }, + "filters": Array [], + "query": Object { + "esql": "from logstash | stats avg(bytes) by extension.keyword", + }, + "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": "Heat map", + "visualizationType": "lnsHeatmap", + }, + "requestData": Object { + "breakdownField": undefined, + "dataViewId": "index-pattern-with-timefield-id", + "timeField": "timestamp", + "timeInterval": undefined, + }, + "suggestionType": "lensSuggestion", +} +`; + +exports[`external_vis_context exportVisContext should work correctly 2`] = ` +Object { + "attributes": Object { + "references": Array [ + Object { + "id": "index-pattern-with-timefield-id", + "name": "textBasedLanguages-datasource-layer-suggestion", + "type": "index-pattern", + }, + ], + "state": Object { + "datasourceStates": Object { + "textBased": Object { + "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 { + "esql": "FROM \\"kibana_sample_data_flights\\"", + }, + }, + "layers": Object { + "46aa21fa-b747-4543-bf90-0b40007c546d": Object { + "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 { + "esql": "FROM kibana_sample_data_flights | keep Dest, AvgTicketPrice", + }, + "timeField": "timestamp", + }, + }, + }, + }, + "filters": Array [], + "query": Object { + "esql": "from logstash | stats avg(bytes) by extension.keyword", + }, + "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": "Heat map", + "visualizationType": "lnsHeatmap", + }, + "requestData": Object { + "dataViewId": "index-pattern-with-timefield-id", + "timeField": "timestamp", + }, + "suggestionType": "lensSuggestion", +} +`; diff --git a/src/plugins/unified_histogram/public/layout/hooks/compute_interval.test.ts b/src/plugins/unified_histogram/public/utils/compute_interval.test.ts similarity index 100% rename from src/plugins/unified_histogram/public/layout/hooks/compute_interval.test.ts rename to src/plugins/unified_histogram/public/utils/compute_interval.test.ts diff --git a/src/plugins/unified_histogram/public/layout/hooks/compute_interval.ts b/src/plugins/unified_histogram/public/utils/compute_interval.ts similarity index 100% rename from src/plugins/unified_histogram/public/layout/hooks/compute_interval.ts rename to src/plugins/unified_histogram/public/utils/compute_interval.ts diff --git a/src/plugins/unified_histogram/public/utils/external_vis_context.test.ts b/src/plugins/unified_histogram/public/utils/external_vis_context.test.ts new file mode 100644 index 00000000000000..a786bf102065a0 --- /dev/null +++ b/src/plugins/unified_histogram/public/utils/external_vis_context.test.ts @@ -0,0 +1,164 @@ +/* + * 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 { DataView } from '@kbn/data-views-plugin/common'; +import type { Suggestion } from '@kbn/lens-plugin/public'; +import { + canImportVisContext, + exportVisContext, + isSuggestionShapeAndVisContextCompatible, +} from './external_vis_context'; +import { getLensVisMock } from '../__mocks__/lens_vis'; +import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield'; +import { tableMock, tableQueryMock } from '../__mocks__/table'; +import { UnifiedHistogramSuggestionType, UnifiedHistogramVisContext } from '../types'; + +describe('external_vis_context', () => { + const dataView: DataView = dataViewWithTimefieldMock; + let exportedVisContext: UnifiedHistogramVisContext | undefined; + + describe('exportVisContext', () => { + it('should work correctly', async () => { + const lensVis = await getLensVisMock({ + filters: [], + query: tableQueryMock, + dataView, + timeInterval: 'auto', + breakdownField: undefined, + columns: [], + isPlainRecord: true, + table: tableMock, + }); + + const visContext = lensVis.visContext; + + expect(visContext).toMatchSnapshot(); + + exportedVisContext = exportVisContext(visContext); + expect(exportedVisContext).toMatchSnapshot(); + }); + }); + + describe('canImportVisContext', () => { + it('should work correctly for valid input', async () => { + expect(canImportVisContext(exportedVisContext)).toBe(true); + }); + + it('should work correctly for invalid input', async () => { + expect(canImportVisContext(undefined)).toBe(false); + expect(canImportVisContext({ attributes: {} })).toBe(false); + }); + }); + + describe('isSuggestionAndVisContextCompatible', () => { + it('should work correctly', async () => { + expect(isSuggestionShapeAndVisContextCompatible(undefined, undefined)).toBe(true); + + expect( + isSuggestionShapeAndVisContextCompatible( + { visualizationId: 'lnsPie', visualizationState: { shape: 'donut' } } as Suggestion, + { + suggestionType: UnifiedHistogramSuggestionType.lensSuggestion, + attributes: { + visualizationType: 'lnsPie', + state: { visualization: { shape: 'donut' } }, + }, + } as UnifiedHistogramVisContext + ) + ).toBe(true); + + expect( + isSuggestionShapeAndVisContextCompatible( + { visualizationId: 'lnsPie', visualizationState: { shape: 'donut' } } as Suggestion, + { + suggestionType: UnifiedHistogramSuggestionType.lensSuggestion, + attributes: { + visualizationType: 'lnsPie', + state: { visualization: { shape: 'waffle' } }, + }, + } as UnifiedHistogramVisContext + ) + ).toBe(false); + + expect( + isSuggestionShapeAndVisContextCompatible( + { visualizationId: 'lnsPie', visualizationState: { shape: 'donut' } } as Suggestion, + { + suggestionType: UnifiedHistogramSuggestionType.lensSuggestion, + attributes: { + visualizationType: 'lnsXY', + }, + } as UnifiedHistogramVisContext + ) + ).toBe(false); + + expect( + isSuggestionShapeAndVisContextCompatible( + { + visualizationId: 'lnsXY', + visualizationState: { preferredSeriesType: 'bar_stacked' }, + } as Suggestion, + { + attributes: { + visualizationType: 'lnsXY', + state: { visualization: { preferredSeriesType: 'bar_stacked' } }, + }, + } as UnifiedHistogramVisContext + ) + ).toBe(true); + + expect( + isSuggestionShapeAndVisContextCompatible( + { + visualizationId: 'lnsXY', + visualizationState: { preferredSeriesType: 'bar_stacked' }, + } as Suggestion, + { + suggestionType: UnifiedHistogramSuggestionType.histogramForESQL, + attributes: { + visualizationType: 'lnsXY', + state: { visualization: { preferredSeriesType: 'line' } }, + }, + } as UnifiedHistogramVisContext + ) + ).toBe(true); + + expect( + isSuggestionShapeAndVisContextCompatible( + { + visualizationId: 'lnsXY', + visualizationState: { preferredSeriesType: 'bar_stacked' }, + } as Suggestion, + { + suggestionType: UnifiedHistogramSuggestionType.lensSuggestion, + attributes: { + visualizationType: 'lnsXY', + state: { visualization: { preferredSeriesType: 'line' } }, + }, + } as UnifiedHistogramVisContext + ) + ).toBe(false); + + expect( + isSuggestionShapeAndVisContextCompatible( + { + visualizationId: 'lnsXY', + visualizationState: { preferredSeriesType: 'bar_stacked' }, + } as Suggestion, + { + suggestionType: UnifiedHistogramSuggestionType.histogramForDataView, + attributes: { + visualizationType: 'lnsXY', + state: { visualization: { preferredSeriesType: 'bar_stacked' } }, + }, + } as UnifiedHistogramVisContext + ) + ).toBe(true); + }); + }); +}); diff --git a/src/plugins/unified_histogram/public/utils/external_vis_context.ts b/src/plugins/unified_histogram/public/utils/external_vis_context.ts new file mode 100644 index 00000000000000..380b7dbc01094b --- /dev/null +++ b/src/plugins/unified_histogram/public/utils/external_vis_context.ts @@ -0,0 +1,89 @@ +/* + * 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 { PieVisualizationState, Suggestion, XYState } from '@kbn/lens-plugin/public'; +import { UnifiedHistogramSuggestionType, UnifiedHistogramVisContext } from '../types'; +import { removeTablesFromLensAttributes } from './lens_vis_from_table'; + +export const exportVisContext = ( + visContext: UnifiedHistogramVisContext | undefined +): UnifiedHistogramVisContext | undefined => { + if ( + !visContext || + !visContext.requestData || + !visContext.attributes || + !visContext.suggestionType + ) { + return undefined; + } + + try { + const lightweightVisContext = visContext + ? { + suggestionType: visContext.suggestionType, + requestData: visContext.requestData, + attributes: removeTablesFromLensAttributes(visContext.attributes), + } + : undefined; + + const visContextWithoutUndefinedValues = lightweightVisContext + ? JSON.parse(JSON.stringify(lightweightVisContext)) + : undefined; + + return visContextWithoutUndefinedValues; + } catch { + return undefined; + } +}; + +export function canImportVisContext( + visContext: unknown | undefined +): visContext is UnifiedHistogramVisContext { + return ( + !!visContext && + typeof visContext === 'object' && + 'requestData' in visContext && + 'attributes' in visContext && + 'suggestionType' in visContext && + !!visContext.requestData && + !!visContext.attributes && + !!visContext.suggestionType && + typeof visContext.requestData === 'object' && + typeof visContext.attributes === 'object' && + typeof visContext.suggestionType === 'string' + ); +} + +export const isSuggestionShapeAndVisContextCompatible = ( + suggestion: Suggestion | undefined, + externalVisContext: UnifiedHistogramVisContext | undefined +): boolean => { + if (!suggestion && !externalVisContext) { + return true; + } + + if (suggestion?.visualizationId !== externalVisContext?.attributes?.visualizationType) { + return false; + } + + if (externalVisContext?.suggestionType !== UnifiedHistogramSuggestionType.lensSuggestion) { + return true; + } + + if (suggestion?.visualizationId === 'lnsXY') { + return ( + (suggestion?.visualizationState as XYState)?.preferredSeriesType === + (externalVisContext?.attributes?.state?.visualization as XYState)?.preferredSeriesType + ); + } + + return ( + (suggestion?.visualizationState as PieVisualizationState)?.shape === + (externalVisContext?.attributes?.state?.visualization as PieVisualizationState)?.shape + ); +}; diff --git a/src/plugins/unified_histogram/public/chart/utils/field_supports_breakdown.test.ts b/src/plugins/unified_histogram/public/utils/field_supports_breakdown.test.ts similarity index 100% rename from src/plugins/unified_histogram/public/chart/utils/field_supports_breakdown.test.ts rename to src/plugins/unified_histogram/public/utils/field_supports_breakdown.test.ts diff --git a/src/plugins/unified_histogram/public/chart/utils/field_supports_breakdown.ts b/src/plugins/unified_histogram/public/utils/field_supports_breakdown.ts similarity index 100% rename from src/plugins/unified_histogram/public/chart/utils/field_supports_breakdown.ts rename to src/plugins/unified_histogram/public/utils/field_supports_breakdown.ts diff --git a/src/plugins/unified_histogram/public/utils/lens_vis_from_table.ts b/src/plugins/unified_histogram/public/utils/lens_vis_from_table.ts new file mode 100644 index 00000000000000..565b52767022ac --- /dev/null +++ b/src/plugins/unified_histogram/public/utils/lens_vis_from_table.ts @@ -0,0 +1,57 @@ +/* + * 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 { Datatable } from '@kbn/expressions-plugin/common'; +import type { LensAttributes } from '@kbn/lens-embeddable-utils'; +import type { TextBasedPersistedState } from '@kbn/lens-plugin/public/datasources/text_based/types'; + +export const enrichLensAttributesWithTablesData = ({ + attributes, + table, +}: { + attributes: LensAttributes; + table: Datatable | undefined; +}): LensAttributes => { + if (!attributes.state.datasourceStates.textBased) { + return attributes; + } + + const layers = attributes.state.datasourceStates.textBased?.layers; + + if (!layers) { + return attributes; + } + + const updatedAttributes = { + ...attributes, + state: { + ...attributes.state, + datasourceStates: { + ...attributes.state.datasourceStates, + textBased: { + ...attributes.state.datasourceStates.textBased, + layers: {} as TextBasedPersistedState['layers'], + }, + }, + }, + }; + + for (const key of Object.keys(layers)) { + const newLayer = { ...layers[key], table }; + if (!table) { + delete newLayer.table; + } + updatedAttributes.state.datasourceStates.textBased.layers[key] = newLayer; + } + + return updatedAttributes; +}; + +export const removeTablesFromLensAttributes = (attributes: LensAttributes): LensAttributes => { + return enrichLensAttributesWithTablesData({ attributes, table: undefined }); +}; diff --git a/src/plugins/unified_histogram/tsconfig.json b/src/plugins/unified_histogram/tsconfig.json index fa266de08ecbff..a1c15026479cc6 100644 --- a/src/plugins/unified_histogram/tsconfig.json +++ b/src/plugins/unified_histogram/tsconfig.json @@ -23,7 +23,6 @@ "@kbn/ui-actions-plugin", "@kbn/kibana-utils-plugin", "@kbn/visualizations-plugin", - "@kbn/discover-utils", "@kbn/resizable-layout", "@kbn/shared-ux-button-toolbar", "@kbn/calculate-width-from-char-count", @@ -31,6 +30,8 @@ "@kbn/i18n-react", "@kbn/field-utils", "@kbn/esql-utils", + "@kbn/discover-utils", + "@kbn/visualization-utils", ], "exclude": [ "target/**/*", diff --git a/test/functional/apps/discover/group3/_lens_vis.ts b/test/functional/apps/discover/group3/_lens_vis.ts new file mode 100644 index 00000000000000..5747dbd85de64a --- /dev/null +++ b/test/functional/apps/discover/group3/_lens_vis.ts @@ -0,0 +1,675 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const testSubjects = getService('testSubjects'); + const monacoEditor = getService('monacoEditor'); + const retry = getService('retry'); + const find = getService('find'); + const browser = getService('browser'); + const toasts = getService('toasts'); + const PageObjects = getPageObjects([ + 'settings', + 'common', + 'discover', + 'header', + 'timePicker', + 'dashboard', + 'unifiedFieldList', + 'unifiedSearch', + ]); + const security = getService('security'); + const defaultSettings = { + defaultIndex: 'logstash-*', + hideAnnouncements: true, + }; + + const defaultTimespan = + 'Sep 19, 2015 @ 06:31:44.000 - Sep 23, 2015 @ 18:31:44.000 (interval: Auto - 3 hours)'; + const defaultTimespanESQL = 'Sep 19, 2015 @ 06:31:44.000 - Sep 23, 2015 @ 18:31:44.000'; + const defaultTotalCount = '14,004'; + + async function checkNoVis(totalCount: string) { + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + expect(await PageObjects.discover.isChartVisible()).to.be(false); + expect(await PageObjects.discover.getHitCount()).to.be(totalCount); + } + + async function checkHistogramVis(timespan: string, totalCount: string) { + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await testSubjects.existOrFail('xyVisChart'); + await testSubjects.existOrFail('unifiedHistogramEditVisualization'); + await testSubjects.existOrFail('unifiedHistogramBreakdownSelectorButton'); + await testSubjects.existOrFail('unifiedHistogramTimeIntervalSelectorButton'); + expect(await PageObjects.discover.getChartTimespan()).to.be(timespan); + expect(await PageObjects.discover.getHitCount()).to.be(totalCount); + } + + async function checkESQLHistogramVis(timespan: string, totalCount: string) { + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await testSubjects.existOrFail('xyVisChart'); + await testSubjects.existOrFail('unifiedHistogramSaveVisualization'); + await testSubjects.existOrFail('unifiedHistogramEditFlyoutVisualization'); + await testSubjects.missingOrFail('unifiedHistogramEditVisualization'); + await testSubjects.missingOrFail('unifiedHistogramBreakdownSelectorButton'); + await testSubjects.missingOrFail('unifiedHistogramTimeIntervalSelectorButton'); + expect(await PageObjects.discover.getChartTimespan()).to.be(timespan); + expect(await PageObjects.discover.getHitCount()).to.be(totalCount); + } + + async function changeVisSeriesType(seriesType: string) { + await testSubjects.click('unifiedHistogramEditFlyoutVisualization'); + await retry.waitFor('flyout', async () => { + return await testSubjects.exists('lnsChartSwitchPopover'); + }); + await testSubjects.click('lnsChartSwitchPopover'); + await testSubjects.setValue('lnsChartSwitchSearch', seriesType, { + clearWithKeyboard: true, + }); + await testSubjects.click(`lnsChartSwitchPopover_${seriesType.toLowerCase()}`); + await retry.try(async () => { + expect(await testSubjects.getVisibleText('lnsChartSwitchPopover')).to.be(seriesType); + }); + + await toasts.dismissAll(); + await testSubjects.scrollIntoView('applyFlyoutButton'); + await testSubjects.click('applyFlyoutButton'); + } + + async function getCurrentVisSeriesTypeLabel() { + await toasts.dismissAll(); + await testSubjects.click('unifiedHistogramEditFlyoutVisualization'); + const seriesType = await testSubjects.getVisibleText('lnsChartSwitchPopover'); + await testSubjects.click('cancelFlyoutButton'); + return seriesType; + } + + async function getCurrentVisChartTitle() { + const chartElement = await find.byCssSelector( + '[data-test-subj="unifiedHistogramChart"] [data-render-complete="true"]' + ); + return await chartElement.getAttribute('data-title'); + } + + describe('discover lens vis', function describeIndexTests() { + before(async () => { + await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader']); + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); + await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/discover'); + await browser.setWindowSize(1300, 1000); + }); + + after(async () => { + await kibanaServer.importExport.unload('test/functional/fixtures/kbn_archiver/discover'); + await kibanaServer.uiSettings.replace({}); + await kibanaServer.savedObjects.cleanStandardList(); + }); + + beforeEach(async function () { + await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); + await kibanaServer.uiSettings.update(defaultSettings); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + }); + + it('should show histogram by default', async () => { + await checkHistogramVis(defaultTimespan, defaultTotalCount); + + await PageObjects.timePicker.setAbsoluteRange( + 'Sep 20, 2015 @ 00:00:00.000', + 'Sep 20, 2015 @ 23:50:13.253' + ); + + const savedSearchTimeSpan = + 'Sep 20, 2015 @ 00:00:00.000 - Sep 20, 2015 @ 23:50:13.253 (interval: Auto - 30 minutes)'; + const savedSearchTotalCount = '4,756'; + await checkHistogramVis(savedSearchTimeSpan, savedSearchTotalCount); + + await PageObjects.discover.saveSearch('testDefault'); + + await checkHistogramVis(savedSearchTimeSpan, savedSearchTotalCount); + + await browser.refresh(); + + await checkHistogramVis(savedSearchTimeSpan, savedSearchTotalCount); + }); + + it('should show no histogram for no results view and recover when time range expanded', async () => { + await PageObjects.timePicker.setAbsoluteRange( + 'Sep 19, 2015 @ 00:00:00.000', + 'Sep 19, 2015 @ 00:00:00.000' + ); + + expect(await PageObjects.discover.hasNoResults()).to.be(true); + + await PageObjects.timePicker.setAbsoluteRange( + 'Sep 20, 2015 @ 00:00:00.000', + 'Sep 20, 2015 @ 00:00:00.000' + ); + + await checkHistogramVis( + 'Sep 20, 2015 @ 00:00:00.000 - Sep 20, 2015 @ 00:00:00.000 (interval: Auto - millisecond)', + '1' + ); + }); + + it('should show no histogram for non-time-based data views and recover for time-based data views', async () => { + await PageObjects.discover.createAdHocDataView('logs*', false); + + await checkNoVis(defaultTotalCount); + + await PageObjects.discover.clickIndexPatternActions(); + await PageObjects.unifiedSearch.editDataView('logs*', '@timestamp'); + + await checkHistogramVis(defaultTimespan, defaultTotalCount); + }); + + it('should show ESQL histogram for text-based query', async () => { + await PageObjects.discover.selectTextBaseLang(); + + await monacoEditor.setCodeEditorValue('from logstash-* | limit 10'); + await testSubjects.click('querySubmitButton'); + + await checkESQLHistogramVis( + 'Sep 19, 2015 @ 06:31:44.000 - Sep 23, 2015 @ 18:31:44.000', + '10' + ); + + await PageObjects.timePicker.setAbsoluteRange( + 'Sep 20, 2015 @ 00:00:00.000', + 'Sep 20, 2015 @ 00:00:00.000' + ); + + await checkESQLHistogramVis('Sep 20, 2015 @ 00:00:00.000 - Sep 20, 2015 @ 00:00:00.000', '1'); + }); + + it('should be able to customize ESQL histogram and save it', async () => { + await PageObjects.discover.selectTextBaseLang(); + + await monacoEditor.setCodeEditorValue('from logstash-* | limit 10'); + await testSubjects.click('querySubmitButton'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await changeVisSeriesType('Line'); + + await PageObjects.discover.saveSearch('testCustomESQLHistogram'); + + await checkESQLHistogramVis( + 'Sep 19, 2015 @ 06:31:44.000 - Sep 23, 2015 @ 18:31:44.000', + '10' + ); + + await testSubjects.click('unifiedHistogramEditFlyoutVisualization'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Line'); + }); + + it('should be able to load a saved search with custom histogram vis, edit vis and revert changes', async () => { + await PageObjects.discover.loadSavedSearch('testCustomESQLHistogram'); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + expect(await getCurrentVisSeriesTypeLabel()).to.be('Line'); + + await testSubjects.missingOrFail('unsavedChangesBadge'); + await checkESQLHistogramVis( + 'Sep 19, 2015 @ 06:31:44.000 - Sep 23, 2015 @ 18:31:44.000', + '10' + ); + + await changeVisSeriesType('Area'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Area'); + + await testSubjects.existOrFail('unsavedChangesBadge'); + + await PageObjects.discover.revertUnsavedChanges(); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + expect(await getCurrentVisSeriesTypeLabel()).to.be('Line'); + + await testSubjects.missingOrFail('unsavedChangesBadge'); + await checkESQLHistogramVis( + 'Sep 19, 2015 @ 06:31:44.000 - Sep 23, 2015 @ 18:31:44.000', + '10' + ); + }); + + it('should be able to load a saved search with custom histogram vis, edit query and revert changes', async () => { + await PageObjects.discover.loadSavedSearch('testCustomESQLHistogram'); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + expect(await getCurrentVisSeriesTypeLabel()).to.be('Line'); + + await testSubjects.missingOrFail('unsavedChangesBadge'); + await checkESQLHistogramVis( + 'Sep 19, 2015 @ 06:31:44.000 - Sep 23, 2015 @ 18:31:44.000', + '10' + ); + expect(await monacoEditor.getCodeEditorValue()).to.be('from logstash-* | limit 10'); + + // by changing the query we reset the histogram customization + await monacoEditor.setCodeEditorValue('from logstash-* | limit 100'); + await testSubjects.click('querySubmitButton'); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + expect(await getCurrentVisSeriesTypeLabel()).to.be('Bar vertical stacked'); + + await checkESQLHistogramVis(defaultTimespanESQL, '100'); + + await testSubjects.existOrFail('unsavedChangesBadge'); + expect(await monacoEditor.getCodeEditorValue()).to.be('from logstash-* | limit 100'); + + await PageObjects.discover.revertUnsavedChanges(); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await testSubjects.missingOrFail('unsavedChangesBadge'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Line'); + + await checkESQLHistogramVis(defaultTimespanESQL, '10'); + expect(await monacoEditor.getCodeEditorValue()).to.be('from logstash-* | limit 10'); + + // now we are changing to a different query to check lens suggestion logic too + await monacoEditor.setCodeEditorValue( + 'from logstash-* | stats averageA = avg(bytes) by extension' + ); + await testSubjects.click('querySubmitButton'); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await checkESQLHistogramVis(defaultTimespanESQL, '5'); + await PageObjects.discover.chooseLensChart('Donut'); + + await testSubjects.existOrFail('unsavedChangesBadge'); + expect(await monacoEditor.getCodeEditorValue()).to.contain('averageA'); + + expect(await PageObjects.discover.getCurrentLensChart()).to.be('Donut'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Donut'); + expect(await getCurrentVisChartTitle()).to.be('Donut'); + + await PageObjects.discover.revertUnsavedChanges(); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await testSubjects.missingOrFail('unsavedChangesBadge'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Line'); + expect(await getCurrentVisChartTitle()).to.be('Bar vertical stacked'); + + await checkESQLHistogramVis(defaultTimespanESQL, '10'); + expect(await monacoEditor.getCodeEditorValue()).to.be('from logstash-* | limit 10'); + }); + + it('should be able to load a saved search with custom histogram vis and handle invalidations', async () => { + await PageObjects.discover.loadSavedSearch('testCustomESQLHistogram'); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + expect(await getCurrentVisSeriesTypeLabel()).to.be('Line'); + + await testSubjects.missingOrFail('unsavedChangesBadge'); + await checkESQLHistogramVis( + 'Sep 19, 2015 @ 06:31:44.000 - Sep 23, 2015 @ 18:31:44.000', + '10' + ); + expect(await monacoEditor.getCodeEditorValue()).to.be('from logstash-* | limit 10'); + + // now we are changing to a different query to check invalidation logic + await monacoEditor.setCodeEditorValue( + 'from logstash-* | stats averageA = avg(bytes) by extension' + ); + await testSubjects.click('querySubmitButton'); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await checkESQLHistogramVis(defaultTimespanESQL, '5'); + await PageObjects.discover.chooseLensChart('Donut'); + + await testSubjects.existOrFail('unsavedChangesBadge'); + expect(await monacoEditor.getCodeEditorValue()).to.contain('averageA'); + + expect(await PageObjects.discover.getCurrentLensChart()).to.be('Donut'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Donut'); + expect(await getCurrentVisChartTitle()).to.be('Donut'); + + await PageObjects.discover.saveSearch('testCustomESQLHistogramInvalidation', true); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await testSubjects.missingOrFail('unsavedChangesBadge'); + expect(await PageObjects.discover.getCurrentLensChart()).to.be('Donut'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Donut'); + expect(await getCurrentVisChartTitle()).to.be('Donut'); + + await browser.refresh(); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await testSubjects.missingOrFail('unsavedChangesBadge'); + expect(await PageObjects.discover.getCurrentLensChart()).to.be('Donut'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Donut'); + expect(await getCurrentVisChartTitle()).to.be('Donut'); + }); + + it('should be able to load a saved search with custom histogram vis and save new customization', async () => { + await PageObjects.discover.loadSavedSearch('testCustomESQLHistogram'); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + expect(await getCurrentVisSeriesTypeLabel()).to.be('Line'); + + await testSubjects.missingOrFail('unsavedChangesBadge'); + await checkESQLHistogramVis( + 'Sep 19, 2015 @ 06:31:44.000 - Sep 23, 2015 @ 18:31:44.000', + '10' + ); + expect(await monacoEditor.getCodeEditorValue()).to.be('from logstash-* | limit 10'); + + // now we are changing to a different query to check invalidation logic + await monacoEditor.setCodeEditorValue( + 'from logstash-* | stats averageA = avg(bytes) by extension' + ); + await testSubjects.click('querySubmitButton'); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await checkESQLHistogramVis(defaultTimespanESQL, '5'); + await PageObjects.discover.chooseLensChart('Donut'); + + await testSubjects.existOrFail('unsavedChangesBadge'); + expect(await monacoEditor.getCodeEditorValue()).to.contain('averageA'); + + expect(await PageObjects.discover.getCurrentLensChart()).to.be('Donut'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Donut'); + expect(await getCurrentVisChartTitle()).to.be('Donut'); + + // now we customize the vis again + await PageObjects.discover.chooseLensChart('Waffle'); + expect(await PageObjects.discover.getCurrentLensChart()).to.be('Waffle'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Waffle'); + expect(await getCurrentVisChartTitle()).to.be('Waffle'); + + await testSubjects.existOrFail('unsavedChangesBadge'); + + await PageObjects.discover.saveSearch( + 'testCustomESQLHistogramInvalidationPlusCustomization', + true + ); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await testSubjects.missingOrFail('unsavedChangesBadge'); + expect(await PageObjects.discover.getCurrentLensChart()).to.be('Waffle'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Waffle'); + expect(await getCurrentVisChartTitle()).to.be('Waffle'); + + await browser.refresh(); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await testSubjects.missingOrFail('unsavedChangesBadge'); + expect(await PageObjects.discover.getCurrentLensChart()).to.be('Waffle'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Waffle'); + expect(await getCurrentVisChartTitle()).to.be('Waffle'); + }); + + it('should be able to customize ESQL vis and save it', async () => { + await PageObjects.discover.selectTextBaseLang(); + + await monacoEditor.setCodeEditorValue( + 'from logstash-* | stats averageB = avg(bytes) by extension' + ); + await testSubjects.click('querySubmitButton'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await checkESQLHistogramVis(defaultTimespanESQL, '5'); + await PageObjects.discover.chooseLensChart('Donut'); + + await PageObjects.discover.saveSearch('testCustomESQLVis'); + await PageObjects.discover.saveSearch('testCustomESQLVisDonut', true); + + expect(await PageObjects.discover.getCurrentLensChart()).to.be('Donut'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Donut'); + expect(await getCurrentVisChartTitle()).to.be('Donut'); + + await browser.refresh(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + expect(await PageObjects.discover.getCurrentLensChart()).to.be('Donut'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Donut'); + expect(await getCurrentVisChartTitle()).to.be('Donut'); + }); + + it('should be able to load a saved search with custom vis, edit query and revert changes', async () => { + await PageObjects.discover.loadSavedSearch('testCustomESQLVisDonut'); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + expect(await PageObjects.discover.getCurrentLensChart()).to.be('Donut'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Donut'); + expect(await getCurrentVisChartTitle()).to.be('Donut'); + + await testSubjects.missingOrFail('unsavedChangesBadge'); + + // by changing the query we reset the vis customization to histogram + await monacoEditor.setCodeEditorValue('from logstash-* | limit 100'); + await testSubjects.click('querySubmitButton'); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + expect(await getCurrentVisSeriesTypeLabel()).to.be('Bar vertical stacked'); + + await checkESQLHistogramVis(defaultTimespanESQL, '100'); + + await testSubjects.existOrFail('unsavedChangesBadge'); + expect(await monacoEditor.getCodeEditorValue()).to.be('from logstash-* | limit 100'); + + await PageObjects.discover.revertUnsavedChanges(); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await testSubjects.missingOrFail('unsavedChangesBadge'); + expect(await PageObjects.discover.getCurrentLensChart()).to.be('Donut'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Donut'); + expect(await getCurrentVisChartTitle()).to.be('Donut'); + + expect(await monacoEditor.getCodeEditorValue()).to.contain('averageB'); + + // should be still Donut after reverting and saving again + await PageObjects.discover.saveSearch('testCustomESQLVisDonut'); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await testSubjects.missingOrFail('unsavedChangesBadge'); + expect(await PageObjects.discover.getCurrentLensChart()).to.be('Donut'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Donut'); + expect(await getCurrentVisChartTitle()).to.be('Donut'); + }); + + it('should be able to change to an unfamiliar vis type via lens flyout', async () => { + await PageObjects.discover.loadSavedSearch('testCustomESQLVisDonut'); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + expect(await PageObjects.discover.getCurrentLensChart()).to.be('Donut'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Donut'); + expect(await getCurrentVisChartTitle()).to.be('Donut'); + + await testSubjects.missingOrFail('unsavedChangesBadge'); + + await changeVisSeriesType('Pie'); + + await testSubjects.existOrFail('unsavedChangesBadge'); + + expect(await PageObjects.discover.getCurrentLensChart()).to.be('Customized'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Pie'); + expect(await getCurrentVisChartTitle()).to.be('Donut'); + + await PageObjects.discover.saveSearch('testCustomESQLVisPie', true); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + expect(await PageObjects.discover.getCurrentLensChart()).to.be('Customized'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Pie'); + expect(await getCurrentVisChartTitle()).to.be('Donut'); + + await browser.refresh(); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + expect(await PageObjects.discover.getCurrentLensChart()).to.be('Customized'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Pie'); + expect(await getCurrentVisChartTitle()).to.be('Donut'); + + await monacoEditor.setCodeEditorValue( + 'from logstash-* | stats averageB = avg(bytes) by extension.raw' + ); + await testSubjects.click('querySubmitButton'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + expect(await PageObjects.discover.getCurrentLensChart()).to.be('Bar vertical stacked'); + + await testSubjects.existOrFail('unsavedChangesBadge'); + + await PageObjects.discover.revertUnsavedChanges(); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await testSubjects.missingOrFail('unsavedChangesBadge'); + expect(await PageObjects.discover.getCurrentLensChart()).to.be('Customized'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Pie'); + expect(await getCurrentVisChartTitle()).to.be('Donut'); + }); + + it('should be able to load a saved search with custom vis, edit vis and revert changes', async () => { + await PageObjects.discover.loadSavedSearch('testCustomESQLVis'); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + expect(await PageObjects.discover.getCurrentLensChart()).to.be('Donut'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Donut'); + expect(await getCurrentVisChartTitle()).to.be('Donut'); + + await testSubjects.missingOrFail('unsavedChangesBadge'); + + await PageObjects.discover.chooseLensChart('Waffle'); + expect(await PageObjects.discover.getCurrentLensChart()).to.be('Waffle'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Waffle'); + expect(await getCurrentVisChartTitle()).to.be('Waffle'); + + await testSubjects.existOrFail('unsavedChangesBadge'); + + await PageObjects.discover.revertUnsavedChanges(); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await testSubjects.missingOrFail('unsavedChangesBadge'); + expect(await PageObjects.discover.getCurrentLensChart()).to.be('Donut'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Donut'); + expect(await getCurrentVisChartTitle()).to.be('Donut'); + + await PageObjects.discover.chooseLensChart('Bar vertical stacked'); + await changeVisSeriesType('Line'); + + await testSubjects.existOrFail('unsavedChangesBadge'); + await PageObjects.discover.saveUnsavedChanges(); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await testSubjects.missingOrFail('unsavedChangesBadge'); + expect(await PageObjects.discover.getCurrentLensChart()).to.be('Customized'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Line'); + expect(await getCurrentVisChartTitle()).to.be('Bar vertical stacked'); + }); + + it('should close lens flyout on revert changes', async () => { + await PageObjects.discover.selectTextBaseLang(); + + await monacoEditor.setCodeEditorValue( + 'from logstash-* | stats averageB = avg(bytes) by extension' + ); + await testSubjects.click('querySubmitButton'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + expect(await PageObjects.discover.getCurrentLensChart()).to.be('Bar vertical stacked'); + + await testSubjects.missingOrFail('unsavedChangesBadge'); + + await PageObjects.discover.chooseLensChart('Treemap'); + expect(await PageObjects.discover.getCurrentLensChart()).to.be('Treemap'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Treemap'); + expect(await getCurrentVisChartTitle()).to.be('Treemap'); + + await PageObjects.discover.saveSearch('testCustomESQLVisRevert'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await testSubjects.missingOrFail('unsavedChangesBadge'); + + await PageObjects.discover.chooseLensChart('Donut'); + expect(await PageObjects.discover.getCurrentLensChart()).to.be('Donut'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Donut'); + expect(await getCurrentVisChartTitle()).to.be('Donut'); + + await testSubjects.click('unifiedHistogramEditFlyoutVisualization'); // open the flyout + await testSubjects.existOrFail('lnsEditOnFlyFlyout'); + + await testSubjects.existOrFail('unsavedChangesBadge'); + await PageObjects.discover.revertUnsavedChanges(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await testSubjects.missingOrFail('unsavedChangesBadge'); + await testSubjects.missingOrFail('lnsEditOnFlyFlyout'); // it should close the flyout + expect(await PageObjects.discover.getCurrentLensChart()).to.be('Treemap'); + expect(await getCurrentVisSeriesTypeLabel()).to.be('Treemap'); + expect(await getCurrentVisChartTitle()).to.be('Treemap'); + }); + }); +} diff --git a/test/functional/apps/discover/group3/index.ts b/test/functional/apps/discover/group3/index.ts index 2fe5a4ebb1db1a..a80ae44e498017 100644 --- a/test/functional/apps/discover/group3/index.ts +++ b/test/functional/apps/discover/group3/index.ts @@ -30,5 +30,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./_view_mode_toggle')); loadTestFile(require.resolve('./_unsaved_changes_badge')); loadTestFile(require.resolve('./_panels_toggle')); + loadTestFile(require.resolve('./_lens_vis')); }); } diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index 07f82309c321b6..47165f90952ee7 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -243,6 +243,12 @@ export class DiscoverPageObject extends FtrService { await this.comboBox.set('unifiedHistogramSuggestionSelector', chart); } + public async getCurrentLensChart() { + return ( + await this.comboBox.getComboBoxSelectedOptions('unifiedHistogramSuggestionSelector') + )?.[0]; + } + public async getHistogramLegendList() { const unifiedHistogram = await this.testSubjects.find('unifiedHistogramChart'); const list = await unifiedHistogram.findAllByClassName('echLegendItem__label'); diff --git a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/flyout_wrapper.tsx b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/flyout_wrapper.tsx index 009d21d65eb57c..f8ee1c5779693d 100644 --- a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/flyout_wrapper.tsx +++ b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/flyout_wrapper.tsx @@ -133,6 +133,7 @@ export const FlyoutWrapper = ({ { title: 'foo', id: 'foo', toSpec: jest.fn(), + toMinimalSpec: jest.fn(), isPersisted: jest.fn().mockReturnValue(false), }) ),