[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>
This commit is contained in:
Hannah Mudge 2024-03-06 11:24:42 -07:00 committed by GitHub
parent edb11ebb36
commit 603b5f1069
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 330 additions and 303 deletions

View file

@ -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<string | undefined>('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,

View file

@ -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<PublishesSavedObjectId & PublishesLocalUnifiedSearch>
) => {
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;

View file

@ -6,26 +6,26 @@
* Side Public License, v 1.
*/
import { ReactElement } from 'react';
import { SearchInput } from '..';
import { DiscoverServices } from '../build_services';
import { discoverServiceMock } from '../__mocks__/services';
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 { createSearchSourceMock } from '@kbn/data-plugin/public/mocks';
import type { DataView } from '@kbn/data-views-plugin/common';
import type { SavedSearchByValueAttributes } from '@kbn/saved-search-plugin/public';
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 { SavedSearchEmbeddableComponent } from './saved_search_embeddable_component';
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);

View file

@ -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();

View file

@ -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<SearchInput, SearchOutput> {
getSavedSearch(): SavedSearch | undefined;
hasTimeRange(): boolean;
}
export type ISearchEmbeddable = IEmbeddable<SearchInput, SearchOutput> &
HasSavedSearch &
HasTimeRange;
export interface SearchEmbeddable extends Embeddable<SearchInput, SearchOutput> {
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;

View file

@ -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)
);
});
});

View file

@ -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 { i18n } from '@kbn/i18n';
import { type IEmbeddable, ViewMode } from '@kbn/embeddable-plugin/public';
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 { ViewMode } from '@kbn/embeddable-plugin/public';
import { i18n } from '@kbn/i18n';
import {
apiCanAccessViewMode,
apiHasType,
apiIsOfType,
CanAccessViewMode,
EmbeddableApiContext,
getInheritedViewMode,
HasType,
} from '@kbn/presentation-publishing';
import type { Action } from '@kbn/ui-actions-plugin/public';
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<ViewSearchContext> {
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<EmbeddableApiContext> {
public id = ACTION_VIEW_SAVED_SEARCH;
public readonly type = ACTION_VIEW_SAVED_SEARCH;
@ -31,38 +50,29 @@ export class ViewSavedSearchAction implements Action<ViewSearchContext> {
private readonly locator: DiscoverAppLocator
) {}
async execute(context: ActionExecutionContext<ViewSearchContext>): Promise<void> {
const embeddable = context.embeddable as SavedSearchEmbeddable;
const savedSearch = embeddable.getSavedSearch();
if (!savedSearch) {
async execute({ embeddable }: EmbeddableApiContext): Promise<void> {
if (!compatibilityCheck(embeddable)) {
return;
}
const locatorParams = getDiscoverLocatorParams({
input: embeddable.getInput(),
savedSearch,
});
const locatorParams = getDiscoverLocatorParams(embeddable);
await this.locator.navigate(locatorParams);
}
getDisplayName(context: ActionExecutionContext<ViewSearchContext>): string {
getDisplayName(): string {
return i18n.translate('discover.savedSearchEmbeddable.action.viewSavedSearch.displayName', {
defaultMessage: 'Open in Discover',
});
}
getIconType(context: ActionExecutionContext<ViewSearchContext>): string | undefined {
getIconType(): string | undefined {
return 'inspect';
}
async isCompatible(context: ActionExecutionContext<ViewSearchContext>) {
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;
}
}

View file

@ -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/**/*"]
}

View file

@ -5,13 +5,28 @@
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { DiscoverStart } from '@kbn/discover-plugin/public';
import { ViewMode, IEmbeddable } from '@kbn/embeddable-plugin/public';
import { StartServicesGetter } from '@kbn/kibana-utils-plugin/public';
import { CoreStart } from '@kbn/core/public';
import { KibanaLocation } from '@kbn/share-plugin/public';
import { DiscoverAppLocatorParams } from '@kbn/discover-plugin/common';
import { DiscoverStart } from '@kbn/discover-plugin/public';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { i18n } from '@kbn/i18n';
import { StartServicesGetter } from '@kbn/kibana-utils-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<PluginDeps, unknown, CoreDeps>;
}
export abstract class AbstractExploreDataAction<Context extends { embeddable?: IEmbeddable }> {
public readonly getIconType = (context: Context): string => 'discoverApp';
type AbstractExploreDataActionApi = CanAccessViewMode & HasType & PublishesDataViews;
public readonly getDisplayName = (context: Context): string =>
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)
);
};
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<KibanaLocation>;
protected async getLocation(
{ embeddable }: EmbeddableApiContext,
eventParams?: DiscoverAppLocatorParams
): Promise<KibanaLocation> {
const { plugins } = this.params.start();
const { locator } = plugins.discover;
public async isCompatible({ embeddable }: Context): Promise<boolean> {
if (!embeddable) return false;
if (embeddable.type === LENS_DOC_TYPE) return false;
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 }: EmbeddableApiContext): Promise<boolean> {
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<void> {
if (!shared.hasExactlyOneIndexPattern(context.embeddable)) return;
public async execute(api: EmbeddableApiContext): Promise<void> {
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<string> {
const { embeddable } = context;
public async getHref(api: EmbeddableApiContext): Promise<string> {
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;
}

View file

@ -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<undefined | DataView[]>([
{
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<undefined | DataView[]>([]);
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<ViewModeType>(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,

View file

@ -5,27 +5,31 @@
* 2.0.
*/
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 {
apiIsOfType,
apiPublishesPartialLocalUnifiedSearch,
HasParentApi,
PublishesLocalUnifiedSearch,
} from '@kbn/presentation-publishing';
import { KibanaLocation } from '@kbn/share-plugin/public';
import * as shared from './shared';
import { Action } from '@kbn/ui-actions-plugin/public';
import { ApplyGlobalFilterActionContext } from '@kbn/unified-search-plugin/public';
import { AbstractExploreDataAction } from './abstract_explore_data_action';
export interface ExploreDataChartActionContext extends ApplyGlobalFilterActionContext {
embeddable?: IEmbeddable;
}
export const ACTION_EXPLORE_DATA_CHART = 'ACTION_EXPLORE_DATA_CHART';
export interface ExploreDataChartActionContext extends ApplyGlobalFilterActionContext {
embeddable: Partial<PublishesLocalUnifiedSearch> &
Partial<HasParentApi<Partial<PublishesLocalUnifiedSearch>>>;
}
/**
* 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<ExploreDataChartActionContext>
extends AbstractExploreDataAction
implements Action<ExploreDataChartActionContext>
{
public readonly id = ACTION_EXPLORE_DATA_CHART;
@ -35,44 +39,22 @@ export class ExploreDataChartAction
public readonly order = 200;
public async isCompatible(context: ExploreDataChartActionContext): Promise<boolean> {
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<KibanaLocation> => {
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<SearchInput>;
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 });
};
}

View file

@ -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<undefined | DataView[]>([
{
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<undefined | DataView[]>([]);
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<ViewModeType>(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: [],
});
});
});

View file

@ -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<IEmbeddable<EmbeddableQueryInput>>;
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<EmbeddableQueryContext>
implements Action<EmbeddableQueryContext>
extends AbstractExploreDataAction
implements Action<EmbeddableApiContext>
{
public readonly id = ACTION_EXPLORE_DATA;
public readonly type = ACTION_EXPLORE_DATA;
public readonly order = 200;
protected readonly getLocation = async (
context: EmbeddableQueryContext
): Promise<KibanaLocation> => {
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;
};
}

View file

@ -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 getDataViews = (embeddable: EmbeddableApiContext['embeddable']): string[] => {
if (!apiPublishesDataViews(embeddable)) return [];
const dataViews: DataView[] = embeddable.dataViews.getValue() ?? [];
return dataViews.reduce(
(prev: string[], current: DataView) => (current.id ? [...prev, current.id] : prev),
[]
);
};
export const getIndexPatterns = (embeddable?: IEmbeddable): string[] => {
if (!embeddable) return [];
const output = embeddable.getOutput();
return isOutputWithIndexPatterns(output) ? output.indexPatterns.map(({ id }) => id) : [];
};
export const hasExactlyOneIndexPattern = (embeddable?: IEmbeddable): boolean =>
getIndexPatterns(embeddable).length === 1;
export const hasExactlyOneDataView = (embeddable: EmbeddableApiContext['embeddable']): boolean =>
getDataViews(embeddable).length === 1;

View file

@ -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/**/*",