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