diff --git a/src/plugins/dashboard/public/dashboard_actions/_dashboard_actions_strings.ts b/src/plugins/dashboard/public/dashboard_actions/_dashboard_actions_strings.ts index 5767e4f843751..fec2d60e01763 100644 --- a/src/plugins/dashboard/public/dashboard_actions/_dashboard_actions_strings.ts +++ b/src/plugins/dashboard/public/dashboard_actions/_dashboard_actions_strings.ts @@ -42,7 +42,7 @@ export const dashboardAddToLibraryActionStrings = { }), getSuccessMessage: (panelTitle: string) => i18n.translate('dashboard.panel.addToLibrary.successMessage', { - defaultMessage: `Panel {panelTitle} was added to the visualize library`, + defaultMessage: `Panel {panelTitle} was added to the library`, values: { panelTitle }, }), }; @@ -91,7 +91,7 @@ export const dashboardUnlinkFromLibraryActionStrings = { }), getSuccessMessage: (panelTitle: string) => i18n.translate('dashboard.panel.unlinkFromLibrary.successMessage', { - defaultMessage: `Panel {panelTitle} is no longer connected to the visualize library`, + defaultMessage: `Panel {panelTitle} is no longer connected to the library`, values: { panelTitle }, }), }; @@ -99,7 +99,7 @@ export const dashboardUnlinkFromLibraryActionStrings = { export const dashboardLibraryNotificationStrings = { getDisplayName: () => i18n.translate('dashboard.panel.LibraryNotification', { - defaultMessage: 'Visualize Library Notification', + defaultMessage: 'Library Notification', }), getTooltip: () => i18n.translate('dashboard.panel.libraryNotification.toolTip', { diff --git a/src/plugins/dashboard/public/dashboard_container/_dashboard_container_strings.ts b/src/plugins/dashboard/public/dashboard_container/_dashboard_container_strings.ts index 2fd068f5b5816..54e9989b4362a 100644 --- a/src/plugins/dashboard/public/dashboard_container/_dashboard_container_strings.ts +++ b/src/plugins/dashboard/public/dashboard_container/_dashboard_container_strings.ts @@ -19,7 +19,7 @@ export const emptyScreenStrings = { }), getEditModeSubtitle: () => i18n.translate('dashboard.emptyScreen.editModeSubtitle', { - defaultMessage: 'Create a visualization of your data, or add one from the Visualize Library.', + defaultMessage: 'Create a visualization of your data, or add one from the library.', }), getAddFromLibraryButtonTitle: () => i18n.translate('dashboard.emptyScreen.addFromLibrary', { diff --git a/src/plugins/dashboard/public/dashboard_container/component/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/dashboard_container/component/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap index a15ed4749288d..46d3b77578b3b 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/dashboard_container/component/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -59,7 +59,7 @@ exports[`DashboardEmptyScreen renders correctly with edit mode 1`] = ` class="euiText emotion-euiText-s-euiTextColor-subdued" > - Create a visualization of your data, or add one from the Visualize Library. + Create a visualization of your data, or add one from the library. diff --git a/src/plugins/discover/common/embeddable/index.ts b/src/plugins/discover/common/embeddable/index.ts new file mode 100644 index 0000000000000..ea4e1a78190d5 --- /dev/null +++ b/src/plugins/discover/common/embeddable/index.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export { inject, extract } from './search_inject_extract'; diff --git a/src/plugins/discover/common/embeddable/search_inject_extract.test.ts b/src/plugins/discover/common/embeddable/search_inject_extract.test.ts new file mode 100644 index 0000000000000..7ebaed4003627 --- /dev/null +++ b/src/plugins/discover/common/embeddable/search_inject_extract.test.ts @@ -0,0 +1,76 @@ +/* + * 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 { extract, inject } from './search_inject_extract'; + +describe('search inject extract', () => { + describe('inject', () => { + it('should not inject references if state does not have attributes', () => { + const state = { type: 'type', id: 'id' }; + const injectedReferences = [{ name: 'name', type: 'type', id: 'id' }]; + expect(inject(state, injectedReferences)).toEqual(state); + }); + + it('should inject references if state has references with the same name', () => { + const state = { + type: 'type', + id: 'id', + attributes: { + references: [{ name: 'name', type: 'type', id: '1' }], + }, + }; + const injectedReferences = [{ name: 'name', type: 'type', id: '2' }]; + expect(inject(state, injectedReferences)).toEqual({ + ...state, + attributes: { + ...state.attributes, + references: injectedReferences, + }, + }); + }); + + it('should clear references if state has no references with the same name', () => { + const state = { + type: 'type', + id: 'id', + attributes: { + references: [{ name: 'name', type: 'type', id: '1' }], + }, + }; + const injectedReferences = [{ name: 'other', type: 'type', id: '2' }]; + expect(inject(state, injectedReferences)).toEqual({ + ...state, + attributes: { + ...state.attributes, + references: [], + }, + }); + }); + }); + + describe('extract', () => { + it('should not extract references if state does not have attributes', () => { + const state = { type: 'type', id: 'id' }; + expect(extract(state)).toEqual({ state, references: [] }); + }); + + it('should extract references if state has references', () => { + const state = { + type: 'type', + id: 'id', + attributes: { + references: [{ name: 'name', type: 'type', id: '1' }], + }, + }; + expect(extract(state)).toEqual({ + state, + references: [{ name: 'name', type: 'type', id: '1' }], + }); + }); + }); +}); diff --git a/src/plugins/discover/common/embeddable/search_inject_extract.ts b/src/plugins/discover/common/embeddable/search_inject_extract.ts new file mode 100644 index 0000000000000..d8d15f327bb0d --- /dev/null +++ b/src/plugins/discover/common/embeddable/search_inject_extract.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SavedObjectReference } from '@kbn/core-saved-objects-server'; +import type { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; +import type { SearchByValueInput } from '@kbn/saved-search-plugin/public'; + +export const inject = ( + state: EmbeddableStateWithType, + injectedReferences: SavedObjectReference[] +): EmbeddableStateWithType => { + if (hasAttributes(state)) { + // Filter out references that are not in the state + // https://github.com/elastic/kibana/pull/119079 + const references = state.attributes.references + .map((stateRef) => + injectedReferences.find((injectedRef) => injectedRef.name === stateRef.name) + ) + .filter(Boolean); + + state = { + ...state, + attributes: { + ...state.attributes, + references, + }, + } as EmbeddableStateWithType; + } + + return state; +}; + +export const extract = ( + state: EmbeddableStateWithType +): { state: EmbeddableStateWithType; references: SavedObjectReference[] } => { + let references: SavedObjectReference[] = []; + + if (hasAttributes(state)) { + references = state.attributes.references; + } + + return { state, references }; +}; + +const hasAttributes = ( + state: EmbeddableStateWithType +): state is EmbeddableStateWithType & SearchByValueInput => 'attributes' in state; diff --git a/src/plugins/discover/public/__mocks__/services.ts b/src/plugins/discover/public/__mocks__/services.ts index 5aa6b6551bc11..5b02f1e86907d 100644 --- a/src/plugins/discover/public/__mocks__/services.ts +++ b/src/plugins/discover/public/__mocks__/services.ts @@ -221,6 +221,7 @@ export function createDiscoverServicesMock(): DiscoverServices { useUrl: jest.fn(() => ''), navigate: jest.fn(), getUrl: jest.fn(() => Promise.resolve('')), + getRedirectUrl: jest.fn(() => ''), }, contextLocator: { getRedirectUrl: jest.fn(() => '') }, singleDocLocator: { getRedirectUrl: jest.fn(() => '') }, 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 new file mode 100644 index 0000000000000..54e519adddcb7 --- /dev/null +++ b/src/plugins/discover/public/embeddable/get_discover_locator_params.test.ts @@ -0,0 +1,37 @@ +/* + * 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 { 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({ + savedSearchId: 'savedObjectId', + }); + }); + + it('should return Discover params if input has no savedObjectId', () => { + const input = {} as SearchInput; + expect(getDiscoverLocatorParams({ input, savedSearch: savedSearchMock })).toEqual({ + dataViewId: savedSearchMock.searchSource.getField('index')?.id, + dataViewSpec: savedSearchMock.searchSource.getField('index')?.toMinimalSpec(), + timeRange: savedSearchMock.timeRange, + refreshInterval: savedSearchMock.refreshInterval, + filters: savedSearchMock.searchSource.getField('filter'), + query: savedSearchMock.searchSource.getField('query'), + columns: savedSearchMock.columns, + sort: savedSearchMock.sort, + viewMode: savedSearchMock.viewMode, + hideAggregatedPreview: savedSearchMock.hideAggregatedPreview, + breakdownField: savedSearchMock.breakdownField, + }); + }); +}); diff --git a/src/plugins/discover/public/embeddable/get_discover_locator_params.ts b/src/plugins/discover/public/embeddable/get_discover_locator_params.ts new file mode 100644 index 0000000000000..abc5d67e9435e --- /dev/null +++ b/src/plugins/discover/public/embeddable/get_discover_locator_params.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Filter } from '@kbn/es-query'; +import type { SavedSearch } from '@kbn/saved-search-plugin/common'; +import type { SearchByReferenceInput } from '@kbn/saved-search-plugin/public'; +import type { DiscoverAppLocatorParams } from '../../common'; +import type { SearchInput } from './types'; + +export const getDiscoverLocatorParams = ({ + input, + savedSearch, +}: { + input: SearchInput; + savedSearch: SavedSearch; +}) => { + const dataView = savedSearch.searchSource.getField('index'); + const savedObjectId = (input as SearchByReferenceInput).savedObjectId; + 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, + }; + + 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 3e2e0e2402c4f..56a143a7598d8 100644 --- a/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts +++ b/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts @@ -7,22 +7,33 @@ */ import { ReactElement } from 'react'; -import { FilterManager } from '@kbn/data-plugin/public'; -import { createFilterManagerMock } from '@kbn/data-plugin/public/query/filter_manager/filter_manager.mock'; import { SearchInput } from '..'; -import { getSavedSearchUrl } from '@kbn/saved-search-plugin/public'; 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, of, throwError } from 'rxjs'; +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'); + return { + ...actual, + getDiscoverLocatorParams: jest.fn(actual.getDiscoverLocatorParams), + }; +}); let discoverComponent: ReactWrapper; @@ -36,69 +47,85 @@ jest.mock('react-dom', () => { }; }); -const waitOneTick = () => new Promise((resolve) => setTimeout(resolve, 0)); +const waitOneTick = () => act(() => new Promise((resolve) => setTimeout(resolve, 0))); + function getSearchResponse(nrOfHits: number) { - const hits = new Array(nrOfHits).map((idx) => ({ id: idx })); - return of({ + const hits = new Array(nrOfHits).fill(null).map((_, idx) => ({ id: idx })); + return { rawResponse: { hits: { hits, total: nrOfHits }, }, isPartial: false, isRunning: false, - }); + }; } +const createSearchFnMock = (nrOfHits: number) => { + let resolveSearch = () => {}; + const search = jest.fn(() => { + return new Observable((subscriber) => { + resolveSearch = () => { + subscriber.next(getSearchResponse(nrOfHits)); + subscriber.complete(); + }; + }); + }); + return { search, resolveSearch: () => resolveSearch() }; +}; + const dataViewMock = buildDataViewMock({ name: 'the-data-view', fields: deepMockedFields }); describe('saved search embeddable', () => { let mountpoint: HTMLDivElement; - let filterManagerMock: jest.Mocked; let servicesMock: jest.Mocked; let executeTriggerActions: jest.Mock; let showFieldStatisticsMockValue: boolean = false; let viewModeMockValue: VIEW_MODE = VIEW_MODE.DOCUMENT_LEVEL; - const createEmbeddable = (searchMock?: jest.Mock, customTitle?: string) => { - const searchSource = createSearchSourceMock({ index: dataViewMock }, undefined, searchMock); - const savedSearchMock = { + const createEmbeddable = ({ + searchMock, + customTitle, + dataView = dataViewMock, + byValue, + }: { + searchMock?: jest.Mock; + customTitle?: string; + dataView?: DataView; + byValue?: boolean; + } = {}) => { + const searchSource = createSearchSourceMock({ index: dataView }, undefined, searchMock); + const savedSearch = { id: 'mock-id', title: 'saved search', sort: [['message', 'asc']] as Array<[string, string]>, searchSource, viewMode: viewModeMockValue, }; - - const url = getSavedSearchUrl(savedSearchMock.id); - const editUrl = `/app/discover${url}`; - const indexPatterns = [dataViewMock]; + executeTriggerActions = jest.fn(); + jest + .spyOn(servicesMock.savedSearch.byValue, 'toSavedSearch') + .mockReturnValue(Promise.resolve(savedSearch)); const savedSearchEmbeddableConfig: SearchEmbeddableConfig = { - savedSearch: savedSearchMock, - editUrl, - editPath: url, editable: true, - indexPatterns, - filterManager: filterManagerMock, services: servicesMock, + executeTriggerActions, }; - const searchInput: SearchInput = { + const baseInput = { id: 'mock-embeddable-id', + viewMode: ViewMode.EDIT, timeRange: { from: 'now-15m', to: 'now' }, columns: ['message', 'extension'], rowHeight: 30, rowsPerPage: 50, }; + const searchInput: SearchInput = byValue + ? { ...baseInput, attributes: {} as SavedSearchByValueAttributes } + : { ...baseInput, savedObjectId: savedSearch.id }; if (customTitle) { searchInput.title = customTitle; } - - executeTriggerActions = jest.fn(); - - const embeddable = new SavedSearchEmbeddable( - savedSearchEmbeddableConfig, - searchInput, - executeTriggerActions - ); + const embeddable = new SavedSearchEmbeddable(savedSearchEmbeddableConfig, searchInput); // this helps to trigger reload // eslint-disable-next-line dot-notation @@ -106,12 +133,11 @@ describe('saved search embeddable', () => { (input) => (input.lastReloadRequestTime = Date.now()) ); - return { embeddable, searchInput, searchSource }; + return { embeddable, searchInput, searchSource, savedSearch }; }; beforeEach(() => { mountpoint = document.createElement('div'); - filterManagerMock = createFilterManagerMock(); showFieldStatisticsMockValue = false; viewModeMockValue = VIEW_MODE.DOCUMENT_LEVEL; @@ -134,17 +160,17 @@ describe('saved search embeddable', () => { const { embeddable } = createEmbeddable(); jest.spyOn(embeddable, 'updateOutput'); + await waitOneTick(); + expect(render).toHaveBeenCalledTimes(0); embeddable.render(mountpoint); expect(render).toHaveBeenCalledTimes(1); - await waitOneTick(); - expect(render).toHaveBeenCalledTimes(2); const searchProps = discoverComponent.find(SavedSearchEmbeddableComponent).prop('searchProps'); searchProps.onAddColumn!('bytes'); await waitOneTick(); expect(searchProps.columns).toEqual(['message', 'extension', 'bytes']); - expect(render).toHaveBeenCalledTimes(4); // twice per an update to show and then hide a loading indicator + expect(render).toHaveBeenCalledTimes(3); // twice per an update to show and then hide a loading indicator searchProps.onRemoveColumn!('bytes'); await waitOneTick(); @@ -175,10 +201,12 @@ describe('saved search embeddable', () => { it('should render saved search embeddable when successfully loading data', async () => { // mock return data - const search = jest.fn().mockReturnValue(getSearchResponse(1)); - const { embeddable } = createEmbeddable(search); + const { search, resolveSearch } = createSearchFnMock(1); + const { embeddable } = createEmbeddable({ searchMock: search }); jest.spyOn(embeddable, 'updateOutput'); + await waitOneTick(); + // check that loading state const loadingOutput = embeddable.getOutput(); expect(loadingOutput.loading).toBe(true); @@ -189,6 +217,7 @@ describe('saved search embeddable', () => { expect(render).toHaveBeenCalledTimes(1); // wait for data fetching + resolveSearch(); await waitOneTick(); expect(render).toHaveBeenCalledTimes(2); @@ -201,10 +230,12 @@ describe('saved search embeddable', () => { it('should render saved search embeddable when empty data is returned', async () => { // mock return data - const search = jest.fn().mockReturnValue(getSearchResponse(0)); - const { embeddable } = createEmbeddable(search); + const { search, resolveSearch } = createSearchFnMock(0); + const { embeddable } = createEmbeddable({ searchMock: search }); jest.spyOn(embeddable, 'updateOutput'); + await waitOneTick(); + // check that loading state const loadingOutput = embeddable.getOutput(); expect(loadingOutput.loading).toBe(true); @@ -215,6 +246,7 @@ describe('saved search embeddable', () => { expect(render).toHaveBeenCalledTimes(1); // wait for data fetching + resolveSearch(); await waitOneTick(); expect(render).toHaveBeenCalledTimes(2); @@ -229,9 +261,12 @@ describe('saved search embeddable', () => { showFieldStatisticsMockValue = true; viewModeMockValue = VIEW_MODE.AGGREGATED_LEVEL; - const { embeddable } = createEmbeddable(); + const { search, resolveSearch } = createSearchFnMock(1); + const { embeddable } = createEmbeddable({ searchMock: search }); jest.spyOn(embeddable, 'updateOutput'); + await waitOneTick(); + // check that loading state const loadingOutput = embeddable.getOutput(); expect(loadingOutput.loading).toBe(true); @@ -242,6 +277,7 @@ describe('saved search embeddable', () => { expect(render).toHaveBeenCalledTimes(1); // wait for data fetching + resolveSearch(); await waitOneTick(); expect(render).toHaveBeenCalledTimes(2); @@ -254,14 +290,14 @@ describe('saved search embeddable', () => { it('should emit error output in case of fetch error', async () => { const search = jest.fn().mockReturnValue(throwError(new Error('Fetch error'))); - const { embeddable } = createEmbeddable(search); + const { embeddable } = createEmbeddable({ searchMock: search }); jest.spyOn(embeddable, 'updateOutput'); embeddable.render(mountpoint); // wait for data fetching await waitOneTick(); - expect((embeddable.updateOutput as jest.Mock).mock.calls[1][0].error.message).toBe( + expect((embeddable.updateOutput as jest.Mock).mock.calls[2][0].error.message).toBe( 'Fetch error' ); // check that loading state @@ -273,8 +309,8 @@ describe('saved search embeddable', () => { it('should not fetch data if only a new input title is set', async () => { const search = jest.fn().mockReturnValue(getSearchResponse(1)); - const { embeddable, searchInput } = createEmbeddable(search); - + const { embeddable, searchInput } = createEmbeddable({ searchMock: search }); + await waitOneTick(); embeddable.render(mountpoint); // wait for data fetching await waitOneTick(); @@ -284,9 +320,11 @@ describe('saved search embeddable', () => { await waitOneTick(); expect(search).toHaveBeenCalledTimes(1); }); + it('should not reload when the input title doesnt change', async () => { const search = jest.fn().mockReturnValue(getSearchResponse(1)); - const { embeddable } = createEmbeddable(search, 'custom title'); + const { embeddable } = createEmbeddable({ searchMock: search, customTitle: 'custom title' }); + await waitOneTick(); embeddable.reload = jest.fn(); embeddable.render(mountpoint); // wait for data fetching @@ -300,7 +338,8 @@ describe('saved search embeddable', () => { it('should reload when a different input title is set', async () => { const search = jest.fn().mockReturnValue(getSearchResponse(1)); - const { embeddable } = createEmbeddable(search, 'custom title'); + const { embeddable } = createEmbeddable({ searchMock: search, customTitle: 'custom title' }); + await waitOneTick(); embeddable.reload = jest.fn(); embeddable.render(mountpoint); @@ -314,7 +353,8 @@ describe('saved search embeddable', () => { it('should not reload and fetch when a input title matches the saved search title', async () => { const search = jest.fn().mockReturnValue(getSearchResponse(1)); - const { embeddable } = createEmbeddable(search); + const { embeddable } = createEmbeddable({ searchMock: search }); + await waitOneTick(); embeddable.reload = jest.fn(); embeddable.render(mountpoint); await waitOneTick(); @@ -350,4 +390,79 @@ describe('saved search embeddable', () => { expect(updateOutput).toHaveBeenCalledTimes(5); expect(abortSignals[2].aborted).toBe(false); }); + + describe('edit link params', () => { + const runEditLinkTest = async (dataView?: DataView, byValue?: boolean) => { + jest + .spyOn(servicesMock.locator, 'getUrl') + .mockClear() + .mockResolvedValueOnce('/base/mock-url'); + jest + .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); + (getDiscoverLocatorParams as jest.Mock).mockClear(); + await waitOneTick(); + expect(getDiscoverLocatorParams).toHaveBeenCalledTimes(1); + expect(getDiscoverLocatorParams).toHaveBeenCalledWith(getLocatorParamsArgs); + expect(servicesMock.locator.getUrl).toHaveBeenCalledTimes(1); + expect(servicesMock.locator.getUrl).toHaveBeenCalledWith(locatorParams); + expect(servicesMock.core.http.basePath.remove).toHaveBeenCalledTimes(1); + expect(servicesMock.core.http.basePath.remove).toHaveBeenCalledWith('/base/mock-url'); + const { editApp, editPath, editUrl } = embeddable.getOutput(); + expect(editApp).toBe('discover'); + expect(editPath).toBe('/mock-url'); + expect(editUrl).toBe('/base/mock-url'); + }; + + it('should correctly output edit link params for by reference saved search', async () => { + await runEditLinkTest(); + }); + + it('should correctly output edit link params for by reference saved search with ad hoc data view', async () => { + await runEditLinkTest(dataViewAdHoc); + }); + + it('should correctly output edit link params for by value saved search', async () => { + await runEditLinkTest(undefined, true); + }); + + it('should correctly output edit link params for by value saved search with ad hoc data view', async () => { + jest + .spyOn(servicesMock.locator, 'getRedirectUrl') + .mockClear() + .mockReturnValueOnce('/base/mock-url'); + jest + .spyOn(servicesMock.core.http.basePath, 'remove') + .mockClear() + .mockReturnValueOnce('/mock-url'); + const { embeddable, searchInput, savedSearch } = createEmbeddable({ + dataView: dataViewAdHoc, + byValue: true, + }); + const getLocatorParamsArgs = { + input: searchInput, + savedSearch, + }; + const locatorParams = getDiscoverLocatorParams(getLocatorParamsArgs); + (getDiscoverLocatorParams as jest.Mock).mockClear(); + await waitOneTick(); + expect(getDiscoverLocatorParams).toHaveBeenCalledTimes(1); + expect(getDiscoverLocatorParams).toHaveBeenCalledWith(getLocatorParamsArgs); + expect(servicesMock.locator.getRedirectUrl).toHaveBeenCalledTimes(1); + expect(servicesMock.locator.getRedirectUrl).toHaveBeenCalledWith(locatorParams); + expect(servicesMock.core.http.basePath.remove).toHaveBeenCalledTimes(1); + expect(servicesMock.core.http.basePath.remove).toHaveBeenCalledWith('/base/mock-url'); + const { editApp, editPath, editUrl } = embeddable.getOutput(); + expect(editApp).toBe('r'); + expect(editPath).toBe('/mock-url'); + expect(editUrl).toBe('/base/mock-url'); + }); + }); }); diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx index 7136d8b1ab8d3..425659809f126 100644 --- a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx +++ b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx @@ -20,20 +20,29 @@ import { i18n } from '@kbn/i18n'; import { isEqual } from 'lodash'; import { I18nProvider } from '@kbn/i18n-react'; import type { KibanaExecutionContext } from '@kbn/core/public'; -import { Container, Embeddable, FilterableEmbeddable } from '@kbn/embeddable-plugin/public'; +import { + Container, + Embeddable, + FilterableEmbeddable, + ReferenceOrValueEmbeddable, +} from '@kbn/embeddable-plugin/public'; import { Adapters, RequestAdapter } from '@kbn/inspector-plugin/common'; -import type { SortOrder } from '@kbn/saved-search-plugin/public'; +import type { + SavedSearchAttributeService, + SearchByReferenceInput, + SearchByValueInput, + SortOrder, +} from '@kbn/saved-search-plugin/public'; import { APPLY_FILTER_TRIGGER, - FilterManager, generateFilters, mapAndFlattenFilters, } from '@kbn/data-plugin/public'; -import { ISearchSource } from '@kbn/data-plugin/public'; -import { DataView, DataViewField } from '@kbn/data-views-plugin/public'; -import { UiActionsStart } from '@kbn/ui-actions-plugin/public'; +import type { ISearchSource } from '@kbn/data-plugin/public'; +import type { DataView, DataViewField } from '@kbn/data-views-plugin/public'; +import type { UiActionsStart } from '@kbn/ui-actions-plugin/public'; import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; -import { SavedSearch } from '@kbn/saved-search-plugin/public'; +import type { SavedSearch } from '@kbn/saved-search-plugin/public'; import { METRIC_TYPE } from '@kbn/analytics'; import { CellActionsProvider } from '@kbn/cell-actions'; import type { DataTableRecord, EsHitRecord } from '@kbn/discover-utils/types'; @@ -47,22 +56,23 @@ import { buildDataTableRecord, } from '@kbn/discover-utils'; import { VIEW_MODE } from '../../common/constants'; +import type { ISearchEmbeddable, SearchInput, SearchOutput } from './types'; +import type { DiscoverServices } from '../build_services'; import { getSortForEmbeddable, SortPair } from '../utils/sorting'; -import { ISearchEmbeddable, SearchInput, SearchOutput } from './types'; import { SEARCH_EMBEDDABLE_TYPE, SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER_ID } from './constants'; -import { DiscoverServices } from '../build_services'; import { SavedSearchEmbeddableComponent } from './saved_search_embeddable_component'; import * as columnActions from '../components/doc_table/actions/columns'; import { handleSourceColumnState } from '../utils/state_helpers'; -import { DiscoverGridProps } from '../components/discover_grid/discover_grid'; -import { DiscoverGridSettings } from '../components/discover_grid/types'; -import { DocTableProps } from '../components/doc_table/doc_table_wrapper'; +import type { DiscoverGridProps } from '../components/discover_grid/discover_grid'; +import type { DiscoverGridSettings } from '../components/discover_grid/types'; +import type { DocTableProps } from '../components/doc_table/doc_table_wrapper'; import { updateSearchSource } from './utils/update_search_source'; import { FieldStatisticsTable } from '../application/main/components/field_stats_table'; import { isTextBasedQuery } from '../application/main/utils/is_text_based_query'; import { getValidViewMode } from '../application/main/utils/get_valid_view_mode'; import { fetchSql } from '../application/main/utils/fetch_sql'; import { ADHOC_DATA_VIEW_RENDER_EVENT } from '../constants'; +import { getDiscoverLocatorParams } from './get_discover_locator_params'; export type SearchProps = Partial & Partial & { @@ -82,73 +92,53 @@ export type SearchProps = Partial & }; export interface SearchEmbeddableConfig { - savedSearch: SavedSearch; - editUrl: string; - editPath: string; - indexPatterns?: DataView[]; editable: boolean; - filterManager: FilterManager; services: DiscoverServices; + executeTriggerActions: UiActionsStart['executeTriggerActions']; } export class SavedSearchEmbeddable extends Embeddable - implements ISearchEmbeddable, FilterableEmbeddable + implements + ISearchEmbeddable, + FilterableEmbeddable, + ReferenceOrValueEmbeddable { - private readonly savedSearch: SavedSearch; - private inspectorAdapters: Adapters; - private panelTitle: string = ''; - private filtersSearchSource!: ISearchSource; - private subscription?: Subscription; public readonly type = SEARCH_EMBEDDABLE_TYPE; - private filterManager: FilterManager; - private abortController?: AbortController; - private services: DiscoverServices; + public readonly deferEmbeddableLoad = true; + private readonly services: DiscoverServices; + private readonly executeTriggerActions: UiActionsStart['executeTriggerActions']; + private readonly attributeService: SavedSearchAttributeService; + private readonly inspectorAdapters: Adapters; + private readonly subscription?: Subscription; + + private abortController?: AbortController; + private savedSearch: SavedSearch | undefined; + private panelTitle: string = ''; + private filtersSearchSource!: ISearchSource; private prevTimeRange?: TimeRange; private prevFilters?: Filter[]; private prevQuery?: Query; private prevSort?: SortOrder[]; private prevSearchSessionId?: string; private searchProps?: SearchProps; - + private initialized?: boolean; private node?: HTMLElement; constructor( - { - savedSearch, - editUrl, - editPath, - indexPatterns, - editable, - filterManager, - services, - }: SearchEmbeddableConfig, + { editable, services, executeTriggerActions }: SearchEmbeddableConfig, initialInput: SearchInput, - private readonly executeTriggerActions: UiActionsStart['executeTriggerActions'], parent?: Container ) { - super( - initialInput, - { - defaultTitle: savedSearch.title, - defaultDescription: savedSearch.description, - editUrl, - editPath, - editApp: 'discover', - indexPatterns, - editable, - }, - parent - ); + super(initialInput, { editApp: 'discover', editable }, parent); + this.services = services; - this.filterManager = filterManager; - this.savedSearch = savedSearch; + this.executeTriggerActions = executeTriggerActions; + this.attributeService = services.savedSearch.byValue.attributeService; this.inspectorAdapters = { requests: new RequestAdapter(), }; - this.panelTitle = this.input.title ? this.input.title : savedSearch.title ?? ''; - this.initializeSearchEmbeddableProps(); this.subscription = this.getUpdated$().subscribe(() => { const titleChanged = this.output.title && this.panelTitle !== this.output.title; @@ -164,6 +154,89 @@ export class SavedSearchEmbeddable this.reload(isFetchRequired); } }); + + this.initializeSavedSearch(initialInput).then(() => { + this.initializeSearchEmbeddableProps(); + }); + } + + private async initializeSavedSearch(input: SearchInput) { + try { + const unwrapResult = await this.attributeService.unwrapAttributes(input); + + if (this.destroyed) { + return; + } + + this.savedSearch = await this.services.savedSearch.byValue.toSavedSearch( + (input as SearchByReferenceInput)?.savedObjectId, + unwrapResult + ); + + this.panelTitle = this.savedSearch.title ?? ''; + + await this.initializeOutput(); + + // deferred loading of this embeddable is complete + this.setInitializationFinished(); + + this.initialized = true; + } catch (e) { + this.onFatalError(e); + } + } + + private async initializeOutput() { + const savedSearch = this.savedSearch; + + if (!savedSearch) { + return; + } + + const dataView = savedSearch.searchSource.getField('index'); + const indexPatterns = dataView ? [dataView] : []; + const input = this.getInput(); + const title = input.hidePanelTitles ? '' : input.title ?? savedSearch.title; + const description = input.hidePanelTitles ? '' : input.description ?? savedSearch.description; + const savedObjectId = (input as SearchByReferenceInput).savedObjectId; + const locatorParams = getDiscoverLocatorParams({ input, savedSearch }); + // 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(); + const editUrl = useRedirect + ? this.services.locator.getRedirectUrl(locatorParams) + : await this.services.locator.getUrl(locatorParams); + const editPath = this.services.core.http.basePath.remove(editUrl); + const editApp = useRedirect ? 'r' : 'discover'; + + this.updateOutput({ + ...this.getOutput(), + defaultTitle: savedSearch.title, + defaultDescription: savedSearch.description, + title, + description, + editApp, + editPath, + editUrl, + indexPatterns, + }); + } + + public inputIsRefType( + input: SearchByValueInput | SearchByReferenceInput + ): input is SearchByReferenceInput { + return this.attributeService.inputIsRefType(input); + } + + public async getInputAsValueType() { + return this.attributeService.getInputAsValueType(this.getExplicitInput()); + } + + public async getInputAsRefType() { + return this.attributeService.getInputAsRefType(this.getExplicitInput(), { + showSaveModal: true, + saveModalTitle: this.getTitle(), + }); } public reportsEmbeddableLoad() { @@ -176,22 +249,25 @@ export class SavedSearchEmbeddable }; private fetch = async () => { + const savedSearch = this.savedSearch; + const searchProps = this.searchProps; + + if (!savedSearch || !searchProps) { + return; + } + const searchSessionId = this.input.searchSessionId; const useNewFieldsApi = !this.services.uiSettings.get(SEARCH_FIELDS_FROM_SOURCE, false); - if (!this.searchProps) return; - - const { searchSource } = this.savedSearch; + const currentAbortController = new AbortController(); // Abort any in-progress requests - if (this.abortController) this.abortController.abort(); - - const currentAbortController = new AbortController(); + this.abortController?.abort(); this.abortController = currentAbortController; updateSearchSource( - searchSource, - this.searchProps!.dataView, - this.searchProps!.sort, + savedSearch.searchSource, + searchProps.dataView, + searchProps.sort, useNewFieldsApi, { sampleSize: this.services.uiSettings.get(SAMPLE_SIZE_SETTING), @@ -202,7 +278,7 @@ export class SavedSearchEmbeddable // Log request to inspector this.inspectorAdapters.requests!.reset(); - this.searchProps!.isLoading = true; + searchProps.isLoading = true; const wasAlreadyRendered = this.getOutput().rendered; @@ -222,7 +298,7 @@ export class SavedSearchEmbeddable const child: KibanaExecutionContext = { type: this.type, name: 'discover', - id: this.savedSearch.id!, + id: savedSearch.id, description: this.output.title || this.output.defaultTitle || '', url: this.output.editUrl, }; @@ -233,15 +309,15 @@ export class SavedSearchEmbeddable } : child; - const query = this.savedSearch.searchSource.getField('query'); - const dataView = this.savedSearch.searchSource.getField('index')!; - const useSql = this.isTextBasedSearch(this.savedSearch); + const query = savedSearch.searchSource.getField('query'); + const dataView = savedSearch.searchSource.getField('index')!; + const useSql = this.isTextBasedSearch(savedSearch); try { // Request SQL data if (useSql && query) { const result = await fetchSql( - this.savedSearch.searchSource.getField('query')!, + savedSearch.searchSource.getField('query')!, dataView, this.services.data, this.services.expressions, @@ -249,23 +325,25 @@ export class SavedSearchEmbeddable this.input.filters, this.input.query ); + this.updateOutput({ ...this.getOutput(), loading: false, }); - this.searchProps!.rows = result.records; - this.searchProps!.totalHitCount = result.records.length; - this.searchProps!.isLoading = false; - this.searchProps!.isPlainRecord = true; - this.searchProps!.showTimeCol = false; - this.searchProps!.isSortEnabled = true; + searchProps.rows = result.records; + searchProps.totalHitCount = result.records.length; + searchProps.isLoading = false; + searchProps.isPlainRecord = true; + searchProps.showTimeCol = false; + searchProps.isSortEnabled = true; + return; } // Request document data const { rawResponse: resp } = await lastValueFrom( - searchSource.fetch$({ + savedSearch.searchSource.fetch$({ abortSignal: currentAbortController.signal, sessionId: searchSessionId, inspector: { @@ -287,13 +365,14 @@ export class SavedSearchEmbeddable loading: false, }); - this.searchProps!.rows = resp.hits.hits.map((hit) => - buildDataTableRecord(hit as EsHitRecord, this.searchProps!.dataView) + searchProps.rows = resp.hits.hits.map((hit) => + buildDataTableRecord(hit as EsHitRecord, searchProps.dataView) ); - this.searchProps!.totalHitCount = resp.hits.total as number; - this.searchProps!.isLoading = false; + searchProps.totalHitCount = resp.hits.total as number; + searchProps.isLoading = false; } catch (error) { const cancelled = !!currentAbortController?.signal.aborted; + if (!this.destroyed && !cancelled) { this.updateOutput({ ...this.getOutput(), @@ -301,7 +380,7 @@ export class SavedSearchEmbeddable error, }); - this.searchProps!.isLoading = false; + searchProps.isLoading = false; } } }; @@ -311,14 +390,17 @@ export class SavedSearchEmbeddable } private initializeSearchEmbeddableProps() { - const { searchSource } = this.savedSearch; + const savedSearch = this.savedSearch; - const dataView = searchSource.getField('index'); + if (!savedSearch) { + return; + } + + const dataView = savedSearch.searchSource.getField('index'); if (!dataView) { return; } - const sort = this.getSort(this.savedSearch.sort, dataView); if (!dataView.isPersisted()) { // one used adhoc data view @@ -326,17 +408,17 @@ export class SavedSearchEmbeddable } const props: SearchProps = { - columns: this.savedSearch.columns, - savedSearchId: this.savedSearch.id, - filters: this.savedSearch.searchSource.getField('filter') as Filter[], + columns: savedSearch.columns, + savedSearchId: savedSearch.id, + filters: savedSearch.searchSource.getField('filter') as Filter[], dataView, isLoading: false, - sort, + sort: this.getSort(savedSearch.sort, dataView), rows: [], - searchDescription: this.savedSearch.description, - description: this.savedSearch.description, + searchDescription: savedSearch.description, + description: savedSearch.description, inspectorAdapters: this.inspectorAdapters, - searchTitle: this.savedSearch.title, + searchTitle: savedSearch.title, services: this.services, onAddColumn: (columnName: string) => { if (!props.columns) { @@ -372,7 +454,7 @@ export class SavedSearchEmbeddable sampleSize: this.services.uiSettings.get(SAMPLE_SIZE_SETTING), onFilter: async (field, value, operator) => { let filters = generateFilters( - this.filterManager, + this.services.filterManager, // @ts-expect-error field, value, @@ -392,35 +474,36 @@ export class SavedSearchEmbeddable useNewFieldsApi: !this.services.uiSettings.get(SEARCH_FIELDS_FROM_SOURCE, false), showTimeCol: !this.services.uiSettings.get(DOC_HIDE_TIME_COLUMN_SETTING, false), ariaLabelledBy: 'documentsAriaLabel', - rowHeightState: this.input.rowHeight || this.savedSearch.rowHeight, + rowHeightState: this.input.rowHeight || savedSearch.rowHeight, onUpdateRowHeight: (rowHeight) => { this.updateInput({ rowHeight }); }, - rowsPerPageState: this.input.rowsPerPage || this.savedSearch.rowsPerPage, + rowsPerPageState: this.input.rowsPerPage || savedSearch.rowsPerPage, onUpdateRowsPerPage: (rowsPerPage) => { this.updateInput({ rowsPerPage }); }, cellActionsTriggerId: SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER_ID, }; - const timeRangeSearchSource = searchSource.create(); + const timeRangeSearchSource = savedSearch.searchSource.create(); + timeRangeSearchSource.setField('filter', () => { const timeRange = this.getTimeRange(); if (!this.searchProps || !timeRange) return; return this.services.timefilter.createFilter(dataView, timeRange); }); - this.filtersSearchSource = searchSource.create(); - this.filtersSearchSource.setParent(timeRangeSearchSource); + this.filtersSearchSource = savedSearch.searchSource.create(); - searchSource.setParent(this.filtersSearchSource); + this.filtersSearchSource.setParent(timeRangeSearchSource); + savedSearch.searchSource.setParent(this.filtersSearchSource); this.load(props); props.isLoading = true; - if (this.savedSearch.grid) { - props.settings = this.savedSearch.grid; + if (savedSearch.grid) { + props.settings = savedSearch.grid; } } @@ -461,28 +544,34 @@ export class SavedSearchEmbeddable searchProps: SearchProps, { forceFetch = false }: { forceFetch: boolean } = { forceFetch: false } ) { + const savedSearch = this.savedSearch; + + if (!savedSearch) { + return; + } + const isFetchRequired = this.isFetchRequired(searchProps); - // If there is column or sort data on the panel, that means the original columns or sort settings have - // been overridden in a dashboard. - searchProps.columns = handleSourceColumnState( - { columns: this.input.columns || this.savedSearch.columns }, + // If there is column or sort data on the panel, that means the original + // columns or sort settings have been overridden in a dashboard. + const columnState = handleSourceColumnState( + { columns: this.input.columns || savedSearch.columns }, this.services.core.uiSettings - ).columns; - searchProps.sort = this.getSort( - this.input.sort || this.savedSearch.sort, - searchProps?.dataView ); + searchProps.columns = columnState.columns; + searchProps.sort = this.getSort(this.input.sort || savedSearch.sort, searchProps?.dataView); searchProps.sharedItemTitle = this.panelTitle; searchProps.searchTitle = this.panelTitle; - searchProps.rowHeightState = this.input.rowHeight || this.savedSearch.rowHeight; - searchProps.rowsPerPageState = this.input.rowsPerPage || this.savedSearch.rowsPerPage; - searchProps.filters = this.savedSearch.searchSource.getField('filter') as Filter[]; - searchProps.savedSearchId = this.savedSearch.id; + searchProps.rowHeightState = this.input.rowHeight || savedSearch.rowHeight; + searchProps.rowsPerPageState = this.input.rowsPerPage || savedSearch.rowsPerPage; + searchProps.filters = savedSearch.searchSource.getField('filter') as Filter[]; + searchProps.savedSearchId = savedSearch.id; + if (forceFetch || isFetchRequired) { this.filtersSearchSource.setField('filter', this.input.filters); this.filtersSearchSource.setField('query', this.input.query); + if (this.input.query?.query || this.input.filters?.length) { this.filtersSearchSource.setField('highlightAll', true); } else { @@ -495,35 +584,34 @@ export class SavedSearchEmbeddable this.prevSearchSessionId = this.input.searchSessionId; this.prevSort = this.input.sort; this.searchProps = searchProps; + await this.fetch(); } else if (this.searchProps && this.node) { this.searchProps = searchProps; } } - /** - * - * @param {Element} domNode - */ public async render(domNode: HTMLElement) { - if (!this.searchProps) { - throw new Error('Search props not defined'); - } - super.render(domNode as HTMLElement); - this.node = domNode; + if (!this.searchProps || !this.initialized || this.destroyed) { + return; + } + + super.render(domNode); this.renderReactComponent(this.node, this.searchProps!); } private renderReactComponent(domNode: HTMLElement, searchProps: SearchProps) { - if (!searchProps) { + const savedSearch = this.savedSearch; + + if (!searchProps || !savedSearch) { return; } const viewMode = getValidViewMode({ - viewMode: this.savedSearch.viewMode, - isTextBasedQueryMode: this.isTextBasedSearch(this.savedSearch), + viewMode: savedSearch.viewMode, + isTextBasedQueryMode: this.isTextBasedSearch(savedSearch), }); if ( @@ -540,7 +628,7 @@ export class SavedSearchEmbeddable , domNode ); + this.updateOutput({ ...this.getOutput(), rendered: true, }); + return; } - const useLegacyTable = this.services.uiSettings.get(DOC_TABLE_LEGACY); - const query = this.savedSearch.searchSource.getField('query'); + const useLegacyTable = this.services.uiSettings.get(DOC_TABLE_LEGACY); + const query = savedSearch.searchSource.getField('query'); const props = { - savedSearch: this.savedSearch, + savedSearch, searchProps, useLegacyTable, query, }; + if (searchProps.services) { const { getTriggerCompatibleActions } = searchProps.services.uiActions; + ReactDOM.render( @@ -608,12 +700,16 @@ export class SavedSearchEmbeddable } public reload(forceFetch = true) { - if (this.searchProps) { + if (this.searchProps && this.initialized && !this.destroyed) { this.load(this.searchProps, forceFetch); } } public getSavedSearch(): SavedSearch { + if (!this.savedSearch) { + throw new Error('Saved search not defined'); + } + return this.savedSearch; } @@ -626,7 +722,7 @@ export class SavedSearchEmbeddable */ public async getFilters() { return mapAndFlattenFilters( - (this.savedSearch.searchSource.getFields().filter as Filter[]) ?? [] + (this.savedSearch?.searchSource.getFields().filter as Filter[]) ?? [] ); } @@ -634,19 +730,21 @@ export class SavedSearchEmbeddable * @returns Local/panel-level query for Saved Search embeddable */ public async getQuery() { - return this.savedSearch.searchSource.getFields().query; + return this.savedSearch?.searchSource.getFields().query; } public destroy() { super.destroy(); + if (this.searchProps) { delete this.searchProps; } + if (this.node) { unmountComponentAtNode(this.node); } - this.subscription?.unsubscribe(); - if (this.abortController) this.abortController.abort(); + this.subscription?.unsubscribe(); + this.abortController?.abort(); } } diff --git a/src/plugins/discover/public/embeddable/search_embeddable_factory.test.ts b/src/plugins/discover/public/embeddable/search_embeddable_factory.test.ts index 54a2dad5607a7..2494cc42ee1e5 100644 --- a/src/plugins/discover/public/embeddable/search_embeddable_factory.test.ts +++ b/src/plugins/discover/public/embeddable/search_embeddable_factory.test.ts @@ -8,9 +8,8 @@ import { discoverServiceMock } from '../__mocks__/services'; import { SearchEmbeddableFactory, type StartServices } from './search_embeddable_factory'; -import { createSearchSourceMock } from '@kbn/data-plugin/public/mocks'; -import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; import { ErrorEmbeddable } from '@kbn/embeddable-plugin/public'; +import type { SearchByValueInput } from '@kbn/saved-search-plugin/public'; jest.mock('@kbn/embeddable-plugin/public', () => { return { @@ -21,6 +20,7 @@ jest.mock('@kbn/embeddable-plugin/public', () => { const input = { id: 'mock-embeddable-id', + savedObjectId: 'mock-saved-object-id', timeRange: { from: 'now-15m', to: 'now' }, columns: ['message', 'extension'], rowHeight: 30, @@ -30,37 +30,52 @@ const input = { const ErrorEmbeddableMock = ErrorEmbeddable as unknown as jest.Mock; describe('SearchEmbeddableFactory', () => { - it('should create factory correctly', async () => { - const savedSearchMock = { - id: 'mock-id', - sort: [['message', 'asc']] as Array<[string, string]>, - searchSource: createSearchSourceMock({ index: dataViewMock }, undefined), - }; - - const mockGet = jest.fn().mockResolvedValue(savedSearchMock); - discoverServiceMock.savedSearch.get = mockGet; + it('should create factory correctly from saved object', async () => { + const mockUnwrap = jest + .spyOn(discoverServiceMock.savedSearch.byValue.attributeService, 'unwrapAttributes') + .mockClear(); const factory = new SearchEmbeddableFactory( () => Promise.resolve({ executeTriggerActions: jest.fn() } as unknown as StartServices), () => Promise.resolve(discoverServiceMock) ); + const embeddable = await factory.createFromSavedObject('saved-object-id', input); - expect(mockGet.mock.calls[0][0]).toEqual('saved-object-id'); + expect(mockUnwrap).toHaveBeenCalledTimes(1); + expect(mockUnwrap).toHaveBeenLastCalledWith(input); expect(embeddable).toBeDefined(); }); - it('should throw an error when saved search could not be found', async () => { - const mockGet = jest.fn().mockRejectedValue('Could not find saved search'); - discoverServiceMock.savedSearch.get = mockGet; + it('should create factory correctly from by value input', async () => { + const mockUnwrap = jest + .spyOn(discoverServiceMock.savedSearch.byValue.attributeService, 'unwrapAttributes') + .mockClear(); const factory = new SearchEmbeddableFactory( () => Promise.resolve({ executeTriggerActions: jest.fn() } as unknown as StartServices), () => Promise.resolve(discoverServiceMock) ); + const { savedObjectId, ...byValueInput } = input; + const embeddable = await factory.create(byValueInput as SearchByValueInput); + + expect(mockUnwrap).toHaveBeenCalledTimes(1); + expect(mockUnwrap).toHaveBeenLastCalledWith(byValueInput); + expect(embeddable).toBeDefined(); + }); + + it('should show error embeddable when create throws an error', async () => { + const error = new Error('Failed to create embeddable'); + const factory = new SearchEmbeddableFactory( + () => { + throw error; + }, + () => Promise.resolve(discoverServiceMock) + ); + await factory.createFromSavedObject('saved-object-id', input); - expect(ErrorEmbeddableMock.mock.calls[0][0]).toEqual('Could not find saved search'); + expect(ErrorEmbeddableMock.mock.calls[0][0]).toEqual(error); }); }); diff --git a/src/plugins/discover/public/embeddable/search_embeddable_factory.ts b/src/plugins/discover/public/embeddable/search_embeddable_factory.ts index 695a1e830115f..9afe34648b30e 100644 --- a/src/plugins/discover/public/embeddable/search_embeddable_factory.ts +++ b/src/plugins/discover/public/embeddable/search_embeddable_factory.ts @@ -7,20 +7,18 @@ */ import { i18n } from '@kbn/i18n'; -import { UiActionsStart } from '@kbn/ui-actions-plugin/public'; +import type { UiActionsStart } from '@kbn/ui-actions-plugin/public'; import { EmbeddableFactoryDefinition, Container, ErrorEmbeddable, } from '@kbn/embeddable-plugin/public'; - -import type { TimeRange } from '@kbn/es-query'; - -import { getSavedSearchUrl } from '@kbn/saved-search-plugin/public'; -import { SearchInput, SearchOutput } from './types'; +import type { SearchByReferenceInput } from '@kbn/saved-search-plugin/public'; +import type { SearchInput, SearchOutput } from './types'; import { SEARCH_EMBEDDABLE_TYPE } from './constants'; -import { SavedSearchEmbeddable } from './saved_search_embeddable'; -import { DiscoverServices } from '../build_services'; +import type { SavedSearchEmbeddable } from './saved_search_embeddable'; +import type { DiscoverServices } from '../build_services'; +import { inject, extract } from '../../common/embeddable'; export interface StartServices { executeTriggerActions: UiActionsStart['executeTriggerActions']; @@ -38,6 +36,8 @@ export class SearchEmbeddableFactory type: 'search', getIconForSavedObject: () => 'discoverApp', }; + public readonly inject = inject; + public readonly extract = extract; constructor( private getStartServices: () => Promise, @@ -60,42 +60,36 @@ export class SearchEmbeddableFactory public createFromSavedObject = async ( savedObjectId: string, - input: Partial & { id: string; timeRange: TimeRange }, + input: SearchByReferenceInput, parent?: Container ): Promise => { - const services = await this.getDiscoverServices(); - const filterManager = services.filterManager; - const url = getSavedSearchUrl(savedObjectId); - const editUrl = services.addBasePath(`/app/discover${url}`); - try { - const savedSearch = await services.savedSearch.get(savedObjectId); + if (!input.savedObjectId) { + input.savedObjectId = savedObjectId; + } + + return this.create(input, parent); + }; - const dataView = savedSearch.searchSource.getField('index'); + public async create(input: SearchInput, parent?: Container) { + try { + const services = await this.getDiscoverServices(); const { executeTriggerActions } = await this.getStartServices(); const { SavedSearchEmbeddable: SavedSearchEmbeddableClass } = await import( './saved_search_embeddable' ); + return new SavedSearchEmbeddableClass( { - savedSearch, - editUrl, - editPath: url, - filterManager, - editable: services.capabilities.discover.save as boolean, - indexPatterns: dataView ? [dataView] : [], + editable: Boolean(services.capabilities.discover.save), services, + executeTriggerActions, }, input, - executeTriggerActions, parent ); } catch (e) { console.error(e); // eslint-disable-line no-console return new ErrorEmbeddable(e, input, parent); } - }; - - public async create(input: SearchInput) { - return new ErrorEmbeddable('Saved searches can only be created from a saved object', input); } } diff --git a/src/plugins/discover/public/embeddable/types.ts b/src/plugins/discover/public/embeddable/types.ts index 4dd049c8de9a9..1459ff8d1c880 100644 --- a/src/plugins/discover/public/embeddable/types.ts +++ b/src/plugins/discover/public/embeddable/types.ts @@ -6,31 +6,17 @@ * Side Public License, v 1. */ -import { - Embeddable, - EmbeddableInput, - EmbeddableOutput, - IEmbeddable, -} from '@kbn/embeddable-plugin/public'; -import type { Filter, TimeRange, Query } from '@kbn/es-query'; -import { DataView } from '@kbn/data-views-plugin/public'; -import { SavedSearch } from '@kbn/saved-search-plugin/public'; -import type { SortOrder } from '@kbn/saved-search-plugin/public'; +import type { Embeddable, EmbeddableOutput, IEmbeddable } from '@kbn/embeddable-plugin/public'; +import type { DataView } from '@kbn/data-views-plugin/public'; +import type { + SavedSearch, + SearchByReferenceInput, + SearchByValueInput, +} from '@kbn/saved-search-plugin/public'; -export interface SearchInput extends EmbeddableInput { - timeRange: TimeRange; - timeslice?: [number, number]; - query?: Query; - filters?: Filter[]; - hidePanelTitles?: boolean; - columns?: string[]; - sort?: SortOrder[]; - rowHeight?: number; - rowsPerPage?: number; -} +export type SearchInput = SearchByValueInput | SearchByReferenceInput; export interface SearchOutput extends EmbeddableOutput { - editUrl: string; indexPatterns?: DataView[]; editable: boolean; } 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 1e8181a9ce4b4..0f9b1698c54e9 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 @@ -7,27 +7,22 @@ */ import { ContactCardEmbeddable } from '@kbn/embeddable-plugin/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 { DataView } from '@kbn/data-views-plugin/public'; -import { createFilterManagerMock } from '@kbn/data-plugin/public/query/filter_manager/filter_manager.mock'; import { ViewMode } from '@kbn/embeddable-plugin/public'; +import { getDiscoverLocatorParams } from './get_discover_locator_params'; const applicationMock = createStartContractMock(); -const savedSearch = savedSearchMock; -const dataViews = [] as DataView[]; const services = discoverServiceMock; -const filterManager = createFilterManagerMock(); const searchInput = { timeRange: { from: '2021-09-15', to: '2021-09-16', }, id: '1', + savedObjectId: 'mock-saved-object-id', viewMode: ViewMode.VIEW, }; const executeTriggerActions = async (triggerId: string, context: object) => { @@ -35,28 +30,20 @@ const executeTriggerActions = async (triggerId: string, context: object) => { }; const trigger = { id: 'ACTION_VIEW_SAVED_SEARCH' }; const embeddableConfig = { - savedSearch, - editUrl: '', - editPath: '', - dataViews, editable: true, - filterManager, services, + executeTriggerActions, }; 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 - ); + const action = new ViewSavedSearchAction(applicationMock, services.locator); + const embeddable = new SavedSearchEmbeddable(embeddableConfig, searchInput); 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 action = new ViewSavedSearchAction(applicationMock, services.locator); const embeddable = new ContactCardEmbeddable( { id: '123', @@ -76,9 +63,9 @@ describe('view saved search action', () => { }); it('is not visible when in edit mode', async () => { - const action = new ViewSavedSearchAction(applicationMock); + const action = new ViewSavedSearchAction(applicationMock, services.locator); const input = { ...searchInput, viewMode: ViewMode.EDIT }; - const embeddable = new SavedSearchEmbeddable(embeddableConfig, input, executeTriggerActions); + const embeddable = new SavedSearchEmbeddable(embeddableConfig, input); expect( await action.isCompatible({ embeddable, @@ -88,15 +75,15 @@ describe('view saved search action', () => { }); it('execute navigates to a saved search', async () => { - const action = new ViewSavedSearchAction(applicationMock); - const embeddable = new SavedSearchEmbeddable( - embeddableConfig, - searchInput, - executeTriggerActions - ); + 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 }); - expect(applicationMock.navigateToApp).toHaveBeenCalledWith('discover', { - path: `#/view/${savedSearch.id}`, - }); + expect(discoverServiceMock.locator.navigate).toHaveBeenCalledWith( + getDiscoverLocatorParams({ + input: embeddable.getInput(), + savedSearch: embeddable.getSavedSearch(), + }) + ); }); }); 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 dde5889aa1fdb..75cf0971c1481 100644 --- a/src/plugins/discover/public/embeddable/view_saved_search_action.ts +++ b/src/plugins/discover/public/embeddable/view_saved_search_action.ts @@ -5,14 +5,16 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { ActionExecutionContext } from '@kbn/ui-actions-plugin/public'; -import { ApplicationStart } from '@kbn/core/public'; + +import type { ActionExecutionContext } from '@kbn/ui-actions-plugin/public'; +import type { ApplicationStart } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; -import { IEmbeddable, ViewMode } from '@kbn/embeddable-plugin/public'; -import { Action } from '@kbn/ui-actions-plugin/public'; -import { getSavedSearchUrl } from '@kbn/saved-search-plugin/public'; +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 { SavedSearchEmbeddable } from './saved_search_embeddable'; +import type { SavedSearchEmbeddable } from './saved_search_embeddable'; +import type { DiscoverAppLocator } from '../../common'; +import { getDiscoverLocatorParams } from './get_discover_locator_params'; export const ACTION_VIEW_SAVED_SEARCH = 'ACTION_VIEW_SAVED_SEARCH'; @@ -24,14 +26,18 @@ export class ViewSavedSearchAction implements Action { public id = ACTION_VIEW_SAVED_SEARCH; public readonly type = ACTION_VIEW_SAVED_SEARCH; - constructor(private readonly application: ApplicationStart) {} + constructor( + private readonly application: ApplicationStart, + private readonly locator: DiscoverAppLocator + ) {} async execute(context: ActionExecutionContext): Promise { - const { embeddable } = context; - const savedSearchId = (embeddable as SavedSearchEmbeddable).getSavedSearch().id; - const path = getSavedSearchUrl(savedSearchId); - const app = embeddable ? embeddable.getOutput().editApp : undefined; - await this.application.navigateToApp(app ? app : 'discover', { path }); + const embeddable = context.embeddable as SavedSearchEmbeddable; + const locatorParams = getDiscoverLocatorParams({ + input: embeddable.getInput(), + savedSearch: embeddable.getSavedSearch(), + }); + await this.locator.navigate(locatorParams); } getDisplayName(context: ActionExecutionContext): string { diff --git a/src/plugins/discover/public/plugin.tsx b/src/plugins/discover/public/plugin.tsx index 6226484778fa2..7d795263a072c 100644 --- a/src/plugins/discover/public/plugin.tsx +++ b/src/plugins/discover/public/plugin.tsx @@ -422,7 +422,7 @@ export class DiscoverPlugin // initializeServices are assigned at start and used // when the application/embeddable is mounted - const viewSavedSearchAction = new ViewSavedSearchAction(core.application); + const viewSavedSearchAction = new ViewSavedSearchAction(core.application, this.locator!); plugins.uiActions.addTriggerAction('CONTEXT_MENU_TRIGGER', viewSavedSearchAction); plugins.uiActions.registerTrigger(SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER); diff --git a/src/plugins/discover/server/embeddable/index.ts b/src/plugins/discover/server/embeddable/index.ts new file mode 100644 index 0000000000000..13091f9b65275 --- /dev/null +++ b/src/plugins/discover/server/embeddable/index.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export { createSearchEmbeddableFactory } from './search_embeddable_factory'; diff --git a/src/plugins/discover/server/embeddable/search_embeddable_factory.ts b/src/plugins/discover/server/embeddable/search_embeddable_factory.ts new file mode 100644 index 0000000000000..602227f9f93c6 --- /dev/null +++ b/src/plugins/discover/server/embeddable/search_embeddable_factory.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { EmbeddableRegistryDefinition } from '@kbn/embeddable-plugin/server'; +import { SEARCH_EMBEDDABLE_TYPE } from '@kbn/discover-utils'; +import { inject, extract } from '../../common/embeddable'; + +export const createSearchEmbeddableFactory = (): EmbeddableRegistryDefinition => ({ + id: SEARCH_EMBEDDABLE_TYPE, + inject, + extract, +}); diff --git a/src/plugins/discover/server/plugin.ts b/src/plugins/discover/server/plugin.ts index 666ab85ad2105..b12833edf67a1 100644 --- a/src/plugins/discover/server/plugin.ts +++ b/src/plugins/discover/server/plugin.ts @@ -8,12 +8,14 @@ import type { CoreSetup, CoreStart, Plugin } from '@kbn/core/server'; import type { PluginSetup as DataPluginSetup } from '@kbn/data-plugin/server'; +import type { EmbeddableSetup } from '@kbn/embeddable-plugin/server'; import type { HomeServerPluginSetup } from '@kbn/home-plugin/server'; import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/common'; import type { SharePluginSetup } from '@kbn/share-plugin/server'; import type { DiscoverServerPluginStart, DiscoverServerPluginStartDeps } from '.'; import { DiscoverAppLocatorDefinition } from '../common/locator'; import { capabilitiesProvider } from './capabilities_provider'; +import { createSearchEmbeddableFactory } from './embeddable'; import { initializeLocatorServices } from './locator'; import { registerSampleData } from './sample_data'; import { getUiSettings } from './ui_settings'; @@ -25,6 +27,7 @@ export class DiscoverServerPlugin core: CoreSetup, plugins: { data: DataPluginSetup; + embeddable: EmbeddableSetup; home?: HomeServerPluginSetup; share?: SharePluginSetup; } @@ -42,6 +45,8 @@ export class DiscoverServerPlugin ); } + plugins.embeddable.registerEmbeddableFactory(createSearchEmbeddableFactory()); + return {}; } diff --git a/src/plugins/discover/tsconfig.json b/src/plugins/discover/tsconfig.json index 51377e4246122..ab6d47d6a86d1 100644 --- a/src/plugins/discover/tsconfig.json +++ b/src/plugins/discover/tsconfig.json @@ -63,6 +63,7 @@ "@kbn/cell-actions", "@kbn/shared-ux-utility", "@kbn/core-application-browser", + "@kbn/core-saved-objects-server", "@kbn/discover-utils" ], "exclude": [ diff --git a/src/plugins/saved_search/common/index.ts b/src/plugins/saved_search/common/index.ts index 8915ab582b3e2..4669ecd3bd4b9 100644 --- a/src/plugins/saved_search/common/index.ts +++ b/src/plugins/saved_search/common/index.ts @@ -21,6 +21,5 @@ export enum VIEW_MODE { AGGREGATED_LEVEL = 'aggregated', } -export { SavedSearchType } from './constants'; -export { LATEST_VERSION } from './constants'; +export { SavedSearchType, LATEST_VERSION } from './constants'; export { getKibanaContextFn } from './expressions/kibana_context'; diff --git a/src/plugins/saved_search/common/saved_searches_utils.ts b/src/plugins/saved_search/common/saved_searches_utils.ts index 41934b86a36d5..324baca435232 100644 --- a/src/plugins/saved_search/common/saved_searches_utils.ts +++ b/src/plugins/saved_search/common/saved_searches_utils.ts @@ -9,7 +9,7 @@ import { SavedSearch, SavedSearchAttributes } from '.'; export const fromSavedSearchAttributes = ( - id: string, + id: string | undefined, attributes: SavedSearchAttributes, tags: string[] | undefined, searchSource: SavedSearch['searchSource'] diff --git a/src/plugins/saved_search/common/service/get_saved_searches.ts b/src/plugins/saved_search/common/service/get_saved_searches.ts index 63a41a52b5391..653403c9f0b47 100644 --- a/src/plugins/saved_search/common/service/get_saved_searches.ts +++ b/src/plugins/saved_search/common/service/get_saved_searches.ts @@ -11,9 +11,9 @@ import { injectReferences, parseSearchSourceJSON } from '@kbn/data-plugin/common // these won't exist in on server import type { SpacesApi } from '@kbn/spaces-plugin/public'; import type { SavedObjectsTaggingApi } from '@kbn/saved-objects-tagging-oss-plugin/public'; - import { i18n } from '@kbn/i18n'; -import type { SavedSearch } from '../types'; +import type { Reference } from '@kbn/content-management-utils'; +import type { SavedSearch, SavedSearchAttributes } from '../types'; import { SavedSearchType as SAVED_SEARCH_TYPE } from '..'; import { fromSavedSearchAttributes } from './saved_searches_utils'; import type { SavedSearchCrudTypes } from '../content_management'; @@ -31,9 +31,9 @@ const getSavedSearchUrlConflictMessage = async (json: string) => values: { json }, }); -export const getSavedSearch = async ( +export const getSearchSavedObject = async ( savedSearchId: string, - { searchSourceCreate, spaces, savedObjectsTagging, getSavedSrch }: GetSavedSearchDependencies + { spaces, getSavedSrch }: GetSavedSearchDependencies ) => { const so = await getSavedSrch(savedSearchId); @@ -55,34 +55,64 @@ export const getSavedSearch = async ( ); } - const savedSearch = so.item; + return so; +}; +export const convertToSavedSearch = async ( + { + savedSearchId, + attributes, + references, + sharingSavedObjectProps, + }: { + savedSearchId: string | undefined; + attributes: SavedSearchAttributes; + references: Reference[]; + sharingSavedObjectProps: SavedSearch['sharingSavedObjectProps']; + }, + { searchSourceCreate, savedObjectsTagging }: GetSavedSearchDependencies +) => { const parsedSearchSourceJSON = parseSearchSourceJSON( - savedSearch.attributes.kibanaSavedObjectMeta?.searchSourceJSON ?? '{}' + attributes.kibanaSavedObjectMeta?.searchSourceJSON ?? '{}' ); const searchSourceValues = injectReferences( parsedSearchSourceJSON as Parameters[0], - savedSearch.references + references ); // front end only const tags = savedObjectsTagging - ? savedObjectsTagging.ui.getTagIdsFromReferences(savedSearch.references) + ? savedObjectsTagging.ui.getTagIdsFromReferences(references) : undefined; const returnVal = fromSavedSearchAttributes( savedSearchId, - savedSearch.attributes, + attributes, tags, - savedSearch.references, + references, await searchSourceCreate(searchSourceValues), - so.meta + sharingSavedObjectProps ); return returnVal; }; +export const getSavedSearch = async (savedSearchId: string, deps: GetSavedSearchDependencies) => { + const so = await getSearchSavedObject(savedSearchId, deps); + const savedSearch = await convertToSavedSearch( + { + savedSearchId, + attributes: so.item.attributes, + references: so.item.references, + sharingSavedObjectProps: so.meta, + }, + deps + ); + + return savedSearch; +}; + /** * Returns a new saved search * Used when e.g. Discover is opened without a saved search id diff --git a/src/plugins/saved_search/common/service/saved_searches_utils.ts b/src/plugins/saved_search/common/service/saved_searches_utils.ts index c4e663e5f6352..ef99a0b87ad5c 100644 --- a/src/plugins/saved_search/common/service/saved_searches_utils.ts +++ b/src/plugins/saved_search/common/service/saved_searches_utils.ts @@ -14,7 +14,7 @@ import { fromSavedSearchAttributes as fromSavedSearchAttributesCommon } from '.. export { getSavedSearchUrl, getSavedSearchFullPathUrl } from '..'; export const fromSavedSearchAttributes = ( - id: string, + id: string | undefined, attributes: SavedSearchAttributes, tags: string[] | undefined, references: SavedObjectReference[] | undefined, diff --git a/src/plugins/saved_search/kibana.jsonc b/src/plugins/saved_search/kibana.jsonc index 03df75a7d7924..da389103a5f78 100644 --- a/src/plugins/saved_search/kibana.jsonc +++ b/src/plugins/saved_search/kibana.jsonc @@ -7,19 +7,9 @@ "id": "savedSearch", "server": true, "browser": true, - "requiredPlugins": [ - "data", - "contentManagement", - "expressions" - ], - "optionalPlugins": [ - "spaces", - "savedObjectsTaggingOss" - ], - "requiredBundles": [ - ], - "extraPublicDirs": [ - "common" - ] + "requiredPlugins": ["data", "contentManagement", "embeddable", "expressions"], + "optionalPlugins": ["spaces", "savedObjectsTaggingOss"], + "requiredBundles": [], + "extraPublicDirs": ["common"] } } diff --git a/src/plugins/saved_search/public/index.ts b/src/plugins/saved_search/public/index.ts index e5161bb040d7b..eb7342633894c 100644 --- a/src/plugins/saved_search/public/index.ts +++ b/src/plugins/saved_search/public/index.ts @@ -6,13 +6,21 @@ * Side Public License, v 1. */ -export type { SortOrder } from '../common/types'; -export type { SavedSearch, SaveSavedSearchOptions } from './services/saved_searches'; +import { SavedSearchPublicPlugin } from './plugin'; +export type { SortOrder } from '../common/types'; +export type { + SavedSearch, + SaveSavedSearchOptions, + SearchByReferenceInput, + SearchByValueInput, + SavedSearchByValueAttributes, + SavedSearchAttributeService, + SavedSearchUnwrapMetaInfo, + SavedSearchUnwrapResult, +} from './services/saved_searches'; export { getSavedSearchFullPathUrl, getSavedSearchUrl } from './services/saved_searches'; - export { VIEW_MODE } from '../common'; -import { SavedSearchPublicPlugin } from './plugin'; export type { SavedSearchPublicPluginStart } from './plugin'; export function plugin() { diff --git a/src/plugins/saved_search/public/mocks.ts b/src/plugins/saved_search/public/mocks.ts index 60019c7f68ca1..3e0e20bd6e7a7 100644 --- a/src/plugins/saved_search/public/mocks.ts +++ b/src/plugins/saved_search/public/mocks.ts @@ -10,6 +10,8 @@ import { of } from 'rxjs'; import { SearchSource, IKibanaSearchResponse } from '@kbn/data-plugin/public'; import { SearchSourceDependencies } from '@kbn/data-plugin/common/search'; import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; +import type { SavedSearchPublicPluginStart } from './plugin'; +import type { SavedSearchAttributeService } from './services/saved_searches'; const createEmptySearchSource = jest.fn(() => { const deps = { @@ -29,7 +31,7 @@ const createEmptySearchSource = jest.fn(() => { return searchSource; }); -const savedSearchStartMock = () => ({ +const savedSearchStartMock = (): SavedSearchPublicPluginStart => ({ get: jest.fn().mockImplementation(() => ({ id: 'savedSearch', title: 'savedSearchTitle', @@ -40,7 +42,24 @@ const savedSearchStartMock = () => ({ searchSource: createEmptySearchSource(), })), save: jest.fn(), - find: jest.fn(), + byValue: { + attributeService: { + getInputAsRefType: jest.fn(), + getInputAsValueType: jest.fn(), + inputIsRefType: jest.fn(), + unwrapAttributes: jest.fn(() => ({ + attributes: { id: 'savedSearch', title: 'savedSearchTitle' }, + })), + wrapAttributes: jest.fn(), + } as unknown as SavedSearchAttributeService, + toSavedSearch: jest.fn((id, result) => + Promise.resolve({ + id, + title: result.attributes.title, + searchSource: createEmptySearchSource(), + }) + ), + }, }); export const savedSearchPluginMock = { diff --git a/src/plugins/saved_search/public/plugin.ts b/src/plugins/saved_search/public/plugin.ts index 2d8d53c821ae5..8e8dd697cc55c 100644 --- a/src/plugins/saved_search/public/plugin.ts +++ b/src/plugins/saved_search/public/plugin.ts @@ -17,17 +17,24 @@ import type { ContentManagementPublicStart, } from '@kbn/content-management-plugin/public'; import type { SOWithMetadata } from '@kbn/content-management-utils'; +import type { EmbeddableStart } from '@kbn/embeddable-plugin/public'; import { getSavedSearch, saveSavedSearch, SaveSavedSearchOptions, getNewSavedSearch, + SavedSearchUnwrapResult, } from './services/saved_searches'; import { SavedSearch, SavedSearchAttributes } from '../common/types'; import { SavedSearchType, LATEST_VERSION } from '../common'; import { SavedSearchesService } from './services/saved_searches/saved_searches_service'; import { kibanaContext } from '../common/expressions'; import { getKibanaContext } from './expressions/kibana_context'; +import { + type SavedSearchAttributeService, + getSavedSearchAttributeService, + toSavedSearch, +} from './services/saved_searches'; /** * Saved search plugin public Setup contract @@ -46,6 +53,13 @@ export interface SavedSearchPublicPluginStart { savedSearch: SavedSearch, options?: SaveSavedSearchOptions ) => ReturnType; + byValue: { + attributeService: SavedSearchAttributeService; + toSavedSearch: ( + id: string | undefined, + result: SavedSearchUnwrapResult + ) => Promise; + }; } /** @@ -64,6 +78,7 @@ export interface SavedSearchPublicStartDependencies { spaces?: SpacesApi; savedObjectsTaggingOss?: SavedObjectTaggingOssPluginStart; contentManagement: ContentManagementPublicStart; + embeddable: EmbeddableStart; } export class SavedSearchPublicPlugin @@ -104,14 +119,31 @@ export class SavedSearchPublicPlugin } public start( - core: CoreStart, + _: CoreStart, { data: { search }, spaces, savedObjectsTaggingOss, contentManagement: { client: contentManagement }, + embeddable, }: SavedSearchPublicStartDependencies ): SavedSearchPublicPluginStart { - return new SavedSearchesService({ search, spaces, savedObjectsTaggingOss, contentManagement }); + const deps = { search, spaces, savedObjectsTaggingOss, contentManagement, embeddable }; + const service = new SavedSearchesService(deps); + + return { + get: (savedSearchId: string) => service.get(savedSearchId), + getAll: () => service.getAll(), + getNew: () => service.getNew(), + save: (savedSearch: SavedSearch, options?: SaveSavedSearchOptions) => { + return service.save(savedSearch, options); + }, + byValue: { + attributeService: getSavedSearchAttributeService(deps), + toSavedSearch: async (id: string | undefined, result: SavedSearchUnwrapResult) => { + return toSavedSearch(id, result, deps); + }, + }, + }; } } diff --git a/src/plugins/saved_search/public/services/saved_searches/check_for_duplicate_title.ts b/src/plugins/saved_search/public/services/saved_searches/check_for_duplicate_title.ts new file mode 100644 index 0000000000000..49264e24e25ae --- /dev/null +++ b/src/plugins/saved_search/public/services/saved_searches/check_for_duplicate_title.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; +import type { SavedSearchCrudTypes } from '../../../common/content_management'; +import { SAVED_SEARCH_TYPE } from './constants'; + +const hasDuplicatedTitle = async ( + title: string, + contentManagement: ContentManagementPublicStart['client'] +): Promise => { + if (!title) { + return; + } + + const response = await contentManagement.search< + SavedSearchCrudTypes['SearchIn'], + SavedSearchCrudTypes['SearchOut'] + >({ + contentTypeId: SAVED_SEARCH_TYPE, + query: { + text: `"${title}"`, + }, + options: { + searchFields: ['title'], + fields: ['title'], + }, + }); + + return response.hits.some((obj) => obj.attributes.title.toLowerCase() === title.toLowerCase()); +}; + +export const checkForDuplicateTitle = async ({ + title, + isTitleDuplicateConfirmed, + onTitleDuplicate, + contentManagement, +}: { + title: string | undefined; + isTitleDuplicateConfirmed: boolean | undefined; + onTitleDuplicate: (() => void) | undefined; + contentManagement: ContentManagementPublicStart['client']; +}) => { + if ( + title && + !isTitleDuplicateConfirmed && + onTitleDuplicate && + (await hasDuplicatedTitle(title, contentManagement)) + ) { + onTitleDuplicate(); + return Promise.reject(new Error(`Saved search title already exists: ${title}`)); + } + + return true; +}; diff --git a/src/plugins/saved_search/public/services/saved_searches/create_get_saved_search_deps.ts b/src/plugins/saved_search/public/services/saved_searches/create_get_saved_search_deps.ts new file mode 100644 index 0000000000000..bfacd8a13b7d6 --- /dev/null +++ b/src/plugins/saved_search/public/services/saved_searches/create_get_saved_search_deps.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { type SavedSearchCrudTypes, SavedSearchType } from '../../../common/content_management'; +import type { GetSavedSearchDependencies } from '../../../common/service/get_saved_searches'; +import type { SavedSearchesServiceDeps } from './saved_searches_service'; + +export const createGetSavedSearchDeps = ({ + spaces, + savedObjectsTaggingOss, + search, + contentManagement, +}: SavedSearchesServiceDeps): GetSavedSearchDependencies => ({ + spaces, + savedObjectsTagging: savedObjectsTaggingOss?.getTaggingApi(), + searchSourceCreate: search.searchSource.create, + getSavedSrch: (id: string) => { + return contentManagement.get({ + contentTypeId: SavedSearchType, + id, + }); + }, +}); diff --git a/src/plugins/saved_search/public/services/saved_searches/index.ts b/src/plugins/saved_search/public/services/saved_searches/index.ts index 456e777292778..add8464cd8d8b 100644 --- a/src/plugins/saved_search/public/services/saved_searches/index.ts +++ b/src/plugins/saved_search/public/services/saved_searches/index.ts @@ -14,4 +14,16 @@ export { export type { SaveSavedSearchOptions } from './save_saved_searches'; export { saveSavedSearch } from './save_saved_searches'; export { SAVED_SEARCH_TYPE } from './constants'; -export type { SavedSearch } from './types'; +export type { + SavedSearch, + SearchByReferenceInput, + SearchByValueInput, + SavedSearchByValueAttributes, +} from './types'; +export { + getSavedSearchAttributeService, + toSavedSearch, + type SavedSearchAttributeService, + type SavedSearchUnwrapMetaInfo, + type SavedSearchUnwrapResult, +} from './saved_search_attribute_service'; diff --git a/src/plugins/saved_search/public/services/saved_searches/save_saved_searches.ts b/src/plugins/saved_search/public/services/saved_searches/save_saved_searches.ts index 4792680285bfc..6594dd3696053 100644 --- a/src/plugins/saved_search/public/services/saved_searches/save_saved_searches.ts +++ b/src/plugins/saved_search/public/services/saved_searches/save_saved_searches.ts @@ -8,10 +8,13 @@ import type { SavedObjectsTaggingApi } from '@kbn/saved-objects-tagging-oss-plugin/public'; import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; +import type { Reference } from '@kbn/content-management-utils'; +import type { SavedSearchAttributes } from '../../../common'; import type { SavedSearch } from './types'; import { SAVED_SEARCH_TYPE } from './constants'; import { toSavedSearchAttributes } from '../../../common/service/saved_searches_utils'; import type { SavedSearchCrudTypes } from '../../../common/content_management'; +import { checkForDuplicateTitle } from './check_for_duplicate_title'; export interface SaveSavedSearchOptions { onTitleDuplicate?: () => void; @@ -19,29 +22,36 @@ export interface SaveSavedSearchOptions { copyOnSave?: boolean; } -const hasDuplicatedTitle = async ( - title: string, +export const saveSearchSavedObject = async ( + id: string | undefined, + attributes: SavedSearchAttributes, + references: Reference[] | undefined, contentManagement: ContentManagementPublicStart['client'] -): Promise => { - if (!title) { - return; - } - - const response = await contentManagement.search< - SavedSearchCrudTypes['SearchIn'], - SavedSearchCrudTypes['SearchOut'] - >({ - contentTypeId: SAVED_SEARCH_TYPE, - query: { - text: `"${title}"`, - }, - options: { - searchFields: ['title'], - fields: ['title'], - }, - }); +) => { + const resp = id + ? await contentManagement.update< + SavedSearchCrudTypes['UpdateIn'], + SavedSearchCrudTypes['UpdateOut'] + >({ + contentTypeId: SAVED_SEARCH_TYPE, + id, + data: attributes, + options: { + references, + }, + }) + : await contentManagement.create< + SavedSearchCrudTypes['CreateIn'], + SavedSearchCrudTypes['CreateOut'] + >({ + contentTypeId: SAVED_SEARCH_TYPE, + data: attributes, + options: { + references, + }, + }); - return response.hits.some((obj) => obj.attributes.title.toLowerCase() === title.toLowerCase()); + return resp.item.id; }; /** @internal **/ @@ -53,14 +63,15 @@ export const saveSavedSearch = async ( ): Promise => { const isNew = options.copyOnSave || !savedSearch.id; - if (savedSearch.title) { - if ( - isNew && - !options.isTitleDuplicateConfirmed && - options.onTitleDuplicate && - (await hasDuplicatedTitle(savedSearch.title, contentManagement)) - ) { - options.onTitleDuplicate(); + if (isNew) { + try { + await checkForDuplicateTitle({ + title: savedSearch.title, + isTitleDuplicateConfirmed: options.isTitleDuplicateConfirmed, + onTitleDuplicate: options.onTitleDuplicate, + contentManagement, + }); + } catch { return; } } @@ -69,28 +80,11 @@ export const saveSavedSearch = async ( const references = savedObjectsTagging ? savedObjectsTagging.ui.updateTagsReferences(originalReferences, savedSearch.tags ?? []) : originalReferences; - const resp = isNew - ? await contentManagement.create< - SavedSearchCrudTypes['CreateIn'], - SavedSearchCrudTypes['CreateOut'] - >({ - contentTypeId: SAVED_SEARCH_TYPE, - data: toSavedSearchAttributes(savedSearch, searchSourceJSON), - options: { - references, - }, - }) - : await contentManagement.update< - SavedSearchCrudTypes['UpdateIn'], - SavedSearchCrudTypes['UpdateOut'] - >({ - contentTypeId: SAVED_SEARCH_TYPE, - id: savedSearch.id!, - data: toSavedSearchAttributes(savedSearch, searchSourceJSON), - options: { - references, - }, - }); - return resp.item.id; + return saveSearchSavedObject( + isNew ? undefined : savedSearch.id, + toSavedSearchAttributes(savedSearch, searchSourceJSON), + references, + contentManagement + ); }; diff --git a/src/plugins/saved_search/public/services/saved_searches/saved_search_attribute_service.test.ts b/src/plugins/saved_search/public/services/saved_searches/saved_search_attribute_service.test.ts new file mode 100644 index 0000000000000..cc6a6ec79ffea --- /dev/null +++ b/src/plugins/saved_search/public/services/saved_searches/saved_search_attribute_service.test.ts @@ -0,0 +1,246 @@ +/* + * 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 { contentManagementMock } from '@kbn/content-management-plugin/public/mocks'; +import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; +import { getSavedSearchAttributeService } from './saved_search_attribute_service'; +import { spacesPluginMock } from '@kbn/spaces-plugin/public/mocks'; +import { AttributeService, type EmbeddableStart } from '@kbn/embeddable-plugin/public'; +import { coreMock } from '@kbn/core/public/mocks'; +import { SEARCH_EMBEDDABLE_TYPE } from '@kbn/discover-utils'; +import { saveSearchSavedObject } from './save_saved_searches'; +import { + SavedSearchByValueAttributes, + SearchByReferenceInput, + SearchByValueInput, + toSavedSearch, +} from '.'; +import { omit } from 'lodash'; +import { + type GetSavedSearchDependencies, + getSearchSavedObject, +} from '../../../common/service/get_saved_searches'; +import { createGetSavedSearchDeps } from './create_get_saved_search_deps'; + +const mockServices = { + contentManagement: contentManagementMock.createStartContract().client, + search: dataPluginMock.createStartContract().search, + spaces: spacesPluginMock.createStartContract(), + embeddable: { + getAttributeService: jest.fn( + (_, opts) => + new AttributeService( + SEARCH_EMBEDDABLE_TYPE, + coreMock.createStart().notifications.toasts, + opts + ) + ), + } as unknown as EmbeddableStart, +}; + +jest.mock('./save_saved_searches', () => { + const actual = jest.requireActual('./save_saved_searches'); + return { + ...actual, + saveSearchSavedObject: jest.fn(actual.saveSearchSavedObject), + }; +}); + +jest.mock('../../../common/service/get_saved_searches', () => { + const actual = jest.requireActual('../../../common/service/get_saved_searches'); + return { + ...actual, + getSearchSavedObject: jest.fn(actual.getSearchSavedObject), + }; +}); + +jest.mock('./create_get_saved_search_deps', () => { + const actual = jest.requireActual('./create_get_saved_search_deps'); + let deps: GetSavedSearchDependencies; + return { + ...actual, + createGetSavedSearchDeps: jest.fn().mockImplementation((services) => { + if (deps) return deps; + deps = actual.createGetSavedSearchDeps(services); + return deps; + }), + }; +}); + +jest + .spyOn(mockServices.contentManagement, 'update') + .mockImplementation(async ({ id }) => ({ item: { id } })); + +jest.spyOn(mockServices.contentManagement, 'get').mockImplementation(async ({ id }) => ({ + item: { attributes: { id }, references: [] }, + meta: { outcome: 'success' }, +})); + +describe('getSavedSearchAttributeService', () => { + it('should return saved search attribute service', () => { + const savedSearchAttributeService = getSavedSearchAttributeService(mockServices); + expect(savedSearchAttributeService).toBeDefined(); + }); + + it('should call saveSearchSavedObject when wrapAttributes is called with a by ref saved search', async () => { + const savedSearchAttributeService = getSavedSearchAttributeService(mockServices); + const savedObjectId = 'saved-object-id'; + const input: SearchByReferenceInput = { + id: 'mock-embeddable-id', + savedObjectId, + timeRange: { from: 'now-15m', to: 'now' }, + }; + const attrs: SavedSearchByValueAttributes = { + title: 'saved-search-title', + sort: [], + columns: [], + grid: {}, + hideChart: false, + isTextBasedQuery: false, + kibanaSavedObjectMeta: { + searchSourceJSON: '{}', + }, + references: [], + }; + const result = await savedSearchAttributeService.wrapAttributes(attrs, true, input); + expect(result).toEqual(input); + expect(saveSearchSavedObject).toHaveBeenCalledTimes(1); + expect(saveSearchSavedObject).toHaveBeenCalledWith( + savedObjectId, + { + ...omit(attrs, 'references'), + description: '', + }, + [], + mockServices.contentManagement + ); + }); + + it('should call getSearchSavedObject when unwrapAttributes is called with a by ref saved search', async () => { + const savedSearchAttributeService = getSavedSearchAttributeService(mockServices); + const savedObjectId = 'saved-object-id'; + const input: SearchByReferenceInput = { + id: 'mock-embeddable-id', + savedObjectId, + timeRange: { from: 'now-15m', to: 'now' }, + }; + const result = await savedSearchAttributeService.unwrapAttributes(input); + expect(result).toEqual({ + attributes: { + id: savedObjectId, + references: [], + }, + metaInfo: { + sharingSavedObjectProps: { + outcome: 'success', + }, + }, + }); + expect(getSearchSavedObject).toHaveBeenCalledTimes(1); + expect(getSearchSavedObject).toHaveBeenCalledWith( + savedObjectId, + createGetSavedSearchDeps(mockServices) + ); + }); + + describe('toSavedSearch', () => { + it('should convert attributes to saved search', async () => { + const savedSearchAttributeService = getSavedSearchAttributeService(mockServices); + const savedObjectId = 'saved-object-id'; + const attributes: SavedSearchByValueAttributes = { + title: 'saved-search-title', + sort: [['@timestamp', 'desc']], + columns: ['message', 'extension'], + grid: {}, + hideChart: false, + isTextBasedQuery: false, + kibanaSavedObjectMeta: { + searchSourceJSON: '{}', + }, + references: [ + { + id: '1', + name: 'ref_0', + type: 'index-pattern', + }, + ], + }; + const input: SearchByValueInput = { + id: 'mock-embeddable-id', + attributes, + timeRange: { from: 'now-15m', to: 'now' }, + }; + const result = await savedSearchAttributeService.unwrapAttributes(input); + const savedSearch = await toSavedSearch(savedObjectId, result, mockServices); + expect(savedSearch).toMatchInlineSnapshot(` + Object { + "breakdownField": undefined, + "columns": Array [ + "message", + "extension", + ], + "description": "", + "grid": Object {}, + "hideAggregatedPreview": undefined, + "hideChart": false, + "id": "saved-object-id", + "isTextBasedQuery": false, + "references": Array [ + Object { + "id": "1", + "name": "ref_0", + "type": "index-pattern", + }, + ], + "refreshInterval": undefined, + "rowHeight": undefined, + "rowsPerPage": undefined, + "searchSource": Object { + "create": [MockFunction], + "createChild": [MockFunction], + "createCopy": [MockFunction], + "destroy": [MockFunction], + "fetch": [MockFunction], + "fetch$": [MockFunction], + "getActiveIndexFilter": [MockFunction], + "getField": [MockFunction], + "getFields": [MockFunction], + "getId": [MockFunction], + "getOwnField": [MockFunction], + "getParent": [MockFunction], + "getSearchRequestBody": [MockFunction], + "getSerializedFields": [MockFunction], + "history": Array [], + "onRequestStart": [MockFunction], + "parseActiveIndexPatternFromQueryString": [MockFunction], + "removeField": [MockFunction], + "serialize": [MockFunction], + "setField": [MockFunction], + "setFields": [MockFunction], + "setOverwriteDataViewType": [MockFunction], + "setParent": [MockFunction], + "toExpressionAst": [MockFunction], + }, + "sharingSavedObjectProps": undefined, + "sort": Array [ + Array [ + "@timestamp", + "desc", + ], + ], + "tags": undefined, + "timeRange": undefined, + "timeRestore": undefined, + "title": "saved-search-title", + "usesAdHocDataView": undefined, + "viewMode": undefined, + } + `); + }); + }); +}); diff --git a/src/plugins/saved_search/public/services/saved_searches/saved_search_attribute_service.ts b/src/plugins/saved_search/public/services/saved_searches/saved_search_attribute_service.ts new file mode 100644 index 0000000000000..f79b010bd62d2 --- /dev/null +++ b/src/plugins/saved_search/public/services/saved_searches/saved_search_attribute_service.ts @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { AttributeService, EmbeddableStart } from '@kbn/embeddable-plugin/public'; +import type { OnSaveProps } from '@kbn/saved-objects-plugin/public'; +import { SEARCH_EMBEDDABLE_TYPE } from '@kbn/discover-utils'; +import type { + SavedSearch, + SavedSearchByValueAttributes, + SearchByReferenceInput, + SearchByValueInput, +} from './types'; +import type { SavedSearchesServiceDeps } from './saved_searches_service'; +import { + getSearchSavedObject, + convertToSavedSearch, +} from '../../../common/service/get_saved_searches'; +import { checkForDuplicateTitle } from './check_for_duplicate_title'; +import { saveSearchSavedObject } from './save_saved_searches'; +import { createGetSavedSearchDeps } from './create_get_saved_search_deps'; + +export interface SavedSearchUnwrapMetaInfo { + sharingSavedObjectProps: SavedSearch['sharingSavedObjectProps']; +} + +export interface SavedSearchUnwrapResult { + attributes: SavedSearchByValueAttributes; + metaInfo?: SavedSearchUnwrapMetaInfo; +} + +export type SavedSearchAttributeService = AttributeService< + SavedSearchByValueAttributes, + SearchByValueInput, + SearchByReferenceInput, + SavedSearchUnwrapMetaInfo +>; + +export function getSavedSearchAttributeService( + services: SavedSearchesServiceDeps & { + embeddable: EmbeddableStart; + } +): SavedSearchAttributeService { + return services.embeddable.getAttributeService< + SavedSearchByValueAttributes, + SearchByValueInput, + SearchByReferenceInput, + SavedSearchUnwrapMetaInfo + >(SEARCH_EMBEDDABLE_TYPE, { + saveMethod: async (attributes: SavedSearchByValueAttributes, savedObjectId?: string) => { + const { references, attributes: attrs } = splitReferences(attributes); + const id = await saveSearchSavedObject( + savedObjectId, + attrs, + references, + services.contentManagement + ); + + return { id }; + }, + unwrapMethod: async (savedObjectId: string): Promise => { + const so = await getSearchSavedObject(savedObjectId, createGetSavedSearchDeps(services)); + + return { + attributes: { + ...so.item.attributes, + references: so.item.references, + }, + metaInfo: { + sharingSavedObjectProps: so.meta, + }, + }; + }, + checkForDuplicateTitle: (props: OnSaveProps) => { + return checkForDuplicateTitle({ + title: props.newTitle, + isTitleDuplicateConfirmed: props.isTitleDuplicateConfirmed, + onTitleDuplicate: props.onTitleDuplicate, + contentManagement: services.contentManagement, + }); + }, + }); +} + +export const toSavedSearch = async ( + id: string | undefined, + result: SavedSearchUnwrapResult, + services: SavedSearchesServiceDeps +) => { + const { sharingSavedObjectProps } = result.metaInfo ?? {}; + + return await convertToSavedSearch( + { + ...splitReferences(result.attributes), + savedSearchId: id, + sharingSavedObjectProps, + }, + createGetSavedSearchDeps(services) + ); +}; + +const splitReferences = (attributes: SavedSearchByValueAttributes) => { + const { references, ...attrs } = attributes; + + return { + references, + attributes: { + ...attrs, + description: attrs.description ?? '', + }, + }; +}; diff --git a/src/plugins/saved_search/public/services/saved_searches/saved_searches_service.ts b/src/plugins/saved_search/public/services/saved_searches/saved_searches_service.ts index 4b38626e63b21..fe08494b10afc 100644 --- a/src/plugins/saved_search/public/services/saved_searches/saved_searches_service.ts +++ b/src/plugins/saved_search/public/services/saved_searches/saved_searches_service.ts @@ -14,8 +14,9 @@ import { getSavedSearch, saveSavedSearch, SaveSavedSearchOptions, getNewSavedSea import type { SavedSearchCrudTypes } from '../../../common/content_management'; import { SavedSearchType } from '../../../common'; import type { SavedSearch } from '../../../common/types'; +import { createGetSavedSearchDeps } from './create_get_saved_search_deps'; -interface SavedSearchesServiceDeps { +export interface SavedSearchesServiceDeps { search: DataPublicPluginStart['search']; contentManagement: ContentManagementPublicStart['client']; spaces?: SpacesApi; @@ -26,19 +27,7 @@ export class SavedSearchesService { constructor(private deps: SavedSearchesServiceDeps) {} get = (savedSearchId: string) => { - const { search, contentManagement, spaces, savedObjectsTaggingOss } = this.deps; - const getViaCm = (id: string) => - contentManagement.get({ - contentTypeId: SavedSearchType, - id, - }); - - return getSavedSearch(savedSearchId, { - getSavedSrch: getViaCm, - spaces, - searchSourceCreate: search.searchSource.create, - savedObjectsTagging: savedObjectsTaggingOss?.getTaggingApi(), - }); + return getSavedSearch(savedSearchId, createGetSavedSearchDeps(this.deps)); }; getAll = async () => { const { contentManagement } = this.deps; diff --git a/src/plugins/saved_search/public/services/saved_searches/types.ts b/src/plugins/saved_search/public/services/saved_searches/types.ts index 2850b479cb114..5e0f2637ae2aa 100644 --- a/src/plugins/saved_search/public/services/saved_searches/types.ts +++ b/src/plugins/saved_search/public/services/saved_searches/types.ts @@ -6,8 +6,13 @@ * Side Public License, v 1. */ +import type { EmbeddableInput, SavedObjectEmbeddableInput } from '@kbn/embeddable-plugin/public'; +import type { Filter, TimeRange, Query } from '@kbn/es-query'; import type { ResolvedSimpleSavedObject } from '@kbn/core/public'; -import { SavedSearch as SavedSearchCommon } from '../../../common'; +import type { Reference } from '@kbn/content-management-utils'; +import type { SortOrder } from '../..'; +import type { SavedSearchAttributes } from '../../../common'; +import type { SavedSearch as SavedSearchCommon } from '../../../common'; /** @public **/ export interface SavedSearch extends SavedSearchCommon { @@ -18,3 +23,26 @@ export interface SavedSearch extends SavedSearchCommon { errorJSON?: string; }; } + +interface SearchBaseInput extends EmbeddableInput { + timeRange: TimeRange; + timeslice?: [number, number]; + query?: Query; + filters?: Filter[]; + hidePanelTitles?: boolean; + columns?: string[]; + sort?: SortOrder[]; + rowHeight?: number; + rowsPerPage?: number; +} + +export type SavedSearchByValueAttributes = Omit & { + description?: string; + references: Reference[]; +}; + +export type SearchByValueInput = { + attributes: SavedSearchByValueAttributes; +} & SearchBaseInput; + +export type SearchByReferenceInput = SavedObjectEmbeddableInput & SearchBaseInput; diff --git a/src/plugins/saved_search/tsconfig.json b/src/plugins/saved_search/tsconfig.json index 468279bbf31cc..491461c2efc5a 100644 --- a/src/plugins/saved_search/tsconfig.json +++ b/src/plugins/saved_search/tsconfig.json @@ -25,6 +25,10 @@ "@kbn/es-query", "@kbn/utility-types-jest", "@kbn/expressions-plugin", + "@kbn/embeddable-plugin", + "@kbn/saved-objects-plugin", + "@kbn/es-query", + "@kbn/discover-utils", ], "exclude": [ "target/**/*", diff --git a/src/plugins/visualizations/public/visualize_app/utils/get_visualization_instance.test.ts b/src/plugins/visualizations/public/visualize_app/utils/get_visualization_instance.test.ts index fecf7fea7f36c..b6304b6e96ad6 100644 --- a/src/plugins/visualizations/public/visualize_app/utils/get_visualization_instance.test.ts +++ b/src/plugins/visualizations/public/visualize_app/utils/get_visualization_instance.test.ts @@ -14,6 +14,7 @@ import { import { createVisualizeServicesMock } from './mocks'; import { BehaviorSubject } from 'rxjs'; import type { VisualizeServices } from '../types'; +import { savedSearchPluginMock } from '@kbn/saved-search-plugin/public/mocks'; const commonSerializedVisMock = { type: 'area', @@ -60,14 +61,12 @@ describe('getVisualizationInstance', () => { getOutput$: jest.fn(() => subj.asObservable()), })); mockServices.savedSearch = { + ...savedSearchPluginMock.createStartContract(), get: jest.fn().mockImplementation(() => ({ id: 'savedSearch', searchSource: {}, title: 'savedSearchTitle', })), - getAll: jest.fn(), - getNew: jest.fn().mockImplementation(() => ({})), - save: jest.fn().mockImplementation(() => ({})), }; }); diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index fc5483f40425c..2a9fe25793ed4 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -1128,9 +1128,7 @@ "dashboard.listing.unsaved.unsavedChangesTitle": "Vous avez des modifications non enregistrées dans le {dash} suivant :", "dashboard.loadingError.dashboardGridErrorMessage": "Impossible de charger le tableau de bord : {message}", "dashboard.noMatchRoute.bannerText": "L'application de tableau de bord ne reconnaît pas cet itinéraire : {route}.", - "dashboard.panel.addToLibrary.successMessage": "Le panneau {panelTitle} a été ajouté à la bibliothèque Visualize", "dashboard.panel.unableToMigratePanelDataForSixThreeZeroErrorMessage": "Impossible de migrer les données du panneau pour une rétrocompatibilité avec \"6.3.0\". Le panneau ne contient pas le champ attendu : {key}", - "dashboard.panel.unlinkFromLibrary.successMessage": "Le panneau {panelTitle} n'est plus connecté à la bibliothèque Visualize", "dashboard.panelStorageError.clearError": "Une erreur s'est produite lors de la suppression des modifications non enregistrées : {message}", "dashboard.panelStorageError.getError": "Une erreur s'est produite lors de la récupération des modifications non enregistrées : {message}", "dashboard.panelStorageError.setError": "Une erreur s'est produite lors de la définition des modifications non enregistrées : {message}", @@ -1190,7 +1188,6 @@ "dashboard.emptyScreen.addFromLibrary": "Ajouter depuis la bibliothèque", "dashboard.emptyScreen.createVisualization": "Créer une visualisation", "dashboard.emptyScreen.editDashboard": "Modifier le tableau de bord", - "dashboard.emptyScreen.editModeSubtitle": "Créez une visualisation de vos données ou ajoutez-en une depuis la bibliothèque Visualize.", "dashboard.emptyScreen.editModeTitle": "Ce tableau de bord est vide. Remplissons-le.", "dashboard.emptyScreen.noPermissionsSubtitle": "Des privilèges supplémentaires sont requis pour pouvoir modifier ce tableau de bord.", "dashboard.emptyScreen.noPermissionsTitle": "Ce tableau de bord est vide.", @@ -1232,7 +1229,6 @@ "dashboard.panel.filters.modal.editButton": "Modifier les filtres", "dashboard.panel.filters.modal.filtersTitle": "Filtres", "dashboard.panel.filters.modal.queryTitle": "Recherche", - "dashboard.panel.LibraryNotification": "Notification de la bibliothèque Visualize", "dashboard.panel.libraryNotification.ariaLabel": "Afficher les informations de la bibliothèque et dissocier ce panneau", "dashboard.panel.libraryNotification.toolTip": "La modification de ce panneau pourrait affecter d’autres tableaux de bord. Pour modifier ce panneau uniquement, dissociez-le de la bibliothèque.", "dashboard.panel.removePanel.replacePanel": "Remplacer le panneau", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index f3ab8290d3ab3..3c77ec069a426 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -1142,9 +1142,7 @@ "dashboard.listing.unsaved.unsavedChangesTitle": "次の{dash}には保存されていない変更があります:", "dashboard.loadingError.dashboardGridErrorMessage": "ダッシュボードが読み込めません:{message}", "dashboard.noMatchRoute.bannerText": "ダッシュボードアプリケーションはこのルート{route}を認識できません。", - "dashboard.panel.addToLibrary.successMessage": "パネル{panelTitle}はVisualizeライブラリに追加されました", "dashboard.panel.unableToMigratePanelDataForSixThreeZeroErrorMessage": "「6.3.0」のダッシュボードの互換性のため、パネルデータを移行できませんでした。パネルに必要なフィールドがありません:{key}", - "dashboard.panel.unlinkFromLibrary.successMessage": "パネル{panelTitle}はVisualizeライブラリに接続されていません", "dashboard.panelStorageError.clearError": "保存されていない変更の消去中にエラーが発生しました:{message}", "dashboard.panelStorageError.getError": "保存されていない変更の取得中にエラーが発生しました:{message}", "dashboard.panelStorageError.setError": "保存されていない変更の設定中にエラーが発生しました:{message}", @@ -1204,7 +1202,6 @@ "dashboard.emptyScreen.addFromLibrary": "ライブラリから追加", "dashboard.emptyScreen.createVisualization": "ビジュアライゼーションを作成", "dashboard.emptyScreen.editDashboard": "ダッシュボードを編集", - "dashboard.emptyScreen.editModeSubtitle": "データのビジュアライゼーションを作成するか、Visualizeライブラリから1つ追加します。", "dashboard.emptyScreen.editModeTitle": "このダッシュボードは空です。コンテンツを追加しましょう!", "dashboard.emptyScreen.noPermissionsSubtitle": "このダッシュボードを編集するには、追加権限が必要です。", "dashboard.emptyScreen.noPermissionsTitle": "このダッシュボードは空です。", @@ -1246,7 +1243,6 @@ "dashboard.panel.filters.modal.editButton": "フィルターを編集", "dashboard.panel.filters.modal.filtersTitle": "フィルター", "dashboard.panel.filters.modal.queryTitle": "クエリ", - "dashboard.panel.LibraryNotification": "Visualize ライブラリ通知", "dashboard.panel.libraryNotification.ariaLabel": "ライブラリ情報を表示し、このパネルのリンクを解除します", "dashboard.panel.libraryNotification.toolTip": "このパネルを編集すると、他のダッシュボードに影響する場合があります。このパネルのみを変更するには、ライブラリからリンクを解除します。", "dashboard.panel.removePanel.replacePanel": "パネルの交換", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 56704e9f03c89..e82f87bf5ef2e 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -1142,9 +1142,7 @@ "dashboard.listing.unsaved.unsavedChangesTitle": "在以下 {dash} 中有未保存更改:", "dashboard.loadingError.dashboardGridErrorMessage": "无法加载仪表板:{message}", "dashboard.noMatchRoute.bannerText": "Dashboard 应用程序无法识别此路由:{route}。", - "dashboard.panel.addToLibrary.successMessage": "面板 {panelTitle} 已添加到可视化库", "dashboard.panel.unableToMigratePanelDataForSixThreeZeroErrorMessage": "无法迁移用于“6.3.0”向后兼容的面板数据,面板不包含所需字段:{key}", - "dashboard.panel.unlinkFromLibrary.successMessage": "面板 {panelTitle} 不再与可视化库连接", "dashboard.panelStorageError.clearError": "清除未保存更改时遇到错误:{message}", "dashboard.panelStorageError.getError": "获取未保存更改时遇到错误:{message}", "dashboard.panelStorageError.setError": "设置未保存更改时遇到错误:{message}", @@ -1204,7 +1202,6 @@ "dashboard.emptyScreen.addFromLibrary": "从库中添加", "dashboard.emptyScreen.createVisualization": "创建可视化", "dashboard.emptyScreen.editDashboard": "编辑仪表板", - "dashboard.emptyScreen.editModeSubtitle": "创建数据可视化,或从 Visualize 库中添加一个可视化。", "dashboard.emptyScreen.editModeTitle": "此仪表板是空的。让我们来填充它!", "dashboard.emptyScreen.noPermissionsSubtitle": "您还需要其他权限,才能编辑此仪表板。", "dashboard.emptyScreen.noPermissionsTitle": "此仪表板是空的。", @@ -1246,7 +1243,6 @@ "dashboard.panel.filters.modal.editButton": "编辑筛选", "dashboard.panel.filters.modal.filtersTitle": "筛选", "dashboard.panel.filters.modal.queryTitle": "查询", - "dashboard.panel.LibraryNotification": "可视化库通知", "dashboard.panel.libraryNotification.ariaLabel": "查看库信息并取消链接此面板", "dashboard.panel.libraryNotification.toolTip": "编辑此面板可能会影响其他仪表板。要仅更改此面板,请取消其与库的链接。", "dashboard.panel.removePanel.replacePanel": "替换面板", diff --git a/x-pack/test/functional/apps/dashboard/group2/dashboard_search_by_value.ts b/x-pack/test/functional/apps/dashboard/group2/dashboard_search_by_value.ts new file mode 100644 index 0000000000000..beb87afce4549 --- /dev/null +++ b/x-pack/test/functional/apps/dashboard/group2/dashboard_search_by_value.ts @@ -0,0 +1,109 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const dataGrid = getService('dataGrid'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const dashboardPanelActions = getService('dashboardPanelActions'); + const filterBar = getService('filterBar'); + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects(['common', 'dashboard', 'header', 'timePicker', 'discover']); + + describe('saved searches by value', () => { + before(async () => { + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/dashboard/current/data'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); + await kibanaServer.uiSettings.replace({ + defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', + }); + await PageObjects.common.setTime({ + from: 'Sep 22, 2015 @ 00:00:00.000', + to: 'Sep 23, 2015 @ 00:00:00.000', + }); + }); + + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + await PageObjects.common.unsetTime(); + }); + + beforeEach(async () => { + await PageObjects.common.navigateToApp('dashboard'); + await filterBar.ensureFieldEditorModalIsClosed(); + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.dashboard.clickNewDashboard(); + }); + + const addSearchEmbeddableToDashboard = async () => { + await dashboardAddPanel.addSavedSearch('Rendering-Test:-saved-search'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.waitForRenderComplete(); + const rows = await dataGrid.getDocTableRows(); + expect(rows.length).to.be.above(0); + }; + + it('should allow cloning a by ref saved search embeddable to a by value embeddable', async () => { + await addSearchEmbeddableToDashboard(); + let panels = await testSubjects.findAll(`embeddablePanel`); + expect(panels.length).to.be(1); + expect( + await testSubjects.descendantExists( + 'embeddablePanelNotification-ACTION_LIBRARY_NOTIFICATION', + panels[0] + ) + ).to.be(true); + await dashboardPanelActions.clonePanelByTitle('RenderingTest:savedsearch'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.waitForRenderComplete(); + panels = await testSubjects.findAll('embeddablePanel'); + expect(panels.length).to.be(2); + expect( + await testSubjects.descendantExists( + 'embeddablePanelNotification-ACTION_LIBRARY_NOTIFICATION', + panels[0] + ) + ).to.be(true); + expect( + await testSubjects.descendantExists( + 'embeddablePanelNotification-ACTION_LIBRARY_NOTIFICATION', + panels[1] + ) + ).to.be(false); + }); + + it('should allow unlinking a by ref saved search embeddable from library', async () => { + await addSearchEmbeddableToDashboard(); + let panels = await testSubjects.findAll(`embeddablePanel`); + expect(panels.length).to.be(1); + expect( + await testSubjects.descendantExists( + 'embeddablePanelNotification-ACTION_LIBRARY_NOTIFICATION', + panels[0] + ) + ).to.be(true); + await dashboardPanelActions.unlinkFromLibary(panels[0]); + await testSubjects.existOrFail('unlinkPanelSuccess'); + panels = await testSubjects.findAll('embeddablePanel'); + expect(panels.length).to.be(1); + expect( + await testSubjects.descendantExists( + 'embeddablePanelNotification-ACTION_LIBRARY_NOTIFICATION', + panels[0] + ) + ).to.be(false); + }); + }); +} diff --git a/x-pack/test/functional/apps/dashboard/group2/index.ts b/x-pack/test/functional/apps/dashboard/group2/index.ts index 8b45cda030252..a233126f6e4a6 100644 --- a/x-pack/test/functional/apps/dashboard/group2/index.ts +++ b/x-pack/test/functional/apps/dashboard/group2/index.ts @@ -13,6 +13,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./_async_dashboard')); loadTestFile(require.resolve('./dashboard_lens_by_value')); loadTestFile(require.resolve('./dashboard_maps_by_value')); + loadTestFile(require.resolve('./dashboard_search_by_value')); loadTestFile(require.resolve('./panel_titles')); loadTestFile(require.resolve('./panel_time_range'));