From c708c499481fcc2394b301db019e22b2b5a8831a Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Mon, 8 Apr 2024 16:04:52 +0200 Subject: [PATCH] [Discover] Allow to store the configured ES|QL visualization v3 (#175227) - Resolves https://github.com/elastic/kibana/issues/167887 ## Summary On Discover page user can see a visualization for data view and ES|QL modes. For ES|QL mode it's also allowed to customize the visualization. This PR allows to save such customization together with a saved search. In more details, various types of Lens visualization can be shown on Discover page: - If in the default (data view) mode, Unified Histogram shows a "formBased" histogram (`type: UnifiedHistogramSuggestionType.histogramForDataView` in this PR) - If in the ES|QL mode, 2 scenarios are possible (so far only these are customizable): - If Lens has suggestions for the current query, Unified Histogram shows one of them (`type: UnifiedHistogramSuggestionType.lensSuggestion` in this PR) Example query: `from kibana_sample_data_logs | stats avg(bytes) by message.keyword` - If Lens suggestion list is empty, Unified Histogram shows a "textBased" histogram (`type: UnifiedHistogramSuggestionType.histogramForESQL` in this PR). Example query: `from kibana_sample_data_logs | limit 10` The main flow is that Unified Histogram first picks a suggestion (one of `UnifiedHistogramSuggestionType` type), then calculates lens attributes which are necessary to build Lens embeddable. With a saved search we are saving those calculated lens attributes under `savedSearch.visContext`. For handling this logic, I refactored `useLensSuggestion`, `getLensAttributes` into `LensVisService`. Restoring a saved customization adds complexity to the flow as it should pick now not just any available suggestion but the suggestion which matches to the previously saved lens attributes. Changes to the current query, time range, time field etc can make the current vis context incompatible and we have to drop the vis customization. This PR already includes this logic of invalidating the stored lens attributes if they are not compatible any more. New vis context will override the previous one when user presses Save for the current search. Until then, we try to restore the customization from the previously saved vis context (for example when the query changes back to the compatible one). What can invalidate the saved vis context and drop the user's customization: - data view id - data view time field name - query/filters - time range if it has a different time interval - text based columns affect what lens suggestions are available Flow of creating a new search: ![1](https://github.com/elastic/kibana/assets/1415710/9274d895-cedb-454a-9a9d-3b0cf600d801) Flow of editing a saved search: ![2](https://github.com/elastic/kibana/assets/1415710/086ce4a0-f679-4d96-892b-631bcfee7ee3)
Previous details - Previous approach https://github.com/elastic/kibana/pull/174373 (saving current suggestion instead of lens attributes) - Previous approach https://github.com/elastic/kibana/pull/174783 (saving lens attributes but it's based on existing hooks) But I was stuck with how to make "Unsaved changes" badge work well when user tries to revert changes. For testing in ES|QL mode I use `from kibana_sample_data_logs | limit 10` as query, customize color of a lens histogram, and save it with a saved search. Next I check 2 cases: 1. edit query limit `from kibana_sample_data_logs | limit 100`, see that vis customization gets reset which is expected, press "Revert changes" in the "Unsaved changes" badge => notice that reset did not work 2. edit only histogram color, press "Revert changes" in the "Unsaved changes" badge => notice that reset did not work Here are some nuances with the state management I am seeing which together do not allow to successfully revert unsaved changes: - For ES|QL histogram lens attributes include a modified query `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"` which means that not only changes to the original query but also a different time interval invalidates the saved lens attributes. - In ES|QL mode, `query` prop update is delayed for `UnifiedHistogramContainer` component until Discover finishes the documents fetch https://github.com/elastic/kibana/blob/fc2ec957fe78900967da26c80817aea8a0bd2c65/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.ts#L346 which means that Discover should make a request on revert changes. And It's not happening for (2) as it does not make sense for Discover to trigger refetch if only `visContext` changes so we should find another way. With (1) there is another problem that Discover `visContext` state gets hijacked by lens attributes invalidation logic (as query is not sync yet to UnifiedHistogram) before fetch is completed or get [a chance to be fired](https://github.com/elastic/kibana/blob/6038f92b1fcaeedf635a0eab68fd9cdadd1103d3/src/plugins/discover/public/application/main/hooks/utils/build_state_subscribe.ts#L51-L54). I tried delaying `externalVisContext` prop update too (to keep in sync with `query` update) but it does not help https://github.com/elastic/kibana/blob/fc2ec957fe78900967da26c80817aea8a0bd2c65/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.ts#L437 - Unified Histogram should signal to Discover to start a refetch when current suggestion changes https://github.com/elastic/kibana/blob/fc2ec957fe78900967da26c80817aea8a0bd2c65/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.ts#L289 - for some reason this logic is required for "Revert changes" to work as it triggers the refetch. I would expect Discover on its own to notice the change in query and refetch data but it does not seem to be the case.
Other challenges - [ ] Since we are starting to save lens attributes inside a saved search object (similar to how Dashboard saves lens vis by value), we should integrate Lens migrations into saved search migrations logic. I made a quick PoC how it could look like here https://github.com/jughosta/kibana/commit/4529711d0ddfd1e559be099f5c3263e099847b46 This showed that adding Lens plugin as a dependency to saved search plugin causes lots of circular deps in Kibana. To resolve that I am suggesting to spit saved search plugin into 2 plugins https://github.com/elastic/kibana/pull/174939 - not the best solution but it seems impossible to split lens plugins instead. Updates here: - [x] revert the code regarding migrations and saved search plugin split - [x] create a github issue to handle client side migrations once their API is available https://github.com/elastic/kibana/issues/179151 - [x] Discover syncs app state with URL which means that the new `visContext` (large lens attributes object) ends up in the URL too. We should exclude `visContext` from URL sync as it can make the URL too long. Updates here: we are not using appState for this any more - [x] Changes from https://github.com/elastic/kibana/pull/171081 would need to be refactored and integrated into the new `LensVisService`. - [x] Refactor after https://github.com/elastic/kibana/pull/177790 - [x] Handle a case when no chart is available for current ES|QL query - [ ] For ES|QL histogram lens attributes include a modified query `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"` which means that not only changes to the original query but also a different time range can reset the customization of lens vis as it gets a different time interval based on current time range - New update from Stratoula: - [ ] would it help to persist response of `onApplyCb` instead of lens attributes? <= the shape does not seem to be different and it works as it is so I'm keeping lens attributes - [x] use new `getLensAttributes` from https://github.com/elastic/kibana/pull/174677
10x flaky test https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5578 ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Matthias Wilhelm Co-authored-by: Stratoula Kalafateli --- .../src/get_lens_attributes.test.ts | 1 + .../src/get_lens_attributes.ts | 24 +- .../check_registered_types.test.ts | 2 +- .../layout/discover_histogram_layout.tsx | 2 +- .../layout/use_discover_histogram.ts | 120 ++- .../components/top_nav/get_top_nav_badges.tsx | 8 +- .../components/top_nav/on_save_search.tsx | 11 + .../discover_internal_state_container.ts | 23 +- .../discover_saved_search_container.ts | 23 + .../main/services/discover_state.ts | 1 + .../main/services/load_saved_search.ts | 3 + .../application/main/utils/fetch_all.test.ts | 3 + .../content_management/v1/cm_services.ts | 19 + .../common/saved_searches_utils.ts | 1 + .../common/service/get_saved_searches.test.ts | 2 + .../service/saved_searches_utils.test.ts | 2 + .../common/service/saved_searches_utils.ts | 1 + src/plugins/saved_search/common/types.ts | 17 + .../saved_search_attribute_service.test.ts | 1 + .../saved_search_storage.ts | 1 + .../server/saved_objects/schema.ts | 22 + .../server/saved_objects/search.ts | 8 + .../public/__mocks__/data_view.ts | 6 +- .../__mocks__/data_view_with_timefield.ts | 6 + .../public/__mocks__/lens_vis.ts | 97 +++ .../public/__mocks__/services.tsx | 19 +- .../public/__mocks__/suggestions.ts | 85 ++ .../public/__mocks__/table.ts | 49 ++ .../public/chart/breakdown_field_selector.tsx | 2 +- .../public/chart/chart.test.tsx | 64 +- .../unified_histogram/public/chart/chart.tsx | 170 ++-- .../public/chart/chart_config_panel.test.tsx | 22 +- .../public/chart/chart_config_panel.tsx | 42 +- .../public/chart/histogram.test.tsx | 57 +- .../public/chart/histogram.tsx | 12 +- .../chart/hooks/use_edit_visualization.ts | 4 +- .../public/chart/hooks/use_lens_props.test.ts | 99 +-- .../public/chart/hooks/use_lens_props.ts | 17 +- .../chart/hooks/use_time_range.test.tsx | 2 +- .../public/chart/hooks/use_time_range.tsx | 2 +- .../public/chart/hooks/use_total_hits.ts | 2 +- .../public/chart/suggestion_selector.tsx | 78 +- .../public/chart/utils/get_lens_attributes.ts | 233 ------ .../public/container/container.tsx | 38 +- .../container/hooks/use_state_props.test.ts | 23 +- .../public/container/hooks/use_state_props.ts | 8 +- .../container/services/state_service.test.ts | 5 +- .../container/services/state_service.ts | 18 +- .../public/container/utils/state_selectors.ts | 1 - .../hooks/use_request_params.test.ts | 2 +- .../{chart => }/hooks/use_request_params.tsx | 12 +- .../hooks/use_stable_callback.test.ts | 0 .../{chart => }/hooks/use_stable_callback.ts | 0 src/plugins/unified_histogram/public/index.ts | 4 +- .../layout/hooks/use_lens_suggestions.test.ts | 224 ------ .../layout/hooks/use_lens_suggestions.ts | 152 ---- .../public/layout/layout.test.tsx | 1 + .../public/layout/layout.tsx | 171 ++-- .../lens_vis_service.attributes.test.ts} | 206 +++-- .../lens_vis_service.suggestions.test.ts | 194 +++++ .../public/services/lens_vis_service.ts | 754 ++++++++++++++++++ src/plugins/unified_histogram/public/types.ts | 47 +- .../external_vis_context.test.ts.snap | 342 ++++++++ .../hooks => utils}/compute_interval.test.ts | 0 .../hooks => utils}/compute_interval.ts | 0 .../public/utils/external_vis_context.test.ts | 164 ++++ .../public/utils/external_vis_context.ts | 89 +++ .../utils/field_supports_breakdown.test.ts | 0 .../utils/field_supports_breakdown.ts | 0 .../public/utils/lens_vis_from_table.ts | 57 ++ src/plugins/unified_histogram/tsconfig.json | 3 +- .../apps/discover/group3/_lens_vis.ts | 675 ++++++++++++++++ test/functional/apps/discover/group3/index.ts | 1 + test/functional/page_objects/discover_page.ts | 6 + .../shared/edit_on_the_fly/flyout_wrapper.tsx | 1 + .../get_edit_lens_configuration.tsx | 1 + .../public/functions/visualize_esql.test.tsx | 1 + 77 files changed, 3452 insertions(+), 1111 deletions(-) create mode 100644 src/plugins/unified_histogram/public/__mocks__/lens_vis.ts create mode 100644 src/plugins/unified_histogram/public/__mocks__/table.ts delete mode 100644 src/plugins/unified_histogram/public/chart/utils/get_lens_attributes.ts rename src/plugins/unified_histogram/public/{chart => }/hooks/use_request_params.test.ts (95%) rename src/plugins/unified_histogram/public/{chart => }/hooks/use_request_params.tsx (85%) rename src/plugins/unified_histogram/public/{chart => }/hooks/use_stable_callback.test.ts (100%) rename src/plugins/unified_histogram/public/{chart => }/hooks/use_stable_callback.ts (100%) delete mode 100644 src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.test.ts delete mode 100644 src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.ts rename src/plugins/unified_histogram/public/{chart/utils/get_lens_attributes.test.ts => services/lens_vis_service.attributes.test.ts} (87%) create mode 100644 src/plugins/unified_histogram/public/services/lens_vis_service.suggestions.test.ts create mode 100644 src/plugins/unified_histogram/public/services/lens_vis_service.ts create mode 100644 src/plugins/unified_histogram/public/utils/__snapshots__/external_vis_context.test.ts.snap rename src/plugins/unified_histogram/public/{layout/hooks => utils}/compute_interval.test.ts (100%) rename src/plugins/unified_histogram/public/{layout/hooks => utils}/compute_interval.ts (100%) create mode 100644 src/plugins/unified_histogram/public/utils/external_vis_context.test.ts create mode 100644 src/plugins/unified_histogram/public/utils/external_vis_context.ts rename src/plugins/unified_histogram/public/{chart => }/utils/field_supports_breakdown.test.ts (100%) rename src/plugins/unified_histogram/public/{chart => }/utils/field_supports_breakdown.ts (100%) create mode 100644 src/plugins/unified_histogram/public/utils/lens_vis_from_table.ts create mode 100644 test/functional/apps/discover/group3/_lens_vis.ts 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 94e0b8e752926..8b0a22c63d005 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 38a2dd29b841e..3a62c7bee736f 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 7ec28e55fad21..9a1299b6f1fe4 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 79f4e9e74cc96..6b53ea8769017 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 b8bfed44563a5..5617b724df490 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 37daf11478bfc..30d58a58e1882 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 84c056f60ad01..f22d07b4d4d89 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 5825e4d2cef6c..4b26822bf04a5 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 e1544ffffbe4c..76ffca5443017 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 1bab2e0328af8..94a0a80c54fd9 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 d5a5be0935d8c..ac9e6f60526d1 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 d9c96e9bce0e9..13aaedeeb6e9e 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 24319df7d43ac..bc9d18b21e5b7 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 b71819e96e210..d8a1dbcd4cafa 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 be971f1469ade..ea9403fda6476 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 716d9db855a02..3972f38caa5b5 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 ce3da85a2d3bd..11a848f8baaf8 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 d58f8c1cec7fc..34ada26b0c1a4 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 81b7e68cae319..ae1e457fc7d4b 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 53fe82eb6e1e4..d3ff8633637d2 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 851a14417b400..fb0308915fe72 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 a913e513e897f..6c6a9bb81c1ed 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 ffc429c1aa887..62184359c5abd 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 3868ed2c70af5..2075b28c92226 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 0000000000000..9ac64493806fe --- /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 b7efb79941412..ddfbc9eecc405 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 9e3a00d396047..9da1fb2fc4317 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 0000000000000..9aa28fdd5ed4c --- /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 78df66f50873e..5fbae47f63109 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 474da6bce5bf7..05f4e1a2b079a 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 cfc096e8f197f..7c93e8bf5254d 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 5238fc0ac12bb..4f4eaa9faf6cc 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 314226525296e..654d4e9ab93ab 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 70d406b7f9be8..8a65426e4a9a2 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 b02732bfcbfc9..8b70e08684971 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 36b4e5c8f4e4d..de483cbdb63ec 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 29827a46dd705..8c4d1ec9b16a7 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 1e86bf5d9614e..e37e8fdf44c8f 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 791d332a3a89f..f04b18de28f61 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 dfd14df6f452b..038847db56150 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 cad20279bfdf0..82a7cc4d814c2 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 b5c9bca754ac5..0000000000000 --- 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 3756b5da94e7b..ef18a2ba992e0 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 15c1ef83a4b8c..44a216178f6d5 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 d78afc50c15f5..7afdb029fd3cc 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 40304a967243a..6249c3e423877 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 1a79389e2bc6f..dd70dc646c9fb 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 f0707cdbe747e..9c2c98b1aeae4 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 f3889d1de6a42..c49bcd4ce195b 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 c5ea702f898f0..dfa58629903ef 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 5b32836bfb258..08f79f7e2ee94 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 f74cc8a3c5925..0000000000000 --- 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 c45a8c1d701a6..0000000000000 --- 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 a10df63e7c328..dcb96b093cac7 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 aaeb67b15b101..5ceae61e13a9e 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 3c049649d5c20..780069747a64a 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 0000000000000..7993f933a8054 --- /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 0000000000000..7b1cf7cdaf55e --- /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 3ba27f7c5b26e..d19c4481f202f 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 0000000000000..fb4014f969700 --- /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 0000000000000..a786bf102065a --- /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 0000000000000..380b7dbc01094 --- /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 0000000000000..565b52767022a --- /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 fa266de08ecbf..a1c15026479cc 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 0000000000000..5747dbd85de64 --- /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 2fe5a4ebb1db1..a80ae44e49801 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 07f82309c321b..47165f90952ee 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 009d21d65eb57..f8ee1c5779693 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), }) ),