Skip to content

Commit

Permalink
[Embeddable] [Discover] Decouple Discover actions from Embeddable fra…
Browse files Browse the repository at this point in the history
…mework (#176953)

Part of #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>
  • Loading branch information
Heenawter and kibanamachine authored Mar 6, 2024
1 parent edb11eb commit 603b5f1
Show file tree
Hide file tree
Showing 15 changed files with 322 additions and 295 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
32 changes: 25 additions & 7 deletions src/plugins/discover/public/embeddable/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 () => {
Expand All @@ -57,7 +56,6 @@ describe('view saved search action', () => {
expect(
await action.isCompatible({
embeddable,
trigger,
})
).toBe(false);
});
Expand All @@ -69,7 +67,6 @@ describe('view saved search action', () => {
expect(
await action.isCompatible({
embeddable,
trigger,
})
).toBe(false);
});
Expand All @@ -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)
);
});
});
60 changes: 35 additions & 25 deletions src/plugins/discover/public/embeddable/view_saved_search_action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<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;

Expand All @@ -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;
}
}
3 changes: 2 additions & 1 deletion src/plugins/discover/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/**/*"]
}
Loading

0 comments on commit 603b5f1

Please sign in to comment.