[Embeddable Rebuild] [Saved Search] Migrate saved search embeddable to new embeddable framework (#180536)

Closes https://github.com/elastic/kibana/issues/174959

## Summary

This PR converts the Saved Search embeddable to the new React embeddable
framework. There should not be **any** changes in user-facing behaviour
(except for the intentional change described
[here](https://github.com/elastic/kibana/pull/180536#discussion_r1647924825))
- therefore, testing of this PR should be focused on ensuring that no
behaviour is changed and/or broken with this refactor.

> [!WARNING]  
> The saved search embeddable takes **many forms** and so, while I tried
my best to test everything thoroughly, it is very, very likely that I
missed some test cases due to not being the expert in this area. It is
important that @elastic/kibana-data-discovery in particular approaches
this PR review with a fine-tooth comb 🙇 Thanks so much.

### Notes about the embeddable state:
As part of this refactor, I made three significant changes to how the
state is managed:

1. Once the embeddable is being built in `buildEmbeddable`, the **only
difference** between the runtime state of a by reference and a by value
panel is that the by reference one will have three saved object-specific
keys: `savedObjectId`, `savedObjectDescription`, and `savedObjectTitle`.
2. Number 1 made it possible for me to "flatten out" the runtime state
of the embeddable by removing the `attributes` key, which makes it
easier to access the pieces of state that you need.
3. Previously, the `savedSearch` element of the Saved Search embeddable
object was never modified; instead, changes made to the columns, sort,
sample size, etc. from the dashboard were stored in `explicitInput`.
This essentially created two sources of truth.
   
With the new embeddable system, we only ever want **one** source of
truth - so, the saved search is now modified **directly** when making
changes from the dashboard. However, in order to keep behaviour
consistent with the old embeddable, changes made from the dashboard to a
by reference saved search **should not** modify the underlying saved
object (this behaviour will have to change if we ever want inline
editing for saved searches, but that is another discussion) - therefore,
when **serializing** the runtime state (which happens when the dashboard
is saved), we [only serialize state that has **changed** from the
initial
state](https://github.com/elastic/kibana/pull/180536/files#diff-7346937694685b85c017fb608c6582afb3aded0912bfb42fffa4b32a6d27fdbbR93-R117);
then, on deserialization, we take this "changed" state and
[**overwrite** the state of the saved search with
it](https://github.com/elastic/kibana/pull/180536/files#diff-7346937694685b85c017fb608c6582afb3aded0912bfb42fffa4b32a6d27fdbbR44-R54).
    
Note that this **only** applies to by reference saved searches - with by
value saved searches, we don't have to worry about this and can freely
modify the state.

I also had to make changes to how the **search source** is stored in
runtime state. Previously, when initializing the embeddable, fetching
the saved search saved object also created and returned an
**unserializable** search source object. However, in the new system,
runtime state **most always be serializable** (see
https://github.com/elastic/kibana/pull/186052) - therefore, I've had to
instead use the **serialized** search source in my runtime state saved
search - therefore, I've had to make changes to `toSavedSearch` method
to [allow for a **serializable** saved search to be
returned](https://github.com/elastic/kibana/pull/180536/files#diff-3baaeaeef5893a5a4db6379a1ed888406a8584cb9d0c7440f273040e4aa28166R160-R169).

| | Runtime state (`input`) before | Runtime state after |
|--------|--------|--------|
| **By value** |
![image](d019f904-aac3-4bf2-8f9f-a98787d3b78a)
|
![image](dd820202-f1ef-4404-9450-610989204015)
|
| **By reference** |
![image](ebb0d4a9-b918-48a4-8690-0434a2a17561)
|
![image](16fa1e4d-064d-457b-98af-4697f52de4dd)
|


### Checklist

- [x] 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)
- [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
- [x] 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>
This commit is contained in:
Hannah Mudge 2024-07-22 15:14:21 -06:00 committed by GitHub
parent e93e3034a7
commit 5c4eae1286
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 2827 additions and 2353 deletions

View file

@ -12,24 +12,23 @@ import { CoreStart } from '@kbn/core/public';
import { coreMock } from '@kbn/core/public/mocks';
import type { SearchSource } from '@kbn/data-plugin/common';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { PublishesSavedSearch } from '@kbn/discover-plugin/public';
import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
import { LicenseCheckState } from '@kbn/licensing-plugin/public';
import { licensingMock } from '@kbn/licensing-plugin/public/mocks';
import type { SavedSearch } from '@kbn/saved-search-plugin/public';
import { EmbeddableApiContext } from '@kbn/presentation-publishing';
import { ReportingAPIClient } from '@kbn/reporting-public';
import type { ClientConfigType } from '@kbn/reporting-public/types';
import {
ActionContext,
type PanelActionDependencies,
ReportingCsvPanelAction,
} from './get_csv_panel_action';
import type { SavedSearch } from '@kbn/saved-search-plugin/public';
import { BehaviorSubject } from 'rxjs';
import { ReportingCsvPanelAction, type PanelActionDependencies } from './get_csv_panel_action';
const core = coreMock.createSetup();
let apiClient: ReportingAPIClient;
describe('GetCsvReportPanelAction', () => {
let csvConfig: ClientConfigType['csv'];
let context: ActionContext;
let context: EmbeddableApiContext;
let mockLicenseState: LicenseCheckState;
let mockSearchSource: SearchSource;
let mockStartServicesPayload: [CoreStart, PanelActionDependencies, unknown];
@ -93,9 +92,7 @@ describe('GetCsvReportPanelAction', () => {
context = {
embeddable: {
type: 'search',
getSavedSearch: () => {
return { searchSource: mockSearchSource };
},
savedSearch$: new BehaviorSubject({ searchSource: mockSearchSource }),
getTitle: () => `The Dude`,
getInspectorAdapters: () => null,
getInput: () => ({
@ -106,8 +103,11 @@ describe('GetCsvReportPanelAction', () => {
},
}),
hasTimeRange: () => true,
parentApi: {
viewMode: new BehaviorSubject('view'),
},
},
} as unknown as ActionContext;
} as EmbeddableApiContext;
});
afterEach(() => {
@ -145,12 +145,10 @@ describe('GetCsvReportPanelAction', () => {
getField: jest.fn((name) => (name === 'index' ? dataViewMock : undefined)),
getSerializedFields: jest.fn().mockImplementation(() => ({ testData: 'testDataValue' })),
} as unknown as SearchSource;
context.embeddable.getSavedSearch = () => {
return {
searchSource: mockSearchSource,
columns: ['column_a', 'column_b'],
} as unknown as SavedSearch;
};
(context.embeddable as PublishesSavedSearch).savedSearch$ = new BehaviorSubject({
searchSource: mockSearchSource,
columns: ['column_a', 'column_b'],
} as unknown as SavedSearch);
const panel = new ReportingCsvPanelAction({
core,

View file

@ -17,32 +17,35 @@ import {
ThemeServiceSetup,
} from '@kbn/core/public';
import { DataPublicPluginStart, SerializedSearchSourceFields } from '@kbn/data-plugin/public';
import type { ISearchEmbeddable } from '@kbn/discover-plugin/public';
import { loadSharingDataHelpers, SEARCH_EMBEDDABLE_TYPE } from '@kbn/discover-plugin/public';
import type { IEmbeddable } from '@kbn/embeddable-plugin/public';
import {
loadSharingDataHelpers,
SEARCH_EMBEDDABLE_TYPE,
apiPublishesSavedSearch,
PublishesSavedSearch,
HasTimeRange,
} from '@kbn/discover-plugin/public';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { LicensingPluginStart } from '@kbn/licensing-plugin/public';
import type { SavedSearch } from '@kbn/saved-search-plugin/public';
import {
apiCanAccessViewMode,
apiHasType,
apiIsOfType,
CanAccessViewMode,
EmbeddableApiContext,
getInheritedViewMode,
HasType,
} from '@kbn/presentation-publishing';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { CSV_REPORTING_ACTION, JobAppParamsCSV } from '@kbn/reporting-export-types-csv-common';
import { SavedSearch } from '@kbn/saved-search-plugin/public';
import type { UiActionsActionDefinition as ActionDefinition } from '@kbn/ui-actions-plugin/public';
import { IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import { CSV_REPORTING_ACTION, JobAppParamsCSV } from '@kbn/reporting-export-types-csv-common';
import type { ClientConfigType } from '@kbn/reporting-public/types';
import { checkLicense } from '@kbn/reporting-public/license_check';
import type { ReportingAPIClient } from '@kbn/reporting-public/reporting_api_client';
import { getI18nStrings } from './strings';
function isSavedSearchEmbeddable(
embeddable: IEmbeddable | ISearchEmbeddable
): embeddable is ISearchEmbeddable {
return embeddable.type === SEARCH_EMBEDDABLE_TYPE;
}
export interface ActionContext {
embeddable: ISearchEmbeddable;
}
export interface PanelActionDependencies {
data: DataPublicPluginStart;
licensing: LicensingPluginStart;
@ -79,7 +82,19 @@ interface ExecutionParams {
i18nStart: I18nStart;
}
export class ReportingCsvPanelAction implements ActionDefinition<ActionContext> {
type GetCsvActionApi = HasType & PublishesSavedSearch & CanAccessViewMode & HasTimeRange;
const compatibilityCheck = (api: EmbeddableApiContext['embeddable']): api is GetCsvActionApi => {
return (
apiHasType(api) &&
apiIsOfType(api, SEARCH_EMBEDDABLE_TYPE) &&
apiPublishesSavedSearch(api) &&
apiCanAccessViewMode(api) &&
Boolean((api as unknown as HasTimeRange).hasTimeRange)
);
};
export class ReportingCsvPanelAction implements ActionDefinition<EmbeddableApiContext> {
private isDownloading: boolean;
public readonly type = '';
public readonly id = CSV_REPORTING_ACTION;
@ -118,10 +133,10 @@ export class ReportingCsvPanelAction implements ActionDefinition<ActionContext>
return await getSharingData(savedSearch.searchSource, savedSearch, { uiSettings, data });
}
public isCompatible = async (context: ActionContext) => {
public isCompatible = async (context: EmbeddableApiContext) => {
const { embeddable } = context;
if (embeddable.type !== 'search') {
if (!compatibilityCheck(embeddable)) {
return false;
}
@ -138,7 +153,7 @@ export class ReportingCsvPanelAction implements ActionDefinition<ActionContext>
return false;
}
return embeddable.getInput().viewMode !== ViewMode.EDIT;
return getInheritedViewMode(embeddable) !== ViewMode.EDIT;
};
/**
@ -240,14 +255,14 @@ export class ReportingCsvPanelAction implements ActionDefinition<ActionContext>
});
};
public execute = async (context: ActionContext) => {
public execute = async (context: EmbeddableApiContext) => {
const { embeddable } = context;
if (!isSavedSearchEmbeddable(embeddable) || !(await this.isCompatible(context))) {
if (!compatibilityCheck(embeddable) || !(await this.isCompatible(context))) {
throw new IncompatibleActionError();
}
const savedSearch = embeddable.getSavedSearch();
const savedSearch = embeddable.savedSearch$.getValue();
if (!savedSearch || this.isDownloading) {
return;

View file

@ -27,5 +27,6 @@
"@kbn/ui-actions-plugin",
"@kbn/react-kibana-mount",
"@kbn/reporting-public",
"@kbn/presentation-publishing",
]
}

View file

@ -2,17 +2,10 @@
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
"types": ["jest", "node"]
},
"include": [
"**/*.ts", "**/*.tsx"
],
"exclude": [
"target/**/*"
],
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["target/**/*"],
"kbn_references": [
"@kbn/reporting-common",
"@kbn/core",

View file

@ -1062,6 +1062,7 @@ export const UnifiedDataTable = ({
data-test-subj="discoverDocTable"
data-render-complete={isRenderComplete}
data-shared-item=""
data-rendering-count={1} // TODO: Fix this as part of https://github.com/elastic/kibana/issues/179376
data-title={searchTitle}
data-description={searchDescription}
data-document-number={displayedRows.length}

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import { SavedSearchByValueAttributes } from '@kbn/saved-search-plugin/public';
import { extract, inject } from './search_inject_extract';
describe('search inject extract', () => {
@ -65,7 +66,7 @@ describe('search inject extract', () => {
id: 'id',
attributes: {
references: [{ name: 'name', type: 'type', id: '1' }],
},
} as SavedSearchByValueAttributes,
};
expect(extract(state)).toEqual({
state,

View file

@ -7,13 +7,13 @@
*/
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';
import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common';
import type { SavedSearchByValueAttributes } from '@kbn/saved-search-plugin/public';
export const inject = (
state: EmbeddableStateWithType,
injectedReferences: SavedObjectReference[]
): EmbeddableStateWithType => {
): EmbeddableStateWithType & { attributes?: SavedSearchByValueAttributes } => {
if (hasAttributes(state)) {
// Filter out references that are not in the state
// https://github.com/elastic/kibana/pull/119079
@ -36,7 +36,7 @@ export const inject = (
};
export const extract = (
state: EmbeddableStateWithType
state: EmbeddableStateWithType & { attributes?: SavedSearchByValueAttributes }
): { state: EmbeddableStateWithType; references: SavedObjectReference[] } => {
let references: SavedObjectReference[] = [];
@ -49,4 +49,5 @@ export const extract = (
const hasAttributes = (
state: EmbeddableStateWithType
): state is EmbeddableStateWithType & SearchByValueInput => 'attributes' in state;
): state is EmbeddableStateWithType & { attributes: SavedSearchByValueAttributes } =>
'attributes' in state;

View file

@ -5,7 +5,7 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { Observable, of } from 'rxjs';
import { BehaviorSubject, Observable, of } from 'rxjs';
import { DiscoverServices } from '../build_services';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
@ -35,6 +35,7 @@ import { TopNavMenu } from '@kbn/navigation-plugin/public';
import { FORMATS_UI_SETTINGS } from '@kbn/field-formats-plugin/common';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { fieldFormatsMock } from '@kbn/field-formats-plugin/common/mocks';
import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks';
import { LocalStorageMock } from './local_storage_mock';
import { createDiscoverDataViewsMock } from './data_views';
import { SearchSourceDependencies } from '@kbn/data-plugin/common';
@ -142,6 +143,7 @@ export function createDiscoverServicesMock(): DiscoverServices {
const theme = themeServiceMock.createSetupContract({ darkMode: false });
corePluginMock.theme = theme;
corePluginMock.chrome.getActiveSolutionNavId$.mockReturnValue(new BehaviorSubject(null));
return {
core: corePluginMock,
@ -163,6 +165,7 @@ export function createDiscoverServicesMock(): DiscoverServices {
PatternAnalysisComponent: jest.fn(() => createElement('div')),
},
docLinks: docLinksServiceMock.createStartContract(),
embeddable: embeddablePluginMock.createStartContract(),
capabilities: {
visualize: {
show: true,

View file

@ -8,7 +8,7 @@
import { pluck } from 'rxjs';
import { lastValueFrom } from 'rxjs';
import { i18n } from '@kbn/i18n';
import type { Query, AggregateQuery, Filter } from '@kbn/es-query';
import { Query, AggregateQuery, Filter, TimeRange } from '@kbn/es-query';
import type { Adapters } from '@kbn/inspector-plugin/common';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { ExpressionsStart } from '@kbn/expressions-plugin/public';
@ -30,6 +30,7 @@ export function fetchEsql({
query,
inputQuery,
filters,
inputTimeRange,
dataView,
abortSignal,
inspectorAdapters,
@ -40,6 +41,7 @@ export function fetchEsql({
query: Query | AggregateQuery;
inputQuery?: Query;
filters?: Filter[];
inputTimeRange?: TimeRange;
dataView: DataView;
abortSignal?: AbortSignal;
inspectorAdapters: Adapters;
@ -47,7 +49,7 @@ export function fetchEsql({
expressions: ExpressionsStart;
profilesManager: ProfilesManager;
}): Promise<RecordsFetchResponse> {
const timeRange = data.query.timefilter.timefilter.getTime();
const timeRange = inputTimeRange ?? data.query.timefilter.timefilter.getTime();
return textBasedQueryStateToAstWithValidation({
filters,
query,

View file

@ -17,7 +17,7 @@ import {
MAX_ROWS_PER_PAGE_OPTION,
} from './components/pager/tool_bar_pagination';
import { DocTableProps, DocTableRenderProps, DocTableWrapper } from './doc_table_wrapper';
import { SavedSearchEmbeddableBase } from '../../embeddable/saved_search_embeddable_base';
import { SavedSearchEmbeddableBase } from '../../embeddable/components/saved_search_embeddable_base';
export interface DocTableEmbeddableProps extends Omit<DocTableProps, 'dataTestSubj'> {
totalHitCount?: number;

View file

@ -0,0 +1,61 @@
/*
* 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 { BehaviorSubject } from 'rxjs';
import { SearchSource } from '@kbn/data-plugin/common';
import type { DataView } from '@kbn/data-views-plugin/common';
import { DataTableRecord } from '@kbn/discover-utils';
import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
import { TimeRange } from '@kbn/es-query';
import { DatatableColumnMeta } from '@kbn/expressions-plugin/common';
import { FetchContext } from '@kbn/presentation-publishing';
import { DiscoverGridSettings, SavedSearch, VIEW_MODE } from '@kbn/saved-search-plugin/common';
import { SearchResponseIncompleteWarning } from '@kbn/search-response-warnings/src/types';
import { SortOrder } from '@kbn/unified-data-table';
export const getMockedSearchApi = ({
searchSource,
savedSearch,
}: {
searchSource: SearchSource;
savedSearch: SavedSearch;
}) => {
return {
api: {
uuid: 'testEmbeddable',
savedObjectId: new BehaviorSubject<string | undefined>(undefined),
dataViews: new BehaviorSubject<DataView[] | undefined>([
searchSource.getField('index') ?? dataViewMock,
]),
panelTitle: new BehaviorSubject<string | undefined>(undefined),
defaultPanelTitle: new BehaviorSubject<string | undefined>(undefined),
hidePanelTitle: new BehaviorSubject<boolean | undefined>(false),
fetchContext$: new BehaviorSubject<FetchContext | undefined>(undefined),
timeRange$: new BehaviorSubject<TimeRange | undefined>(undefined),
setTimeRange: jest.fn(),
dataLoading: new BehaviorSubject<boolean | undefined>(undefined),
blockingError: new BehaviorSubject<Error | undefined>(undefined),
fetchWarnings$: new BehaviorSubject<SearchResponseIncompleteWarning[]>([]),
savedSearch$: new BehaviorSubject<SavedSearch>(savedSearch),
},
stateManager: {
sort: new BehaviorSubject<SortOrder[] | undefined>(savedSearch.sort),
columns: new BehaviorSubject<string[] | undefined>(savedSearch.columns),
viewMode: new BehaviorSubject<VIEW_MODE | undefined>(savedSearch.viewMode),
rowHeight: new BehaviorSubject<number | undefined>(savedSearch.rowHeight),
headerRowHeight: new BehaviorSubject<number | undefined>(savedSearch.headerRowHeight),
rowsPerPage: new BehaviorSubject<number | undefined>(savedSearch.rowsPerPage),
sampleSize: new BehaviorSubject<number | undefined>(savedSearch.sampleSize),
grid: new BehaviorSubject<DiscoverGridSettings | undefined>(savedSearch.grid),
rows: new BehaviorSubject<DataTableRecord[]>([]),
totalHitCount: new BehaviorSubject<number | undefined>(0),
columnsMeta: new BehaviorSubject<Record<string, DatatableColumnMeta> | undefined>(undefined),
},
};
};

View file

@ -6,34 +6,29 @@
* Side Public License, v 1.
*/
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 { discoverServiceMock } from '../__mocks__/services';
import { SEARCH_EMBEDDABLE_TYPE } from '@kbn/discover-utils';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { getDiscoverLocatorParams } from './get_discover_locator_params';
import { SavedSearch } from '@kbn/saved-search-plugin/common';
import { BehaviorSubject } from 'rxjs';
import { discoverServiceMock } from '../../__mocks__/services';
import { createStartContractMock } from '../../__mocks__/start_contract';
import { SearchEmbeddableApi } from '../types';
import { getDiscoverLocatorParams } from '../utils/get_discover_locator_params';
import { ViewSavedSearchAction } from './view_saved_search_action';
const applicationMock = createStartContractMock();
const services = discoverServiceMock;
const searchInput = {
timeRange: {
from: '2021-09-15',
to: '2021-09-16',
const compatibleEmbeddableApi: SearchEmbeddableApi = {
type: SEARCH_EMBEDDABLE_TYPE,
savedSearch$: new BehaviorSubject({
searchSource: { getField: jest.fn() },
} as unknown as SavedSearch),
parentApi: {
viewMode: new BehaviorSubject('view'),
},
id: '1',
savedObjectId: 'mock-saved-object-id',
viewMode: ViewMode.VIEW,
};
const executeTriggerActions = async (triggerId: string, context: object) => {
return Promise.resolve(undefined);
};
const embeddableConfig = {
editable: true,
services,
executeTriggerActions,
};
} as unknown as SearchEmbeddableApi;
jest
.spyOn(services.core.chrome, 'getActiveSolutionNavId$')
@ -42,47 +37,33 @@ jest
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, services.locator);
const embeddable = new SavedSearchEmbeddable(embeddableConfig, searchInput);
expect(await action.isCompatible({ embeddable })).toBe(true);
expect(await action.isCompatible({ embeddable: compatibleEmbeddableApi })).toBe(true);
});
it('is not compatible when embeddable not of type saved search', async () => {
const action = new ViewSavedSearchAction(applicationMock, services.locator);
const embeddable = new ContactCardEmbeddable(
{
id: '123',
firstName: 'sue',
viewMode: ViewMode.EDIT,
},
{
execAction: () => Promise.resolve(undefined),
}
);
expect(
await action.isCompatible({
embeddable,
embeddable: { ...compatibleEmbeddableApi, type: 'CONTACT_CARD_EMBEDDABLE' },
})
).toBe(false);
});
it('is not visible when in edit mode', async () => {
const action = new ViewSavedSearchAction(applicationMock, services.locator);
const input = { ...searchInput, viewMode: ViewMode.EDIT };
const embeddable = new SavedSearchEmbeddable(embeddableConfig, input);
expect(
await action.isCompatible({
embeddable,
embeddable: { ...compatibleEmbeddableApi, viewMode: new BehaviorSubject(ViewMode.EDIT) },
})
).toBe(false);
});
it('execute navigates to a saved search', async () => {
const action = new ViewSavedSearchAction(applicationMock, services.locator);
const embeddable = new SavedSearchEmbeddable(embeddableConfig, searchInput);
await new Promise((resolve) => setTimeout(resolve, 0));
await action.execute({ embeddable });
await action.execute({ embeddable: compatibleEmbeddableApi });
expect(discoverServiceMock.locator.navigate).toHaveBeenCalledWith(
getDiscoverLocatorParams(embeddable)
getDiscoverLocatorParams(compatibleEmbeddableApi)
);
});
});

View file

@ -21,13 +21,13 @@ import {
} from '@kbn/presentation-publishing';
import type { Action } from '@kbn/ui-actions-plugin/public';
import type { DiscoverAppLocator } from '../../common';
import { getDiscoverLocatorParams } from './get_discover_locator_params';
import { apiHasSavedSearch, HasSavedSearch } from './types';
import type { DiscoverAppLocator } from '../../../common';
import { PublishesSavedSearch, apiPublishesSavedSearch } from '../types';
import { getDiscoverLocatorParams } from '../utils/get_discover_locator_params';
export const ACTION_VIEW_SAVED_SEARCH = 'ACTION_VIEW_SAVED_SEARCH';
type ViewSavedSearchActionApi = CanAccessViewMode & HasType & HasSavedSearch;
type ViewSavedSearchActionApi = CanAccessViewMode & HasType & PublishesSavedSearch;
const compatibilityCheck = (
api: EmbeddableApiContext['embeddable']
@ -37,7 +37,7 @@ const compatibilityCheck = (
getInheritedViewMode(api) === ViewMode.VIEW &&
apiHasType(api) &&
apiIsOfType(api, SEARCH_EMBEDDABLE_TYPE) &&
apiHasSavedSearch(api)
apiPublishesSavedSearch(api)
);
};

View file

@ -13,7 +13,7 @@ import {
type SearchResponseWarning,
SearchResponseWarningsBadge,
} from '@kbn/search-response-warnings';
import { TotalDocuments } from '../application/main/components/total_documents/total_documents';
import { TotalDocuments } from '../../application/main/components/total_documents/total_documents';
const containerStyles = css`
width: 100%;

View file

@ -16,12 +16,12 @@ import {
DataLoadingState as DiscoverGridLoadingState,
getRenderCustomToolbarWithElements,
} from '@kbn/unified-data-table';
import { DiscoverGrid } from '../components/discover_grid';
import { DiscoverGrid } from '../../components/discover_grid';
import './saved_search_grid.scss';
import { DiscoverGridFlyout } from '../components/discover_grid_flyout';
import { DiscoverGridFlyout } from '../../components/discover_grid_flyout';
import { SavedSearchEmbeddableBase } from './saved_search_embeddable_base';
import { TotalDocuments } from '../application/main/components/total_documents/total_documents';
import { useProfileAccessor } from '../context_awareness';
import { TotalDocuments } from '../../application/main/components/total_documents/total_documents';
import { useProfileAccessor } from '../../context_awareness';
export interface DiscoverGridEmbeddableProps
extends Omit<UnifiedDataTableProps, 'sampleSizeState'> {

View file

@ -0,0 +1,55 @@
/*
* 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 React, { useMemo } from 'react';
import { BehaviorSubject } from 'rxjs';
import type { DataView } from '@kbn/data-views-plugin/common';
import { FetchContext, useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
import { DocViewFilterFn } from '@kbn/unified-doc-viewer/types';
import { FieldStatisticsTable } from '../../application/main/components/field_stats_table';
import { isEsqlMode } from '../initialize_fetch';
import type { SearchEmbeddableApi, SearchEmbeddableStateManager } from '../types';
interface SavedSearchEmbeddableComponentProps {
api: SearchEmbeddableApi & {
fetchContext$: BehaviorSubject<FetchContext | undefined>;
};
dataView: DataView;
onAddFilter?: DocViewFilterFn;
stateManager: SearchEmbeddableStateManager;
}
export function SearchEmbeddablFieldStatsTableComponent({
api,
dataView,
onAddFilter,
stateManager,
}: SavedSearchEmbeddableComponentProps) {
const [fetchContext, savedSearch] = useBatchedPublishingSubjects(
api.fetchContext$,
api.savedSearch$
);
const isEsql = useMemo(() => isEsqlMode(savedSearch), [savedSearch]);
return (
<FieldStatisticsTable
dataView={dataView}
columns={savedSearch.columns ?? []}
savedSearch={savedSearch}
filters={fetchContext?.filters}
query={fetchContext?.query}
onAddFilter={onAddFilter}
searchSessionId={fetchContext?.searchSessionId}
isEsqlMode={isEsql}
timeRange={fetchContext?.timeRange}
/>
);
}

View file

@ -0,0 +1,194 @@
/*
* 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 React, { useMemo } from 'react';
import { BehaviorSubject } from 'rxjs';
import type { DataView } from '@kbn/data-views-plugin/common';
import {
DOC_HIDE_TIME_COLUMN_SETTING,
isLegacyTableEnabled,
SEARCH_FIELDS_FROM_SOURCE,
} from '@kbn/discover-utils';
import { Filter } from '@kbn/es-query';
import {
useBatchedOptionalPublishingSubjects,
useBatchedPublishingSubjects,
} from '@kbn/presentation-publishing';
import { SortOrder } from '@kbn/saved-search-plugin/public';
import { SearchResponseIncompleteWarning } from '@kbn/search-response-warnings/src/types';
import { columnActions, DataLoadingState } from '@kbn/unified-data-table';
import { DocViewFilterFn } from '@kbn/unified-doc-viewer/types';
import { DiscoverDocTableEmbeddable } from '../../components/doc_table/create_doc_table_embeddable';
import { useDiscoverServices } from '../../hooks/use_discover_services';
import { getSortForEmbeddable } from '../../utils';
import { getAllowedSampleSize, getMaxAllowedSampleSize } from '../../utils/get_allowed_sample_size';
import { SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER_ID } from '../constants';
import { isEsqlMode } from '../initialize_fetch';
import type { SearchEmbeddableApi, SearchEmbeddableStateManager } from '../types';
import { DiscoverGridEmbeddable } from './saved_search_grid';
interface SavedSearchEmbeddableComponentProps {
api: SearchEmbeddableApi & { fetchWarnings$: BehaviorSubject<SearchResponseIncompleteWarning[]> };
dataView: DataView;
onAddFilter?: DocViewFilterFn;
stateManager: SearchEmbeddableStateManager;
}
const DiscoverDocTableEmbeddableMemoized = React.memo(DiscoverDocTableEmbeddable);
const DiscoverGridEmbeddableMemoized = React.memo(DiscoverGridEmbeddable);
export function SearchEmbeddableGridComponent({
api,
dataView,
onAddFilter,
stateManager,
}: SavedSearchEmbeddableComponentProps) {
const discoverServices = useDiscoverServices();
const [
loading,
savedSearch,
savedSearchId,
interceptedWarnings,
rows,
totalHitCount,
columnsMeta,
] = useBatchedPublishingSubjects(
api.dataLoading,
api.savedSearch$,
api.savedObjectId,
api.fetchWarnings$,
stateManager.rows,
stateManager.totalHitCount,
stateManager.columnsMeta
);
const [panelTitle, panelDescription, savedSearchTitle, savedSearchDescription] =
useBatchedOptionalPublishingSubjects(
api.panelTitle,
api.panelDescription,
api.defaultPanelTitle,
api.defaultPanelDescription
);
const isEsql = useMemo(() => isEsqlMode(savedSearch), [savedSearch]);
const useLegacyTable = useMemo(
() =>
isLegacyTableEnabled({
uiSettings: discoverServices.uiSettings,
isEsqlMode: isEsql,
}),
[discoverServices, isEsql]
);
const sort = useMemo(() => {
return getSortForEmbeddable(savedSearch.sort, dataView, discoverServices.uiSettings, isEsql);
}, [savedSearch.sort, dataView, isEsql, discoverServices.uiSettings]);
const onStateEditedProps = useMemo(
() => ({
onAddColumn: (columnName: string) => {
if (!savedSearch.columns) {
return;
}
const updatedColumns = columnActions.addColumn(savedSearch.columns, columnName, true);
stateManager.columns.next(updatedColumns);
},
onSetColumns: (updatedColumns: string[]) => {
stateManager.columns.next(updatedColumns);
},
onMoveColumn: (columnName: string, newIndex: number) => {
if (!savedSearch.columns) {
return;
}
const updatedColumns = columnActions.moveColumn(savedSearch.columns, columnName, newIndex);
stateManager.columns.next(updatedColumns);
},
onRemoveColumn: (columnName: string) => {
if (!savedSearch.columns) {
return;
}
const updatedColumns = columnActions.removeColumn(savedSearch.columns, columnName, true);
stateManager.columns.next(updatedColumns);
},
onUpdateRowsPerPage: (newRowsPerPage: number | undefined) => {
stateManager.rowsPerPage.next(newRowsPerPage);
},
onUpdateRowHeight: (newRowHeight: number | undefined) => {
stateManager.rowHeight.next(newRowHeight);
},
onUpdateHeaderRowHeight: (newHeaderRowHeight: number | undefined) => {
stateManager.headerRowHeight.next(newHeaderRowHeight);
},
onSort: (nextSort: string[][]) => {
const sortOrderArr: SortOrder[] = [];
nextSort.forEach((arr) => {
sortOrderArr.push(arr as SortOrder);
});
stateManager.sort.next(sortOrderArr);
},
onUpdateSampleSize: (newSampleSize: number | undefined) => {
stateManager.sampleSize.next(newSampleSize);
},
}),
[stateManager, savedSearch.columns]
);
const fetchedSampleSize = useMemo(() => {
return getAllowedSampleSize(savedSearch.sampleSize, discoverServices.uiSettings);
}, [savedSearch.sampleSize, discoverServices]);
const sharedProps = {
columns: savedSearch.columns ?? [],
dataView,
interceptedWarnings,
onFilter: onAddFilter,
rows,
rowsPerPageState: savedSearch.rowsPerPage,
sampleSizeState: fetchedSampleSize,
searchDescription: panelDescription || savedSearchDescription,
sort,
totalHitCount,
useNewFieldsApi: !discoverServices.uiSettings.get(SEARCH_FIELDS_FROM_SOURCE, false),
};
if (useLegacyTable) {
return (
<DiscoverDocTableEmbeddableMemoized
{...sharedProps}
{...onStateEditedProps}
filters={savedSearch.searchSource.getField('filter') as Filter[]}
isEsqlMode={isEsql}
isLoading={Boolean(loading)}
sharedItemTitle={panelTitle || savedSearchTitle}
/>
);
}
return (
<DiscoverGridEmbeddableMemoized
{...sharedProps}
{...onStateEditedProps}
settings={savedSearch.grid}
ariaLabelledBy={'documentsAriaLabel'}
cellActionsTriggerId={SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER_ID}
columnsMeta={columnsMeta}
headerRowHeightState={savedSearch.headerRowHeight}
isPlainRecord={isEsql}
loadingState={Boolean(loading) ? DataLoadingState.loading : DataLoadingState.loaded}
maxAllowedSampleSize={getMaxAllowedSampleSize(discoverServices.uiSettings)}
query={savedSearch.searchSource.getField('query')}
rowHeightState={savedSearch.rowHeight}
savedSearchId={savedSearchId}
searchTitle={panelTitle || savedSearchTitle}
services={discoverServices}
showTimeCol={!discoverServices.uiSettings.get(DOC_HIDE_TIME_COLUMN_SETTING, false)}
/>
);
}

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import { SavedSearchAttributes } from '@kbn/saved-search-plugin/common';
import type { Trigger } from '@kbn/ui-actions-plugin/public';
export { SEARCH_EMBEDDABLE_TYPE } from '@kbn/discover-utils';
@ -19,3 +20,23 @@ export const SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER: Trigger = {
description:
'This trigger is used to replace the cell actions for Discover saved search embeddable grid.',
} as const;
export const DEFAULT_HEADER_ROW_HEIGHT_LINES = 3;
/** This constant refers to the parts of the saved search state that can be edited from a dashboard */
export const EDITABLE_SAVED_SEARCH_KEYS: Readonly<Array<keyof SavedSearchAttributes>> = [
'sort',
'columns',
'rowHeight',
'sampleSize',
'rowsPerPage',
'headerRowHeight',
] as const;
/** This constant refers to the dashboard panel specific state */
export const EDITABLE_PANEL_KEYS = [
'title', // panel title
'description', // panel description
'timeRange', // panel custom time range
'hidePanelTitles', // panel hidden title
] as const;

View file

@ -0,0 +1,291 @@
/*
* 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 React from 'react';
import { BehaviorSubject, Observable } from 'rxjs';
import { createSearchSourceMock } from '@kbn/data-plugin/public/mocks';
import type { DataView } from '@kbn/data-views-plugin/common';
import { SHOW_FIELD_STATISTICS } from '@kbn/discover-utils';
import { buildDataViewMock, deepMockedFields } from '@kbn/discover-utils/src/__mocks__';
import { BuildReactEmbeddableApiRegistration } from '@kbn/embeddable-plugin/public/react_embeddable_system/types';
import { PresentationContainer } from '@kbn/presentation-containers';
import { PhaseEvent, PublishesUnifiedSearch, StateComparators } from '@kbn/presentation-publishing';
import { VIEW_MODE } from '@kbn/saved-search-plugin/common';
import { act, render } from '@testing-library/react';
import { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import { createDataViewDataSource } from '../../common/data_sources';
import { discoverServiceMock } from '../__mocks__/services';
import { getSearchEmbeddableFactory } from './get_search_embeddable_factory';
import {
SearchEmbeddableApi,
SearchEmbeddableRuntimeState,
SearchEmbeddableSerializedState,
} from './types';
describe('saved search embeddable', () => {
const mockServices = {
discoverServices: discoverServiceMock,
startServices: {
executeTriggerActions: jest.fn(),
isEditable: jest.fn().mockReturnValue(true),
},
};
const dataViewMock = buildDataViewMock({ name: 'the-data-view', fields: deepMockedFields });
const uuid = 'mock-embeddable-id';
const factory = getSearchEmbeddableFactory(mockServices);
const dashboadFilters = new BehaviorSubject<Filter[] | undefined>(undefined);
const mockedDashboardApi = {
filters$: dashboadFilters,
timeRange$: new BehaviorSubject<TimeRange | undefined>(undefined),
query$: new BehaviorSubject<Query | AggregateQuery | undefined>(undefined),
} as unknown as PresentationContainer & PublishesUnifiedSearch;
const getSearchResponse = (nrOfHits: number) => {
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 buildApiMock = (
api: BuildReactEmbeddableApiRegistration<
SearchEmbeddableSerializedState,
SearchEmbeddableRuntimeState,
SearchEmbeddableApi
>,
_: StateComparators<SearchEmbeddableRuntimeState>
) => ({
...api,
uuid,
type: factory.type,
parentApi: mockedDashboardApi,
phase$: new BehaviorSubject<PhaseEvent | undefined>(undefined),
resetUnsavedChanges: jest.fn(),
snapshotRuntimeState: jest.fn(),
unsavedChanges: new BehaviorSubject<Partial<SearchEmbeddableRuntimeState> | undefined>(
undefined
),
});
const getInitialRuntimeState = ({
searchMock,
dataView = dataViewMock,
partialState = {},
}: {
searchMock?: jest.Mock;
dataView?: DataView;
partialState?: Partial<SearchEmbeddableRuntimeState>;
} = {}): SearchEmbeddableRuntimeState => {
const searchSource = createSearchSourceMock({ index: dataView }, undefined, searchMock);
discoverServiceMock.data.search.searchSource.create = jest
.fn()
.mockResolvedValueOnce(searchSource);
return {
timeRange: { from: 'now-15m', to: 'now' },
columns: ['message', 'extension'],
rowHeight: 30,
headerRowHeight: 5,
rowsPerPage: 50,
sampleSize: 250,
serializedSearchSource: searchSource.getSerializedFields(),
...partialState,
};
};
const waitOneTick = () => act(() => new Promise((resolve) => setTimeout(resolve, 0)));
describe('search embeddable component', () => {
it('should render empty grid when empty data is returned', async () => {
const { search, resolveSearch } = createSearchFnMock(0);
const { Component, api } = await factory.buildEmbeddable(
getInitialRuntimeState({ searchMock: search }),
buildApiMock,
uuid,
mockedDashboardApi,
jest.fn().mockImplementation((newApi) => newApi)
);
await waitOneTick(); // wait for build to complete
const discoverComponent = render(<Component />);
// wait for data fetching
expect(api.dataLoading.getValue()).toBe(true);
resolveSearch();
await waitOneTick();
expect(api.dataLoading.getValue()).toBe(false);
expect(discoverComponent.queryByTestId('embeddedSavedSearchDocTable')).toBeInTheDocument();
expect(discoverComponent.getByTestId('embeddedSavedSearchDocTable').textContent).toEqual(
'No results found'
);
});
it('should render field stats table in AGGREGATED_LEVEL view mode', async () => {
const { search, resolveSearch } = createSearchFnMock(0);
const { Component, api } = await factory.buildEmbeddable(
getInitialRuntimeState({
searchMock: search,
partialState: { viewMode: VIEW_MODE.AGGREGATED_LEVEL },
}),
buildApiMock,
uuid,
mockedDashboardApi,
jest.fn().mockImplementation((newApi) => newApi)
);
await waitOneTick(); // wait for build to complete
discoverServiceMock.uiSettings.get = jest.fn().mockImplementationOnce((key: string) => {
if (key === SHOW_FIELD_STATISTICS) return true;
});
const discoverComponent = render(<Component />);
// wait for data fetching
expect(api.dataLoading.getValue()).toBe(true);
resolveSearch();
await waitOneTick();
expect(api.dataLoading.getValue()).toBe(false);
expect(discoverComponent.queryByTestId('dscFieldStatsEmbeddedContent')).toBeInTheDocument();
});
});
describe('search embeddable api', () => {
it('should not fetch data if only a new input title is set', async () => {
const { search, resolveSearch } = createSearchFnMock(1);
const { api } = await factory.buildEmbeddable(
getInitialRuntimeState({
searchMock: search,
partialState: { viewMode: VIEW_MODE.AGGREGATED_LEVEL },
}),
buildApiMock,
uuid,
mockedDashboardApi,
jest.fn().mockImplementation((newApi) => newApi)
);
await waitOneTick(); // wait for build to complete
// wait for data fetching
expect(api.dataLoading.getValue()).toBe(true);
resolveSearch();
await waitOneTick();
expect(api.dataLoading.getValue()).toBe(false);
expect(search).toHaveBeenCalledTimes(1);
api.setPanelTitle('custom title');
await waitOneTick();
expect(search).toHaveBeenCalledTimes(1);
});
});
describe('context awareness', () => {
beforeAll(() => {
jest
.spyOn(discoverServiceMock.core.chrome, 'getActiveSolutionNavId$')
.mockReturnValue(new BehaviorSubject('test'));
});
afterAll(() => {
jest.resetAllMocks();
});
it('should resolve root profile on init', async () => {
const resolveRootProfileSpy = jest.spyOn(
discoverServiceMock.profilesManager,
'resolveRootProfile'
);
await factory.buildEmbeddable(
getInitialRuntimeState(),
buildApiMock,
uuid,
mockedDashboardApi,
jest.fn().mockImplementation((newApi) => newApi)
);
await waitOneTick(); // wait for build to complete
expect(resolveRootProfileSpy).toHaveBeenCalledWith({ solutionNavId: 'test' });
resolveRootProfileSpy.mockReset();
expect(resolveRootProfileSpy).not.toHaveBeenCalled();
});
it('should resolve data source profile when fetching', async () => {
const resolveDataSourceProfileSpy = jest.spyOn(
discoverServiceMock.profilesManager,
'resolveDataSourceProfile'
);
const { api } = await factory.buildEmbeddable(
getInitialRuntimeState(),
buildApiMock,
uuid,
mockedDashboardApi,
jest.fn().mockImplementation((newApi) => newApi)
);
await waitOneTick(); // wait for build to complete
expect(resolveDataSourceProfileSpy).toHaveBeenCalledWith({
dataSource: createDataViewDataSource({ dataViewId: dataViewMock.id! }),
dataView: dataViewMock,
query: api.savedSearch$.getValue().searchSource.getField('query'),
});
resolveDataSourceProfileSpy.mockReset();
expect(resolveDataSourceProfileSpy).not.toHaveBeenCalled();
// trigger a refetch
dashboadFilters.next([]);
await waitOneTick();
expect(resolveDataSourceProfileSpy).toHaveBeenCalled();
});
it('should pass cell renderers from profile', async () => {
const { search, resolveSearch } = createSearchFnMock(1);
const { Component, api } = await factory.buildEmbeddable(
getInitialRuntimeState({
searchMock: search,
partialState: { columns: ['rootProfile', 'message', 'extension'] },
}),
buildApiMock,
uuid,
mockedDashboardApi,
jest.fn().mockImplementation((newApi) => newApi)
);
await waitOneTick(); // wait for build to complete
const discoverComponent = render(<Component />);
// wait for data fetching
expect(api.dataLoading.getValue()).toBe(true);
resolveSearch();
await waitOneTick();
expect(api.dataLoading.getValue()).toBe(false);
const discoverGridComponent = discoverComponent.queryByTestId('discoverDocTable');
expect(discoverGridComponent).toBeInTheDocument();
expect(discoverComponent.queryByText('data-source-profile')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,314 @@
/*
* 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 { omit } from 'lodash';
import React, { useCallback, useEffect, useMemo } from 'react';
import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { CellActionsProvider } from '@kbn/cell-actions';
import { APPLY_FILTER_TRIGGER, generateFilters } from '@kbn/data-plugin/public';
import { SEARCH_EMBEDDABLE_TYPE, SHOW_FIELD_STATISTICS } from '@kbn/discover-utils';
import { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public';
import { FilterStateStore } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import {
FetchContext,
initializeTimeRange,
initializeTitles,
useBatchedPublishingSubjects,
} from '@kbn/presentation-publishing';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import { VIEW_MODE } from '@kbn/saved-search-plugin/common';
import { SearchResponseIncompleteWarning } from '@kbn/search-response-warnings/src/types';
import { getValidViewMode } from '../application/main/utils/get_valid_view_mode';
import { DiscoverServices } from '../build_services';
import { SearchEmbeddablFieldStatsTableComponent } from './components/search_embeddable_field_stats_table_component';
import { SearchEmbeddableGridComponent } from './components/search_embeddable_grid_component';
import { initializeEditApi } from './initialize_edit_api';
import { initializeFetch, isEsqlMode } from './initialize_fetch';
import { initializeSearchEmbeddableApi } from './initialize_search_embeddable_api';
import {
SearchEmbeddableApi,
SearchEmbeddableRuntimeState,
SearchEmbeddableSerializedState,
} from './types';
import { deserializeState, serializeState } from './utils/serialization_utils';
export const getSearchEmbeddableFactory = ({
startServices,
discoverServices,
}: {
startServices: {
executeTriggerActions: (triggerId: string, context: object) => Promise<void>;
isEditable: () => boolean;
};
discoverServices: DiscoverServices;
}) => {
const { save, checkForDuplicateTitle } = discoverServices.savedSearch;
const savedSearchEmbeddableFactory: ReactEmbeddableFactory<
SearchEmbeddableSerializedState,
SearchEmbeddableRuntimeState,
SearchEmbeddableApi
> = {
type: SEARCH_EMBEDDABLE_TYPE,
deserializeState: async (serializedState) => {
return deserializeState({ serializedState, discoverServices });
},
buildEmbeddable: async (initialState, buildApi, uuid, parentApi) => {
/** One Discover context awareness */
const solutionNavId = await firstValueFrom(
discoverServices.core.chrome.getActiveSolutionNavId$()
);
await discoverServices.profilesManager.resolveRootProfile({ solutionNavId });
/** Specific by-reference state */
const savedObjectId$ = new BehaviorSubject<string | undefined>(initialState?.savedObjectId);
const defaultPanelTitle$ = new BehaviorSubject<string | undefined>(
initialState?.savedObjectTitle
);
const defaultPanelDescription$ = new BehaviorSubject<string | undefined>(
initialState?.savedObjectDescription
);
/** All other state */
const blockingError$ = new BehaviorSubject<Error | undefined>(undefined);
const dataLoading$ = new BehaviorSubject<boolean | undefined>(true);
const fetchContext$ = new BehaviorSubject<FetchContext | undefined>(undefined);
const fetchWarnings$ = new BehaviorSubject<SearchResponseIncompleteWarning[]>([]);
/** Build API */
const { titlesApi, titleComparators, serializeTitles } = initializeTitles(initialState);
const timeRange = initializeTimeRange(initialState);
const searchEmbeddable = await initializeSearchEmbeddableApi(initialState, {
discoverServices,
});
const unsubscribeFromFetch = initializeFetch({
api: {
parentApi,
...titlesApi,
...timeRange.api,
savedSearch$: searchEmbeddable.api.savedSearch$,
dataViews: searchEmbeddable.api.dataViews,
savedObjectId: savedObjectId$,
dataLoading: dataLoading$,
blockingError: blockingError$,
fetchContext$,
fetchWarnings$,
},
discoverServices,
stateManager: searchEmbeddable.stateManager,
});
const api: SearchEmbeddableApi = buildApi(
{
...titlesApi,
...searchEmbeddable.api,
...timeRange.api,
...initializeEditApi({
uuid,
parentApi,
partialApi: { ...searchEmbeddable.api, fetchContext$, savedObjectId: savedObjectId$ },
discoverServices,
isEditable: startServices.isEditable,
}),
dataLoading: dataLoading$,
blockingError: blockingError$,
savedObjectId: savedObjectId$,
defaultPanelTitle: defaultPanelTitle$,
defaultPanelDescription: defaultPanelDescription$,
getByValueRuntimeSnapshot: () => {
const savedSearch = searchEmbeddable.api.savedSearch$.getValue();
return {
...serializeTitles(),
...timeRange.serialize(),
...omit(savedSearch, 'searchSource'),
serializedSearchSource: savedSearch.searchSource.getSerializedFields(),
};
},
hasTimeRange: () => {
const fetchContext = fetchContext$.getValue();
return fetchContext?.timeslice !== undefined || fetchContext?.timeRange !== undefined;
},
getTypeDisplayName: () =>
i18n.translate('discover.embeddable.search.displayName', {
defaultMessage: 'search',
}),
canLinkToLibrary: async () => {
return (
discoverServices.capabilities.discover.save && !Boolean(savedObjectId$.getValue())
);
},
canUnlinkFromLibrary: async () => Boolean(savedObjectId$.getValue()),
libraryId$: savedObjectId$,
saveToLibrary: async (title: string) => {
const savedObjectId = await save({
...api.savedSearch$.getValue(),
title,
});
defaultPanelTitle$.next(title);
savedObjectId$.next(savedObjectId!);
return savedObjectId!;
},
checkForDuplicateTitle: (newTitle, isTitleDuplicateConfirmed, onTitleDuplicate) =>
checkForDuplicateTitle({
newTitle,
isTitleDuplicateConfirmed,
onTitleDuplicate,
}),
unlinkFromLibrary: () => {
savedObjectId$.next(undefined);
if ((titlesApi.panelTitle.getValue() ?? '').length === 0) {
titlesApi.setPanelTitle(defaultPanelTitle$.getValue());
}
if ((titlesApi.panelDescription.getValue() ?? '').length === 0) {
titlesApi.setPanelDescription(defaultPanelDescription$.getValue());
}
defaultPanelTitle$.next(undefined);
defaultPanelDescription$.next(undefined);
},
serializeState: async () =>
serializeState({
uuid,
initialState,
savedSearch: searchEmbeddable.api.savedSearch$.getValue(),
serializeTitles,
serializeTimeRange: timeRange.serialize,
savedObjectId: savedObjectId$.getValue(),
discoverServices,
}),
},
{
...titleComparators,
...timeRange.comparators,
...searchEmbeddable.comparators,
savedObjectId: [savedObjectId$, (value) => savedObjectId$.next(value)],
savedObjectTitle: [defaultPanelTitle$, (value) => defaultPanelTitle$.next(value)],
savedObjectDescription: [
defaultPanelDescription$,
(value) => defaultPanelDescription$.next(value),
],
}
);
return {
api,
Component: () => {
const [savedSearch, dataViews] = useBatchedPublishingSubjects(
api.savedSearch$,
api.dataViews
);
useEffect(() => {
return () => {
searchEmbeddable.cleanup();
unsubscribeFromFetch();
};
}, []);
const viewMode = useMemo(() => {
if (!savedSearch.searchSource) return;
return getValidViewMode({
viewMode: savedSearch.viewMode,
isEsqlMode: isEsqlMode(savedSearch),
});
}, [savedSearch]);
const dataView = useMemo(() => {
const hasDataView = (dataViews ?? []).length > 0;
if (!hasDataView) {
blockingError$.next(
new Error(
i18n.translate('discover.embeddable.search.dataViewError', {
defaultMessage: 'Missing data view {indexPatternId}',
values: {
indexPatternId:
typeof initialState.serializedSearchSource?.index === 'string'
? initialState.serializedSearchSource.index
: initialState.serializedSearchSource?.index?.id ?? '',
},
})
)
);
return;
}
return dataViews![0];
}, [dataViews]);
const onAddFilter = useCallback(
async (field, value, operator) => {
if (!dataView) return;
let newFilters = generateFilters(
discoverServices.filterManager,
field,
value,
operator,
dataView
);
newFilters = newFilters.map((filter) => ({
...filter,
$state: { store: FilterStateStore.APP_STATE },
}));
await startServices.executeTriggerActions(APPLY_FILTER_TRIGGER, {
embeddable: api,
filters: newFilters,
});
},
[dataView]
);
const renderAsFieldStatsTable = useMemo(
() =>
Boolean(discoverServices.uiSettings.get(SHOW_FIELD_STATISTICS)) &&
viewMode === VIEW_MODE.AGGREGATED_LEVEL &&
Boolean(dataView) &&
Array.isArray(savedSearch.columns),
[savedSearch, dataView, viewMode]
);
return (
<KibanaRenderContextProvider {...discoverServices.core}>
<KibanaContextProvider services={discoverServices}>
{renderAsFieldStatsTable ? (
<SearchEmbeddablFieldStatsTableComponent
api={{
...api,
fetchContext$,
}}
dataView={dataView!}
onAddFilter={isEsqlMode(savedSearch) ? undefined : onAddFilter}
stateManager={searchEmbeddable.stateManager}
/>
) : (
<CellActionsProvider
getTriggerCompatibleActions={
discoverServices.uiActions.getTriggerCompatibleActions
}
>
<SearchEmbeddableGridComponent
api={{ ...api, fetchWarnings$ }}
dataView={dataView!}
onAddFilter={isEsqlMode(savedSearch) ? undefined : onAddFilter}
stateManager={searchEmbeddable.stateManager}
/>
</CellActionsProvider>
)}
</KibanaContextProvider>
</KibanaRenderContextProvider>
);
},
};
},
};
return savedSearchEmbeddableFactory;
};

View file

@ -8,4 +8,3 @@
export { SEARCH_EMBEDDABLE_TYPE, SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER_ID } from './constants';
export * from './types';
export * from './search_embeddable_factory';

View file

@ -0,0 +1,154 @@
/*
* 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 { createSearchSourceMock } from '@kbn/data-plugin/public/mocks';
import type { DataView } from '@kbn/data-views-plugin/common';
import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
import { VIEW_MODE } from '@kbn/saved-search-plugin/common';
import { dataViewAdHoc } from '../__mocks__/data_view_complex';
import { discoverServiceMock } from '../__mocks__/services';
import { getAppTarget, initializeEditApi } from './initialize_edit_api';
import { getDiscoverLocatorParams } from './utils/get_discover_locator_params';
import { getMockedSearchApi } from './__mocks__/get_mocked_api';
describe('initialize edit api', () => {
const searchSource = createSearchSourceMock({ index: dataViewMock });
const savedSearch = {
id: 'mock-id',
title: 'saved search',
sort: [['message', 'asc']] as Array<[string, string]>,
searchSource,
viewMode: VIEW_MODE.DOCUMENT_LEVEL,
managed: false,
};
const { api: mockedApi } = getMockedSearchApi({ searchSource, savedSearch });
const waitOneTick = () => new Promise((resolve) => setTimeout(resolve, 0));
describe('get app target', () => {
const runEditLinkTest = async (dataView?: DataView, byValue?: boolean) => {
jest
.spyOn(discoverServiceMock.locator, 'getUrl')
.mockClear()
.mockResolvedValueOnce('/base/mock-url');
jest
.spyOn(discoverServiceMock.core.http.basePath, 'remove')
.mockClear()
.mockReturnValueOnce('/mock-url');
if (dataView) {
mockedApi.dataViews.next([dataView]);
} else {
mockedApi.dataViews.next([dataViewMock]);
}
if (byValue) {
mockedApi.savedObjectId.next(undefined);
} else {
mockedApi.savedObjectId.next('test-id');
}
await waitOneTick();
const {
path: editPath,
app: editApp,
editUrl,
} = await getAppTarget(mockedApi, discoverServiceMock);
return { editPath, editApp, editUrl };
};
const testByReference = ({
editPath,
editApp,
editUrl,
}: {
editPath: string;
editApp: string;
editUrl: string;
}) => {
const locatorParams = getDiscoverLocatorParams(mockedApi);
expect(discoverServiceMock.locator.getUrl).toHaveBeenCalledTimes(1);
expect(discoverServiceMock.locator.getUrl).toHaveBeenCalledWith(locatorParams);
expect(discoverServiceMock.core.http.basePath.remove).toHaveBeenCalledTimes(1);
expect(discoverServiceMock.core.http.basePath.remove).toHaveBeenCalledWith('/base/mock-url');
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 () => {
const { editPath, editApp, editUrl } = await runEditLinkTest();
testByReference({ editPath, editApp, editUrl });
});
it('should correctly output edit link params for by reference saved search with ad hoc data view', async () => {
const { editPath, editApp, editUrl } = await runEditLinkTest(dataViewAdHoc);
testByReference({ editPath, editApp, editUrl });
});
it('should correctly output edit link params for by value saved search', async () => {
const { editPath, editApp, editUrl } = await runEditLinkTest(undefined, true);
testByReference({ editPath, editApp, editUrl });
});
it('should correctly output edit link params for by value saved search with ad hoc data view', async () => {
jest
.spyOn(discoverServiceMock.locator, 'getRedirectUrl')
.mockClear()
.mockReturnValueOnce('/base/mock-url');
jest
.spyOn(discoverServiceMock.core.http.basePath, 'remove')
.mockClear()
.mockReturnValueOnce('/mock-url');
const { editPath, editApp, editUrl } = await runEditLinkTest(dataViewAdHoc, true);
const locatorParams = getDiscoverLocatorParams(mockedApi);
expect(discoverServiceMock.locator.getRedirectUrl).toHaveBeenCalledTimes(1);
expect(discoverServiceMock.locator.getRedirectUrl).toHaveBeenCalledWith(locatorParams);
expect(discoverServiceMock.core.http.basePath.remove).toHaveBeenCalledTimes(1);
expect(discoverServiceMock.core.http.basePath.remove).toHaveBeenCalledWith('/base/mock-url');
expect(editApp).toBe('r');
expect(editPath).toBe('/mock-url');
expect(editUrl).toBe('/base/mock-url');
});
});
test('on edit calls `navigateToEditor`', async () => {
const mockedNavigate = jest.fn();
discoverServiceMock.embeddable.getStateTransfer = jest.fn().mockImplementation(() => ({
navigateToEditor: mockedNavigate,
}));
mockedApi.dataViews.next([dataViewMock]);
await waitOneTick();
const { onEdit } = initializeEditApi({
uuid: 'test',
parentApi: {
getAppContext: jest.fn().mockResolvedValue({
getCurrentPath: jest.fn(),
currentAppId: 'dashboard',
}),
},
partialApi: mockedApi,
isEditable: () => true,
discoverServices: discoverServiceMock,
});
await onEdit();
expect(mockedNavigate).toBeCalledTimes(1);
expect(mockedNavigate).toBeCalledWith('discover', {
path: '/mock-url',
state: expect.any(Object),
});
});
});

View file

@ -0,0 +1,99 @@
/*
* 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 { i18n } from '@kbn/i18n';
import {
apiHasAppContext,
FetchContext,
HasAppContext,
HasEditCapabilities,
PublishesDataViews,
PublishesSavedObjectId,
PublishingSubject,
} from '@kbn/presentation-publishing';
import { DiscoverServices } from '../build_services';
import { PublishesSavedSearch } from './types';
import { getDiscoverLocatorParams } from './utils/get_discover_locator_params';
type SavedSearchPartialApi = PublishesSavedSearch &
PublishesSavedObjectId &
PublishesDataViews & { fetchContext$: PublishingSubject<FetchContext | undefined> };
export async function getAppTarget(
partialApi: SavedSearchPartialApi,
discoverServices: DiscoverServices
) {
const savedObjectId = partialApi.savedObjectId.getValue();
const dataViews = partialApi.dataViews.getValue();
const locatorParams = getDiscoverLocatorParams(partialApi);
// 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 && !dataViews?.[0]?.isPersisted();
const editUrl = useRedirect
? discoverServices.locator.getRedirectUrl(locatorParams)
: await discoverServices.locator.getUrl(locatorParams);
const editPath = discoverServices.core.http.basePath.remove(editUrl);
const editApp = useRedirect ? 'r' : 'discover';
return { path: editPath, app: editApp, editUrl };
}
export function initializeEditApi<
ParentApiType = unknown,
ReturnType = ParentApiType extends HasAppContext ? HasEditCapabilities : {}
>({
uuid,
parentApi,
partialApi,
isEditable,
discoverServices,
}: {
uuid: string;
parentApi?: ParentApiType;
partialApi: PublishesSavedSearch &
PublishesSavedObjectId &
PublishesDataViews & { fetchContext$: PublishingSubject<FetchContext | undefined> };
isEditable: () => boolean;
discoverServices: DiscoverServices;
}): ReturnType {
/**
* If the parent is providing context, then the embeddable state transfer service can be used
* and editing should be allowed; otherwise, do not provide editing capabilities
*/
if (!parentApi || !apiHasAppContext(parentApi)) {
return {} as ReturnType;
}
const parentApiContext = parentApi.getAppContext();
return {
getTypeDisplayName: () =>
i18n.translate('discover.embeddable.search.displayName', {
defaultMessage: 'search',
}),
onEdit: async () => {
const appTarget = await getAppTarget(partialApi, discoverServices);
const stateTransfer = discoverServices.embeddable.getStateTransfer();
await stateTransfer.navigateToEditor(appTarget.app, {
path: appTarget.path,
state: {
embeddableId: uuid,
valueInput: partialApi.savedSearch$.getValue(),
originatingApp: parentApiContext.currentAppId,
searchSessionId: partialApi.fetchContext$.getValue()?.searchSessionId,
originatingPath: parentApiContext.getCurrentPath?.(),
},
});
},
isEditingEnabled: isEditable,
getEditHref: async () => {
return (await getAppTarget(partialApi, discoverServices))?.path;
},
} as ReturnType;
}

View file

@ -0,0 +1,107 @@
/*
* 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 { Observable, of } from 'rxjs';
import { createSearchSourceMock } from '@kbn/data-plugin/public/mocks';
import { buildDataTableRecord } from '@kbn/discover-utils';
import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
import { VIEW_MODE } from '@kbn/saved-search-plugin/common';
import { discoverServiceMock } from '../__mocks__/services';
import { initializeFetch } from './initialize_fetch';
import { getMockedSearchApi } from './__mocks__/get_mocked_api';
describe('initialize fetch', () => {
const searchSource = createSearchSourceMock({ index: dataViewMock });
const savedSearch = {
id: 'mock-id',
title: 'saved search',
sort: [['message', 'asc']] as Array<[string, string]>,
searchSource,
viewMode: VIEW_MODE.DOCUMENT_LEVEL,
managed: false,
};
const { api: mockedApi, stateManager } = getMockedSearchApi({ searchSource, savedSearch });
const waitOneTick = () => new Promise((resolve) => setTimeout(resolve, 0));
beforeAll(async () => {
initializeFetch({
api: mockedApi,
stateManager,
discoverServices: discoverServiceMock,
});
await waitOneTick();
});
it('should set state via state manager', async () => {
expect(stateManager.rows.getValue()).toEqual([]);
expect(stateManager.totalHitCount.getValue()).toEqual(0);
searchSource.fetch$ = jest.fn().mockImplementation(() =>
of({
rawResponse: {
hits: {
hits: [
{ _id: '1', _index: dataViewMock.id },
{ _id: '2', _index: dataViewMock.id },
],
total: 2,
},
},
})
);
mockedApi.savedSearch$.next(savedSearch); // reload
await waitOneTick();
expect(stateManager.rows.getValue()).toEqual(
[
{ _id: '1', _index: dataViewMock.id },
{ _id: '2', _index: dataViewMock.id },
].map((hit) => buildDataTableRecord(hit, dataViewMock))
);
expect(stateManager.totalHitCount.getValue()).toEqual(2);
});
it('should catch and emit error', async () => {
expect(mockedApi.blockingError.getValue()).toBeUndefined();
searchSource.fetch$ = jest.fn().mockImplementation(
() =>
new Observable(() => {
throw new Error('Search failed');
})
);
mockedApi.savedSearch$.next(savedSearch);
await waitOneTick();
expect(mockedApi.blockingError.getValue()).toBeDefined();
expect(mockedApi.blockingError.getValue()?.message).toBe('Search failed');
});
it('should correctly handle aborted requests', async () => {
const abortSignals: AbortSignal[] = [];
searchSource.fetch$ = jest.fn().mockImplementation(
(options) =>
new Observable(() => {
abortSignals.push(options.abortSignal);
})
);
mockedApi.savedSearch$.next(savedSearch); // reload
mockedApi.savedSearch$.next(savedSearch); // reload a second time to trigger abort
await waitOneTick();
expect(abortSignals[0].aborted).toBe(true); // first request should have been aborted
expect(abortSignals[1].aborted).toBe(false); // second request was not aborted
mockedApi.savedSearch$.next(savedSearch); // reload a third time
await waitOneTick();
expect(abortSignals[2].aborted).toBe(false); // third request was not aborted
});
});

View file

@ -0,0 +1,237 @@
/*
* 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 { BehaviorSubject, combineLatest, lastValueFrom, switchMap, tap } from 'rxjs';
import { KibanaExecutionContext } from '@kbn/core/types';
import {
buildDataTableRecord,
SEARCH_EMBEDDABLE_TYPE,
SEARCH_FIELDS_FROM_SOURCE,
SORT_DEFAULT_ORDER_SETTING,
} from '@kbn/discover-utils';
import { EsHitRecord } from '@kbn/discover-utils/types';
import { isOfAggregateQueryType, isOfQueryType } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { RequestAdapter } from '@kbn/inspector-plugin/common';
import {
apiHasExecutionContext,
apiHasParentApi,
fetch$,
FetchContext,
HasParentApi,
PublishesDataViews,
PublishesPanelTitle,
PublishesSavedObjectId,
} from '@kbn/presentation-publishing';
import { PublishesWritableTimeRange } from '@kbn/presentation-publishing/interfaces/fetch/publishes_unified_search';
import { SavedSearch } from '@kbn/saved-search-plugin/public';
import { SearchResponseWarning } from '@kbn/search-response-warnings';
import { SearchResponseIncompleteWarning } from '@kbn/search-response-warnings/src/types';
import { getTextBasedColumnsMeta } from '@kbn/unified-data-table';
import { createDataViewDataSource, createEsqlDataSource } from '../../common/data_sources';
import { fetchEsql } from '../application/main/data_fetching/fetch_esql';
import { DiscoverServices } from '../build_services';
import { getAllowedSampleSize } from '../utils/get_allowed_sample_size';
import { getAppTarget } from './initialize_edit_api';
import { PublishesSavedSearch, SearchEmbeddableStateManager } from './types';
import { getTimeRangeFromFetchContext, updateSearchSource } from './utils/update_search_source';
type SavedSearchPartialFetchApi = PublishesSavedSearch &
PublishesSavedObjectId &
PublishesDataViews &
PublishesPanelTitle &
PublishesWritableTimeRange & {
fetchContext$: BehaviorSubject<FetchContext | undefined>;
dataLoading: BehaviorSubject<boolean | undefined>;
blockingError: BehaviorSubject<Error | undefined>;
fetchWarnings$: BehaviorSubject<SearchResponseIncompleteWarning[]>;
} & Partial<HasParentApi>;
export const isEsqlMode = (savedSearch: Pick<SavedSearch, 'searchSource'>): boolean => {
const query = savedSearch.searchSource.getField('query');
return isOfAggregateQueryType(query);
};
const getExecutionContext = async (
api: SavedSearchPartialFetchApi,
discoverServices: DiscoverServices
) => {
const { editUrl } = await getAppTarget(api, discoverServices);
const childContext: KibanaExecutionContext = {
type: SEARCH_EMBEDDABLE_TYPE,
name: 'discover',
id: api.savedObjectId.getValue(),
description: api.panelTitle?.getValue() || api.defaultPanelTitle?.getValue() || '',
url: editUrl,
};
const executionContext =
apiHasParentApi(api) && apiHasExecutionContext(api.parentApi)
? {
...api.parentApi?.executionContext,
child: childContext,
}
: childContext;
return executionContext;
};
export function initializeFetch({
api,
stateManager,
discoverServices,
}: {
api: SavedSearchPartialFetchApi;
stateManager: SearchEmbeddableStateManager;
discoverServices: DiscoverServices;
}) {
const requestAdapter = new RequestAdapter();
let abortController: AbortController | undefined;
const fetchSubscription = combineLatest([fetch$(api), api.savedSearch$, api.dataViews])
.pipe(
tap(() => {
// abort any in-progress requests
if (abortController) {
abortController.abort();
abortController = undefined;
}
}),
switchMap(async ([fetchContext, savedSearch, dataViews]) => {
const dataView = dataViews?.length ? dataViews[0] : undefined;
api.blockingError.next(undefined);
if (!dataView || !savedSearch.searchSource) {
return;
}
const useNewFieldsApi = !discoverServices.uiSettings.get(SEARCH_FIELDS_FROM_SOURCE, false);
updateSearchSource(
discoverServices,
savedSearch.searchSource,
dataView,
savedSearch.sort,
getAllowedSampleSize(savedSearch.sampleSize, discoverServices.uiSettings),
useNewFieldsApi,
fetchContext,
{
sortDir: discoverServices.uiSettings.get(SORT_DEFAULT_ORDER_SETTING),
}
);
const searchSessionId = fetchContext.searchSessionId;
const searchSourceQuery = savedSearch.searchSource.getField('query');
// Log request to inspector
requestAdapter.reset();
try {
api.dataLoading.next(true);
// Get new abort controller
const currentAbortController = new AbortController();
abortController = currentAbortController;
await discoverServices.profilesManager.resolveDataSourceProfile({
dataSource: isOfAggregateQueryType(searchSourceQuery)
? createEsqlDataSource()
: dataView.id
? createDataViewDataSource({ dataViewId: dataView.id })
: undefined,
dataView,
query: searchSourceQuery,
});
const esqlMode = isEsqlMode(savedSearch);
if (
esqlMode &&
searchSourceQuery &&
(!fetchContext.query || isOfQueryType(fetchContext.query))
) {
// Request ES|QL data
const result = await fetchEsql({
query: searchSourceQuery,
inputTimeRange: getTimeRangeFromFetchContext(fetchContext),
inputQuery: fetchContext.query,
filters: fetchContext.filters,
dataView,
abortSignal: currentAbortController.signal,
inspectorAdapters: discoverServices.inspector,
data: discoverServices.data,
expressions: discoverServices.expressions,
profilesManager: discoverServices.profilesManager,
});
return {
columnsMeta: result.esqlQueryColumns
? getTextBasedColumnsMeta(result.esqlQueryColumns)
: undefined,
rows: result.records,
hitCount: result.records.length,
fetchContext,
};
}
const executionContext = await getExecutionContext(api, discoverServices);
/**
* Fetch via saved search
*/
const { rawResponse: resp } = await lastValueFrom(
savedSearch.searchSource.fetch$({
abortSignal: currentAbortController.signal,
sessionId: searchSessionId,
inspector: {
adapter: requestAdapter,
title: i18n.translate('discover.embeddable.inspectorRequestDataTitle', {
defaultMessage: 'Data',
}),
description: i18n.translate('discover.embeddable.inspectorRequestDescription', {
defaultMessage:
'This request queries Elasticsearch to fetch the data for the search.',
}),
},
executionContext,
disableWarningToasts: true,
})
);
const interceptedWarnings: SearchResponseWarning[] = [];
discoverServices.data.search.showWarnings(requestAdapter, (warning) => {
interceptedWarnings.push(warning);
return true; // suppress the default behaviour
});
return {
warnings: interceptedWarnings,
rows: resp.hits.hits.map((hit) => buildDataTableRecord(hit as EsHitRecord, dataView)),
hitCount: resp.hits.total as number,
fetchContext,
};
} catch (error) {
return { error };
}
})
)
.subscribe((next) => {
api.dataLoading.next(false);
if (!next || Object.hasOwn(next, 'error')) {
api.blockingError.next(next?.error);
return;
}
stateManager.rows.next(next.rows ?? []);
stateManager.totalHitCount.next(next.hitCount);
api.fetchWarnings$.next(next.warnings ?? []);
api.fetchContext$.next(next.fetchContext);
if (Object.hasOwn(next, 'columnsMeta')) {
stateManager.columnsMeta.next(next.columnsMeta);
}
});
return () => {
fetchSubscription.unsubscribe();
};
}

View file

@ -0,0 +1,205 @@
/*
* 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 { pick } from 'lodash';
import deepEqual from 'react-fast-compare';
import { BehaviorSubject, combineLatest, map, Observable, skip } from 'rxjs';
import { ISearchSource, SerializedSearchSourceFields } from '@kbn/data-plugin/common';
import { DataView } from '@kbn/data-views-plugin/common';
import { ROW_HEIGHT_OPTION, SAMPLE_SIZE_SETTING } from '@kbn/discover-utils';
import { DataTableRecord } from '@kbn/discover-utils/types';
import type {
PublishesDataViews,
PublishesUnifiedSearch,
StateComparators,
} from '@kbn/presentation-publishing';
import { DiscoverGridSettings, SavedSearch } from '@kbn/saved-search-plugin/common';
import { SortOrder, VIEW_MODE } from '@kbn/saved-search-plugin/public';
import { DataTableColumnsMeta } from '@kbn/unified-data-table';
import { AggregateQuery, Filter, Query } from '@kbn/es-query';
import { getDefaultRowsPerPage } from '../../common/constants';
import { DiscoverServices } from '../build_services';
import { DEFAULT_HEADER_ROW_HEIGHT_LINES, EDITABLE_SAVED_SEARCH_KEYS } from './constants';
import {
PublishesSavedSearch,
SearchEmbeddableRuntimeState,
SearchEmbeddableSerializedAttributes,
SearchEmbeddableStateManager,
} from './types';
const initializeSearchSource = async (
dataService: DiscoverServices['data'],
serializedSearchSource?: SerializedSearchSourceFields
) => {
const [searchSource, parentSearchSource] = await Promise.all([
dataService.search.searchSource.create(serializedSearchSource),
dataService.search.searchSource.create(),
]);
searchSource.setParent(parentSearchSource);
const dataView = searchSource.getField('index');
return { searchSource, dataView };
};
const initializedSavedSearch = (
stateManager: SearchEmbeddableStateManager,
searchSource: ISearchSource,
discoverServices: DiscoverServices
): SavedSearch => {
return {
...Object.keys(stateManager).reduce((prev, key) => {
return {
...prev,
[key]: stateManager[key as keyof SearchEmbeddableStateManager].getValue(),
};
}, discoverServices.savedSearch.getNew()),
searchSource,
};
};
export const initializeSearchEmbeddableApi = async (
initialState: SearchEmbeddableRuntimeState,
{
discoverServices,
}: {
discoverServices: DiscoverServices;
}
): Promise<{
api: PublishesSavedSearch & PublishesDataViews & Partial<PublishesUnifiedSearch>;
stateManager: SearchEmbeddableStateManager;
comparators: StateComparators<SearchEmbeddableSerializedAttributes>;
cleanup: () => void;
}> => {
const serializedSearchSource$ = new BehaviorSubject(initialState.serializedSearchSource);
/** We **must** have a search source, so start by initializing it */
const { searchSource, dataView } = await initializeSearchSource(
discoverServices.data,
initialState.serializedSearchSource
);
const searchSource$ = new BehaviorSubject<ISearchSource>(searchSource);
const dataViews = new BehaviorSubject<DataView[] | undefined>(dataView ? [dataView] : undefined);
/** This is the state that can be initialized from the saved initial state */
const columns$ = new BehaviorSubject<string[] | undefined>(initialState.columns);
const grid$ = new BehaviorSubject<DiscoverGridSettings | undefined>(initialState.grid);
const rowHeight$ = new BehaviorSubject<number | undefined>(initialState.rowHeight);
const rowsPerPage$ = new BehaviorSubject<number | undefined>(initialState.rowsPerPage);
const headerRowHeight$ = new BehaviorSubject<number | undefined>(initialState.headerRowHeight);
const sort$ = new BehaviorSubject<SortOrder[] | undefined>(initialState.sort);
const sampleSize$ = new BehaviorSubject<number | undefined>(initialState.sampleSize);
const savedSearchViewMode$ = new BehaviorSubject<VIEW_MODE | undefined>(initialState.viewMode);
/**
* This is the state that comes from the search source that needs individual publishing subjects for the API
* - Note that these subjects can't currently be changed on their own, and therefore we do not need to keep
* them "in sync" with changes to the search source. This would change with inline editing.
*/
const filters$ = new BehaviorSubject<Filter[] | undefined>(
searchSource.getField('filter') as Filter[]
);
const query$ = new BehaviorSubject<Query | AggregateQuery | undefined>(
searchSource.getField('query')
);
/** This is the state that has to be fetched */
const rows$ = new BehaviorSubject<DataTableRecord[]>([]);
const columnsMeta$ = new BehaviorSubject<DataTableColumnsMeta | undefined>(undefined);
const totalHitCount$ = new BehaviorSubject<number | undefined>(undefined);
const defaultRowHeight = discoverServices.uiSettings.get(ROW_HEIGHT_OPTION);
const defaultRowsPerPage = getDefaultRowsPerPage(discoverServices.uiSettings);
const defaultSampleSize = discoverServices.uiSettings.get(SAMPLE_SIZE_SETTING);
/**
* The state manager is used to modify the state of the saved search - this should never be
* treated as the source of truth
*/
const stateManager: SearchEmbeddableStateManager = {
columns: columns$,
columnsMeta: columnsMeta$,
grid: grid$,
headerRowHeight: headerRowHeight$,
rows: rows$,
rowHeight: rowHeight$,
rowsPerPage: rowsPerPage$,
sampleSize: sampleSize$,
sort: sort$,
totalHitCount: totalHitCount$,
viewMode: savedSearchViewMode$,
};
/** The saved search should be the source of truth for all state */
const savedSearch$ = new BehaviorSubject(
initializedSavedSearch(stateManager, searchSource, discoverServices)
);
/** This will fire when any of the **editable** state changes */
const onAnyStateChange: Observable<Partial<SearchEmbeddableSerializedAttributes>> = combineLatest(
pick(stateManager, EDITABLE_SAVED_SEARCH_KEYS)
);
/** Keep the saved search in sync with any state changes */
const syncSavedSearch = combineLatest([onAnyStateChange, searchSource$])
.pipe(
skip(1),
map(([newState, newSearchSource]) => ({
...savedSearch$.getValue(),
...newState,
searchSource: newSearchSource,
}))
)
.subscribe((newSavedSearch) => {
savedSearch$.next(newSavedSearch);
});
return {
cleanup: () => {
syncSavedSearch.unsubscribe();
},
api: {
dataViews,
savedSearch$,
filters$,
query$,
},
stateManager,
comparators: {
sort: [sort$, (value) => sort$.next(value), (a, b) => deepEqual(a, b)],
columns: [columns$, (value) => columns$.next(value), (a, b) => deepEqual(a, b)],
sampleSize: [
sampleSize$,
(value) => sampleSize$.next(value),
(a, b) => (a ?? defaultSampleSize) === (b ?? defaultSampleSize),
],
rowsPerPage: [
rowsPerPage$,
(value) => rowsPerPage$.next(value),
(a, b) => (a ?? defaultRowsPerPage) === (b ?? defaultRowsPerPage),
],
rowHeight: [
rowHeight$,
(value) => rowHeight$.next(value),
(a, b) => (a ?? defaultRowHeight) === (b ?? defaultRowHeight),
],
headerRowHeight: [
headerRowHeight$,
(value) => headerRowHeight$.next(value),
(a, b) => (a ?? DEFAULT_HEADER_ROW_HEIGHT_LINES) === (b ?? DEFAULT_HEADER_ROW_HEIGHT_LINES),
],
/** The following can't currently be changed from the dashboard */
serializedSearchSource: [
serializedSearchSource$,
(value) => serializedSearchSource$.next(value),
],
viewMode: [savedSearchViewMode$, (value) => savedSearchViewMode$.next(value)],
grid: [grid$, (value) => grid$.next(value)],
},
};
};

View file

@ -1,537 +0,0 @@
/*
* 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 { IUiSettingsClient } from '@kbn/core-ui-settings-browser';
import { createSearchSourceMock } from '@kbn/data-plugin/public/mocks';
import type { DataView } from '@kbn/data-views-plugin/common';
import { createDataViewDataSource } from '../../common/data_sources';
import { SHOW_FIELD_STATISTICS } from '@kbn/discover-utils';
import { buildDataViewMock, deepMockedFields } from '@kbn/discover-utils/src/__mocks__';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import type { SavedSearchByValueAttributes } from '@kbn/saved-search-plugin/public';
import { ReactWrapper } from 'enzyme';
import { ReactElement } from 'react';
import { render } from 'react-dom';
import { act } from 'react-dom/test-utils';
import { BehaviorSubject, Observable, throwError } from 'rxjs';
import { SearchInput } from '..';
import { VIEW_MODE } from '../../common/constants';
import { DiscoverServices } from '../build_services';
import { dataViewAdHoc } from '../__mocks__/data_view_complex';
import { discoverServiceMock } from '../__mocks__/services';
import { getDiscoverLocatorParams } from './get_discover_locator_params';
import { SavedSearchEmbeddable, SearchEmbeddableConfig } from './saved_search_embeddable';
import { SavedSearchEmbeddableComponent } from './saved_search_embeddable_component';
import { DiscoverGrid } from '../components/discover_grid';
jest.mock('./get_discover_locator_params', () => {
const actual = jest.requireActual('./get_discover_locator_params');
return {
...actual,
getDiscoverLocatorParams: jest.fn(actual.getDiscoverLocatorParams),
};
});
let discoverComponent: ReactWrapper;
jest.mock('react-dom', () => {
const { mount } = jest.requireActual('enzyme');
return {
...jest.requireActual('react-dom'),
render: jest.fn((component: ReactElement) => {
discoverComponent = mount(component);
}),
};
});
const waitOneTick = () => act(() => new Promise((resolve) => setTimeout(resolve, 0)));
function getSearchResponse(nrOfHits: number) {
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 servicesMock: jest.Mocked<DiscoverServices>;
let executeTriggerActions: jest.Mock;
let showFieldStatisticsMockValue: boolean = false;
let viewModeMockValue: VIEW_MODE = VIEW_MODE.DOCUMENT_LEVEL;
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,
managed: false,
};
executeTriggerActions = jest.fn();
jest
.spyOn(servicesMock.savedSearch.byValue, 'toSavedSearch')
.mockReturnValue(Promise.resolve(savedSearch));
const savedSearchEmbeddableConfig: SearchEmbeddableConfig = {
editable: true,
services: servicesMock,
executeTriggerActions,
};
const baseInput = {
id: 'mock-embeddable-id',
viewMode: ViewMode.EDIT,
timeRange: { from: 'now-15m', to: 'now' },
columns: ['message', 'extension'],
rowHeight: 30,
headerRowHeight: 5,
rowsPerPage: 50,
sampleSize: 250,
};
const searchInput: SearchInput = byValue
? { ...baseInput, attributes: {} as SavedSearchByValueAttributes }
: { ...baseInput, savedObjectId: savedSearch.id };
if (customTitle) {
searchInput.title = customTitle;
}
const embeddable = new SavedSearchEmbeddable(savedSearchEmbeddableConfig, searchInput);
// this helps to trigger reload
// eslint-disable-next-line dot-notation
embeddable['inputSubject'].next = jest.fn(
(input) => (input.lastReloadRequestTime = Date.now())
);
return { embeddable, searchInput, searchSource, savedSearch };
};
beforeEach(() => {
jest.clearAllMocks();
mountpoint = document.createElement('div');
showFieldStatisticsMockValue = false;
viewModeMockValue = VIEW_MODE.DOCUMENT_LEVEL;
servicesMock = discoverServiceMock as unknown as jest.Mocked<DiscoverServices>;
(servicesMock.uiSettings as unknown as jest.Mocked<IUiSettingsClient>).get.mockImplementation(
(key: string) => {
if (key === SHOW_FIELD_STATISTICS) return showFieldStatisticsMockValue;
}
);
jest
.spyOn(servicesMock.core.chrome, 'getActiveSolutionNavId$')
.mockReturnValue(new BehaviorSubject('test'));
});
afterEach(() => {
mountpoint.remove();
jest.resetAllMocks();
});
it('should update input correctly', async () => {
const { embeddable } = createEmbeddable();
jest.spyOn(embeddable, 'updateOutput');
await waitOneTick();
expect(render).toHaveBeenCalledTimes(0);
embeddable.render(mountpoint);
expect(render).toHaveBeenCalledTimes(1);
const searchProps = discoverComponent.find(SavedSearchEmbeddableComponent).prop('searchProps');
searchProps.onAddColumn!('bytes');
await waitOneTick();
expect(searchProps.columns).toEqual(['message', 'extension', 'bytes']);
expect(render).toHaveBeenCalledTimes(3); // twice per an update to show and then hide a loading indicator
searchProps.onRemoveColumn!('bytes');
await waitOneTick();
expect(searchProps.columns).toEqual(['message', 'extension']);
searchProps.onSetColumns!(['message', 'bytes', 'extension'], false);
await waitOneTick();
expect(searchProps.columns).toEqual(['message', 'bytes', 'extension']);
searchProps.onMoveColumn!('bytes', 2);
await waitOneTick();
expect(searchProps.columns).toEqual(['message', 'extension', 'bytes']);
expect(searchProps.rowHeightState).toEqual(30);
searchProps.onUpdateRowHeight!(40);
await waitOneTick();
expect(searchProps.rowHeightState).toEqual(40);
expect(searchProps.headerRowHeightState).toEqual(5);
searchProps.onUpdateHeaderRowHeight!(3);
await waitOneTick();
expect(searchProps.headerRowHeightState).toEqual(3);
expect(searchProps.rowsPerPageState).toEqual(50);
searchProps.onUpdateRowsPerPage!(100);
await waitOneTick();
expect(searchProps.rowsPerPageState).toEqual(100);
expect(
discoverComponent.find(SavedSearchEmbeddableComponent).prop('fetchedSampleSize')
).toEqual(250);
searchProps.onUpdateSampleSize!(300);
await waitOneTick();
expect(
discoverComponent.find(SavedSearchEmbeddableComponent).prop('fetchedSampleSize')
).toEqual(300);
searchProps.onFilter!({ name: 'customer_id', type: 'string', scripted: false }, [17], '+');
await waitOneTick();
expect(executeTriggerActions).toHaveBeenCalled();
});
it('should render saved search embeddable when successfully loading data', async () => {
// mock return data
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);
expect(loadingOutput.rendered).toBe(false);
expect(loadingOutput.error).toBe(undefined);
embeddable.render(mountpoint);
expect(render).toHaveBeenCalledTimes(1);
// wait for data fetching
resolveSearch();
await waitOneTick();
expect(render).toHaveBeenCalledTimes(2);
// check that loading state
const loadedOutput = embeddable.getOutput();
expect(loadedOutput.loading).toBe(false);
expect(loadedOutput.rendered).toBe(true);
expect(loadedOutput.error).toBe(undefined);
});
it('should render saved search embeddable when empty data is returned', async () => {
// mock return data
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);
expect(loadingOutput.rendered).toBe(false);
expect(loadingOutput.error).toBe(undefined);
embeddable.render(mountpoint);
expect(render).toHaveBeenCalledTimes(1);
// wait for data fetching
resolveSearch();
await waitOneTick();
expect(render).toHaveBeenCalledTimes(2);
// check that loading state
const loadedOutput = embeddable.getOutput();
expect(loadedOutput.loading).toBe(false);
expect(loadedOutput.rendered).toBe(true);
expect(loadedOutput.error).toBe(undefined);
});
it('should render in AGGREGATED_LEVEL view mode', async () => {
showFieldStatisticsMockValue = true;
viewModeMockValue = VIEW_MODE.AGGREGATED_LEVEL;
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);
expect(loadingOutput.rendered).toBe(false);
expect(loadingOutput.error).toBe(undefined);
embeddable.render(mountpoint);
expect(render).toHaveBeenCalledTimes(1);
// wait for data fetching
resolveSearch();
await waitOneTick();
expect(render).toHaveBeenCalledTimes(2);
// check that loading state
const loadedOutput = embeddable.getOutput();
expect(loadedOutput.loading).toBe(false);
expect(loadedOutput.rendered).toBe(true);
expect(loadedOutput.error).toBe(undefined);
});
it('should emit error output in case of fetch error', async () => {
const search = jest.fn().mockReturnValue(throwError(new Error('Fetch error')));
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[2][0].error.message).toBe(
'Fetch error'
);
// check that loading state
const loadedOutput = embeddable.getOutput();
expect(loadedOutput.loading).toBe(false);
expect(loadedOutput.rendered).toBe(true);
expect(loadedOutput.error).not.toBe(undefined);
});
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({ searchMock: search });
await waitOneTick();
embeddable.render(mountpoint);
// wait for data fetching
await waitOneTick();
expect(search).toHaveBeenCalledTimes(1);
embeddable.updateOutput({ title: 'custom title' });
embeddable.updateInput(searchInput);
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({ searchMock: search, customTitle: 'custom title' });
embeddable.reload = jest.fn();
await waitOneTick();
embeddable.render(mountpoint);
// wait for data fetching
await waitOneTick();
embeddable.updateOutput({ title: 'custom title' });
await waitOneTick();
expect(embeddable.reload).toHaveBeenCalledTimes(0);
expect(search).toHaveBeenCalledTimes(1);
});
it('should reload when a different input title is set', async () => {
const search = jest.fn().mockReturnValue(getSearchResponse(1));
const { embeddable } = createEmbeddable({ searchMock: search, customTitle: 'custom title' });
embeddable.reload = jest.fn();
await waitOneTick();
embeddable.render(mountpoint);
await waitOneTick();
embeddable.updateOutput({ title: 'custom title changed' });
await waitOneTick();
expect(embeddable.reload).toHaveBeenCalledTimes(1);
expect(search).toHaveBeenCalledTimes(1);
});
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({ searchMock: search });
embeddable.reload = jest.fn();
await waitOneTick();
embeddable.render(mountpoint);
await waitOneTick();
embeddable.updateOutput({ title: 'saved search' });
await waitOneTick();
expect(embeddable.reload).toHaveBeenCalledTimes(0);
expect(search).toHaveBeenCalledTimes(1);
});
it('should correctly handle aborted requests', async () => {
const { embeddable, searchSource } = createEmbeddable();
await waitOneTick();
const updateOutput = jest.spyOn(embeddable, 'updateOutput');
const abortSignals: AbortSignal[] = [];
jest.spyOn(searchSource, 'fetch$').mockImplementation(
(options) =>
new Observable(() => {
if (options?.abortSignal) {
abortSignals.push(options.abortSignal);
}
throw new Error('Search failed');
})
);
embeddable.reload();
embeddable.reload();
await waitOneTick();
expect(updateOutput).toHaveBeenCalledTimes(3);
expect(abortSignals[0].aborted).toBe(true);
expect(abortSignals[1].aborted).toBe(false);
embeddable.reload();
await waitOneTick();
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 } = createEmbeddable({ dataView, byValue });
const locatorParams = getDiscoverLocatorParams(embeddable);
(getDiscoverLocatorParams as jest.Mock).mockClear();
await waitOneTick();
expect(getDiscoverLocatorParams).toHaveBeenCalledTimes(1);
expect(getDiscoverLocatorParams).toHaveBeenCalledWith(embeddable);
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 } = createEmbeddable({
dataView: dataViewAdHoc,
byValue: true,
});
const locatorParams = getDiscoverLocatorParams(embeddable);
(getDiscoverLocatorParams as jest.Mock).mockClear();
await waitOneTick();
expect(getDiscoverLocatorParams).toHaveBeenCalledTimes(1);
expect(getDiscoverLocatorParams).toHaveBeenCalledWith(embeddable);
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');
});
});
describe('context awareness', () => {
it('should resolve root profile on init', async () => {
const resolveRootProfileSpy = jest.spyOn(
discoverServiceMock.profilesManager,
'resolveRootProfile'
);
const { embeddable } = createEmbeddable();
expect(resolveRootProfileSpy).not.toHaveBeenCalled();
await waitOneTick();
expect(resolveRootProfileSpy).toHaveBeenCalledWith({ solutionNavId: 'test' });
resolveRootProfileSpy.mockReset();
expect(resolveRootProfileSpy).not.toHaveBeenCalled();
embeddable.reload();
await waitOneTick();
expect(resolveRootProfileSpy).not.toHaveBeenCalled();
});
it('should resolve data source profile when fetching', async () => {
const resolveDataSourceProfileSpy = jest.spyOn(
discoverServiceMock.profilesManager,
'resolveDataSourceProfile'
);
const { embeddable } = createEmbeddable();
expect(resolveDataSourceProfileSpy).not.toHaveBeenCalled();
await waitOneTick();
expect(resolveDataSourceProfileSpy).toHaveBeenCalledWith({
dataSource: createDataViewDataSource({ dataViewId: dataViewMock.id! }),
dataView: dataViewMock,
query: embeddable.getInput().query,
});
resolveDataSourceProfileSpy.mockReset();
expect(resolveDataSourceProfileSpy).not.toHaveBeenCalled();
embeddable.reload();
expect(resolveDataSourceProfileSpy).toHaveBeenCalledWith({
dataSource: createDataViewDataSource({ dataViewId: dataViewMock.id! }),
dataView: dataViewMock,
query: embeddable.getInput().query,
});
});
it('should pass cell renderers from profile', async () => {
const { embeddable } = createEmbeddable();
await waitOneTick();
embeddable.render(mountpoint);
const discoverGridComponent = discoverComponent.find(DiscoverGrid);
expect(discoverGridComponent.exists()).toBeTruthy();
expect(Object.keys(discoverGridComponent.prop('externalCustomRenderers')!)).toEqual([
'rootProfile',
]);
});
});
});

View file

@ -1,805 +0,0 @@
/*
* 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 { firstValueFrom, lastValueFrom, Subscription } from 'rxjs';
import {
onlyDisabledFiltersChanged,
Filter,
Query,
TimeRange,
FilterStateStore,
isOfAggregateQueryType,
} from '@kbn/es-query';
import React from 'react';
import ReactDOM, { unmountComponentAtNode } from 'react-dom';
import { i18n } from '@kbn/i18n';
import { isEqual } from 'lodash';
import type { KibanaExecutionContext } from '@kbn/core/public';
import {
Container,
Embeddable,
FilterableEmbeddable,
ReferenceOrValueEmbeddable,
} from '@kbn/embeddable-plugin/public';
import { Adapters, RequestAdapter } from '@kbn/inspector-plugin/common';
import type {
SavedSearchAttributeService,
SearchByReferenceInput,
SearchByValueInput,
SortOrder,
} from '@kbn/saved-search-plugin/public';
import {
APPLY_FILTER_TRIGGER,
generateFilters,
mapAndFlattenFilters,
} from '@kbn/data-plugin/public';
import type { ISearchSource } from '@kbn/data-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import type { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import { KibanaContextProvider } from '@kbn/kibana-react-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 { SearchResponseWarning } from '@kbn/search-response-warnings';
import type { EsHitRecord } from '@kbn/discover-utils/types';
import {
DOC_HIDE_TIME_COLUMN_SETTING,
SEARCH_FIELDS_FROM_SOURCE,
SHOW_FIELD_STATISTICS,
SORT_DEFAULT_ORDER_SETTING,
buildDataTableRecord,
isLegacyTableEnabled,
} from '@kbn/discover-utils';
import { columnActions, getTextBasedColumnsMeta } from '@kbn/unified-data-table';
import { VIEW_MODE, getDefaultRowsPerPage } from '../../common/constants';
import type { ISearchEmbeddable, SearchInput, SearchOutput, SearchProps } from './types';
import type { DiscoverServices } from '../build_services';
import { getSortForEmbeddable, SortPair } from '../utils/sorting';
import { getMaxAllowedSampleSize, getAllowedSampleSize } from '../utils/get_allowed_sample_size';
import { SEARCH_EMBEDDABLE_TYPE, SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER_ID } from './constants';
import { SavedSearchEmbeddableComponent } from './saved_search_embeddable_component';
import { handleSourceColumnState } from '../utils/state_helpers';
import { updateSearchSource } from './utils/update_search_source';
import { FieldStatisticsTable } from '../application/main/components/field_stats_table';
import { fetchEsql } from '../application/main/data_fetching/fetch_esql';
import { getValidViewMode } from '../application/main/utils/get_valid_view_mode';
import { ADHOC_DATA_VIEW_RENDER_EVENT } from '../constants';
import { getDiscoverLocatorParams } from './get_discover_locator_params';
import { createDataViewDataSource, createEsqlDataSource } from '../../common/data_sources';
export interface SearchEmbeddableConfig {
editable: boolean;
services: DiscoverServices;
executeTriggerActions: UiActionsStart['executeTriggerActions'];
}
export class SavedSearchEmbeddable
extends Embeddable<SearchInput, SearchOutput>
implements
ISearchEmbeddable,
FilterableEmbeddable,
ReferenceOrValueEmbeddable<SearchByValueInput, SearchByReferenceInput>
{
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 panelTitleInternal: string = '';
private filtersSearchSource!: ISearchSource;
private prevTimeRange?: TimeRange;
private prevFilters?: Filter[];
private prevQuery?: Query;
private prevSort?: SortOrder[];
private prevSearchSessionId?: string;
private prevSampleSizeInput?: number;
private searchProps?: SearchProps;
private initialized?: boolean;
private node?: HTMLElement;
constructor(
{ editable, services, executeTriggerActions }: SearchEmbeddableConfig,
initialInput: SearchInput,
parent?: Container
) {
super(initialInput, { editApp: 'discover', editable }, parent);
this.services = services;
this.executeTriggerActions = executeTriggerActions;
this.attributeService = services.savedSearch.byValue.attributeService;
this.inspectorAdapters = {
requests: new RequestAdapter(),
};
this.subscription = this.getUpdated$().subscribe(() => {
const titleChanged = this.output.title && this.panelTitleInternal !== this.output.title;
if (titleChanged) {
this.panelTitleInternal = this.output.title || '';
}
if (!this.searchProps) {
return;
}
const isFetchRequired = this.isFetchRequired(this.searchProps);
const isRerenderRequired = this.isRerenderRequired(this.searchProps);
if (titleChanged || isFetchRequired || isRerenderRequired) {
this.reload(isFetchRequired);
}
});
this.initializeSavedSearch(initialInput).then(() => {
this.initializeSearchEmbeddableProps();
});
}
private getCurrentTitle() {
return this.input.hidePanelTitles ? '' : this.input.title ?? this.savedSearch?.title ?? '';
}
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.panelTitleInternal = this.getCurrentTitle();
await this.initializeOutput();
const solutionNavId = await firstValueFrom(
this.services.core.chrome.getActiveSolutionNavId$()
);
await this.services.profilesManager.resolveRootProfile({ solutionNavId });
// 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 = this.getCurrentTitle();
const description = input.hidePanelTitles ? '' : input.description ?? savedSearch.description;
const savedObjectId = (input as SearchByReferenceInput).savedObjectId;
const locatorParams = getDiscoverLocatorParams(this);
// 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() {
return true;
}
private isEsqlMode = (savedSearch: SavedSearch): boolean => {
const query = savedSearch.searchSource.getField('query');
return isOfAggregateQueryType(query);
};
private getFetchedSampleSize = (searchProps: SearchProps): number => {
return getAllowedSampleSize(searchProps.sampleSizeState, this.services.uiSettings);
};
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);
const currentAbortController = new AbortController();
// Abort any in-progress requests
this.abortController?.abort();
this.abortController = currentAbortController;
updateSearchSource(
savedSearch.searchSource,
searchProps.dataView,
searchProps.sort,
this.getFetchedSampleSize(searchProps),
useNewFieldsApi,
{
sortDir: this.services.uiSettings.get(SORT_DEFAULT_ORDER_SETTING),
}
);
// Log request to inspector
this.inspectorAdapters.requests!.reset();
searchProps.isLoading = true;
searchProps.interceptedWarnings = undefined;
const wasAlreadyRendered = this.getOutput().rendered;
this.updateOutput({
...this.getOutput(),
loading: true,
rendered: false,
error: undefined,
});
if (wasAlreadyRendered && this.node) {
// to show a loading indicator during a refetch, we need to rerender here
this.render(this.node);
}
const parentContext = this.input.executionContext;
const child: KibanaExecutionContext = {
type: this.type,
name: 'discover',
id: savedSearch.id,
description: this.output.title || this.output.defaultTitle || '',
url: this.output.editUrl,
};
const executionContext = parentContext
? {
...parentContext,
child,
}
: child;
const query = savedSearch.searchSource.getField('query');
const dataView = savedSearch.searchSource.getField('index')!;
const isEsqlMode = this.isEsqlMode(savedSearch);
try {
await this.services.profilesManager.resolveDataSourceProfile({
dataSource: isOfAggregateQueryType(query)
? createEsqlDataSource()
: dataView.id
? createDataViewDataSource({ dataViewId: dataView.id })
: undefined,
dataView,
query,
});
// Request ES|QL data
if (isEsqlMode && query) {
const result = await fetchEsql({
query: savedSearch.searchSource.getField('query')!,
inputQuery: this.input.query,
filters: this.input.filters,
dataView,
abortSignal: this.abortController.signal,
inspectorAdapters: this.services.inspector,
data: this.services.data,
expressions: this.services.expressions,
profilesManager: this.services.profilesManager,
});
this.updateOutput({
...this.getOutput(),
loading: false,
});
searchProps.columnsMeta = result.esqlQueryColumns
? getTextBasedColumnsMeta(result.esqlQueryColumns)
: undefined;
searchProps.rows = result.records;
searchProps.totalHitCount = result.records.length;
searchProps.isLoading = false;
searchProps.isPlainRecord = true;
searchProps.isSortEnabled = true;
return;
}
// Request document data
const { rawResponse: resp } = await lastValueFrom(
savedSearch.searchSource.fetch$({
abortSignal: currentAbortController.signal,
sessionId: searchSessionId,
inspector: {
adapter: this.inspectorAdapters.requests,
title: i18n.translate('discover.embeddable.inspectorRequestDataTitle', {
defaultMessage: 'Data',
}),
description: i18n.translate('discover.embeddable.inspectorRequestDescription', {
defaultMessage:
'This request queries Elasticsearch to fetch the data for the search.',
}),
},
executionContext,
disableWarningToasts: true,
})
);
if (this.inspectorAdapters.requests) {
const interceptedWarnings: SearchResponseWarning[] = [];
this.services.data.search.showWarnings(this.inspectorAdapters.requests, (warning) => {
interceptedWarnings.push(warning);
return true; // suppress the default behaviour
});
searchProps.interceptedWarnings = interceptedWarnings;
}
this.updateOutput({
...this.getOutput(),
loading: false,
});
searchProps.rows = resp.hits.hits.map((hit) =>
buildDataTableRecord(hit as EsHitRecord, searchProps.dataView)
);
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(),
loading: false,
error,
});
searchProps.isLoading = false;
}
}
};
private getSort(
sort: SortPair[] | undefined,
dataView: DataView | undefined,
isEsqlMode: boolean
) {
return getSortForEmbeddable(sort, dataView, this.services.uiSettings, isEsqlMode);
}
private initializeSearchEmbeddableProps() {
const savedSearch = this.savedSearch;
if (!savedSearch) {
return;
}
const dataView = savedSearch.searchSource.getField('index');
if (!dataView) {
return;
}
if (!dataView.isPersisted()) {
// one used adhoc data view
this.services.trackUiMetric?.(METRIC_TYPE.COUNT, ADHOC_DATA_VIEW_RENDER_EVENT);
}
const props: SearchProps = {
columns: savedSearch.columns || [],
savedSearchId: savedSearch.id,
filters: savedSearch.searchSource.getField('filter') as Filter[],
dataView,
isLoading: false,
sort: this.getSort(savedSearch.sort, dataView, this.isEsqlMode(savedSearch)),
rows: [],
searchDescription: savedSearch.description,
description: savedSearch.description,
inspectorAdapters: this.inspectorAdapters,
searchTitle: savedSearch.title,
services: this.services,
onAddColumn: (columnName: string) => {
if (!props.columns) {
return;
}
const updatedColumns = columnActions.addColumn(props.columns, columnName, true);
this.updateInput({ columns: updatedColumns });
},
onRemoveColumn: (columnName: string) => {
if (!props.columns) {
return;
}
const updatedColumns = columnActions.removeColumn(props.columns, columnName, true);
this.updateInput({ columns: updatedColumns });
},
onMoveColumn: (columnName: string, newIndex: number) => {
if (!props.columns) {
return;
}
const columns = columnActions.moveColumn(props.columns, columnName, newIndex);
this.updateInput({ columns });
},
onSetColumns: (columns: string[]) => {
this.updateInput({ columns });
},
onSort: (nextSort: string[][]) => {
const sortOrderArr: SortOrder[] = [];
nextSort.forEach((arr) => {
sortOrderArr.push(arr as SortOrder);
});
this.updateInput({ sort: sortOrderArr });
},
// I don't want to create filters when is embedded
...(!this.isEsqlMode(savedSearch) && {
onFilter: async (field, value, operator) => {
let filters = generateFilters(
this.services.filterManager,
// @ts-expect-error
field,
value,
operator,
dataView
);
filters = filters.map((filter) => ({
...filter,
$state: { store: FilterStateStore.APP_STATE },
}));
await this.executeTriggerActions(APPLY_FILTER_TRIGGER, {
embeddable: this,
filters,
});
},
}),
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 || savedSearch.rowHeight,
onUpdateRowHeight: (rowHeight) => {
this.updateInput({ rowHeight });
},
headerRowHeightState: this.input.headerRowHeight || savedSearch.headerRowHeight,
onUpdateHeaderRowHeight: (headerRowHeight) => {
this.updateInput({ headerRowHeight });
},
rowsPerPageState: this.input.rowsPerPage || savedSearch.rowsPerPage,
onUpdateRowsPerPage: (rowsPerPage) => {
this.updateInput({ rowsPerPage });
},
sampleSizeState: this.input.sampleSize || savedSearch.sampleSize,
onUpdateSampleSize: (sampleSize) => {
this.updateInput({ sampleSize });
},
cellActionsTriggerId: SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER_ID,
};
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 = savedSearch.searchSource.create();
this.filtersSearchSource.setParent(timeRangeSearchSource);
savedSearch.searchSource.setParent(this.filtersSearchSource);
this.load(props);
props.isLoading = true;
if (savedSearch.grid) {
props.settings = savedSearch.grid;
}
}
private getTimeRange() {
return this.input.timeslice !== undefined
? {
from: new Date(this.input.timeslice[0]).toISOString(),
to: new Date(this.input.timeslice[1]).toISOString(),
mode: 'absolute' as 'absolute',
}
: this.input.timeRange;
}
private isFetchRequired(searchProps?: SearchProps) {
if (!searchProps || !searchProps.dataView) {
return false;
}
return (
!onlyDisabledFiltersChanged(this.input.filters, this.prevFilters) ||
!isEqual(this.prevQuery, this.input.query) ||
!isEqual(this.prevTimeRange, this.getTimeRange()) ||
!isEqual(this.prevSort, this.input.sort) ||
this.prevSampleSizeInput !== this.input.sampleSize ||
this.prevSearchSessionId !== this.input.searchSessionId
);
}
private isRerenderRequired(searchProps?: SearchProps) {
if (!searchProps) {
return false;
}
return (
this.input.rowsPerPage !== searchProps.rowsPerPageState ||
this.input.sampleSize !== searchProps.sampleSizeState ||
(this.input.columns && !isEqual(this.input.columns, searchProps.columns))
);
}
private async pushContainerStateParamsToProps(
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.
const columnState = handleSourceColumnState(
{ columns: this.input.columns || savedSearch.columns },
this.services.core.uiSettings
);
searchProps.columns = columnState.columns || [];
searchProps.sort = this.getSort(
this.input.sort || savedSearch.sort,
searchProps?.dataView,
this.isEsqlMode(savedSearch)
);
searchProps.sharedItemTitle = this.panelTitleInternal;
searchProps.searchTitle = this.panelTitleInternal;
searchProps.rowHeightState = this.input.rowHeight ?? savedSearch.rowHeight;
searchProps.headerRowHeightState = this.input.headerRowHeight ?? savedSearch.headerRowHeight;
searchProps.rowsPerPageState =
this.input.rowsPerPage ||
savedSearch.rowsPerPage ||
getDefaultRowsPerPage(this.services.uiSettings);
searchProps.maxAllowedSampleSize = getMaxAllowedSampleSize(this.services.uiSettings);
searchProps.sampleSizeState = this.input.sampleSize || savedSearch.sampleSize;
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 {
this.filtersSearchSource.removeField('highlightAll');
}
this.prevFilters = this.input.filters;
this.prevQuery = this.input.query;
this.prevTimeRange = this.getTimeRange();
this.prevSearchSessionId = this.input.searchSessionId;
this.prevSort = this.input.sort;
this.prevSampleSizeInput = this.input.sampleSize;
this.searchProps = searchProps;
await this.fetch();
} else if (this.searchProps && this.node) {
this.searchProps = searchProps;
}
}
public async render(domNode: 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) {
const savedSearch = this.savedSearch;
if (!searchProps || !savedSearch) {
return;
}
const isEsqlMode = this.isEsqlMode(savedSearch);
const viewMode = getValidViewMode({
viewMode: savedSearch.viewMode,
isEsqlMode,
});
const timeRange = this.getTimeRange();
if (
this.services.uiSettings.get(SHOW_FIELD_STATISTICS) === true &&
viewMode === VIEW_MODE.AGGREGATED_LEVEL &&
searchProps.services &&
searchProps.dataView &&
Array.isArray(searchProps.columns)
) {
ReactDOM.render(
<KibanaRenderContextProvider {...searchProps.services.core}>
<KibanaContextProvider services={searchProps.services}>
<FieldStatisticsTable
dataView={searchProps.dataView}
columns={searchProps.columns}
savedSearch={savedSearch}
filters={this.input.filters}
query={this.input.query}
onAddFilter={searchProps.onFilter}
searchSessionId={this.input.searchSessionId}
isEsqlMode={isEsqlMode}
timeRange={timeRange}
/>
</KibanaContextProvider>
</KibanaRenderContextProvider>,
domNode
);
this.updateOutput({
...this.getOutput(),
rendered: true,
});
return;
}
const useLegacyTable = isLegacyTableEnabled({
uiSettings: this.services.uiSettings,
isEsqlMode,
});
const query = savedSearch.searchSource.getField('query');
const props = {
savedSearch,
searchProps,
useLegacyTable,
query,
};
if (searchProps.services) {
const { getTriggerCompatibleActions } = searchProps.services.uiActions;
ReactDOM.render(
<KibanaRenderContextProvider {...searchProps.services.core}>
<KibanaContextProvider services={searchProps.services}>
<CellActionsProvider getTriggerCompatibleActions={getTriggerCompatibleActions}>
<SavedSearchEmbeddableComponent
{...props}
fetchedSampleSize={this.getFetchedSampleSize(props.searchProps)}
/>
</CellActionsProvider>
</KibanaContextProvider>
</KibanaRenderContextProvider>,
domNode
);
const hasError = this.getOutput().error !== undefined;
if (this.searchProps!.isLoading === false && props.searchProps.rows !== undefined) {
this.renderComplete.dispatchComplete();
this.updateOutput({
...this.getOutput(),
rendered: true,
});
} else if (hasError) {
this.renderComplete.dispatchError();
this.updateOutput({
...this.getOutput(),
rendered: true,
});
}
}
}
private async load(searchProps: SearchProps, forceFetch = false) {
await this.pushContainerStateParamsToProps(searchProps, { forceFetch });
if (this.node) {
this.render(this.node);
}
}
public reload(forceFetch = true) {
if (this.searchProps && this.initialized && !this.destroyed) {
this.load(this.searchProps, forceFetch);
}
}
public getSavedSearch(): SavedSearch | undefined {
return this.savedSearch;
}
public getInspectorAdapters() {
return this.inspectorAdapters;
}
/**
* @returns Local/panel-level array of filters for Saved Search embeddable
*/
public getFilters() {
return mapAndFlattenFilters(
(this.savedSearch?.searchSource.getFields().filter as Filter[]) ?? []
);
}
/**
* @returns Local/panel-level query for Saved Search embeddable
*/
public getQuery() {
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();
this.abortController?.abort();
}
public hasTimeRange() {
return this.getTimeRange() !== undefined;
}
}

View file

@ -1,50 +0,0 @@
/*
* 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 React from 'react';
import { AggregateQuery, isOfAggregateQueryType, Query } from '@kbn/es-query';
import { DataLoadingState } from '@kbn/unified-data-table';
import { DiscoverGridEmbeddable } from './saved_search_grid';
import { DiscoverDocTableEmbeddable } from '../components/doc_table/create_doc_table_embeddable';
import type { EmbeddableComponentSearchProps } from './types';
interface SavedSearchEmbeddableComponentProps {
fetchedSampleSize: number;
searchProps: EmbeddableComponentSearchProps;
useLegacyTable: boolean;
query?: AggregateQuery | Query;
}
const DiscoverDocTableEmbeddableMemoized = React.memo(DiscoverDocTableEmbeddable);
const DiscoverGridEmbeddableMemoized = React.memo(DiscoverGridEmbeddable);
export function SavedSearchEmbeddableComponent({
fetchedSampleSize,
searchProps,
useLegacyTable,
query,
}: SavedSearchEmbeddableComponentProps) {
if (useLegacyTable) {
return (
<DiscoverDocTableEmbeddableMemoized
{...searchProps}
sampleSizeState={fetchedSampleSize}
isEsqlMode={isOfAggregateQueryType(query)}
/>
);
}
return (
<DiscoverGridEmbeddableMemoized
{...searchProps}
sampleSizeState={fetchedSampleSize}
loadingState={searchProps.isLoading ? DataLoadingState.loading : DataLoadingState.loaded}
query={query}
/>
);
}

View file

@ -1,11 +0,0 @@
/**
* 1. We want the kbnDocTable__container to scroll only when embedded in an embeddable panel
* 2. Force a better looking scrollbar
*/
.embPanel {
.kbnDocTable__container {
@include euiScrollBar; /* 2 */
flex: 1 1 0; /* 1 */
overflow: auto; /* 1 */
}
}

View file

@ -1,82 +0,0 @@
/*
* 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 { discoverServiceMock } from '../__mocks__/services';
import { SearchEmbeddableFactory, type StartServices } from './search_embeddable_factory';
import { ErrorEmbeddable } from '@kbn/embeddable-plugin/public';
import type { SearchByValueInput } from '@kbn/saved-search-plugin/public';
jest.mock('@kbn/embeddable-plugin/public', () => {
return {
...jest.requireActual('@kbn/embeddable-plugin/public'),
ErrorEmbeddable: jest.fn(),
};
});
const input = {
id: 'mock-embeddable-id',
savedObjectId: 'mock-saved-object-id',
timeRange: { from: 'now-15m', to: 'now' },
columns: ['message', 'extension'],
rowHeight: 30,
headerRowHeight: 5,
rowsPerPage: 50,
};
const ErrorEmbeddableMock = ErrorEmbeddable as unknown as jest.Mock;
describe('SearchEmbeddableFactory', () => {
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(mockUnwrap).toHaveBeenCalledTimes(1);
expect(mockUnwrap).toHaveBeenLastCalledWith(input);
expect(embeddable).toBeDefined();
});
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(error);
});
});

View file

@ -1,95 +0,0 @@
/*
* 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 { i18n } from '@kbn/i18n';
import type { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import {
EmbeddableFactoryDefinition,
Container,
ErrorEmbeddable,
} from '@kbn/embeddable-plugin/public';
import type { SearchByReferenceInput } from '@kbn/saved-search-plugin/public';
import type { SearchInput, SearchOutput } from './types';
import { SEARCH_EMBEDDABLE_TYPE } from './constants';
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'];
isEditable: () => boolean;
}
export class SearchEmbeddableFactory
implements EmbeddableFactoryDefinition<SearchInput, SearchOutput, SavedSearchEmbeddable>
{
public readonly type = SEARCH_EMBEDDABLE_TYPE;
public readonly savedObjectMetaData = {
name: i18n.translate('discover.savedSearch.savedObjectName', {
defaultMessage: 'Saved search',
}),
type: 'search',
getIconForSavedObject: () => 'discoverApp',
};
public readonly inject = inject;
public readonly extract = extract;
constructor(
private getStartServices: () => Promise<StartServices>,
private getDiscoverServices: () => Promise<DiscoverServices>
) {}
public canCreateNew() {
return false;
}
public isEditable = async () => {
return (await this.getStartServices()).isEditable();
};
public getDisplayName() {
return i18n.translate('discover.embeddable.search.displayName', {
defaultMessage: 'search',
});
}
public createFromSavedObject = async (
savedObjectId: string,
input: SearchByReferenceInput,
parent?: Container
): Promise<SavedSearchEmbeddable | ErrorEmbeddable> => {
if (!input.savedObjectId) {
input.savedObjectId = savedObjectId;
}
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(
{
editable: Boolean(services.capabilities.discover.save),
services,
executeTriggerActions,
},
input,
parent
);
} catch (e) {
console.error(e); // eslint-disable-line no-console
return new ErrorEmbeddable(e, input, parent);
}
}
}

View file

@ -6,58 +6,101 @@
* Side Public License, v 1.
*/
import type { DataView } from '@kbn/data-views-plugin/public';
import type { Embeddable, EmbeddableOutput, IEmbeddable } from '@kbn/embeddable-plugin/public';
import type {
import { DataTableRecord } from '@kbn/discover-utils/types';
import type { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public';
import {
EmbeddableApiContext,
HasEditCapabilities,
HasInPlaceLibraryTransforms,
PublishesBlockingError,
PublishesDataLoading,
PublishesDataViews,
PublishesSavedObjectId,
PublishesUnifiedSearch,
PublishesWritablePanelTitle,
PublishingSubject,
SerializedTimeRange,
SerializedTitles,
} from '@kbn/presentation-publishing';
import {
SavedSearch,
SearchByReferenceInput,
SearchByValueInput,
} from '@kbn/saved-search-plugin/public';
SavedSearchAttributes,
SerializableSavedSearch,
} from '@kbn/saved-search-plugin/common/types';
import { DataTableColumnsMeta } from '@kbn/unified-data-table';
import { BehaviorSubject } from 'rxjs';
import { EDITABLE_SAVED_SEARCH_KEYS } from './constants';
import type { Adapters } from '@kbn/embeddable-plugin/public';
import { EmbeddableApiContext } from '@kbn/presentation-publishing';
export type SearchEmbeddableState = Pick<
SerializableSavedSearch,
| 'rowHeight'
| 'rowsPerPage'
| 'headerRowHeight'
| 'columns'
| 'sort'
| 'sampleSize'
| 'viewMode'
| 'grid'
> & {
rows: DataTableRecord[];
columnsMeta: DataTableColumnsMeta | undefined;
totalHitCount: number | undefined;
};
import type { DiscoverServices } from '../build_services';
import type { DocTableEmbeddableSearchProps } from '../components/doc_table/doc_table_embeddable';
import type { DiscoverGridEmbeddableSearchProps } from './saved_search_grid';
export type SearchEmbeddableStateManager = {
[key in keyof Required<SearchEmbeddableState>]: BehaviorSubject<SearchEmbeddableState[key]>;
};
export type SearchInput = SearchByValueInput | SearchByReferenceInput;
export type SearchEmbeddableSerializedAttributes = Omit<
SearchEmbeddableState,
'rows' | 'columnsMeta' | 'totalHitCount' | 'searchSource'
> &
Pick<SerializableSavedSearch, 'serializedSearchSource'>;
export interface SearchOutput extends EmbeddableOutput {
indexPatterns?: DataView[];
editable: boolean;
export type SearchEmbeddableSerializedState = SerializedTitles &
SerializedTimeRange &
Partial<Pick<SavedSearchAttributes, typeof EDITABLE_SAVED_SEARCH_KEYS[number]>> & {
// by value
attributes?: SavedSearchAttributes & { references: SavedSearch['references'] };
// by reference
savedObjectId?: string;
};
export type SearchEmbeddableRuntimeState = SearchEmbeddableSerializedAttributes &
SerializedTitles &
SerializedTimeRange & {
savedObjectTitle?: string;
savedObjectId?: string;
savedObjectDescription?: string;
};
export type SearchEmbeddableApi = DefaultEmbeddableApi<
SearchEmbeddableSerializedState,
SearchEmbeddableRuntimeState
> &
PublishesDataViews &
PublishesSavedObjectId &
PublishesDataLoading &
PublishesBlockingError &
PublishesWritablePanelTitle &
PublishesSavedSearch &
PublishesDataViews &
PublishesUnifiedSearch &
HasInPlaceLibraryTransforms &
HasTimeRange &
Partial<HasEditCapabilities & PublishesSavedObjectId>;
export interface PublishesSavedSearch {
savedSearch$: PublishingSubject<SavedSearch>;
}
export type ISearchEmbeddable = IEmbeddable<SearchInput, SearchOutput> &
HasSavedSearch &
HasTimeRange;
export interface SearchEmbeddable extends Embeddable<SearchInput, SearchOutput> {
type: string;
}
export interface HasSavedSearch {
getSavedSearch: () => SavedSearch | undefined;
}
export const apiHasSavedSearch = (
export const apiPublishesSavedSearch = (
api: EmbeddableApiContext['embeddable']
): api is HasSavedSearch => {
const embeddable = api as HasSavedSearch;
return Boolean(embeddable.getSavedSearch) && typeof embeddable.getSavedSearch === 'function';
): api is PublishesSavedSearch => {
const embeddable = api as PublishesSavedSearch;
return Boolean(embeddable.savedSearch$);
};
export interface HasTimeRange {
hasTimeRange(): boolean;
}
export type EmbeddableComponentSearchProps = DiscoverGridEmbeddableSearchProps &
DocTableEmbeddableSearchProps;
export type SearchProps = EmbeddableComponentSearchProps & {
sampleSizeState: number | undefined;
description?: string;
sharedItemTitle?: string;
inspectorAdapters?: Adapters;
services: DiscoverServices;
};

View file

@ -6,8 +6,9 @@
* Side Public License, v 1.
*/
import { SavedSearch } from '@kbn/saved-search-plugin/common';
import { BehaviorSubject } from 'rxjs';
import { savedSearchMock } from '../__mocks__/saved_search';
import { savedSearchMock } from '../../__mocks__/saved_search';
import { getDiscoverLocatorParams } from './get_discover_locator_params';
describe('getDiscoverLocatorParams', () => {
@ -15,7 +16,7 @@ describe('getDiscoverLocatorParams', () => {
expect(
getDiscoverLocatorParams({
savedObjectId: new BehaviorSubject<string | undefined>('savedObjectId'),
getSavedSearch: () => savedSearchMock,
savedSearch$: new BehaviorSubject<SavedSearch>(savedSearchMock),
})
).toEqual({
savedSearchId: 'savedObjectId',
@ -25,7 +26,7 @@ describe('getDiscoverLocatorParams', () => {
it('should return Discover params if input has no savedObjectId', () => {
expect(
getDiscoverLocatorParams({
getSavedSearch: () => savedSearchMock,
savedSearch$: new BehaviorSubject<SavedSearch>(savedSearchMock),
})
).toEqual({
dataViewId: savedSearchMock.searchSource.getField('index')?.id,
@ -38,7 +39,6 @@ describe('getDiscoverLocatorParams', () => {
sort: savedSearchMock.sort,
viewMode: savedSearchMock.viewMode,
hideAggregatedPreview: savedSearchMock.hideAggregatedPreview,
breakdownField: savedSearchMock.breakdownField,
});
});
});

View file

@ -7,14 +7,14 @@
*/
import type { Filter } from '@kbn/es-query';
import { PublishesUnifiedSearch, PublishesSavedObjectId } from '@kbn/presentation-publishing';
import type { DiscoverAppLocatorParams } from '../../common';
import { HasSavedSearch } from './types';
import { PublishesSavedObjectId, PublishesUnifiedSearch } from '@kbn/presentation-publishing';
import { DiscoverAppLocatorParams } from '../../../common';
import { PublishesSavedSearch } from '../types';
export const getDiscoverLocatorParams = (
api: HasSavedSearch & Partial<PublishesSavedObjectId & PublishesUnifiedSearch>
api: PublishesSavedSearch & Partial<PublishesSavedObjectId & PublishesUnifiedSearch>
) => {
const savedSearch = api.getSavedSearch();
const savedSearch = api.savedSearch$.getValue();
const dataView = savedSearch?.searchSource.getField('index');
const savedObjectId = api.savedObjectId?.getValue();
@ -31,7 +31,6 @@ export const getDiscoverLocatorParams = (
sort: savedSearch?.sort,
viewMode: savedSearch?.viewMode,
hideAggregatedPreview: savedSearch?.hideAggregatedPreview,
breakdownField: savedSearch?.breakdownField,
};
return locatorParams;

View file

@ -0,0 +1,195 @@
/*
* 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 { SerializedSearchSourceFields } from '@kbn/data-plugin/common';
import { createSearchSourceMock } from '@kbn/data-plugin/public/mocks';
import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
import { SerializedPanelState } from '@kbn/presentation-containers';
import { toSavedSearchAttributes } from '@kbn/saved-search-plugin/common';
import { SavedSearchUnwrapResult } from '@kbn/saved-search-plugin/public';
import { discoverServiceMock } from '../../__mocks__/services';
import { SearchEmbeddableSerializedState } from '../types';
import { deserializeState, serializeState } from './serialization_utils';
describe('Serialization utils', () => {
const uuid = 'mySearchEmbeddable';
const mockedSavedSearchAttributes: SearchEmbeddableSerializedState['attributes'] = {
kibanaSavedObjectMeta: {
searchSourceJSON: '{"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.index"}',
},
title: 'test1',
sort: [['order_date', 'desc']],
columns: ['_source'],
description: 'description',
grid: {},
hideChart: false,
sampleSize: 100,
isTextBasedQuery: false,
references: [
{
name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
id: dataViewMock.id ?? 'test-id',
type: 'index-pattern',
},
],
};
describe('deserialize state', () => {
test('by value', async () => {
const serializedState: SerializedPanelState<SearchEmbeddableSerializedState> = {
rawState: {
attributes: mockedSavedSearchAttributes,
title: 'test panel title',
},
references: [
{
name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
id: dataViewMock.id ?? 'test-id',
type: 'index-pattern',
},
],
};
const deserializedState = await deserializeState({
serializedState,
discoverServices: discoverServiceMock,
});
expect(discoverServiceMock.savedSearch.byValueToSavedSearch).toBeCalledWith(
serializedState.rawState,
true // should be serializable
);
expect(Object.keys(deserializedState)).toContain('serializedSearchSource');
expect(deserializedState.title).toEqual('test panel title');
});
test('by reference', async () => {
discoverServiceMock.savedSearch.get = jest.fn().mockReturnValue({
savedObjectId: 'savedSearch',
...(await discoverServiceMock.savedSearch.byValueToSavedSearch(
{
attributes: mockedSavedSearchAttributes,
} as unknown as SavedSearchUnwrapResult,
true
)),
});
const serializedState: SerializedPanelState<SearchEmbeddableSerializedState> = {
rawState: {
savedObjectId: 'savedSearch',
title: 'test panel title',
sort: [['order_date', 'asc']], // overwrite the saved object sort
},
references: [],
};
const deserializedState = await deserializeState({
serializedState,
discoverServices: discoverServiceMock,
});
expect(Object.keys(deserializedState)).toContain('serializedSearchSource');
expect(Object.keys(deserializedState)).toContain('savedObjectId');
expect(deserializedState.title).toEqual('test panel title');
expect(deserializedState.sort).toEqual([['order_date', 'asc']]);
});
});
describe('serialize state', () => {
test('by value', async () => {
const searchSource = createSearchSourceMock({
index: dataViewMock,
});
const savedSearch = {
...mockedSavedSearchAttributes,
managed: false,
searchSource,
};
const serializedState = await serializeState({
uuid,
initialState: {
...mockedSavedSearchAttributes,
serializedSearchSource: {} as SerializedSearchSourceFields,
},
savedSearch,
serializeTitles: jest.fn(),
serializeTimeRange: jest.fn(),
discoverServices: discoverServiceMock,
});
expect(serializedState).toEqual({
rawState: {
id: uuid,
type: 'search',
attributes: {
...toSavedSearchAttributes(savedSearch, searchSource.serialize().searchSourceJSON),
references: mockedSavedSearchAttributes.references,
},
},
references: mockedSavedSearchAttributes.references,
});
});
describe('by reference', () => {
const searchSource = createSearchSourceMock({
index: dataViewMock,
});
const savedSearch = {
...mockedSavedSearchAttributes,
managed: false,
searchSource,
};
beforeAll(() => {
discoverServiceMock.savedSearch.get = jest.fn().mockResolvedValue(savedSearch);
});
test('equal state', async () => {
const serializedState = await serializeState({
uuid,
initialState: {},
savedSearch,
serializeTitles: jest.fn(),
serializeTimeRange: jest.fn(),
savedObjectId: 'test-id',
discoverServices: discoverServiceMock,
});
expect(serializedState).toEqual({
rawState: {
savedObjectId: 'test-id',
},
references: [],
});
});
test('overwrite state', async () => {
const serializedState = await serializeState({
uuid,
initialState: {},
savedSearch: { ...savedSearch, sampleSize: 500, sort: [['order_date', 'asc']] },
serializeTitles: jest.fn(),
serializeTimeRange: jest.fn(),
savedObjectId: 'test-id',
discoverServices: discoverServiceMock,
});
expect(serializedState).toEqual({
rawState: {
sampleSize: 500,
savedObjectId: 'test-id',
sort: [['order_date', 'asc']],
},
references: [],
});
});
});
});
});

View file

@ -0,0 +1,136 @@
/*
* 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 { omit, pick } from 'lodash';
import deepEqual from 'react-fast-compare';
import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common';
import { SerializedPanelState } from '@kbn/presentation-containers';
import { SerializedTimeRange, SerializedTitles } from '@kbn/presentation-publishing';
import {
SavedSearch,
SavedSearchAttributes,
toSavedSearchAttributes,
} from '@kbn/saved-search-plugin/common';
import { SavedSearchUnwrapResult } from '@kbn/saved-search-plugin/public';
import { extract, inject } from '../../../common/embeddable/search_inject_extract';
import { DiscoverServices } from '../../build_services';
import {
EDITABLE_PANEL_KEYS,
EDITABLE_SAVED_SEARCH_KEYS,
SEARCH_EMBEDDABLE_TYPE,
} from '../constants';
import { SearchEmbeddableRuntimeState, SearchEmbeddableSerializedState } from '../types';
export const deserializeState = async ({
serializedState,
discoverServices,
}: {
serializedState: SerializedPanelState<SearchEmbeddableSerializedState>;
discoverServices: DiscoverServices;
}) => {
const panelState = pick(serializedState.rawState, EDITABLE_PANEL_KEYS);
const savedObjectId = serializedState.rawState.savedObjectId;
if (savedObjectId) {
// by reference
const { get } = discoverServices.savedSearch;
const so = await get(savedObjectId, true);
const savedObjectOverride = pick(serializedState.rawState, EDITABLE_SAVED_SEARCH_KEYS);
return {
// ignore the time range from the saved object - only global time range + panel time range matter
...omit(so, 'timeRange'),
savedObjectId,
savedObjectTitle: so.title,
savedObjectDescription: so.description,
// Overwrite SO state with dashboard state for title, description, columns, sort, etc.
...panelState,
...savedObjectOverride,
};
} else {
// by value
const { byValueToSavedSearch } = discoverServices.savedSearch;
const savedSearch = await byValueToSavedSearch(
inject(
serializedState.rawState as unknown as EmbeddableStateWithType,
serializedState.references ?? []
) as SavedSearchUnwrapResult,
true
);
return {
...savedSearch,
...panelState,
};
}
};
export const serializeState = async ({
uuid,
initialState,
savedSearch,
serializeTitles,
serializeTimeRange,
savedObjectId,
discoverServices,
}: {
uuid: string;
initialState: SearchEmbeddableRuntimeState;
savedSearch: SavedSearch;
serializeTitles: () => SerializedTitles;
serializeTimeRange: () => SerializedTimeRange;
savedObjectId?: string;
discoverServices: DiscoverServices;
}): Promise<SerializedPanelState<SearchEmbeddableSerializedState>> => {
const searchSource = savedSearch.searchSource;
const { searchSourceJSON, references: originalReferences } = searchSource.serialize();
const savedSearchAttributes = toSavedSearchAttributes(savedSearch, searchSourceJSON);
if (savedObjectId) {
const { get } = discoverServices.savedSearch;
const so = await get(savedObjectId);
// only save the current state that is **different** than the saved object state
const overwriteState = EDITABLE_SAVED_SEARCH_KEYS.reduce((prev, key) => {
if (deepEqual(savedSearchAttributes[key], so[key])) {
return prev;
}
return { ...prev, [key]: savedSearchAttributes[key] };
}, {});
return {
rawState: {
savedObjectId,
// Serialize the current dashboard state into the panel state **without** updating the saved object
...serializeTitles(),
...serializeTimeRange(),
...overwriteState,
},
// No references to extract for by-reference embeddable since all references are stored with by-reference saved object
references: [],
};
}
const { state, references } = extract({
id: uuid,
type: SEARCH_EMBEDDABLE_TYPE,
attributes: {
...savedSearchAttributes,
references: originalReferences,
},
});
return {
rawState: {
...serializeTitles(),
...serializeTimeRange(),
...(state as unknown as SavedSearchAttributes),
},
references,
};
};

View file

@ -6,13 +6,16 @@
* Side Public License, v 1.
*/
import { createSearchSourceMock } from '@kbn/data-plugin/common/search/search_source/mocks';
import { updateSearchSource } from './update_search_source';
import {
buildDataViewMock,
dataViewMock,
shallowMockedFields,
} from '@kbn/discover-utils/src/__mocks__';
import { RangeFilter } from '@kbn/es-query';
import { FetchContext } from '@kbn/presentation-publishing';
import type { SortOrder } from '@kbn/saved-search-plugin/public';
import { discoverServiceMock } from '../../__mocks__/services';
import { updateSearchSource } from './update_search_source';
const dataViewMockWithTimeField = buildDataViewMock({
name: 'the-data-view',
@ -20,6 +23,15 @@ const dataViewMockWithTimeField = buildDataViewMock({
timeFieldName: '@timestamp',
});
const defaultFetchContext: FetchContext = {
isReload: false,
filters: [{ meta: { disabled: false } }],
query: { query: '', language: 'kuery' },
searchSessionId: 'id',
timeRange: { from: 'now-30m', to: 'now', mode: 'relative' },
timeslice: undefined,
};
describe('updateSearchSource', () => {
const defaults = {
sortDir: 'asc',
@ -30,11 +42,13 @@ describe('updateSearchSource', () => {
it('updates a given search source', async () => {
const searchSource = createSearchSourceMock({});
updateSearchSource(
discoverServiceMock,
searchSource,
dataViewMock,
[] as SortOrder[],
customSampleSize,
false,
defaultFetchContext,
defaults
);
expect(searchSource.getField('fields')).toBe(undefined);
@ -46,11 +60,13 @@ describe('updateSearchSource', () => {
it('updates a given search source with the usage of the new fields api', async () => {
const searchSource = createSearchSourceMock({});
updateSearchSource(
discoverServiceMock,
searchSource,
dataViewMock,
[] as SortOrder[],
customSampleSize,
true,
defaultFetchContext,
defaults
);
expect(searchSource.getField('fields')).toEqual([{ field: '*', include_unmapped: true }]);
@ -61,22 +77,26 @@ describe('updateSearchSource', () => {
it('updates a given search source with sort field', async () => {
const searchSource1 = createSearchSourceMock({});
updateSearchSource(
discoverServiceMock,
searchSource1,
dataViewMock,
[] as SortOrder[],
customSampleSize,
true,
defaultFetchContext,
defaults
);
expect(searchSource1.getField('sort')).toEqual([{ _score: 'asc' }]);
const searchSource2 = createSearchSourceMock({});
updateSearchSource(
discoverServiceMock,
searchSource2,
dataViewMockWithTimeField,
[] as SortOrder[],
customSampleSize,
true,
defaultFetchContext,
{
sortDir: 'desc',
}
@ -85,11 +105,13 @@ describe('updateSearchSource', () => {
const searchSource3 = createSearchSourceMock({});
updateSearchSource(
discoverServiceMock,
searchSource3,
dataViewMockWithTimeField,
[['bytes', 'desc']] as SortOrder[],
customSampleSize,
true,
defaultFetchContext,
defaults
);
expect(searchSource3.getField('sort')).toEqual([
@ -101,4 +123,65 @@ describe('updateSearchSource', () => {
},
]);
});
it('updates the parent of a given search source with fetch context', async () => {
const searchSource = createSearchSourceMock({});
const parentSearchSource = createSearchSourceMock({});
searchSource.setParent(parentSearchSource);
updateSearchSource(
discoverServiceMock,
searchSource,
dataViewMock,
[] as SortOrder[],
customSampleSize,
true,
defaultFetchContext,
defaults
);
expect(parentSearchSource.getField('filter')).toEqual([{ meta: { disabled: false } }]);
expect(parentSearchSource.getField('query')).toEqual({ query: '', language: 'kuery' });
});
it('updates the parent of a given search source with time filter fetch context', async () => {
const timeRangeFilter: RangeFilter = {
meta: {
type: 'range',
params: {},
index: dataViewMockWithTimeField.id,
field: '@timestamp',
},
query: {
range: {
'@timestamp': {
format: 'strict_date_optional_time',
gte: '2024-04-17T06:00:00.000Z',
lte: '2024-04-18T05:59:59.999Z',
},
},
},
};
discoverServiceMock.data.query.timefilter.timefilter.createFilter = jest.fn(() => {
return timeRangeFilter;
});
const searchSource = createSearchSourceMock({});
const parentSearchSource = createSearchSourceMock({});
searchSource.setParent(parentSearchSource);
updateSearchSource(
discoverServiceMock,
searchSource,
dataViewMockWithTimeField,
[] as SortOrder[],
customSampleSize,
true,
defaultFetchContext,
defaults
);
expect(parentSearchSource.getField('filter')).toEqual([
timeRangeFilter,
{ meta: { disabled: false } },
]);
});
});

View file

@ -5,17 +5,45 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { DataView } from '@kbn/data-views-plugin/public';
import type { ISearchSource } from '@kbn/data-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import { FetchContext } from '@kbn/presentation-publishing';
import type { SortOrder } from '@kbn/saved-search-plugin/public';
import { DiscoverServices } from '../../build_services';
import { getSortForSearchSource } from '../../utils/sorting';
export const getTimeRangeFromFetchContext = (fetchContext: FetchContext) => {
const timeRange =
fetchContext.timeslice !== undefined
? {
from: new Date(fetchContext.timeslice[0]).toISOString(),
to: new Date(fetchContext.timeslice[1]).toISOString(),
mode: 'absolute' as 'absolute',
}
: fetchContext.timeRange;
if (!timeRange) return undefined;
return timeRange;
};
const getTimeRangeFilter = (
discoverServices: DiscoverServices,
dataView: DataView | undefined,
fetchContext: FetchContext
) => {
const timeRange = getTimeRangeFromFetchContext(fetchContext);
if (!dataView || !timeRange) return undefined;
return discoverServices.timefilter.createFilter(dataView, timeRange);
};
export const updateSearchSource = (
discoverServices: DiscoverServices,
searchSource: ISearchSource,
dataView: DataView | undefined,
sort: (SortOrder[] & string[][]) | undefined,
sampleSize: number,
useNewFieldsApi: boolean,
fetchContext: FetchContext,
defaults: {
sortDir: string;
}
@ -37,4 +65,15 @@ export const updateSearchSource = (
} else {
searchSource.removeField('fields');
}
// if the search source has a parent, update that too based on fetch context
const parentSearchSource = searchSource.getParent();
if (parentSearchSource) {
const timeRangeFilter = getTimeRangeFilter(discoverServices, dataView, fetchContext);
const filters = timeRangeFilter
? [timeRangeFilter, ...(fetchContext.filters ?? [])]
: fetchContext.filters;
parentSearchSource.setField('filter', filters);
parentSearchSource.setField('query', fetchContext.query);
}
};

View file

@ -14,7 +14,6 @@ export function plugin(initializerContext: PluginInitializerContext) {
return new DiscoverPlugin(initializerContext);
}
export type { ISearchEmbeddable, SearchInput } from './embeddable';
export type { DiscoverAppState } from './application/main/state_management/discover_app_state_container';
export type { DiscoverStateContainer } from './application/main/state_management/discover_state';
export type { DataDocumentsMsg } from './application/main/state_management/discover_data_state_container';
@ -28,6 +27,13 @@ export type {
UnifiedHistogramCustomization,
TopNavCustomization,
} from './customizations';
export { SEARCH_EMBEDDABLE_TYPE, SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER_ID } from './embeddable';
export {
SEARCH_EMBEDDABLE_TYPE,
SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER_ID,
apiPublishesSavedSearch,
type PublishesSavedSearch,
type HasTimeRange,
type SearchEmbeddableSerializedState,
} from './embeddable';
export { loadSharingDataHelpers } from './utils';
export { LogsExplorerTabs, type LogsExplorerTabsProps } from './components/logs_explorer_tabs';

View file

@ -20,13 +20,14 @@ import {
import { DEFAULT_APP_CATEGORIES } from '@kbn/core/public';
import { ENABLE_ESQL } from '@kbn/esql-utils';
import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public';
import { TRUNCATE_MAX_HEIGHT } from '@kbn/discover-utils';
import { SEARCH_EMBEDDABLE_TYPE, TRUNCATE_MAX_HEIGHT } from '@kbn/discover-utils';
import { SavedSearchAttributes, SavedSearchType } from '@kbn/saved-search-plugin/common';
import { i18n } from '@kbn/i18n';
import { once } from 'lodash';
import { PLUGIN_ID } from '../common';
import { registerFeature } from './register_feature';
import { buildServices, UrlTracker } from './build_services';
import { SearchEmbeddableFactory } from './embeddable';
import { ViewSavedSearchAction } from './embeddable/view_saved_search_action';
import { ViewSavedSearchAction } from './embeddable/actions/view_saved_search_action';
import { injectTruncateStyles } from './utils/truncate_styles';
import { initializeKbnUrlTracking } from './utils/initialize_kbn_url_tracking';
import {
@ -58,6 +59,7 @@ import {
RootProfileService,
} from './context_awareness';
import { DiscoverSetup, DiscoverSetupPlugins, DiscoverStart, DiscoverStartPlugins } from './types';
import { deserializeState } from './embeddable/utils/serialization_utils';
/**
* Contains Discover, one of the oldest parts of Kibana
@ -368,7 +370,40 @@ export class DiscoverPlugin
return this.getDiscoverServices(coreStart, deps, profilesManager);
};
const factory = new SearchEmbeddableFactory(getStartServices, getDiscoverServicesInternal);
plugins.embeddable.registerEmbeddableFactory(factory.type, factory);
plugins.embeddable.registerReactEmbeddableSavedObject<SavedSearchAttributes>({
onAdd: async (container, savedObject) => {
const services = await getDiscoverServicesInternal();
const initialState = await deserializeState({
serializedState: {
rawState: { savedObjectId: savedObject.id },
references: savedObject.references,
},
discoverServices: services,
});
container.addNewPanel({
panelType: SEARCH_EMBEDDABLE_TYPE,
initialState,
});
},
embeddableType: SEARCH_EMBEDDABLE_TYPE,
savedObjectType: SavedSearchType,
savedObjectName: i18n.translate('discover.savedSearch.savedObjectName', {
defaultMessage: 'Saved search',
}),
getIconForSavedObject: () => 'discoverApp',
});
plugins.embeddable.registerReactEmbeddableFactory(SEARCH_EMBEDDABLE_TYPE, async () => {
const [startServices, discoverServices, { getSearchEmbeddableFactory }] = await Promise.all([
getStartServices(),
getDiscoverServicesInternal(),
import('./embeddable/get_search_embeddable_factory'),
]);
return getSearchEmbeddableFactory({
startServices,
discoverServices,
});
});
}
}

View file

@ -11,3 +11,5 @@
export async function loadSharingDataHelpers() {
return await import('./get_sharing_data');
}
export { getSortForEmbeddable } from './sorting';

View file

@ -92,6 +92,7 @@
"@kbn/aiops-plugin",
"@kbn/data-visualizer-plugin",
"@kbn/search-types",
"@kbn/presentation-containers",
"@kbn/observability-ai-assistant-plugin",
"@kbn/fields-metadata-plugin"
],

View file

@ -23,7 +23,6 @@ export const canUnlinkLegacyEmbeddable = async (embeddable: CommonLegacyEmbeddab
embeddable.getInput()?.viewMode !== ViewMode.VIEW &&
embeddable.getRoot() &&
embeddable.getRoot().isContainer &&
isReferenceOrValueEmbeddable(embeddable) &&
embeddable.inputIsRefType(embeddable.getInput())
);
};

View file

@ -91,7 +91,7 @@ describe('kibanaContextFn', () => {
} as unknown as SavedSearch['searchSource'],
{} as SavedSearch['sharingSavedObjectProps'],
false
)
) as SavedSearch
);
const args = {
...emptyArgs,

View file

@ -29,3 +29,5 @@ export {
MAX_SAVED_SEARCH_SAMPLE_SIZE,
} from './constants';
export { getKibanaContextFn } from './expressions/kibana_context';
export { toSavedSearchAttributes } from './service/saved_searches_utils';

View file

@ -6,36 +6,45 @@
* Side Public License, v 1.
*/
import { SerializedSearchSourceFields } from '@kbn/data-plugin/common';
import { SavedSearch, SavedSearchAttributes } from '.';
import { SerializableSavedSearch } from './types';
export const fromSavedSearchAttributes = (
export const fromSavedSearchAttributes = <
Serialized extends boolean = false,
ReturnType = Serialized extends true ? SerializableSavedSearch : SavedSearch
>(
id: string | undefined,
attributes: SavedSearchAttributes,
tags: string[] | undefined,
searchSource: SavedSearch['searchSource'],
managed: boolean
): SavedSearch => ({
id,
searchSource,
title: attributes.title,
sort: attributes.sort,
columns: attributes.columns,
description: attributes.description,
tags,
grid: attributes.grid,
hideChart: attributes.hideChart,
viewMode: attributes.viewMode,
hideAggregatedPreview: attributes.hideAggregatedPreview,
rowHeight: attributes.rowHeight,
headerRowHeight: attributes.headerRowHeight,
isTextBasedQuery: attributes.isTextBasedQuery,
usesAdHocDataView: attributes.usesAdHocDataView,
timeRestore: attributes.timeRestore,
timeRange: attributes.timeRange,
refreshInterval: attributes.refreshInterval,
rowsPerPage: attributes.rowsPerPage,
sampleSize: attributes.sampleSize,
breakdownField: attributes.breakdownField,
visContext: attributes.visContext,
managed,
});
searchSource: SavedSearch['searchSource'] | SerializedSearchSourceFields,
managed: boolean,
serialized: Serialized = false as Serialized
) =>
({
id,
...(serialized
? { serializedSearchSource: searchSource as SerializedSearchSourceFields }
: { searchSource }),
title: attributes.title,
sort: attributes.sort,
columns: attributes.columns,
description: attributes.description,
tags,
grid: attributes.grid,
hideChart: attributes.hideChart,
viewMode: attributes.viewMode,
hideAggregatedPreview: attributes.hideAggregatedPreview,
rowHeight: attributes.rowHeight,
headerRowHeight: attributes.headerRowHeight,
isTextBasedQuery: attributes.isTextBasedQuery,
usesAdHocDataView: attributes.usesAdHocDataView,
timeRestore: attributes.timeRestore,
timeRange: attributes.timeRange,
refreshInterval: attributes.refreshInterval,
rowsPerPage: attributes.rowsPerPage,
sampleSize: attributes.sampleSize,
breakdownField: attributes.breakdownField,
visContext: attributes.visContext,
managed,
} as ReturnType);

View file

@ -13,7 +13,7 @@ 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 { Reference } from '@kbn/content-management-utils';
import type { SavedSearch, SavedSearchAttributes } from '../types';
import type { SavedSearch, SavedSearchAttributes, SerializableSavedSearch } from '../types';
import { SavedSearchType as SAVED_SEARCH_TYPE } from '..';
import { fromSavedSearchAttributes } from './saved_searches_utils';
import type { SavedSearchCrudTypes } from '../content_management';
@ -58,7 +58,10 @@ export const getSearchSavedObject = async (
return so;
};
export const convertToSavedSearch = async (
export const convertToSavedSearch = async <
Serialized extends boolean = false,
ReturnType = Serialized extends true ? SerializableSavedSearch : SavedSearch
>(
{
savedSearchId,
attributes,
@ -72,8 +75,9 @@ export const convertToSavedSearch = async (
sharingSavedObjectProps: SavedSearch['sharingSavedObjectProps'];
managed: boolean | undefined;
},
{ searchSourceCreate, savedObjectsTagging }: GetSavedSearchDependencies
) => {
{ searchSourceCreate, savedObjectsTagging }: GetSavedSearchDependencies,
serialized?: Serialized
): Promise<ReturnType> => {
const parsedSearchSourceJSON = parseSearchSourceJSON(
attributes.kibanaSavedObjectMeta?.searchSourceJSON ?? '{}'
);
@ -88,20 +92,32 @@ export const convertToSavedSearch = async (
? savedObjectsTagging.ui.getTagIdsFromReferences(references)
: undefined;
const searchSource = serialized
? searchSourceValues
: await searchSourceCreate(searchSourceValues);
const returnVal = fromSavedSearchAttributes(
savedSearchId,
attributes,
tags,
references,
await searchSourceCreate(searchSourceValues),
searchSource,
sharingSavedObjectProps,
Boolean(managed)
Boolean(managed),
serialized
);
return returnVal;
return returnVal as ReturnType;
};
export const getSavedSearch = async (savedSearchId: string, deps: GetSavedSearchDependencies) => {
export const getSavedSearch = async <
Serialized extends boolean = false,
ReturnType = Serialized extends true ? SerializableSavedSearch : SavedSearch
>(
savedSearchId: string,
deps: GetSavedSearchDependencies,
serialized?: Serialized
): Promise<ReturnType> => {
const so = await getSearchSavedObject(savedSearchId, deps);
const savedSearch = await convertToSavedSearch(
{
@ -111,10 +127,11 @@ export const getSavedSearch = async (savedSearchId: string, deps: GetSavedSearch
sharingSavedObjectProps: so.meta,
managed: so.item.managed,
},
deps
deps,
serialized
);
return savedSearch;
return savedSearch as ReturnType;
};
/**

View file

@ -6,23 +6,26 @@
* Side Public License, v 1.
*/
import { pick } from 'lodash';
import type { SavedObjectReference } from '@kbn/core-saved-objects-server';
import type { SavedSearchAttributes, SavedSearch } from '..';
import { SerializedSearchSourceFields } from '@kbn/data-plugin/common';
import { pick } from 'lodash';
import type { SavedSearch, SavedSearchAttributes } from '..';
import { fromSavedSearchAttributes as fromSavedSearchAttributesCommon } from '..';
import { SerializableSavedSearch } from '../types';
export { getSavedSearchUrl, getSavedSearchFullPathUrl } from '..';
export { getSavedSearchFullPathUrl, getSavedSearchUrl } from '..';
export const fromSavedSearchAttributes = (
id: string | undefined,
attributes: SavedSearchAttributes,
tags: string[] | undefined,
references: SavedObjectReference[] | undefined,
searchSource: SavedSearch['searchSource'],
searchSource: SavedSearch['searchSource'] | SerializedSearchSourceFields,
sharingSavedObjectProps: SavedSearch['sharingSavedObjectProps'],
managed: boolean
): SavedSearch => ({
...fromSavedSearchAttributesCommon(id, attributes, tags, searchSource, managed),
managed: boolean,
serialized: boolean = false
): SavedSearch | SerializableSavedSearch => ({
...fromSavedSearchAttributesCommon(id, attributes, tags, searchSource, managed, serialized),
sharingSavedObjectProps,
references,
});

View file

@ -6,7 +6,12 @@
* Side Public License, v 1.
*/
import type { ISearchSource, RefreshInterval, TimeRange } from '@kbn/data-plugin/common';
import type {
ISearchSource,
RefreshInterval,
SerializedSearchSourceFields,
TimeRange,
} from '@kbn/data-plugin/common';
import type { SavedObjectReference } from '@kbn/core-saved-objects-server';
import type { SavedObjectsResolveResponse } from '@kbn/core/server';
import type { SerializableRecord } from '@kbn/utility-types';
@ -37,7 +42,7 @@ export type VisContextUnmapped =
/** @internal **/
export interface SavedSearchAttributes {
title: string;
sort: Array<[string, string]>;
sort: SortOrder[];
columns: string[];
description: string;
grid: DiscoverGridSettings;
@ -66,32 +71,10 @@ export interface SavedSearchAttributes {
export type SortOrder = [string, string];
/** @public **/
export interface SavedSearch {
export type SavedSearch = Partial<SavedSearchAttributes> & {
searchSource: ISearchSource;
id?: string;
title?: string;
sort?: SortOrder[];
columns?: string[];
description?: string;
tags?: string[] | undefined;
grid?: DiscoverGridSettings;
hideChart?: boolean;
viewMode?: VIEW_MODE;
hideAggregatedPreview?: boolean;
rowHeight?: number;
headerRowHeight?: number;
isTextBasedQuery?: boolean;
usesAdHocDataView?: boolean;
// for restoring time range with a saved search
timeRestore?: boolean;
timeRange?: TimeRange;
refreshInterval?: RefreshInterval;
rowsPerPage?: number;
sampleSize?: number;
breakdownField?: string;
visContext?: VisContextUnmapped;
// Whether or not this saved search is managed by the system
managed: boolean;
@ -102,4 +85,9 @@ export interface SavedSearch {
aliasPurpose?: SavedObjectsResolveResponse['alias_purpose'];
errorJSON?: string;
};
}
};
/** @internal **/
export type SerializableSavedSearch = Omit<SavedSearch, 'searchSource'> & {
serializedSearchSource?: SerializedSearchSourceFields;
};

View file

@ -12,10 +12,7 @@ export type { SortOrder } from '../common/types';
export type {
SavedSearch,
SaveSavedSearchOptions,
SearchByReferenceInput,
SearchByValueInput,
SavedSearchByValueAttributes,
SavedSearchAttributeService,
SavedSearchUnwrapMetaInfo,
SavedSearchUnwrapResult,
} from './services/saved_searches';

View file

@ -12,7 +12,9 @@ import { SearchSource } 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';
import { SavedSearch } from '../common';
import { SerializableSavedSearch } from '../common/types';
import { SavedSearchUnwrapResult } from './services/saved_searches';
const createEmptySearchSource = jest.fn(() => {
const deps = {
@ -32,36 +34,38 @@ const createEmptySearchSource = jest.fn(() => {
return searchSource;
});
const toSavedSearchMock = jest.fn((result, serialized) =>
Promise.resolve(
serialized
? ({
title: result.attributes.title,
serializedSearchSource: createEmptySearchSource().getSerializedFields(),
managed: false,
} as SerializableSavedSearch)
: ({
title: result.attributes.title,
searchSource: createEmptySearchSource(),
managed: false,
} as SavedSearch)
)
) as SavedSearchPublicPluginStart['byValueToSavedSearch'];
const savedSearchStartMock = (): SavedSearchPublicPluginStart => ({
get: jest.fn().mockImplementation(() => ({
id: 'savedSearch',
title: 'savedSearchTitle',
searchSource: createEmptySearchSource(),
})),
get: jest
.fn()
.mockImplementation((id, serialized) =>
toSavedSearchMock(
{ attributes: { title: 'savedSearchTitle' } } as SavedSearchUnwrapResult,
serialized
)
),
getAll: jest.fn(),
getNew: jest.fn().mockImplementation(() => ({
searchSource: createEmptySearchSource(),
})),
save: 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(),
managed: false,
})
),
},
checkForDuplicateTitle: jest.fn(),
byValueToSavedSearch: toSavedSearchMock,
});
export const savedSearchPluginMock = {

View file

@ -6,37 +6,32 @@
* Side Public License, v 1.
*/
import { CoreSetup, CoreStart, Plugin, StartServicesAccessor } from '@kbn/core/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { SpacesApi } from '@kbn/spaces-plugin/public';
import type { SavedObjectTaggingOssPluginStart } from '@kbn/saved-objects-tagging-oss-plugin/public';
import { ExpressionsSetup } from '@kbn/expressions-plugin/public';
import { i18n } from '@kbn/i18n';
import type {
ContentManagementPublicSetup,
ContentManagementPublicStart,
} from '@kbn/content-management-plugin/public';
import type { SOWithMetadata } from '@kbn/content-management-utils';
import { CoreSetup, CoreStart, Plugin, StartServicesAccessor } from '@kbn/core/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public';
import {
getSavedSearch,
saveSavedSearch,
SaveSavedSearchOptions,
getNewSavedSearch,
SavedSearchUnwrapResult,
SearchByValueInput,
} 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 { ExpressionsSetup } from '@kbn/expressions-plugin/public';
import { i18n } from '@kbn/i18n';
import { OnSaveProps } from '@kbn/saved-objects-plugin/public';
import type { SavedObjectTaggingOssPluginStart } from '@kbn/saved-objects-tagging-oss-plugin/public';
import type { SpacesApi } from '@kbn/spaces-plugin/public';
import { LATEST_VERSION, SavedSearchType } from '../common';
import { kibanaContext } from '../common/expressions';
import { SavedSearch, SavedSearchAttributes, SerializableSavedSearch } from '../common/types';
import { getKibanaContext } from './expressions/kibana_context';
import {
type SavedSearchAttributeService,
getSavedSearchAttributeService,
toSavedSearch,
getNewSavedSearch,
SavedSearchUnwrapResult,
saveSavedSearch,
SaveSavedSearchOptions,
byValueToSavedSearch,
} from './services/saved_searches';
import { savedObjectToEmbeddableAttributes } from './services/saved_searches/saved_search_attribute_service';
import { checkForDuplicateTitle } from './services/saved_searches/check_for_duplicate_title';
import { SavedSearchesService } from './services/saved_searches/saved_searches_service';
/**
* Saved search plugin public Setup contract
@ -48,20 +43,23 @@ export interface SavedSearchPublicPluginSetup {}
* Saved search plugin public Setup contract
*/
export interface SavedSearchPublicPluginStart {
get: (savedSearchId: string) => ReturnType<typeof getSavedSearch>;
get: <Serialized extends boolean = false>(
savedSearchId: string,
serialized?: Serialized
) => Promise<Serialized extends true ? SerializableSavedSearch : SavedSearch>;
getNew: () => ReturnType<typeof getNewSavedSearch>;
getAll: () => Promise<Array<SOWithMetadata<SavedSearchAttributes>>>;
save: (
savedSearch: SavedSearch,
options?: SaveSavedSearchOptions
) => ReturnType<typeof saveSavedSearch>;
byValue: {
attributeService: SavedSearchAttributeService;
toSavedSearch: (
id: string | undefined,
result: SavedSearchUnwrapResult
) => Promise<SavedSearch>;
};
checkForDuplicateTitle: (
props: Pick<OnSaveProps, 'newTitle' | 'isTitleDuplicateConfirmed' | 'onTitleDuplicate'>
) => Promise<void>;
byValueToSavedSearch: <Serialized extends boolean = false>(
result: SavedSearchUnwrapResult,
serialized?: Serialized
) => Promise<Serialized extends true ? SerializableSavedSearch : SavedSearch>;
}
/**
@ -118,19 +116,6 @@ export class SavedSearchPublicPlugin
expressions.registerType(kibanaContext);
embeddable.registerSavedObjectToPanelMethod<SavedSearchAttributes, SearchByValueInput>(
SavedSearchType,
(savedObject) => {
if (!savedObject.managed) {
return { savedObjectId: savedObject.id };
}
return {
attributes: savedObjectToEmbeddableAttributes(savedObject),
};
}
);
return {};
}
@ -148,17 +133,34 @@ export class SavedSearchPublicPlugin
const service = new SavedSearchesService(deps);
return {
get: (savedSearchId: string) => service.get(savedSearchId),
get: <Serialized extends boolean = false>(
savedSearchId: string,
serialized?: Serialized
): Promise<Serialized extends true ? SerializableSavedSearch : SavedSearch> =>
service.get(savedSearchId, serialized),
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);
},
checkForDuplicateTitle: (
props: Pick<OnSaveProps, 'newTitle' | 'isTitleDuplicateConfirmed' | 'onTitleDuplicate'>
) => {
return checkForDuplicateTitle({
title: props.newTitle,
isTitleDuplicateConfirmed: props.isTitleDuplicateConfirmed,
onTitleDuplicate: props.onTitleDuplicate,
contentManagement: deps.contentManagement,
});
},
byValueToSavedSearch: async <
Serialized extends boolean = boolean,
ReturnType = Serialized extends true ? SerializableSavedSearch : SavedSearch
>(
result: SavedSearchUnwrapResult,
serialized?: Serialized
): Promise<ReturnType> => {
return (await byValueToSavedSearch(result, deps, serialized)) as ReturnType;
},
};
}

View file

@ -56,5 +56,5 @@ export const checkForDuplicateTitle = async ({
return Promise.reject(new Error(`Saved search title already exists: ${title}`));
}
return true;
return;
};

View file

@ -14,16 +14,9 @@ export {
export type { SaveSavedSearchOptions } from './save_saved_searches';
export { saveSavedSearch } from './save_saved_searches';
export { SAVED_SEARCH_TYPE } from './constants';
export type {
SavedSearch,
SearchByReferenceInput,
SearchByValueInput,
SavedSearchByValueAttributes,
} from './types';
export type { SavedSearch, SavedSearchByValueAttributes } from './types';
export {
getSavedSearchAttributeService,
toSavedSearch,
type SavedSearchAttributeService,
byValueToSavedSearch,
type SavedSearchUnwrapMetaInfo,
type SavedSearchUnwrapResult,
} from './saved_search_attribute_service';
} from './to_saved_search';

View file

@ -1,250 +0,0 @@
/*
* 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 {},
"headerRowHeight": undefined,
"hideAggregatedPreview": undefined,
"hideChart": false,
"id": "saved-object-id",
"isTextBasedQuery": false,
"managed": false,
"references": Array [
Object {
"id": "1",
"name": "ref_0",
"type": "index-pattern",
},
],
"refreshInterval": undefined,
"rowHeight": undefined,
"rowsPerPage": undefined,
"sampleSize": 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 [],
"loadDataViewFields": [MockFunction],
"onRequestStart": [MockFunction],
"parseActiveIndexPatternFromQueryString": [MockFunction],
"removeField": [MockFunction],
"serialize": [MockFunction],
"setField": [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,
"visContext": undefined,
}
`);
});
});
});

View file

@ -1,125 +0,0 @@
/*
* 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 { SavedObjectCommon } from '@kbn/saved-objects-finder-plugin/common';
import { SavedSearchAttributes } from '../../../common';
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'];
managed: boolean | undefined;
}
export interface SavedSearchUnwrapResult {
attributes: SavedSearchByValueAttributes;
metaInfo?: SavedSearchUnwrapMetaInfo;
}
export type SavedSearchAttributeService = AttributeService<
SavedSearchByValueAttributes,
SearchByValueInput,
SearchByReferenceInput,
SavedSearchUnwrapMetaInfo
>;
export const savedObjectToEmbeddableAttributes = (
savedObject: SavedObjectCommon<SavedSearchAttributes>
) => ({
...savedObject.attributes,
references: savedObject.references,
});
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: savedObjectToEmbeddableAttributes(so.item),
metaInfo: {
sharingSavedObjectProps: so.meta,
managed: so.item.managed,
},
};
},
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, managed } = result.metaInfo ?? {};
return await convertToSavedSearch(
{
...splitReferences(result.attributes),
savedSearchId: id,
sharingSavedObjectProps,
managed,
},
createGetSavedSearchDeps(services)
);
};
const splitReferences = (attributes: SavedSearchByValueAttributes) => {
const { references, ...attrs } = attributes;
return {
references,
attributes: {
...attrs,
description: attrs.description ?? '',
},
};
};

View file

@ -6,14 +6,14 @@
* Side Public License, v 1.
*/
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public';
import type { SpacesApi } from '@kbn/spaces-plugin/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { SavedObjectTaggingOssPluginStart } from '@kbn/saved-objects-tagging-oss-plugin/public';
import { getSavedSearch, saveSavedSearch, SaveSavedSearchOptions, getNewSavedSearch } from '.';
import type { SavedSearchCrudTypes } from '../../../common/content_management';
import type { SpacesApi } from '@kbn/spaces-plugin/public';
import { getNewSavedSearch, getSavedSearch, saveSavedSearch, SaveSavedSearchOptions } from '.';
import { SavedSearchType } from '../../../common';
import type { SavedSearch } from '../../../common/types';
import type { SavedSearchCrudTypes } from '../../../common/content_management';
import type { SavedSearch, SerializableSavedSearch } from '../../../common/types';
import { createGetSavedSearchDeps } from './create_get_saved_search_deps';
export interface SavedSearchesServiceDeps {
@ -26,8 +26,11 @@ export interface SavedSearchesServiceDeps {
export class SavedSearchesService {
constructor(private deps: SavedSearchesServiceDeps) {}
get = (savedSearchId: string) => {
return getSavedSearch(savedSearchId, createGetSavedSearchDeps(this.deps));
get = <Serialized extends boolean = false>(
savedSearchId: string,
serialized?: Serialized
): Promise<Serialized extends true ? SerializableSavedSearch : SavedSearch> => {
return getSavedSearch(savedSearchId, createGetSavedSearchDeps(this.deps), serialized);
};
getAll = async () => {
const { contentManagement } = this.deps;

View file

@ -0,0 +1,123 @@
/*
* 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 { coreMock } from '@kbn/core/public/mocks';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { SEARCH_EMBEDDABLE_TYPE } from '@kbn/discover-utils';
import { AttributeService, type EmbeddableStart } from '@kbn/embeddable-plugin/public';
import { spacesPluginMock } from '@kbn/spaces-plugin/public/mocks';
import { SavedSearchByValueAttributes, byValueToSavedSearch } from '.';
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,
};
describe('toSavedSearch', () => {
it('succesfully converts attributes to saved search', async () => {
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 savedSearch = await byValueToSavedSearch({ attributes }, mockServices);
expect(savedSearch).toMatchInlineSnapshot(`
Object {
"breakdownField": undefined,
"columns": Array [
"message",
"extension",
],
"description": "",
"grid": Object {},
"headerRowHeight": undefined,
"hideAggregatedPreview": undefined,
"hideChart": false,
"id": undefined,
"isTextBasedQuery": false,
"managed": false,
"references": Array [
Object {
"id": "1",
"name": "ref_0",
"type": "index-pattern",
},
],
"refreshInterval": undefined,
"rowHeight": undefined,
"rowsPerPage": undefined,
"sampleSize": 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 [],
"loadDataViewFields": [MockFunction],
"onRequestStart": [MockFunction],
"parseActiveIndexPatternFromQueryString": [MockFunction],
"removeField": [MockFunction],
"serialize": [MockFunction],
"setField": [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,
"visContext": undefined,
}
`);
});
});

View file

@ -0,0 +1,53 @@
/*
* 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 { convertToSavedSearch } from '../../../common/service/get_saved_searches';
import { createGetSavedSearchDeps } from './create_get_saved_search_deps';
import type { SavedSearchesServiceDeps } from './saved_searches_service';
import type { SavedSearch, SavedSearchByValueAttributes } from './types';
export interface SavedSearchUnwrapMetaInfo {
sharingSavedObjectProps: SavedSearch['sharingSavedObjectProps'];
managed: boolean | undefined;
}
export interface SavedSearchUnwrapResult {
attributes: SavedSearchByValueAttributes;
metaInfo?: SavedSearchUnwrapMetaInfo;
}
export const byValueToSavedSearch = async (
result: SavedSearchUnwrapResult,
services: SavedSearchesServiceDeps,
serializable?: boolean
) => {
const { sharingSavedObjectProps, managed } = result.metaInfo ?? {};
return await convertToSavedSearch(
{
...splitReferences(result.attributes),
savedSearchId: undefined,
sharingSavedObjectProps,
managed,
},
createGetSavedSearchDeps(services),
serializable
);
};
const splitReferences = (attributes: SavedSearchByValueAttributes) => {
const { references, ...attrs } = attributes;
return {
references,
attributes: {
...attrs,
description: attrs.description ?? '',
},
};
};

View file

@ -6,13 +6,9 @@
* 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 type { Reference } from '@kbn/content-management-utils';
import type { SortOrder } from '../..';
import type { SavedSearchAttributes } from '../../../common';
import type { SavedSearch as SavedSearchCommon } from '../../../common';
import type { ResolvedSimpleSavedObject } from '@kbn/core/public';
import type { SavedSearch as SavedSearchCommon, SavedSearchAttributes } from '../../../common';
/** @public **/
export interface SavedSearch extends SavedSearchCommon {
@ -24,27 +20,7 @@ export interface SavedSearch extends SavedSearchCommon {
};
}
interface SearchBaseInput extends EmbeddableInput {
timeRange: TimeRange;
timeslice?: [number, number];
query?: Query;
filters?: Filter[];
hidePanelTitles?: boolean;
columns?: string[];
sort?: SortOrder[];
rowHeight?: number;
headerRowHeight?: number;
rowsPerPage?: number;
sampleSize?: number;
}
export type SavedSearchByValueAttributes = Omit<SavedSearchAttributes, 'description'> & {
description?: string;
references: Reference[];
};
export type SearchByValueInput = {
attributes: SavedSearchByValueAttributes;
} & SearchBaseInput;
export type SearchByReferenceInput = SavedObjectEmbeddableInput & SearchBaseInput;

View file

@ -1,14 +1,9 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"outDir": "target/types"
},
"include": [
"common/**/*",
"public/**/*",
"server/**/*",
"../../../typings/**/*",
],
"include": ["common/**/*", "public/**/*", "server/**/*", "../../../typings/**/*"],
"kbn_references": [
"@kbn/core",
"@kbn/data-plugin",
@ -28,14 +23,11 @@
"@kbn/embeddable-plugin",
"@kbn/saved-objects-plugin",
"@kbn/es-query",
"@kbn/discover-utils",
"@kbn/logging",
"@kbn/core-plugins-server",
"@kbn/utility-types",
"@kbn/saved-objects-finder-plugin",
"@kbn/search-types",
"@kbn/discover-utils",
],
"exclude": [
"target/**/*",
]
"exclude": ["target/**/*"]
}

View file

@ -6,8 +6,10 @@
*/
import { ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common';
import type { SearchInput } from '@kbn/discover-plugin/public';
import type { SearchEmbeddableSerializedState } from '@kbn/discover-plugin/public';
import { SavedObjectReference } from '@kbn/core/types';
import { Filter } from '@kbn/es-query';
import { ViewMode } from '@kbn/presentation-publishing';
import {
EmbeddableTypes,
EmbeddableExpressionType,
@ -22,7 +24,13 @@ interface Arguments {
id: string;
}
type Output = EmbeddableExpression<Partial<SearchInput> & { id: SearchInput['id'] }>;
type Output = EmbeddableExpression<
Partial<SearchEmbeddableSerializedState> & {
id: string;
filters?: Filter[];
viewMode?: ViewMode;
}
>;
export function savedSearch(): ExpressionFunctionDefinition<
'savedSearch',

View file

@ -7,10 +7,7 @@
"id": "reporting",
"server": true,
"browser": true,
"configPath": [
"xpack",
"reporting"
],
"configPath": ["xpack", "reporting"],
"requiredPlugins": [
"data",
"discover",
@ -22,18 +19,9 @@
"taskManager",
"screenshotMode",
"share",
"features",
"features"
],
"optionalPlugins": [
"security",
"spaces",
"usageCollection",
"screenshotting",
],
"requiredBundles": [
"embeddable",
"esUiShared",
"kibanaReact"
]
"optionalPlugins": ["security", "spaces", "usageCollection", "screenshotting"],
"requiredBundles": ["embeddable", "esUiShared", "kibanaReact"]
}
}

View file

@ -94,7 +94,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
panels[0]
)
).to.be(true);
await dashboardPanelActions.legacyUnlinkFromLibrary(panels[0]);
await dashboardPanelActions.unlinkFromLibrary(panels[0]);
await testSubjects.existOrFail('unlinkPanelSuccess');
panels = await testSubjects.findAll('embeddablePanel');
expect(panels.length).to.be(1);