From 603b5f1069c78f587306c02ae8d377a1df0bc9da Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Wed, 6 Mar 2024 11:24:42 -0700 Subject: [PATCH] [Embeddable] [Discover] Decouple Discover actions from Embeddable framework (#176953) Part of https://github.com/elastic/kibana/issues/175138 ## Summary This PR decouples all Discover-owned actions (`ViewSavedSearchAction`, `ExploreDataChartAction`, and `ExploreDataContextMenuAction`) from the Embeddable framework. > [!NOTE] > In order to test the latter two actions, you must add the following to your `kibana.dev.yml`: >```yml > xpack.discoverEnhanced.actions.exploreDataInContextMenu.enabled: true > xpack.discoverEnhanced.actions.exploreDataInChart.enabled: true > ``` ### 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 ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../get_discover_locator_params.test.ts | 17 ++- .../embeddable/get_discover_locator_params.ts | 39 +++--- .../saved_search_embeddable.test.ts | 49 ++++---- .../embeddable/saved_search_embeddable.tsx | 2 +- .../discover/public/embeddable/types.ts | 32 +++-- .../view_saved_search_action.test.ts | 12 +- .../embeddable/view_saved_search_action.ts | 60 +++++---- src/plugins/discover/tsconfig.json | 3 +- .../abstract_explore_data_action.ts | 115 ++++++++++++++---- .../explore_data_chart_action.test.ts | 83 ++++++------- .../explore_data/explore_data_chart_action.ts | 58 +++------ .../explore_data_context_menu_action.test.ts | 71 +++++------ .../explore_data_context_menu_action.ts | 47 +------ .../public/actions/explore_data/shared.ts | 25 ++-- .../plugins/discover_enhanced/tsconfig.json | 4 +- 15 files changed, 322 insertions(+), 295 deletions(-) diff --git a/src/plugins/discover/public/embeddable/get_discover_locator_params.test.ts b/src/plugins/discover/public/embeddable/get_discover_locator_params.test.ts index 54e519adddcb7..06bd01d28041a 100644 --- a/src/plugins/discover/public/embeddable/get_discover_locator_params.test.ts +++ b/src/plugins/discover/public/embeddable/get_discover_locator_params.test.ts @@ -6,21 +6,28 @@ * Side Public License, v 1. */ +import { BehaviorSubject } from 'rxjs'; import { savedSearchMock } from '../__mocks__/saved_search'; import { getDiscoverLocatorParams } from './get_discover_locator_params'; -import type { SearchInput } from './types'; describe('getDiscoverLocatorParams', () => { it('should return saved search id if input has savedObjectId', () => { - const input = { savedObjectId: 'savedObjectId' } as SearchInput; - expect(getDiscoverLocatorParams({ input, savedSearch: savedSearchMock })).toEqual({ + expect( + getDiscoverLocatorParams({ + savedObjectId: new BehaviorSubject('savedObjectId'), + getSavedSearch: () => savedSearchMock, + }) + ).toEqual({ savedSearchId: 'savedObjectId', }); }); it('should return Discover params if input has no savedObjectId', () => { - const input = {} as SearchInput; - expect(getDiscoverLocatorParams({ input, savedSearch: savedSearchMock })).toEqual({ + expect( + getDiscoverLocatorParams({ + getSavedSearch: () => savedSearchMock, + }) + ).toEqual({ dataViewId: savedSearchMock.searchSource.getField('index')?.id, dataViewSpec: savedSearchMock.searchSource.getField('index')?.toMinimalSpec(), timeRange: savedSearchMock.timeRange, diff --git a/src/plugins/discover/public/embeddable/get_discover_locator_params.ts b/src/plugins/discover/public/embeddable/get_discover_locator_params.ts index abc5d67e9435e..27c7b51d707b6 100644 --- a/src/plugins/discover/public/embeddable/get_discover_locator_params.ts +++ b/src/plugins/discover/public/embeddable/get_discover_locator_params.ts @@ -7,34 +7,31 @@ */ import type { Filter } from '@kbn/es-query'; -import type { SavedSearch } from '@kbn/saved-search-plugin/common'; -import type { SearchByReferenceInput } from '@kbn/saved-search-plugin/public'; +import { PublishesLocalUnifiedSearch, PublishesSavedObjectId } from '@kbn/presentation-publishing'; import type { DiscoverAppLocatorParams } from '../../common'; -import type { SearchInput } from './types'; +import { HasSavedSearch } from './types'; -export const getDiscoverLocatorParams = ({ - input, - savedSearch, -}: { - input: SearchInput; - savedSearch: SavedSearch; -}) => { - const dataView = savedSearch.searchSource.getField('index'); - const savedObjectId = (input as SearchByReferenceInput).savedObjectId; +export const getDiscoverLocatorParams = ( + api: HasSavedSearch & Partial +) => { + const savedSearch = api.getSavedSearch(); + + const dataView = savedSearch?.searchSource.getField('index'); + const savedObjectId = api.savedObjectId?.getValue(); const locatorParams: DiscoverAppLocatorParams = savedObjectId ? { savedSearchId: savedObjectId } : { dataViewId: dataView?.id, dataViewSpec: dataView?.toMinimalSpec(), - timeRange: savedSearch.timeRange, - refreshInterval: savedSearch.refreshInterval, - filters: savedSearch.searchSource.getField('filter') as Filter[], - query: savedSearch.searchSource.getField('query'), - columns: savedSearch.columns, - sort: savedSearch.sort, - viewMode: savedSearch.viewMode, - hideAggregatedPreview: savedSearch.hideAggregatedPreview, - breakdownField: savedSearch.breakdownField, + timeRange: savedSearch?.timeRange, + refreshInterval: savedSearch?.refreshInterval, + filters: savedSearch?.searchSource.getField('filter') as Filter[], + query: savedSearch?.searchSource.getField('query'), + columns: savedSearch?.columns, + sort: savedSearch?.sort, + viewMode: savedSearch?.viewMode, + hideAggregatedPreview: savedSearch?.hideAggregatedPreview, + breakdownField: savedSearch?.breakdownField, }; return locatorParams; diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts b/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts index 5f63d160fa50f..35e336df52325 100644 --- a/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts +++ b/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts @@ -6,26 +6,26 @@ * Side Public License, v 1. */ +import { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; +import { createSearchSourceMock } from '@kbn/data-plugin/public/mocks'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import { SHOW_FIELD_STATISTICS } from '@kbn/discover-utils'; +import { buildDataViewMock, deepMockedFields } from '@kbn/discover-utils/src/__mocks__'; +import { ViewMode } from '@kbn/embeddable-plugin/public'; +import type { SavedSearchByValueAttributes } from '@kbn/saved-search-plugin/public'; +import { ReactWrapper } from 'enzyme'; import { ReactElement } from 'react'; +import { render } from 'react-dom'; +import { act } from 'react-dom/test-utils'; +import { Observable, throwError } from 'rxjs'; import { SearchInput } from '..'; +import { VIEW_MODE } from '../../common/constants'; import { DiscoverServices } from '../build_services'; +import { dataViewAdHoc } from '../__mocks__/data_view_complex'; import { discoverServiceMock } from '../__mocks__/services'; +import { getDiscoverLocatorParams } from './get_discover_locator_params'; import { SavedSearchEmbeddable, SearchEmbeddableConfig } from './saved_search_embeddable'; -import { render } from 'react-dom'; -import { createSearchSourceMock } from '@kbn/data-plugin/public/mocks'; -import { Observable, throwError } from 'rxjs'; -import { ReactWrapper } from 'enzyme'; -import { SHOW_FIELD_STATISTICS } from '@kbn/discover-utils'; -import { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; import { SavedSearchEmbeddableComponent } from './saved_search_embeddable_component'; -import { VIEW_MODE } from '../../common/constants'; -import { buildDataViewMock, deepMockedFields } from '@kbn/discover-utils/src/__mocks__'; -import { act } from 'react-dom/test-utils'; -import { getDiscoverLocatorParams } from './get_discover_locator_params'; -import { dataViewAdHoc } from '../__mocks__/data_view_complex'; -import type { DataView } from '@kbn/data-views-plugin/common'; -import type { SavedSearchByValueAttributes } from '@kbn/saved-search-plugin/public'; -import { ViewMode } from '@kbn/embeddable-plugin/public'; jest.mock('./get_discover_locator_params', () => { const actual = jest.requireActual('./get_discover_locator_params'); @@ -418,16 +418,13 @@ describe('saved search embeddable', () => { .spyOn(servicesMock.core.http.basePath, 'remove') .mockClear() .mockReturnValueOnce('/mock-url'); - const { embeddable, searchInput, savedSearch } = createEmbeddable({ dataView, byValue }); - const getLocatorParamsArgs = { - input: searchInput, - savedSearch, - }; - const locatorParams = getDiscoverLocatorParams(getLocatorParamsArgs); + const { embeddable } = createEmbeddable({ dataView, byValue }); + + const locatorParams = getDiscoverLocatorParams(embeddable); (getDiscoverLocatorParams as jest.Mock).mockClear(); await waitOneTick(); expect(getDiscoverLocatorParams).toHaveBeenCalledTimes(1); - expect(getDiscoverLocatorParams).toHaveBeenCalledWith(getLocatorParamsArgs); + expect(getDiscoverLocatorParams).toHaveBeenCalledWith(embeddable); expect(servicesMock.locator.getUrl).toHaveBeenCalledTimes(1); expect(servicesMock.locator.getUrl).toHaveBeenCalledWith(locatorParams); expect(servicesMock.core.http.basePath.remove).toHaveBeenCalledTimes(1); @@ -459,19 +456,15 @@ describe('saved search embeddable', () => { .spyOn(servicesMock.core.http.basePath, 'remove') .mockClear() .mockReturnValueOnce('/mock-url'); - const { embeddable, searchInput, savedSearch } = createEmbeddable({ + const { embeddable } = createEmbeddable({ dataView: dataViewAdHoc, byValue: true, }); - const getLocatorParamsArgs = { - input: searchInput, - savedSearch, - }; - const locatorParams = getDiscoverLocatorParams(getLocatorParamsArgs); + const locatorParams = getDiscoverLocatorParams(embeddable); (getDiscoverLocatorParams as jest.Mock).mockClear(); await waitOneTick(); expect(getDiscoverLocatorParams).toHaveBeenCalledTimes(1); - expect(getDiscoverLocatorParams).toHaveBeenCalledWith(getLocatorParamsArgs); + expect(getDiscoverLocatorParams).toHaveBeenCalledWith(embeddable); expect(servicesMock.locator.getRedirectUrl).toHaveBeenCalledTimes(1); expect(servicesMock.locator.getRedirectUrl).toHaveBeenCalledWith(locatorParams); expect(servicesMock.core.http.basePath.remove).toHaveBeenCalledTimes(1); diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx index f3100bad98a05..65569ebed16d4 100644 --- a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx +++ b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx @@ -185,7 +185,7 @@ export class SavedSearchEmbeddable const title = this.getCurrentTitle(); const description = input.hidePanelTitles ? '' : input.description ?? savedSearch.description; const savedObjectId = (input as SearchByReferenceInput).savedObjectId; - const locatorParams = getDiscoverLocatorParams({ input, savedSearch }); + const locatorParams = getDiscoverLocatorParams(this); // We need to use a redirect URL if this is a by value saved search using // an ad hoc data view to ensure the data view spec gets encoded in the URL const useRedirect = !savedObjectId && !dataView?.isPersisted(); diff --git a/src/plugins/discover/public/embeddable/types.ts b/src/plugins/discover/public/embeddable/types.ts index c025d23006d06..bc99e29d71756 100644 --- a/src/plugins/discover/public/embeddable/types.ts +++ b/src/plugins/discover/public/embeddable/types.ts @@ -6,17 +6,20 @@ * Side Public License, v 1. */ -import type { Embeddable, EmbeddableOutput, IEmbeddable } from '@kbn/embeddable-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/public'; +import type { Embeddable, EmbeddableOutput, IEmbeddable } from '@kbn/embeddable-plugin/public'; import type { SavedSearch, SearchByReferenceInput, SearchByValueInput, } from '@kbn/saved-search-plugin/public'; + import type { Adapters } from '@kbn/embeddable-plugin/public'; -import type { DiscoverGridEmbeddableSearchProps } from './saved_search_grid'; -import type { DocTableEmbeddableSearchProps } from '../components/doc_table/doc_table_embeddable'; +import { EmbeddableApiContext } from '@kbn/presentation-publishing'; + import type { DiscoverServices } from '../build_services'; +import type { DocTableEmbeddableSearchProps } from '../components/doc_table/doc_table_embeddable'; +import type { DiscoverGridEmbeddableSearchProps } from './saved_search_grid'; export type SearchInput = SearchByValueInput | SearchByReferenceInput; @@ -25,17 +28,32 @@ export interface SearchOutput extends EmbeddableOutput { editable: boolean; } -export interface ISearchEmbeddable extends IEmbeddable { - getSavedSearch(): SavedSearch | undefined; - hasTimeRange(): boolean; -} +export type ISearchEmbeddable = IEmbeddable & + HasSavedSearch & + HasTimeRange; export interface SearchEmbeddable extends Embeddable { type: string; } +export interface HasSavedSearch { + getSavedSearch: () => SavedSearch | undefined; +} + +export const apiHasSavedSearch = ( + api: EmbeddableApiContext['embeddable'] +): api is HasSavedSearch => { + const embeddable = api as HasSavedSearch; + return Boolean(embeddable.getSavedSearch) && typeof embeddable.getSavedSearch === 'function'; +}; + +export interface HasTimeRange { + hasTimeRange(): boolean; +} + export type EmbeddableComponentSearchProps = DiscoverGridEmbeddableSearchProps & DocTableEmbeddableSearchProps; + export type SearchProps = EmbeddableComponentSearchProps & { sampleSizeState: number | undefined; description?: string; diff --git a/src/plugins/discover/public/embeddable/view_saved_search_action.test.ts b/src/plugins/discover/public/embeddable/view_saved_search_action.test.ts index 667ded0ba28e8..29c72391fbf12 100644 --- a/src/plugins/discover/public/embeddable/view_saved_search_action.test.ts +++ b/src/plugins/discover/public/embeddable/view_saved_search_action.test.ts @@ -28,7 +28,6 @@ const searchInput = { const executeTriggerActions = async (triggerId: string, context: object) => { return Promise.resolve(undefined); }; -const trigger = { id: 'ACTION_VIEW_SAVED_SEARCH' }; const embeddableConfig = { editable: true, services, @@ -39,7 +38,7 @@ describe('view saved search action', () => { it('is compatible when embeddable is of type saved search, in view mode && appropriate permissions are set', async () => { const action = new ViewSavedSearchAction(applicationMock, services.locator); const embeddable = new SavedSearchEmbeddable(embeddableConfig, searchInput); - expect(await action.isCompatible({ embeddable, trigger })).toBe(true); + expect(await action.isCompatible({ embeddable })).toBe(true); }); it('is not compatible when embeddable not of type saved search', async () => { @@ -57,7 +56,6 @@ describe('view saved search action', () => { expect( await action.isCompatible({ embeddable, - trigger, }) ).toBe(false); }); @@ -69,7 +67,6 @@ describe('view saved search action', () => { expect( await action.isCompatible({ embeddable, - trigger, }) ).toBe(false); }); @@ -78,12 +75,9 @@ describe('view saved search action', () => { const action = new ViewSavedSearchAction(applicationMock, services.locator); const embeddable = new SavedSearchEmbeddable(embeddableConfig, searchInput); await new Promise((resolve) => setTimeout(resolve, 0)); - await action.execute({ embeddable, trigger }); + await action.execute({ embeddable }); expect(discoverServiceMock.locator.navigate).toHaveBeenCalledWith( - getDiscoverLocatorParams({ - input: embeddable.getInput(), - savedSearch: embeddable.getSavedSearch()!, - }) + getDiscoverLocatorParams(embeddable) ); }); }); diff --git a/src/plugins/discover/public/embeddable/view_saved_search_action.ts b/src/plugins/discover/public/embeddable/view_saved_search_action.ts index 1e5044707ce1d..1623fadce1680 100644 --- a/src/plugins/discover/public/embeddable/view_saved_search_action.ts +++ b/src/plugins/discover/public/embeddable/view_saved_search_action.ts @@ -6,23 +6,42 @@ * Side Public License, v 1. */ -import type { ActionExecutionContext } from '@kbn/ui-actions-plugin/public'; import type { ApplicationStart } from '@kbn/core/public'; +import { SEARCH_EMBEDDABLE_TYPE } from '@kbn/discover-utils'; +import { ViewMode } from '@kbn/embeddable-plugin/public'; import { i18n } from '@kbn/i18n'; -import { type IEmbeddable, ViewMode } from '@kbn/embeddable-plugin/public'; +import { + apiCanAccessViewMode, + apiHasType, + apiIsOfType, + CanAccessViewMode, + EmbeddableApiContext, + getInheritedViewMode, + HasType, +} from '@kbn/presentation-publishing'; import type { Action } from '@kbn/ui-actions-plugin/public'; -import { SEARCH_EMBEDDABLE_TYPE } from '@kbn/discover-utils'; -import type { SavedSearchEmbeddable } from './saved_search_embeddable'; + import type { DiscoverAppLocator } from '../../common'; import { getDiscoverLocatorParams } from './get_discover_locator_params'; +import { apiHasSavedSearch, HasSavedSearch } from './types'; export const ACTION_VIEW_SAVED_SEARCH = 'ACTION_VIEW_SAVED_SEARCH'; -export interface ViewSearchContext { - embeddable: IEmbeddable; -} +type ViewSavedSearchActionApi = CanAccessViewMode & HasType & HasSavedSearch; -export class ViewSavedSearchAction implements Action { +const compatibilityCheck = ( + api: EmbeddableApiContext['embeddable'] +): api is ViewSavedSearchActionApi => { + return ( + apiCanAccessViewMode(api) && + getInheritedViewMode(api) === ViewMode.VIEW && + apiHasType(api) && + apiIsOfType(api, SEARCH_EMBEDDABLE_TYPE) && + apiHasSavedSearch(api) + ); +}; + +export class ViewSavedSearchAction implements Action { public id = ACTION_VIEW_SAVED_SEARCH; public readonly type = ACTION_VIEW_SAVED_SEARCH; @@ -31,38 +50,29 @@ export class ViewSavedSearchAction implements Action { private readonly locator: DiscoverAppLocator ) {} - async execute(context: ActionExecutionContext): Promise { - const embeddable = context.embeddable as SavedSearchEmbeddable; - const savedSearch = embeddable.getSavedSearch(); - if (!savedSearch) { + async execute({ embeddable }: EmbeddableApiContext): Promise { + if (!compatibilityCheck(embeddable)) { return; } - const locatorParams = getDiscoverLocatorParams({ - input: embeddable.getInput(), - savedSearch, - }); + + const locatorParams = getDiscoverLocatorParams(embeddable); await this.locator.navigate(locatorParams); } - getDisplayName(context: ActionExecutionContext): string { + getDisplayName(): string { return i18n.translate('discover.savedSearchEmbeddable.action.viewSavedSearch.displayName', { defaultMessage: 'Open in Discover', }); } - getIconType(context: ActionExecutionContext): string | undefined { + getIconType(): string | undefined { return 'inspect'; } - async isCompatible(context: ActionExecutionContext) { - const { embeddable } = context; + async isCompatible({ embeddable }: EmbeddableApiContext) { const { capabilities } = this.application; const hasDiscoverPermissions = (capabilities.discover.show as boolean) || (capabilities.discover.save as boolean); - return Boolean( - embeddable.type === SEARCH_EMBEDDABLE_TYPE && - embeddable.getInput().viewMode === ViewMode.VIEW && - hasDiscoverPermissions - ); + return compatibilityCheck(embeddable) && hasDiscoverPermissions; } } diff --git a/src/plugins/discover/tsconfig.json b/src/plugins/discover/tsconfig.json index 9545277dfb2aa..5c18c8f90b07d 100644 --- a/src/plugins/discover/tsconfig.json +++ b/src/plugins/discover/tsconfig.json @@ -85,7 +85,8 @@ "@kbn/managed-content-badge", "@kbn/deeplinks-analytics", "@kbn/shared-ux-markdown", - "@kbn/data-view-utils" + "@kbn/data-view-utils", + "@kbn/presentation-publishing" ], "exclude": ["target/**/*"] } diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts index be15cc0b27153..f0ed9de21d2dd 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts @@ -5,13 +5,28 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; +import { CoreStart } from '@kbn/core/public'; +import { DiscoverAppLocatorParams } from '@kbn/discover-plugin/common'; import { DiscoverStart } from '@kbn/discover-plugin/public'; -import { ViewMode, IEmbeddable } from '@kbn/embeddable-plugin/public'; +import { ViewMode } from '@kbn/embeddable-plugin/public'; +import { i18n } from '@kbn/i18n'; import { StartServicesGetter } from '@kbn/kibana-utils-plugin/public'; -import { CoreStart } from '@kbn/core/public'; -import { KibanaLocation } from '@kbn/share-plugin/public'; import { DOC_TYPE as LENS_DOC_TYPE } from '@kbn/lens-plugin/common/constants'; +import { + apiCanAccessViewMode, + apiHasParentApi, + apiHasType, + apiIsOfType, + apiPublishesDataViews, + apiPublishesPartialLocalUnifiedSearch, + CanAccessViewMode, + EmbeddableApiContext, + getInheritedViewMode, + HasType, + PublishesDataViews, +} from '@kbn/presentation-publishing'; +import { KibanaLocation } from '@kbn/share-plugin/public'; + import * as shared from './shared'; export const ACTION_EXPLORE_DATA = 'ACTION_EXPLORE_DATA'; @@ -28,54 +43,108 @@ export interface Params { start: StartServicesGetter; } -export abstract class AbstractExploreDataAction { - public readonly getIconType = (context: Context): string => 'discoverApp'; +type AbstractExploreDataActionApi = CanAccessViewMode & HasType & PublishesDataViews; + +const isApiCompatible = (api: unknown | null): api is AbstractExploreDataActionApi => + apiCanAccessViewMode(api) && apiHasType(api) && apiPublishesDataViews(api); + +const compatibilityCheck = (api: EmbeddableApiContext['embeddable']) => { + return ( + isApiCompatible(api) && + getInheritedViewMode(api) === ViewMode.VIEW && + !apiIsOfType(api, LENS_DOC_TYPE) + ); +}; - public readonly getDisplayName = (context: Context): string => +export abstract class AbstractExploreDataAction { + public readonly getIconType = (): string => 'discoverApp'; + + public readonly getDisplayName = (): string => i18n.translate('xpack.discover.FlyoutCreateDrilldownAction.displayName', { defaultMessage: 'Explore underlying data', }); constructor(protected readonly params: Params) {} - protected abstract getLocation(context: Context): Promise; + protected async getLocation( + { embeddable }: EmbeddableApiContext, + eventParams?: DiscoverAppLocatorParams + ): Promise { + const { plugins } = this.params.start(); + const { locator } = plugins.discover; + + if (!locator) { + throw new Error('Discover URL locator not available.'); + } + + const parentParams: DiscoverAppLocatorParams = {}; + if ( + apiHasParentApi(embeddable) && + apiPublishesPartialLocalUnifiedSearch(embeddable.parentApi) + ) { + parentParams.filters = embeddable.parentApi.localFilters?.getValue() ?? []; + parentParams.query = embeddable.parentApi.localQuery?.getValue(); + parentParams.timeRange = embeddable.parentApi.localTimeRange?.getValue(); + } + + const childParams: DiscoverAppLocatorParams = {}; + if (apiPublishesPartialLocalUnifiedSearch(embeddable)) { + childParams.filters = embeddable.localFilters?.getValue() ?? []; + childParams.query = embeddable.localQuery?.getValue(); + childParams.timeRange = embeddable.localTimeRange?.getValue(); + } + + const params: DiscoverAppLocatorParams = { + dataViewId: shared.getDataViews(embeddable)[0], + filters: [ + // combine filters from all possible sources + ...(parentParams.filters ?? []), + ...(childParams.filters ?? []), + ...(eventParams?.filters ?? []), + ], + query: parentParams.query ?? childParams.query, // overwrite the child query with the parent query + // prioritize event time range for chart action; otherwise, overwrite the parent time range with the child's + timeRange: eventParams?.timeRange ?? childParams.timeRange ?? parentParams.timeRange, + }; + + const location = await locator.getLocation(params); + return location; + } - public async isCompatible({ embeddable }: Context): Promise { - if (!embeddable) return false; - if (embeddable.type === LENS_DOC_TYPE) return false; + public async isCompatible({ embeddable }: EmbeddableApiContext): Promise { + if (!compatibilityCheck(embeddable)) return false; const { core, plugins } = this.params.start(); const { capabilities } = core.application; if (capabilities.discover && !capabilities.discover.show) return false; if (!plugins.discover.locator) return false; - if (!shared.hasExactlyOneIndexPattern(embeddable)) return false; - if (embeddable.getInput().viewMode !== ViewMode.VIEW) return false; - return true; + return shared.hasExactlyOneDataView(embeddable); } - public async execute(context: Context): Promise { - if (!shared.hasExactlyOneIndexPattern(context.embeddable)) return; + public async execute(api: EmbeddableApiContext): Promise { + const { embeddable } = api; + if (!this.isCompatible({ embeddable })) return; const { core } = this.params.start(); - const { app, path } = await this.getLocation(context); + const { app, path } = await this.getLocation(api); await core.application.navigateToApp(app, { path, }); } - public async getHref(context: Context): Promise { - const { embeddable } = context; + public async getHref(api: EmbeddableApiContext): Promise { + const { embeddable } = api; - if (!shared.hasExactlyOneIndexPattern(embeddable)) { - throw new Error(`Embeddable not supported for "${this.getDisplayName(context)}" action.`); + if (!this.isCompatible({ embeddable })) { + throw new Error(`Embeddable not supported for "${this.getDisplayName()}" action.`); } const { core } = this.params.start(); - const { app, path } = await this.getLocation(context); - const url = await core.application.getUrlForApp(app, { path, absolute: false }); + const { app, path } = await this.getLocation(api); + const url = core.application.getUrlForApp(app, { path, absolute: false }); return url; } diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.test.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.test.ts index 98878b2264da9..0287d1902785d 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.test.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.test.ts @@ -4,19 +4,21 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { Filter, RangeFilter } from '@kbn/es-query'; -import { ExploreDataChartAction } from './explore_data_chart_action'; -import { Params, PluginDeps } from './abstract_explore_data_action'; import { coreMock } from '@kbn/core/public/mocks'; -import { ExploreDataChartActionContext } from './explore_data_chart_action'; +import { DataView } from '@kbn/data-views-plugin/common'; +import { DiscoverAppLocator } from '@kbn/discover-plugin/common'; +import { ViewMode } from '@kbn/embeddable-plugin/public'; +import type { Filter, RangeFilter } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; +import { ViewMode as ViewModeType } from '@kbn/presentation-publishing'; +import { sharePluginMock } from '@kbn/share-plugin/public/mocks'; import { VisualizeEmbeddableContract, VISUALIZE_EMBEDDABLE_TYPE, } from '@kbn/visualizations-plugin/public'; -import { ViewMode } from '@kbn/embeddable-plugin/public'; -import { DiscoverAppLocator } from '@kbn/discover-plugin/common'; -import { sharePluginMock } from '@kbn/share-plugin/public/mocks'; +import { BehaviorSubject } from 'rxjs'; +import { Params, PluginDeps } from './abstract_explore_data_action'; +import { ExploreDataChartAction, ExploreDataChartActionContext } from './explore_data_chart_action'; const i18nTranslateSpy = i18n.translate as unknown as jest.SpyInstance; @@ -68,22 +70,17 @@ const setup = ( }; const action = new ExploreDataChartAction(params); - const input = { - viewMode: ViewMode.VIEW, - }; - - const output = { - indexPatterns: [ + const embeddable: VisualizeEmbeddableContract = { + type: VISUALIZE_EMBEDDABLE_TYPE, + dataViews: new BehaviorSubject([ { id: 'index-ptr-foo', }, - ], - }; - - const embeddable: VisualizeEmbeddableContract = { - type: VISUALIZE_EMBEDDABLE_TYPE, - getInput: () => input, - getOutput: () => output, + ]), + localFilters: new BehaviorSubject([]), + parentApi: { + viewMode: new BehaviorSubject(ViewMode.VIEW), + }, } as unknown as VisualizeEmbeddableContract; const context = { @@ -92,25 +89,25 @@ const setup = ( embeddable, } as ExploreDataChartActionContext; - return { core, plugins, locator, params, action, input, output, embeddable, context }; + return { core, plugins, locator, params, action, embeddable, context }; }; describe('"Explore underlying data" panel action', () => { test('action has Discover icon', () => { - const { action, context } = setup(); - expect(action.getIconType(context)).toBe('discoverApp'); + const { action } = setup(); + expect(action.getIconType()).toBe('discoverApp'); }); test('title is "Explore underlying data"', () => { - const { action, context } = setup(); - expect(action.getDisplayName(context)).toBe('Explore underlying data'); + const { action } = setup(); + expect(action.getDisplayName()).toBe('Explore underlying data'); }); test('translates title', () => { expect(i18nTranslateSpy).toHaveBeenCalledTimes(0); - const { action, context } = setup(); - action.getDisplayName(context); + const { action } = setup(); + action.getDisplayName(); expect(i18nTranslateSpy).toHaveBeenCalledTimes(1); expect(i18nTranslateSpy.mock.calls[0][0]).toBe( @@ -136,35 +133,35 @@ describe('"Explore underlying data" panel action', () => { expect(isCompatible).toBe(false); }); - test('returns false if embeddable has more than one index pattern', async () => { - const { action, output, context } = setup(); - output.indexPatterns = [ + test('returns false if embeddable has more than one data view', async () => { + const { action, embeddable, context } = setup(); + embeddable.dataViews = new BehaviorSubject([ { id: 'index-ptr-foo', }, { id: 'index-ptr-bar', }, - ]; + ] as any as DataView[]); const isCompatible = await action.isCompatible(context); expect(isCompatible).toBe(false); }); - test('returns false if embeddable does not have index patterns', async () => { - const { action, output, context } = setup(); + test('returns false if embeddable does not have data views', async () => { + const { action, embeddable, context } = setup(); // @ts-expect-error - delete output.indexPatterns; + embeddable.dataViews = undefined; const isCompatible = await action.isCompatible(context); expect(isCompatible).toBe(false); }); - test('returns false if embeddable index patterns are empty', async () => { - const { action, output, context } = setup(); - output.indexPatterns = []; + test('returns false if embeddable data views are empty', async () => { + const { action, embeddable, context } = setup(); + embeddable.dataViews = new BehaviorSubject([]); const isCompatible = await action.isCompatible(context); @@ -172,9 +169,10 @@ describe('"Explore underlying data" panel action', () => { }); test('returns false if dashboard is in edit mode', async () => { - const { action, input, context } = setup(); - input.viewMode = ViewMode.EDIT; - + const { action, embeddable, context } = setup(); + if (embeddable.parentApi) { + embeddable.parentApi.viewMode = new BehaviorSubject(ViewMode.EDIT); + } const isCompatible = await action.isCompatible(context); expect(isCompatible).toBe(false); @@ -189,7 +187,6 @@ describe('"Explore underlying data" panel action', () => { }; const isCompatible = await action.isCompatible(context); - expect(isCompatible).toBe(false); }); }); @@ -205,7 +202,7 @@ describe('"Explore underlying data" panel action', () => { expect(locator.getLocation).toHaveBeenCalledTimes(1); expect(locator.getLocation).toHaveBeenCalledWith({ filters: [], - indexPatternId: 'index-ptr-foo', + dataViewId: 'index-ptr-foo', timeRange: undefined, }); }); @@ -258,7 +255,7 @@ describe('"Explore underlying data" panel action', () => { }, }, ], - indexPatternId: 'index-ptr-foo', + dataViewId: 'index-ptr-foo', timeRange: { from, to, diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.ts index df1d21b3a7270..4b7575a0cef4f 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.ts @@ -5,27 +5,31 @@ * 2.0. */ +import { + apiIsOfType, + apiPublishesPartialLocalUnifiedSearch, + HasParentApi, + PublishesLocalUnifiedSearch, +} from '@kbn/presentation-publishing'; +import { KibanaLocation } from '@kbn/share-plugin/public'; import { Action } from '@kbn/ui-actions-plugin/public'; -import { DiscoverAppLocatorParams } from '@kbn/discover-plugin/common'; -import { SearchInput } from '@kbn/discover-plugin/public'; import { ApplyGlobalFilterActionContext } from '@kbn/unified-search-plugin/public'; -import { IEmbeddable } from '@kbn/embeddable-plugin/public'; -import { KibanaLocation } from '@kbn/share-plugin/public'; -import * as shared from './shared'; import { AbstractExploreDataAction } from './abstract_explore_data_action'; +export const ACTION_EXPLORE_DATA_CHART = 'ACTION_EXPLORE_DATA_CHART'; + export interface ExploreDataChartActionContext extends ApplyGlobalFilterActionContext { - embeddable?: IEmbeddable; + embeddable: Partial & + Partial>>; } -export const ACTION_EXPLORE_DATA_CHART = 'ACTION_EXPLORE_DATA_CHART'; - /** * This is "Explore underlying data" action which appears in popup context * menu when user clicks a value in visualization or brushes a time range. */ + export class ExploreDataChartAction - extends AbstractExploreDataAction + extends AbstractExploreDataAction implements Action { public readonly id = ACTION_EXPLORE_DATA_CHART; @@ -35,44 +39,22 @@ export class ExploreDataChartAction public readonly order = 200; public async isCompatible(context: ExploreDataChartActionContext): Promise { - if (context.embeddable?.type === 'map') return false; // TODO: https://github.com/elastic/kibana/issues/73043 - return super.isCompatible(context); + const { embeddable } = context; + if (apiIsOfType(embeddable, 'map')) { + return false; // TODO: https://github.com/elastic/kibana/issues/73043 + } + return apiPublishesPartialLocalUnifiedSearch(embeddable) && super.isCompatible(context); } protected readonly getLocation = async ( context: ExploreDataChartActionContext ): Promise => { - const { plugins } = this.params.start(); - const { locator } = plugins.discover; - - if (!locator) { - throw new Error('Discover URL locator not available.'); - } - - const { embeddable } = context; const { extractTimeRange } = await import('@kbn/es-query'); const { restOfFilters: filters, timeRange } = extractTimeRange( - context.filters, + context.filters ?? [], context.timeFieldName ); - const params: DiscoverAppLocatorParams = { - filters, - timeRange, - }; - - if (embeddable) { - params.indexPatternId = shared.getIndexPatterns(embeddable)[0] || undefined; - - const input = embeddable.getInput() as Readonly; - - if (input.timeRange && !params.timeRange) params.timeRange = input.timeRange; - if (input.query) params.query = input.query; - if (input.filters) params.filters = [...input.filters, ...(params.filters || [])]; - } - - const location = await locator.getLocation(params); - - return location; + return super.getLocation(context, { filters, timeRange }); }; } diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.test.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.test.ts index 0e56a7edc735f..d2b84b6ddb7bf 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.test.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.test.ts @@ -5,17 +5,20 @@ * 2.0. */ -import { ExploreDataContextMenuAction } from './explore_data_context_menu_action'; -import { Params, PluginDeps } from './abstract_explore_data_action'; import { coreMock } from '@kbn/core/public/mocks'; +import { DataView } from '@kbn/data-views-plugin/common'; +import { DiscoverAppLocator } from '@kbn/discover-plugin/common'; +import { ViewMode } from '@kbn/embeddable-plugin/public'; import { i18n } from '@kbn/i18n'; +import { ViewMode as ViewModeType } from '@kbn/presentation-publishing'; +import { sharePluginMock } from '@kbn/share-plugin/public/mocks'; import { VisualizeEmbeddableContract, VISUALIZE_EMBEDDABLE_TYPE, } from '@kbn/visualizations-plugin/public'; -import { ViewMode } from '@kbn/embeddable-plugin/public'; -import { DiscoverAppLocator } from '@kbn/discover-plugin/common'; -import { sharePluginMock } from '@kbn/share-plugin/public/mocks'; +import { BehaviorSubject } from 'rxjs'; +import { Params, PluginDeps } from './abstract_explore_data_action'; +import { ExploreDataContextMenuAction } from './explore_data_context_menu_action'; const i18nTranslateSpy = i18n.translate as unknown as jest.SpyInstance; @@ -57,47 +60,42 @@ const setup = () => { }; const action = new ExploreDataContextMenuAction(params); - const input = { - viewMode: ViewMode.VIEW, - }; - - const output = { - indexPatterns: [ + const embeddable: VisualizeEmbeddableContract = { + type: VISUALIZE_EMBEDDABLE_TYPE, + dataViews: new BehaviorSubject([ { id: 'index-ptr-foo', }, - ], - }; - - const embeddable: VisualizeEmbeddableContract = { - type: VISUALIZE_EMBEDDABLE_TYPE, - getInput: () => input, - getOutput: () => output, + ]), + parentApi: { + viewMode: new BehaviorSubject(ViewMode.VIEW), + localFilters: new BehaviorSubject([]), + }, } as unknown as VisualizeEmbeddableContract; const context = { embeddable, }; - return { core, plugins, locator, params, action, input, output, embeddable, context }; + return { core, plugins, locator, params, action, embeddable, context }; }; describe('"Explore underlying data" panel action', () => { test('action has Discover icon', () => { - const { action, context } = setup(); - expect(action.getIconType(context)).toBe('discoverApp'); + const { action } = setup(); + expect(action.getIconType()).toBe('discoverApp'); }); test('title is "Explore underlying data"', () => { - const { action, context } = setup(); - expect(action.getDisplayName(context)).toBe('Explore underlying data'); + const { action } = setup(); + expect(action.getDisplayName()).toBe('Explore underlying data'); }); test('translates title', () => { expect(i18nTranslateSpy).toHaveBeenCalledTimes(0); - const { action, context } = setup(); - action.getDisplayName(context); + const { action } = setup(); + action.getDisplayName(); expect(i18nTranslateSpy).toHaveBeenCalledTimes(1); expect(i18nTranslateSpy.mock.calls[0][0]).toBe( @@ -124,15 +122,15 @@ describe('"Explore underlying data" panel action', () => { }); test('returns false if embeddable has more than one index pattern', async () => { - const { action, output, context } = setup(); - output.indexPatterns = [ + const { action, embeddable, context } = setup(); + embeddable.dataViews = new BehaviorSubject([ { id: 'index-ptr-foo', }, { id: 'index-ptr-bar', }, - ]; + ] as any as DataView[]); const isCompatible = await action.isCompatible(context); @@ -140,9 +138,9 @@ describe('"Explore underlying data" panel action', () => { }); test('returns false if embeddable does not have index patterns', async () => { - const { action, output, context } = setup(); + const { action, embeddable, context } = setup(); // @ts-expect-error - delete output.indexPatterns; + embeddable.dataViews = undefined; const isCompatible = await action.isCompatible(context); @@ -150,8 +148,8 @@ describe('"Explore underlying data" panel action', () => { }); test('returns false if embeddable index patterns are empty', async () => { - const { action, output, context } = setup(); - output.indexPatterns = []; + const { action, embeddable, context } = setup(); + embeddable.dataViews = new BehaviorSubject([]); const isCompatible = await action.isCompatible(context); @@ -159,8 +157,10 @@ describe('"Explore underlying data" panel action', () => { }); test('returns false if dashboard is in edit mode', async () => { - const { action, input, context } = setup(); - input.viewMode = ViewMode.EDIT; + const { action, embeddable, context } = setup(); + if (embeddable.parentApi) { + embeddable.parentApi.viewMode = new BehaviorSubject(ViewMode.EDIT); + } const isCompatible = await action.isCompatible(context); @@ -191,7 +191,8 @@ describe('"Explore underlying data" panel action', () => { expect(locator.getLocation).toHaveBeenCalledTimes(1); expect(locator.getLocation).toHaveBeenCalledWith({ - indexPatternId: 'index-ptr-foo', + dataViewId: 'index-ptr-foo', + filters: [], }); }); }); diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.ts index 2329ac6c70714..0ea8534c1f88a 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.ts @@ -4,23 +4,10 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { Filter } from '@kbn/es-query'; +import { EmbeddableApiContext } from '@kbn/presentation-publishing'; import { Action } from '@kbn/ui-actions-plugin/public'; -import { EmbeddableContext, EmbeddableInput, IEmbeddable } from '@kbn/embeddable-plugin/public'; -import type { Query, TimeRange } from '@kbn/es-query'; -import { DiscoverAppLocatorParams } from '@kbn/discover-plugin/common'; -import { KibanaLocation } from '@kbn/share-plugin/public'; -import * as shared from './shared'; import { AbstractExploreDataAction } from './abstract_explore_data_action'; -interface EmbeddableQueryInput extends EmbeddableInput { - query?: Query; - filters?: Filter[]; - timeRange?: TimeRange; -} - -type EmbeddableQueryContext = EmbeddableContext>; - export const ACTION_EXPLORE_DATA = 'ACTION_EXPLORE_DATA'; /** @@ -28,40 +15,12 @@ export const ACTION_EXPLORE_DATA = 'ACTION_EXPLORE_DATA'; * menu of a dashboard panel. */ export class ExploreDataContextMenuAction - extends AbstractExploreDataAction - implements Action + extends AbstractExploreDataAction + implements Action { public readonly id = ACTION_EXPLORE_DATA; public readonly type = ACTION_EXPLORE_DATA; public readonly order = 200; - - protected readonly getLocation = async ( - context: EmbeddableQueryContext - ): Promise => { - const { plugins } = this.params.start(); - const { locator } = plugins.discover; - - if (!locator) { - throw new Error('Discover URL locator not available.'); - } - - const { embeddable } = context; - const params: DiscoverAppLocatorParams = {}; - - if (embeddable) { - params.indexPatternId = shared.getIndexPatterns(embeddable)[0] || undefined; - - const input = embeddable.getInput(); - - if (input.timeRange && !params.timeRange) params.timeRange = input.timeRange; - if (input.query) params.query = input.query; - if (input.filters) params.filters = [...input.filters, ...(params.filters || [])]; - } - - const location = await locator.getLocation(params); - - return location; - }; } diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/shared.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/shared.ts index e4adad896052c..aa96ac17e5bbe 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/shared.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/shared.ts @@ -5,21 +5,18 @@ * 2.0. */ -import { IEmbeddable } from '@kbn/embeddable-plugin/public'; +import { DataView } from '@kbn/data-views-plugin/common'; +import { apiPublishesDataViews, EmbeddableApiContext } from '@kbn/presentation-publishing'; -const isOutputWithIndexPatterns = ( - output: unknown -): output is { indexPatterns: Array<{ id: string }> } => { - if (!output || typeof output !== 'object') return false; - return Array.isArray((output as any).indexPatterns); -}; - -export const getIndexPatterns = (embeddable?: IEmbeddable): string[] => { - if (!embeddable) return []; - const output = embeddable.getOutput(); +export const getDataViews = (embeddable: EmbeddableApiContext['embeddable']): string[] => { + if (!apiPublishesDataViews(embeddable)) return []; - return isOutputWithIndexPatterns(output) ? output.indexPatterns.map(({ id }) => id) : []; + const dataViews: DataView[] = embeddable.dataViews.getValue() ?? []; + return dataViews.reduce( + (prev: string[], current: DataView) => (current.id ? [...prev, current.id] : prev), + [] + ); }; -export const hasExactlyOneIndexPattern = (embeddable?: IEmbeddable): boolean => - getIndexPatterns(embeddable).length === 1; +export const hasExactlyOneDataView = (embeddable: EmbeddableApiContext['embeddable']): boolean => + getDataViews(embeddable).length === 1; diff --git a/x-pack/plugins/discover_enhanced/tsconfig.json b/x-pack/plugins/discover_enhanced/tsconfig.json index 21c18aee7455b..ada69e95f32a1 100644 --- a/x-pack/plugins/discover_enhanced/tsconfig.json +++ b/x-pack/plugins/discover_enhanced/tsconfig.json @@ -17,8 +17,10 @@ "@kbn/ui-actions-plugin", "@kbn/i18n", "@kbn/es-query", - "@kbn/unified-search-plugin", "@kbn/config-schema", + "@kbn/presentation-publishing", + "@kbn/data-views-plugin", + "@kbn/unified-search-plugin", ], "exclude": [ "target/**/*",