From 716fb1444d6f0b7dcaf2e7c9d1b41adaad2cc2cf Mon Sep 17 00:00:00 2001 From: Davis McPhee Date: Wed, 2 Aug 2023 10:41:45 -0300 Subject: [PATCH] [Saved Searches] Add support for saved searches by value (#146849) ## Summary This PR adds support for saved searches by value. Functionality-wise this means that similar to other Dashboard panels, saved search panels now allow unlinking from the library as well as cloning by value instead of by reference. Testing guide: - Test saved searches using both persisted and temporary data views. - Ensure your saved searches include a query, filters, time range, columns, sorting, breakdown field, etc. - Test both the "Unlink from library" and "Save to library" functionality. - Test "Clone panel" functionality using both by reference and by value saved searches (both should clone to a by value saved search). - Test the "Edit search" button functionality in Dashboard edit mode: - All saved search configurations should be included when navigating (search params + persisted & temporary data views). - Test navigation within the same tab (which will use in-app navigation to pass state) and opening the link in a new tab (which will use query params to pass state). - By reference saved searches should use the `/app/discover#/view/{savedSearchId}` route. - By value saved searches using persisted data views should pass all saved search configurations through the app state (`_a`) query param. - By value saved searches using temporary data views should use a locator redirect URL (`/app/r?l=DISCOVER_APP_LOCATOR...`) in order to support encoding their temporary data view in the URL state. - Test the "Open in Discover" button functionality in Dashboard view mode: - By reference saved searches should open the actual saved search in Discover. - By value saved searches should pass all saved search configurations to Discover. The following features are not included in this PR and comprise the remaining work for implementing Time to Visualize for saved searches (to be done at a later date; issue here: #141629): - Save and return / state transfer service. - Save modal with the ability to save directly to a dashboard. Resolves #148995. Unblocks #158632. ### Checklist - [ ] ~Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)~ - [ ] ~[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials~ - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] ~Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/))~ - [ ] ~Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))~ - [ ] ~If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)~ - [ ] ~This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))~ - [ ] ~This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers)~ ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Stratoula Kalafateli --- .../_dashboard_actions_strings.ts | 6 +- .../_dashboard_container_strings.ts | 2 +- .../dashboard_empty_screen.test.tsx.snap | 2 +- .../discover/common/embeddable/index.ts | 9 + .../embeddable/search_inject_extract.test.ts | 76 ++++ .../embeddable/search_inject_extract.ts | 52 +++ .../discover/public/__mocks__/services.ts | 1 + .../get_discover_locator_params.test.ts | 37 ++ .../embeddable/get_discover_locator_params.ts | 41 ++ .../saved_search_embeddable.test.ts | 209 +++++++--- .../embeddable/saved_search_embeddable.tsx | 364 +++++++++++------- .../search_embeddable_factory.test.ts | 47 ++- .../embeddable/search_embeddable_factory.ts | 48 +-- .../discover/public/embeddable/types.ts | 30 +- .../view_saved_search_action.test.ts | 47 +-- .../embeddable/view_saved_search_action.ts | 30 +- src/plugins/discover/public/plugin.tsx | 2 +- .../discover/server/embeddable/index.ts | 9 + .../embeddable/search_embeddable_factory.ts | 17 + src/plugins/discover/server/plugin.ts | 5 + src/plugins/discover/tsconfig.json | 1 + src/plugins/saved_search/common/index.ts | 3 +- .../common/saved_searches_utils.ts | 2 +- .../common/service/get_saved_searches.ts | 52 ++- .../common/service/saved_searches_utils.ts | 2 +- src/plugins/saved_search/kibana.jsonc | 18 +- src/plugins/saved_search/public/index.ts | 16 +- src/plugins/saved_search/public/mocks.ts | 23 +- src/plugins/saved_search/public/plugin.ts | 36 +- .../check_for_duplicate_title.ts | 60 +++ .../create_get_saved_search_deps.ts | 28 ++ .../public/services/saved_searches/index.ts | 14 +- .../saved_searches/save_saved_searches.ts | 98 +++-- .../saved_search_attribute_service.test.ts | 246 ++++++++++++ .../saved_search_attribute_service.ts | 116 ++++++ .../saved_searches/saved_searches_service.ts | 17 +- .../public/services/saved_searches/types.ts | 30 +- src/plugins/saved_search/tsconfig.json | 4 + .../utils/get_visualization_instance.test.ts | 5 +- .../translations/translations/fr-FR.json | 4 - .../translations/translations/ja-JP.json | 4 - .../translations/translations/zh-CN.json | 4 - .../group2/dashboard_search_by_value.ts | 109 ++++++ .../functional/apps/dashboard/group2/index.ts | 1 + 44 files changed, 1514 insertions(+), 413 deletions(-) create mode 100644 src/plugins/discover/common/embeddable/index.ts create mode 100644 src/plugins/discover/common/embeddable/search_inject_extract.test.ts create mode 100644 src/plugins/discover/common/embeddable/search_inject_extract.ts create mode 100644 src/plugins/discover/public/embeddable/get_discover_locator_params.test.ts create mode 100644 src/plugins/discover/public/embeddable/get_discover_locator_params.ts create mode 100644 src/plugins/discover/server/embeddable/index.ts create mode 100644 src/plugins/discover/server/embeddable/search_embeddable_factory.ts create mode 100644 src/plugins/saved_search/public/services/saved_searches/check_for_duplicate_title.ts create mode 100644 src/plugins/saved_search/public/services/saved_searches/create_get_saved_search_deps.ts create mode 100644 src/plugins/saved_search/public/services/saved_searches/saved_search_attribute_service.test.ts create mode 100644 src/plugins/saved_search/public/services/saved_searches/saved_search_attribute_service.ts create mode 100644 x-pack/test/functional/apps/dashboard/group2/dashboard_search_by_value.ts 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 5767e4f8437516..fec2d60e017631 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 2fd068f5b58163..54e9989b4362a0 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 a15ed4749288d1..46d3b77578b3b8 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 00000000000000..ea4e1a78190d54 --- /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 00000000000000..7ebaed40036273 --- /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 00000000000000..d8d15f327bb0d3 --- /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 5aa6b6551bc11c..5b02f1e86907de 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 00000000000000..54e519adddcb76 --- /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 00000000000000..abc5d67e9435e3 --- /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 3e2e0e2402c4f1..56a143a7598d8b 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 7136d8b1ab8d30..425659809f1262 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 54a2dad5607a74..2494cc42ee1e54 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 695a1e830115fc..9afe34648b30e4 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 4dd049c8de9a97..1459ff8d1c8803 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 1e8181a9ce4b4c..0f9b1698c54e9b 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 dde5889aa1fdba..75cf0971c1481d 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 6226484778fa2d..7d795263a072c2 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 00000000000000..13091f9b652754 --- /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 00000000000000..602227f9f93c6a --- /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 666ab85ad21057..b12833edf67a1b 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 51377e42461220..ab6d47d6a86d12 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 8915ab582b3e27..4669ecd3bd4b9b 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 41934b86a36d5e..324baca4352324 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 63a41a52b53919..653403c9f0b472 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 c4e663e5f63528..ef99a0b87ad5c6 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 03df75a7d7924f..da389103a5f788 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 e5161bb040d7b7..eb7342633894c4 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 60019c7f68ca1a..3e0e20bd6e7a73 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 2d8d53c821ae5e..8e8dd697cc55c5 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 00000000000000..49264e24e25aeb --- /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 00000000000000..bfacd8a13b7d63 --- /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 456e7772927782..add8464cd8d8b1 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 4792680285bfc7..6594dd36960530 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 00000000000000..cc6a6ec79ffea3 --- /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 00000000000000..f79b010bd62d27 --- /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 4b38626e63b217..fe08494b10afce 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 2850b479cb1146..5e0f2637ae2aa4 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 468279bbf31cc6..491461c2efc5a6 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 fecf7fea7f36ca..b6304b6e96ad66 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 97d876c3d2def6..3847e59107039e 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 c10961c7b3801e..72f159a5a93e56 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 0d7ca8b6adbd1e..d52f4678ff53bd 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 00000000000000..beb87afce45496 --- /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 8b45cda0302526..a233126f6e4a63 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'));