diff --git a/src/plugins/discover/public/__mocks__/start_contract.ts b/src/plugins/discover/public/__mocks__/start_contract.ts new file mode 100644 index 0000000000000..ac53eb4978c9d --- /dev/null +++ b/src/plugins/discover/public/__mocks__/start_contract.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { ApplicationStart, PublicAppInfo } from 'src/core/public'; +import { deepFreeze } from '@kbn/std'; +import { BehaviorSubject, Subject } from 'rxjs'; + +const capabilities = deepFreeze({ + catalogue: {}, + management: {}, + navLinks: {}, + discover: { + show: true, + edit: false, + }, +}); + +export const createStartContractMock = (): jest.Mocked => { + const currentAppId$ = new Subject(); + + return { + applications$: new BehaviorSubject>(new Map()), + currentAppId$: currentAppId$.asObservable(), + capabilities, + navigateToApp: jest.fn(), + navigateToUrl: jest.fn(), + getUrlForApp: jest.fn(), + }; +}; diff --git a/src/plugins/discover/public/application/embeddable/saved_search_embeddable.tsx b/src/plugins/discover/public/application/embeddable/saved_search_embeddable.tsx index 500bad34e2758..ef8670f976672 100644 --- a/src/plugins/discover/public/application/embeddable/saved_search_embeddable.tsx +++ b/src/plugins/discover/public/application/embeddable/saved_search_embeddable.tsx @@ -216,7 +216,7 @@ export class SavedSearchEmbeddable if (!this.savedSearch.sort || !this.savedSearch.sort.length) { this.savedSearch.sort = getDefaultSort( indexPattern, - getServices().uiSettings.get(SORT_DEFAULT_ORDER_SETTING, 'desc') + this.services.uiSettings.get(SORT_DEFAULT_ORDER_SETTING, 'desc') ); } @@ -226,7 +226,7 @@ export class SavedSearchEmbeddable isLoading: false, sort: getDefaultSort( indexPattern, - getServices().uiSettings.get(SORT_DEFAULT_ORDER_SETTING, 'desc') + this.services.uiSettings.get(SORT_DEFAULT_ORDER_SETTING, 'desc') ), rows: [], searchDescription: this.savedSearch.description, diff --git a/src/plugins/discover/public/application/embeddable/view_saved_search_action.test.ts b/src/plugins/discover/public/application/embeddable/view_saved_search_action.test.ts new file mode 100644 index 0000000000000..5796dacaa83d8 --- /dev/null +++ b/src/plugins/discover/public/application/embeddable/view_saved_search_action.test.ts @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ContactCardEmbeddable } from 'src/plugins/embeddable/public/lib/test_samples'; + +import { ViewSavedSearchAction } from './view_saved_search_action'; +import { SavedSearchEmbeddable } from './saved_search_embeddable'; +import { createStartContractMock } from '../../__mocks__/start_contract'; +import { savedSearchMock } from '../../__mocks__/saved_search'; +import { discoverServiceMock } from '../../__mocks__/services'; +import { IndexPattern } from 'src/plugins/data/common'; +import { createFilterManagerMock } from 'src/plugins/data/public/query/filter_manager/filter_manager.mock'; +import { ViewMode } from 'src/plugins/embeddable/public'; + +const applicationMock = createStartContractMock(); +const savedSearch = savedSearchMock; +const indexPatterns = [] as IndexPattern[]; +const services = discoverServiceMock; +const filterManager = createFilterManagerMock(); +const searchInput = { + timeRange: { + from: '2021-09-15', + to: '2021-09-16', + }, + id: '1', + viewMode: ViewMode.VIEW, +}; +const executeTriggerActions = async (triggerId: string, context: object) => { + return Promise.resolve(undefined); +}; +const trigger = { id: 'ACTION_VIEW_SAVED_SEARCH' }; +const embeddableConfig = { + savedSearch, + editUrl: '', + editPath: '', + indexPatterns, + editable: true, + filterManager, + services, +}; + +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); + const embeddable = new SavedSearchEmbeddable( + embeddableConfig, + searchInput, + executeTriggerActions + ); + expect(await action.isCompatible({ embeddable, trigger })).toBe(true); + }); + + it('is not compatible when embeddable not of type saved search', async () => { + const action = new ViewSavedSearchAction(applicationMock); + const embeddable = new ContactCardEmbeddable( + { + id: '123', + firstName: 'sue', + viewMode: ViewMode.EDIT, + }, + { + execAction: () => Promise.resolve(undefined), + } + ); + expect( + await action.isCompatible({ + embeddable, + trigger, + }) + ).toBe(false); + }); + + it('is not visible when in edit mode', async () => { + const action = new ViewSavedSearchAction(applicationMock); + const input = { ...searchInput, viewMode: ViewMode.EDIT }; + const embeddable = new SavedSearchEmbeddable(embeddableConfig, input, executeTriggerActions); + expect( + await action.isCompatible({ + embeddable, + trigger, + }) + ).toBe(false); + }); + + it('execute navigates to a saved search', async () => { + const action = new ViewSavedSearchAction(applicationMock); + const embeddable = new SavedSearchEmbeddable( + embeddableConfig, + searchInput, + executeTriggerActions + ); + await action.execute({ embeddable, trigger }); + expect(applicationMock.navigateToApp).toHaveBeenCalledWith('discover', { + path: `#/view/${savedSearch.id}`, + }); + }); +}); diff --git a/src/plugins/discover/public/application/embeddable/view_saved_search_action.ts b/src/plugins/discover/public/application/embeddable/view_saved_search_action.ts new file mode 100644 index 0000000000000..69c273f326c61 --- /dev/null +++ b/src/plugins/discover/public/application/embeddable/view_saved_search_action.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { ActionExecutionContext } from 'src/plugins/ui_actions/public'; +import { ApplicationStart } from 'kibana/public'; +import { i18n } from '@kbn/i18n'; +import { IEmbeddable, ViewMode } from '../../../../embeddable/public'; +import { Action } from '../../../../ui_actions/public'; +import { SavedSearchEmbeddable } from './saved_search_embeddable'; +import { SEARCH_EMBEDDABLE_TYPE } from '../../../common'; + +export const ACTION_VIEW_SAVED_SEARCH = 'ACTION_VIEW_SAVED_SEARCH'; + +export interface ViewSearchContext { + embeddable: IEmbeddable; +} + +export class ViewSavedSearchAction implements Action { + public id = ACTION_VIEW_SAVED_SEARCH; + public readonly type = ACTION_VIEW_SAVED_SEARCH; + + constructor(private readonly application: ApplicationStart) {} + + async execute(context: ActionExecutionContext): Promise { + const { embeddable } = context; + const savedSearchId = (embeddable as SavedSearchEmbeddable).getSavedSearch().id; + const path = `#/view/${encodeURIComponent(savedSearchId)}`; + const app = embeddable ? embeddable.getOutput().editApp : undefined; + await this.application.navigateToApp(app ? app : 'discover', { path }); + } + + getDisplayName(context: ActionExecutionContext): string { + return i18n.translate('discover.savedSearchEmbeddable.action.viewSavedSearch.displayName', { + defaultMessage: 'Open in Discover', + }); + } + + getIconType(context: ActionExecutionContext): string | undefined { + return 'inspect'; + } + + async isCompatible(context: ActionExecutionContext) { + const { embeddable } = context; + 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 + ); + } +} diff --git a/src/plugins/discover/public/plugin.tsx b/src/plugins/discover/public/plugin.tsx index 0327b25fd864e..afb83d6cbd667 100644 --- a/src/plugins/discover/public/plugin.tsx +++ b/src/plugins/discover/public/plugin.tsx @@ -60,6 +60,7 @@ import { UsageCollectionSetup } from '../../usage_collection/public'; import { replaceUrlHashQuery } from '../../kibana_utils/public/'; import { IndexPatternFieldEditorStart } from '../../../plugins/index_pattern_field_editor/public'; import { DeferredSpinner } from './shared'; +import { ViewSavedSearchAction } from './application/embeddable/view_saved_search_action'; declare module '../../share/public' { export interface UrlGeneratorStateMapping { @@ -397,6 +398,10 @@ export class DiscoverPlugin // initializeServices are assigned at start and used // when the application/embeddable is mounted + const { uiActions } = plugins; + + const viewSavedSearchAction = new ViewSavedSearchAction(core.application); + uiActions.addTriggerAction('CONTEXT_MENU_TRIGGER', viewSavedSearchAction); setUiActions(plugins.uiActions); const services = buildServices(core, plugins, this.initializerContext); diff --git a/test/functional/apps/dashboard/saved_search_embeddable.ts b/test/functional/apps/dashboard/saved_search_embeddable.ts index 5bcec338aad1e..ce1033fa02075 100644 --- a/test/functional/apps/dashboard/saved_search_embeddable.ts +++ b/test/functional/apps/dashboard/saved_search_embeddable.ts @@ -5,12 +5,13 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const dashboardAddPanel = getService('dashboardAddPanel'); + const dashboardPanelActions = getService('dashboardPanelActions'); + const testSubjects = getService('testSubjects'); const filterBar = getService('filterBar'); const find = getService('find'); const esArchiver = getService('esArchiver'); @@ -61,5 +62,33 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { .map((mark) => $(mark).text()); expect(marks.length).to.be(0); }); + + it('view action leads to a saved search', async function () { + await filterBar.removeAllFilters(); + await PageObjects.dashboard.saveDashboard('Dashboard With Saved Search'); + await PageObjects.dashboard.clickCancelOutOfEditMode(false); + const inViewMode = await PageObjects.dashboard.getIsInViewMode(); + expect(inViewMode).to.equal(true); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.waitForRenderComplete(); + + await dashboardPanelActions.openContextMenu(); + const actionExists = await testSubjects.exists( + 'embeddablePanelAction-ACTION_VIEW_SAVED_SEARCH' + ); + if (!actionExists) { + await dashboardPanelActions.clickContextMenuMoreItem(); + } + const actionElement = await testSubjects.find( + 'embeddablePanelAction-ACTION_VIEW_SAVED_SEARCH' + ); + await actionElement.click(); + + await PageObjects.discover.waitForDiscoverAppOnScreen(); + expect(await PageObjects.discover.getSavedSearchTitle()).to.equal( + 'Rendering Test: saved search' + ); + }); }); } diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index f230dae1d394a..f8af0ef8f883a 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -123,6 +123,11 @@ export class DiscoverPageObject extends FtrService { return await searchLink.isDisplayed(); } + public async getSavedSearchTitle() { + const breadcrumb = await this.find.byCssSelector('[data-test-subj="breadcrumb last"]'); + return await breadcrumb.getVisibleText(); + } + public async loadSavedSearch(searchName: string) { await this.openLoadSavedSearchPanel(); await this.testSubjects.click(`savedObjectTitle${searchName.split(' ').join('-')}`);