[Saved Searches] Add support for saved searches by value (#146849)

## Summary

This PR adds support for saved searches by value. Functionality-wise
this means that similar to other Dashboard panels, saved search panels
now allow unlinking from the library as well as cloning by value instead
of by reference.

Testing guide:
- Test saved searches using both persisted and temporary data views.
- Ensure your saved searches include a query, filters, time range,
columns, sorting, breakdown field, etc.
- Test both the "Unlink from library" and "Save to library"
functionality.
- Test "Clone panel" functionality using both by reference and by value
saved searches (both should clone to a by value saved search).
- Test the "Edit search" button functionality in Dashboard edit mode:
- All saved search configurations should be included when navigating
(search params + persisted & temporary data views).
- Test navigation within the same tab (which will use in-app navigation
to pass state) and opening the link in a new tab (which will use query
params to pass state).
- By reference saved searches should use the
`/app/discover#/view/{savedSearchId}` route.
- By value saved searches using persisted data views should pass all
saved search configurations through the app state (`_a`) query param.
- By value saved searches using temporary data views should use a
locator redirect URL (`/app/r?l=DISCOVER_APP_LOCATOR...`) in order to
support encoding their temporary data view in the URL state.
- Test the "Open in Discover" button functionality in Dashboard view
mode:
- By reference saved searches should open the actual saved search in
Discover.
- By value saved searches should pass all saved search configurations to
Discover.

The following features are not included in this PR and comprise the
remaining work for implementing Time to Visualize for saved searches (to
be done at a later date; issue here: #141629):
- Save and return / state transfer service.
- Save modal with the ability to save directly to a dashboard.

Resolves #148995.
Unblocks #158632.

### Checklist

- [ ] ~Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)~
- [ ]
~[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials~
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] ~Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard
accessibility](https://webaim.org/techniques/keyboard/))~
- [ ] ~Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))~
- [ ] ~If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)~
- [ ] ~This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))~
- [ ] ~This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)~

### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
This commit is contained in:
Davis McPhee 2023-08-02 10:41:45 -03:00 committed by GitHub
parent 59a4e56c50
commit 716fb1444d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 1516 additions and 415 deletions

View file

@ -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', {

View file

@ -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', {

View file

@ -59,7 +59,7 @@ exports[`DashboardEmptyScreen renders correctly with edit mode 1`] = `
class="euiText emotion-euiText-s-euiTextColor-subdued"
>
<span>
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.
</span>
</div>
</div>

View file

@ -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';

View file

@ -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' }],
});
});
});
});

View file

@ -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;

View file

@ -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(() => '') },

View file

@ -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,
});
});
});

View file

@ -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;
};

View file

@ -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<FilterManager>;
let servicesMock: jest.Mocked<DiscoverServices>;
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');
});
});
});

View file

@ -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<DiscoverGridProps> &
Partial<DocTableProps> & {
@ -82,73 +92,53 @@ export type SearchProps = Partial<DiscoverGridProps> &
};
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<SearchInput, SearchOutput>
implements ISearchEmbeddable, FilterableEmbeddable
implements
ISearchEmbeddable,
FilterableEmbeddable,
ReferenceOrValueEmbeddable<SearchByValueInput, SearchByReferenceInput>
{
private readonly savedSearch: SavedSearch;
private inspectorAdapters: Adapters;
public readonly type = SEARCH_EMBEDDABLE_TYPE;
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 subscription?: Subscription;
public readonly type = SEARCH_EMBEDDABLE_TYPE;
private filterManager: FilterManager;
private abortController?: AbortController;
private services: DiscoverServices;
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
<FieldStatisticsTable
dataView={searchProps.dataView}
columns={searchProps.columns}
savedSearch={this.savedSearch}
savedSearch={savedSearch}
filters={this.input.filters}
query={this.input.query}
onAddFilter={searchProps.onFilter}
@ -551,23 +639,27 @@ export class SavedSearchEmbeddable
</I18nProvider>,
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(
<I18nProvider>
<KibanaThemeProvider theme$={searchProps.services.core.theme.theme$}>
@ -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();
}
}

View file

@ -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);
});
});

View file

@ -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<StartServices>,
@ -60,42 +60,36 @@ export class SearchEmbeddableFactory
public createFromSavedObject = async (
savedObjectId: string,
input: Partial<SearchInput> & { id: string; timeRange: TimeRange },
input: SearchByReferenceInput,
parent?: Container
): Promise<SavedSearchEmbeddable | ErrorEmbeddable> => {
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;
}
const dataView = savedSearch.searchSource.getField('index');
return this.create(input, parent);
};
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);
}
}

View file

@ -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;
}

View file

@ -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(),
})
);
});
});

View file

@ -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<ViewSearchContext> {
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<ViewSearchContext>): Promise<void> {
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<ViewSearchContext>): string {

View file

@ -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);

View file

@ -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';

View file

@ -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,
});

View file

@ -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 {};
}

View file

@ -63,6 +63,7 @@
"@kbn/cell-actions",
"@kbn/shared-ux-utility",
"@kbn/core-application-browser",
"@kbn/core-saved-objects-server",
"@kbn/discover-utils"
],
"exclude": [

View file

@ -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';

View file

@ -9,7 +9,7 @@
import { SavedSearch, SavedSearchAttributes } from '.';
export const fromSavedSearchAttributes = (
id: string,
id: string | undefined,
attributes: SavedSearchAttributes,
tags: string[] | undefined,
searchSource: SavedSearch['searchSource']

View file

@ -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<typeof injectReferences>[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

View file

@ -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,

View file

@ -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"]
}
}

View file

@ -6,13 +6,21 @@
* Side Public License, v 1.
*/
export type { SortOrder } from '../common/types';
export type { SavedSearch, SaveSavedSearchOptions } from './services/saved_searches';
export { getSavedSearchFullPathUrl, getSavedSearchUrl } from './services/saved_searches';
export { VIEW_MODE } from '../common';
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';
export type { SavedSearchPublicPluginStart } from './plugin';
export function plugin() {

View file

@ -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 = {

View file

@ -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<typeof saveSavedSearch>;
byValue: {
attributeService: SavedSearchAttributeService;
toSavedSearch: (
id: string | undefined,
result: SavedSearchUnwrapResult
) => Promise<SavedSearch>;
};
}
/**
@ -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);
},
},
};
}
}

View file

@ -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<boolean | void> => {
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;
};

View file

@ -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<SavedSearchCrudTypes['GetIn'], SavedSearchCrudTypes['GetOut']>({
contentTypeId: SavedSearchType,
id,
});
},
});

View file

@ -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';

View file

@ -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<boolean | void> => {
if (!title) {
return;
}
) => {
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,
},
});
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());
return resp.item.id;
};
/** @internal **/
@ -53,14 +63,15 @@ export const saveSavedSearch = async (
): Promise<string | undefined> => {
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
);
};

View file

@ -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,
}
`);
});
});
});

View file

@ -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<SavedSearchUnwrapResult> => {
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 ?? '',
},
};
};

View file

@ -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<SavedSearchCrudTypes['GetIn'], SavedSearchCrudTypes['GetOut']>({
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;

View file

@ -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<SavedSearchAttributes, 'description'> & {
description?: string;
references: Reference[];
};
export type SearchByValueInput = {
attributes: SavedSearchByValueAttributes;
} & SearchBaseInput;
export type SearchByReferenceInput = SavedObjectEmbeddableInput & SearchBaseInput;

View file

@ -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/**/*",

View file

@ -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(() => ({})),
};
});

View file

@ -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 dautres tableaux de bord. Pour modifier ce panneau uniquement, dissociez-le de la bibliothèque.",
"dashboard.panel.removePanel.replacePanel": "Remplacer le panneau",

View file

@ -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": "パネルの交換",

View file

@ -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": "替换面板",

View file

@ -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);
});
});
}

View file

@ -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'));