mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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** |  |  | | **By reference** |  |  | ### 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:
parent
e93e3034a7
commit
5c4eae1286
66 changed files with 2827 additions and 2353 deletions
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -27,5 +27,6 @@
|
|||
"@kbn/ui-actions-plugin",
|
||||
"@kbn/react-kibana-mount",
|
||||
"@kbn/reporting-public",
|
||||
"@kbn/presentation-publishing",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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)
|
||||
);
|
||||
};
|
||||
|
|
@ -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%;
|
|
@ -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'> {
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -8,4 +8,3 @@
|
|||
|
||||
export { SEARCH_EMBEDDABLE_TYPE, SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER_ID } from './constants';
|
||||
export * from './types';
|
||||
export * from './search_embeddable_factory';
|
||||
|
|
|
@ -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),
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
107
src/plugins/discover/public/embeddable/initialize_fetch.test.ts
Normal file
107
src/plugins/discover/public/embeddable/initialize_fetch.test.ts
Normal 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
|
||||
});
|
||||
});
|
237
src/plugins/discover/public/embeddable/initialize_fetch.ts
Normal file
237
src/plugins/discover/public/embeddable/initialize_fetch.ts
Normal 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();
|
||||
};
|
||||
}
|
|
@ -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)],
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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 */
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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 } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,3 +11,5 @@
|
|||
export async function loadSharingDataHelpers() {
|
||||
return await import('./get_sharing_data');
|
||||
}
|
||||
|
||||
export { getSortForEmbeddable } from './sorting';
|
||||
|
|
|
@ -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"
|
||||
],
|
||||
|
|
|
@ -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())
|
||||
);
|
||||
};
|
||||
|
|
|
@ -91,7 +91,7 @@ describe('kibanaContextFn', () => {
|
|||
} as unknown as SavedSearch['searchSource'],
|
||||
{} as SavedSearch['sharingSavedObjectProps'],
|
||||
false
|
||||
)
|
||||
) as SavedSearch
|
||||
);
|
||||
const args = {
|
||||
...emptyArgs,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -12,10 +12,7 @@ export type { SortOrder } from '../common/types';
|
|||
export type {
|
||||
SavedSearch,
|
||||
SaveSavedSearchOptions,
|
||||
SearchByReferenceInput,
|
||||
SearchByValueInput,
|
||||
SavedSearchByValueAttributes,
|
||||
SavedSearchAttributeService,
|
||||
SavedSearchUnwrapMetaInfo,
|
||||
SavedSearchUnwrapResult,
|
||||
} from './services/saved_searches';
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -56,5 +56,5 @@ export const checkForDuplicateTitle = async ({
|
|||
return Promise.reject(new Error(`Saved search title already exists: ${title}`));
|
||||
}
|
||||
|
||||
return true;
|
||||
return;
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 ?? '',
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
|
@ -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 ?? '',
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -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/**/*"]
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue