diff --git a/packages/kbn-reporting/get_csv_panel_actions/panel_actions/get_csv_panel_action.test.ts b/packages/kbn-reporting/get_csv_panel_actions/panel_actions/get_csv_panel_action.test.ts index b057d81cfc877..ddfb162468add 100644 --- a/packages/kbn-reporting/get_csv_panel_actions/panel_actions/get_csv_panel_action.test.ts +++ b/packages/kbn-reporting/get_csv_panel_actions/panel_actions/get_csv_panel_action.test.ts @@ -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, diff --git a/packages/kbn-reporting/get_csv_panel_actions/panel_actions/get_csv_panel_action.tsx b/packages/kbn-reporting/get_csv_panel_actions/panel_actions/get_csv_panel_action.tsx index 699a2316fa262..a220c54cbaafd 100644 --- a/packages/kbn-reporting/get_csv_panel_actions/panel_actions/get_csv_panel_action.tsx +++ b/packages/kbn-reporting/get_csv_panel_actions/panel_actions/get_csv_panel_action.tsx @@ -17,31 +17,34 @@ 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; -} +import { getI18nStrings } from './strings'; export interface PanelActionDependencies { data: DataPublicPluginStart; @@ -79,7 +82,19 @@ interface ExecutionParams { i18nStart: I18nStart; } -export class ReportingCsvPanelAction implements ActionDefinition { +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 { private isDownloading: boolean; public readonly type = ''; public readonly id = CSV_REPORTING_ACTION; @@ -118,10 +133,10 @@ export class ReportingCsvPanelAction implements ActionDefinition 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 return false; } - return embeddable.getInput().viewMode !== ViewMode.EDIT; + return getInheritedViewMode(embeddable) !== ViewMode.EDIT; }; /** @@ -240,14 +255,14 @@ export class ReportingCsvPanelAction implements ActionDefinition }); }; - 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; diff --git a/packages/kbn-reporting/get_csv_panel_actions/tsconfig.json b/packages/kbn-reporting/get_csv_panel_actions/tsconfig.json index 39839a3e9768d..954a92fe814b3 100644 --- a/packages/kbn-reporting/get_csv_panel_actions/tsconfig.json +++ b/packages/kbn-reporting/get_csv_panel_actions/tsconfig.json @@ -27,5 +27,6 @@ "@kbn/ui-actions-plugin", "@kbn/react-kibana-mount", "@kbn/reporting-public", + "@kbn/presentation-publishing", ] } diff --git a/packages/kbn-reporting/public/tsconfig.json b/packages/kbn-reporting/public/tsconfig.json index 1f17ff412c286..9ca3c97766623 100644 --- a/packages/kbn-reporting/public/tsconfig.json +++ b/packages/kbn-reporting/public/tsconfig.json @@ -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", diff --git a/packages/kbn-unified-data-table/src/components/data_table.tsx b/packages/kbn-unified-data-table/src/components/data_table.tsx index e52e8de117968..a47f7e389b743 100644 --- a/packages/kbn-unified-data-table/src/components/data_table.tsx +++ b/packages/kbn-unified-data-table/src/components/data_table.tsx @@ -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} diff --git a/src/plugins/discover/common/embeddable/search_inject_extract.test.ts b/src/plugins/discover/common/embeddable/search_inject_extract.test.ts index 7ebaed4003627..22cd38f29ea08 100644 --- a/src/plugins/discover/common/embeddable/search_inject_extract.test.ts +++ b/src/plugins/discover/common/embeddable/search_inject_extract.test.ts @@ -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, diff --git a/src/plugins/discover/common/embeddable/search_inject_extract.ts b/src/plugins/discover/common/embeddable/search_inject_extract.ts index d8d15f327bb0d..016f32cd43c05 100644 --- a/src/plugins/discover/common/embeddable/search_inject_extract.ts +++ b/src/plugins/discover/common/embeddable/search_inject_extract.ts @@ -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; diff --git a/src/plugins/discover/public/__mocks__/services.ts b/src/plugins/discover/public/__mocks__/services.ts index f75755319a112..501a840744820 100644 --- a/src/plugins/discover/public/__mocks__/services.ts +++ b/src/plugins/discover/public/__mocks__/services.ts @@ -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, diff --git a/src/plugins/discover/public/application/main/data_fetching/fetch_esql.ts b/src/plugins/discover/public/application/main/data_fetching/fetch_esql.ts index 481e5c418d877..235f3848caf9f 100644 --- a/src/plugins/discover/public/application/main/data_fetching/fetch_esql.ts +++ b/src/plugins/discover/public/application/main/data_fetching/fetch_esql.ts @@ -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 { - const timeRange = data.query.timefilter.timefilter.getTime(); + const timeRange = inputTimeRange ?? data.query.timefilter.timefilter.getTime(); return textBasedQueryStateToAstWithValidation({ filters, query, diff --git a/src/plugins/discover/public/components/doc_table/doc_table_embeddable.tsx b/src/plugins/discover/public/components/doc_table/doc_table_embeddable.tsx index 58659b5d4b66a..ff626b4891e76 100644 --- a/src/plugins/discover/public/components/doc_table/doc_table_embeddable.tsx +++ b/src/plugins/discover/public/components/doc_table/doc_table_embeddable.tsx @@ -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 { totalHitCount?: number; diff --git a/src/plugins/discover/public/embeddable/__mocks__/get_mocked_api.ts b/src/plugins/discover/public/embeddable/__mocks__/get_mocked_api.ts new file mode 100644 index 0000000000000..0c324930e1f85 --- /dev/null +++ b/src/plugins/discover/public/embeddable/__mocks__/get_mocked_api.ts @@ -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(undefined), + dataViews: new BehaviorSubject([ + searchSource.getField('index') ?? dataViewMock, + ]), + panelTitle: new BehaviorSubject(undefined), + defaultPanelTitle: new BehaviorSubject(undefined), + hidePanelTitle: new BehaviorSubject(false), + fetchContext$: new BehaviorSubject(undefined), + timeRange$: new BehaviorSubject(undefined), + setTimeRange: jest.fn(), + dataLoading: new BehaviorSubject(undefined), + blockingError: new BehaviorSubject(undefined), + fetchWarnings$: new BehaviorSubject([]), + savedSearch$: new BehaviorSubject(savedSearch), + }, + stateManager: { + sort: new BehaviorSubject(savedSearch.sort), + columns: new BehaviorSubject(savedSearch.columns), + viewMode: new BehaviorSubject(savedSearch.viewMode), + rowHeight: new BehaviorSubject(savedSearch.rowHeight), + headerRowHeight: new BehaviorSubject(savedSearch.headerRowHeight), + rowsPerPage: new BehaviorSubject(savedSearch.rowsPerPage), + sampleSize: new BehaviorSubject(savedSearch.sampleSize), + grid: new BehaviorSubject(savedSearch.grid), + rows: new BehaviorSubject([]), + totalHitCount: new BehaviorSubject(0), + columnsMeta: new BehaviorSubject | undefined>(undefined), + }, + }; +}; diff --git a/src/plugins/discover/public/embeddable/view_saved_search_action.test.ts b/src/plugins/discover/public/embeddable/actions/view_saved_search_action.test.ts similarity index 54% rename from src/plugins/discover/public/embeddable/view_saved_search_action.test.ts rename to src/plugins/discover/public/embeddable/actions/view_saved_search_action.test.ts index 6063e0903ffe5..ed31fd6a97872 100644 --- a/src/plugins/discover/public/embeddable/view_saved_search_action.test.ts +++ b/src/plugins/discover/public/embeddable/actions/view_saved_search_action.test.ts @@ -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) ); }); }); diff --git a/src/plugins/discover/public/embeddable/view_saved_search_action.ts b/src/plugins/discover/public/embeddable/actions/view_saved_search_action.ts similarity index 87% rename from src/plugins/discover/public/embeddable/view_saved_search_action.ts rename to src/plugins/discover/public/embeddable/actions/view_saved_search_action.ts index 1623fadce1680..8f259e89cada5 100644 --- a/src/plugins/discover/public/embeddable/view_saved_search_action.ts +++ b/src/plugins/discover/public/embeddable/actions/view_saved_search_action.ts @@ -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) ); }; diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable_base.tsx b/src/plugins/discover/public/embeddable/components/saved_search_embeddable_base.tsx similarity index 95% rename from src/plugins/discover/public/embeddable/saved_search_embeddable_base.tsx rename to src/plugins/discover/public/embeddable/components/saved_search_embeddable_base.tsx index b82e582fc8f74..e257c78478156 100644 --- a/src/plugins/discover/public/embeddable/saved_search_embeddable_base.tsx +++ b/src/plugins/discover/public/embeddable/components/saved_search_embeddable_base.tsx @@ -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%; diff --git a/src/plugins/discover/public/embeddable/saved_search_grid.scss b/src/plugins/discover/public/embeddable/components/saved_search_grid.scss similarity index 100% rename from src/plugins/discover/public/embeddable/saved_search_grid.scss rename to src/plugins/discover/public/embeddable/components/saved_search_grid.scss diff --git a/src/plugins/discover/public/embeddable/saved_search_grid.tsx b/src/plugins/discover/public/embeddable/components/saved_search_grid.tsx similarity index 93% rename from src/plugins/discover/public/embeddable/saved_search_grid.tsx rename to src/plugins/discover/public/embeddable/components/saved_search_grid.tsx index 39a6dc1307c04..8f8a94fdb7bda 100644 --- a/src/plugins/discover/public/embeddable/saved_search_grid.tsx +++ b/src/plugins/discover/public/embeddable/components/saved_search_grid.tsx @@ -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 { diff --git a/src/plugins/discover/public/embeddable/components/search_embeddable_field_stats_table_component.tsx b/src/plugins/discover/public/embeddable/components/search_embeddable_field_stats_table_component.tsx new file mode 100644 index 0000000000000..d61866babbe2d --- /dev/null +++ b/src/plugins/discover/public/embeddable/components/search_embeddable_field_stats_table_component.tsx @@ -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; + }; + 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 ( + + ); +} diff --git a/src/plugins/discover/public/embeddable/components/search_embeddable_grid_component.tsx b/src/plugins/discover/public/embeddable/components/search_embeddable_grid_component.tsx new file mode 100644 index 0000000000000..fe511f5887dd5 --- /dev/null +++ b/src/plugins/discover/public/embeddable/components/search_embeddable_grid_component.tsx @@ -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 }; + 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 ( + + ); + } + + return ( + + ); +} diff --git a/src/plugins/discover/public/embeddable/constants.ts b/src/plugins/discover/public/embeddable/constants.ts index 84c1babfc7949..7ada36e3a005d 100644 --- a/src/plugins/discover/public/embeddable/constants.ts +++ b/src/plugins/discover/public/embeddable/constants.ts @@ -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> = [ + '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; diff --git a/src/plugins/discover/public/embeddable/get_search_embeddable_factory.test.tsx b/src/plugins/discover/public/embeddable/get_search_embeddable_factory.test.tsx new file mode 100644 index 0000000000000..3d909ae287177 --- /dev/null +++ b/src/plugins/discover/public/embeddable/get_search_embeddable_factory.test.tsx @@ -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(undefined); + const mockedDashboardApi = { + filters$: dashboadFilters, + timeRange$: new BehaviorSubject(undefined), + query$: new BehaviorSubject(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 + ) => ({ + ...api, + uuid, + type: factory.type, + parentApi: mockedDashboardApi, + phase$: new BehaviorSubject(undefined), + resetUnsavedChanges: jest.fn(), + snapshotRuntimeState: jest.fn(), + unsavedChanges: new BehaviorSubject | undefined>( + undefined + ), + }); + + const getInitialRuntimeState = ({ + searchMock, + dataView = dataViewMock, + partialState = {}, + }: { + searchMock?: jest.Mock; + dataView?: DataView; + partialState?: Partial; + } = {}): 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(); + + // 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(); + + // 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(); + + // 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(); + }); + }); +}); diff --git a/src/plugins/discover/public/embeddable/get_search_embeddable_factory.tsx b/src/plugins/discover/public/embeddable/get_search_embeddable_factory.tsx new file mode 100644 index 0000000000000..cbb93c413af8e --- /dev/null +++ b/src/plugins/discover/public/embeddable/get_search_embeddable_factory.tsx @@ -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; + 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(initialState?.savedObjectId); + const defaultPanelTitle$ = new BehaviorSubject( + initialState?.savedObjectTitle + ); + const defaultPanelDescription$ = new BehaviorSubject( + initialState?.savedObjectDescription + ); + + /** All other state */ + const blockingError$ = new BehaviorSubject(undefined); + const dataLoading$ = new BehaviorSubject(true); + const fetchContext$ = new BehaviorSubject(undefined); + const fetchWarnings$ = new BehaviorSubject([]); + + /** 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 ( + + + {renderAsFieldStatsTable ? ( + + ) : ( + + + + )} + + + ); + }, + }; + }, + }; + + return savedSearchEmbeddableFactory; +}; diff --git a/src/plugins/discover/public/embeddable/index.ts b/src/plugins/discover/public/embeddable/index.ts index d07f90866b3b3..b78022a986049 100644 --- a/src/plugins/discover/public/embeddable/index.ts +++ b/src/plugins/discover/public/embeddable/index.ts @@ -8,4 +8,3 @@ export { SEARCH_EMBEDDABLE_TYPE, SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER_ID } from './constants'; export * from './types'; -export * from './search_embeddable_factory'; diff --git a/src/plugins/discover/public/embeddable/initialize_edit_api.test.ts b/src/plugins/discover/public/embeddable/initialize_edit_api.test.ts new file mode 100644 index 0000000000000..5e191213636a5 --- /dev/null +++ b/src/plugins/discover/public/embeddable/initialize_edit_api.test.ts @@ -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), + }); + }); +}); diff --git a/src/plugins/discover/public/embeddable/initialize_edit_api.ts b/src/plugins/discover/public/embeddable/initialize_edit_api.ts new file mode 100644 index 0000000000000..cfdbf2e366b0a --- /dev/null +++ b/src/plugins/discover/public/embeddable/initialize_edit_api.ts @@ -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 }; + +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 }; + 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; +} diff --git a/src/plugins/discover/public/embeddable/initialize_fetch.test.ts b/src/plugins/discover/public/embeddable/initialize_fetch.test.ts new file mode 100644 index 0000000000000..5a494d1b31f4a --- /dev/null +++ b/src/plugins/discover/public/embeddable/initialize_fetch.test.ts @@ -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 + }); +}); diff --git a/src/plugins/discover/public/embeddable/initialize_fetch.ts b/src/plugins/discover/public/embeddable/initialize_fetch.ts new file mode 100644 index 0000000000000..05b465854e361 --- /dev/null +++ b/src/plugins/discover/public/embeddable/initialize_fetch.ts @@ -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; + dataLoading: BehaviorSubject; + blockingError: BehaviorSubject; + fetchWarnings$: BehaviorSubject; + } & Partial; + +export const isEsqlMode = (savedSearch: Pick): 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(); + }; +} diff --git a/src/plugins/discover/public/embeddable/initialize_search_embeddable_api.tsx b/src/plugins/discover/public/embeddable/initialize_search_embeddable_api.tsx new file mode 100644 index 0000000000000..02f64d34fac2c --- /dev/null +++ b/src/plugins/discover/public/embeddable/initialize_search_embeddable_api.tsx @@ -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; + stateManager: SearchEmbeddableStateManager; + comparators: StateComparators; + 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(searchSource); + const dataViews = new BehaviorSubject(dataView ? [dataView] : undefined); + + /** This is the state that can be initialized from the saved initial state */ + const columns$ = new BehaviorSubject(initialState.columns); + const grid$ = new BehaviorSubject(initialState.grid); + const rowHeight$ = new BehaviorSubject(initialState.rowHeight); + const rowsPerPage$ = new BehaviorSubject(initialState.rowsPerPage); + const headerRowHeight$ = new BehaviorSubject(initialState.headerRowHeight); + const sort$ = new BehaviorSubject(initialState.sort); + const sampleSize$ = new BehaviorSubject(initialState.sampleSize); + const savedSearchViewMode$ = new BehaviorSubject(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( + searchSource.getField('filter') as Filter[] + ); + const query$ = new BehaviorSubject( + searchSource.getField('query') + ); + + /** This is the state that has to be fetched */ + const rows$ = new BehaviorSubject([]); + const columnsMeta$ = new BehaviorSubject(undefined); + const totalHitCount$ = new BehaviorSubject(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> = 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)], + }, + }; +}; diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts b/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts deleted file mode 100644 index 53ce8c798f251..0000000000000 --- a/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts +++ /dev/null @@ -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; - - 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; - - (servicesMock.uiSettings as unknown as jest.Mocked).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', - ]); - }); - }); -}); diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx deleted file mode 100644 index 28c1599dbe3d0..0000000000000 --- a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx +++ /dev/null @@ -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 - implements - ISearchEmbeddable, - FilterableEmbeddable, - ReferenceOrValueEmbeddable -{ - 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( - - - - - , - 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( - - - - - - - , - 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; - } -} diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable_component.tsx b/src/plugins/discover/public/embeddable/saved_search_embeddable_component.tsx deleted file mode 100644 index 6114adb414568..0000000000000 --- a/src/plugins/discover/public/embeddable/saved_search_embeddable_component.tsx +++ /dev/null @@ -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 ( - - ); - } - - return ( - - ); -} diff --git a/src/plugins/discover/public/embeddable/search_embeddable.scss b/src/plugins/discover/public/embeddable/search_embeddable.scss deleted file mode 100644 index e9d3843485ac2..0000000000000 --- a/src/plugins/discover/public/embeddable/search_embeddable.scss +++ /dev/null @@ -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 */ - } -} \ No newline at end of file diff --git a/src/plugins/discover/public/embeddable/search_embeddable_factory.test.ts b/src/plugins/discover/public/embeddable/search_embeddable_factory.test.ts deleted file mode 100644 index c3897be27b3b3..0000000000000 --- a/src/plugins/discover/public/embeddable/search_embeddable_factory.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/plugins/discover/public/embeddable/search_embeddable_factory.ts b/src/plugins/discover/public/embeddable/search_embeddable_factory.ts deleted file mode 100644 index 9afe34648b30e..0000000000000 --- a/src/plugins/discover/public/embeddable/search_embeddable_factory.ts +++ /dev/null @@ -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 -{ - 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, - private getDiscoverServices: () => Promise - ) {} - - 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 => { - 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); - } - } -} diff --git a/src/plugins/discover/public/embeddable/types.ts b/src/plugins/discover/public/embeddable/types.ts index bc99e29d71756..dfca63f9406fb 100644 --- a/src/plugins/discover/public/embeddable/types.ts +++ b/src/plugins/discover/public/embeddable/types.ts @@ -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]: BehaviorSubject; +}; -export type SearchInput = SearchByValueInput | SearchByReferenceInput; +export type SearchEmbeddableSerializedAttributes = Omit< + SearchEmbeddableState, + 'rows' | 'columnsMeta' | 'totalHitCount' | 'searchSource' +> & + Pick; -export interface SearchOutput extends EmbeddableOutput { - indexPatterns?: DataView[]; - editable: boolean; -} +export type SearchEmbeddableSerializedState = SerializedTitles & + SerializedTimeRange & + Partial> & { + // by value + attributes?: SavedSearchAttributes & { references: SavedSearch['references'] }; + // by reference + savedObjectId?: string; + }; -export type ISearchEmbeddable = IEmbeddable & - HasSavedSearch & - HasTimeRange; +export type SearchEmbeddableRuntimeState = SearchEmbeddableSerializedAttributes & + SerializedTitles & + SerializedTimeRange & { + savedObjectTitle?: string; + savedObjectId?: string; + savedObjectDescription?: string; + }; -export interface SearchEmbeddable extends Embeddable { - type: string; -} +export type SearchEmbeddableApi = DefaultEmbeddableApi< + SearchEmbeddableSerializedState, + SearchEmbeddableRuntimeState +> & + PublishesDataViews & + PublishesSavedObjectId & + PublishesDataLoading & + PublishesBlockingError & + PublishesWritablePanelTitle & + PublishesSavedSearch & + PublishesDataViews & + PublishesUnifiedSearch & + HasInPlaceLibraryTransforms & + HasTimeRange & + Partial; -export interface HasSavedSearch { - getSavedSearch: () => SavedSearch | undefined; +export interface PublishesSavedSearch { + savedSearch$: PublishingSubject; } -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; -}; diff --git a/src/plugins/discover/public/embeddable/get_discover_locator_params.test.ts b/src/plugins/discover/public/embeddable/utils/get_discover_locator_params.test.ts similarity index 84% rename from src/plugins/discover/public/embeddable/get_discover_locator_params.test.ts rename to src/plugins/discover/public/embeddable/utils/get_discover_locator_params.test.ts index 06bd01d28041a..a3ceb5f3b38a0 100644 --- a/src/plugins/discover/public/embeddable/get_discover_locator_params.test.ts +++ b/src/plugins/discover/public/embeddable/utils/get_discover_locator_params.test.ts @@ -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('savedObjectId'), - getSavedSearch: () => savedSearchMock, + savedSearch$: new BehaviorSubject(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(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, }); }); }); diff --git a/src/plugins/discover/public/embeddable/get_discover_locator_params.ts b/src/plugins/discover/public/embeddable/utils/get_discover_locator_params.ts similarity index 78% rename from src/plugins/discover/public/embeddable/get_discover_locator_params.ts rename to src/plugins/discover/public/embeddable/utils/get_discover_locator_params.ts index 49956cdb7ee28..93703698b1e28 100644 --- a/src/plugins/discover/public/embeddable/get_discover_locator_params.ts +++ b/src/plugins/discover/public/embeddable/utils/get_discover_locator_params.ts @@ -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 + api: PublishesSavedSearch & Partial ) => { - 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; diff --git a/src/plugins/discover/public/embeddable/utils/serialization_utils.test.ts b/src/plugins/discover/public/embeddable/utils/serialization_utils.test.ts new file mode 100644 index 0000000000000..b0ef9360709d3 --- /dev/null +++ b/src/plugins/discover/public/embeddable/utils/serialization_utils.test.ts @@ -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 = { + 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 = { + 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: [], + }); + }); + }); + }); +}); diff --git a/src/plugins/discover/public/embeddable/utils/serialization_utils.ts b/src/plugins/discover/public/embeddable/utils/serialization_utils.ts new file mode 100644 index 0000000000000..bd9f6a8c15533 --- /dev/null +++ b/src/plugins/discover/public/embeddable/utils/serialization_utils.ts @@ -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; + 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> => { + 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, + }; +}; diff --git a/src/plugins/discover/public/embeddable/utils/update_search_source.test.ts b/src/plugins/discover/public/embeddable/utils/update_search_source.test.ts index e87d69cfbfc9b..1e1f5f1ca4e79 100644 --- a/src/plugins/discover/public/embeddable/utils/update_search_source.test.ts +++ b/src/plugins/discover/public/embeddable/utils/update_search_source.test.ts @@ -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 } }, + ]); + }); }); diff --git a/src/plugins/discover/public/embeddable/utils/update_search_source.ts b/src/plugins/discover/public/embeddable/utils/update_search_source.ts index 6e91d8645ab86..c9ebddd1eb02e 100644 --- a/src/plugins/discover/public/embeddable/utils/update_search_source.ts +++ b/src/plugins/discover/public/embeddable/utils/update_search_source.ts @@ -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); + } }; diff --git a/src/plugins/discover/public/index.ts b/src/plugins/discover/public/index.ts index b54356c8fd50b..ed557db31fc2d 100644 --- a/src/plugins/discover/public/index.ts +++ b/src/plugins/discover/public/index.ts @@ -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'; diff --git a/src/plugins/discover/public/plugin.tsx b/src/plugins/discover/public/plugin.tsx index 78da5820f1ddb..fcce348a319d1 100644 --- a/src/plugins/discover/public/plugin.tsx +++ b/src/plugins/discover/public/plugin.tsx @@ -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({ + 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, + }); + }); } } diff --git a/src/plugins/discover/public/utils/index.ts b/src/plugins/discover/public/utils/index.ts index 9a3e709fc71e4..5d909aa8c5e45 100644 --- a/src/plugins/discover/public/utils/index.ts +++ b/src/plugins/discover/public/utils/index.ts @@ -11,3 +11,5 @@ export async function loadSharingDataHelpers() { return await import('./get_sharing_data'); } + +export { getSortForEmbeddable } from './sorting'; diff --git a/src/plugins/discover/tsconfig.json b/src/plugins/discover/tsconfig.json index e82b70dbc7122..526b3ea6ddf3c 100644 --- a/src/plugins/discover/tsconfig.json +++ b/src/plugins/discover/tsconfig.json @@ -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" ], diff --git a/src/plugins/embeddable/public/lib/embeddables/compatibility/unlink_legacy_embeddable.ts b/src/plugins/embeddable/public/lib/embeddables/compatibility/unlink_legacy_embeddable.ts index b8d0137c3c093..d79e7cb641ab9 100644 --- a/src/plugins/embeddable/public/lib/embeddables/compatibility/unlink_legacy_embeddable.ts +++ b/src/plugins/embeddable/public/lib/embeddables/compatibility/unlink_legacy_embeddable.ts @@ -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()) ); }; diff --git a/src/plugins/saved_search/common/expressions/kibana_context.test.ts b/src/plugins/saved_search/common/expressions/kibana_context.test.ts index 0d60de71892ee..400789b0e1f13 100644 --- a/src/plugins/saved_search/common/expressions/kibana_context.test.ts +++ b/src/plugins/saved_search/common/expressions/kibana_context.test.ts @@ -91,7 +91,7 @@ describe('kibanaContextFn', () => { } as unknown as SavedSearch['searchSource'], {} as SavedSearch['sharingSavedObjectProps'], false - ) + ) as SavedSearch ); const args = { ...emptyArgs, diff --git a/src/plugins/saved_search/common/index.ts b/src/plugins/saved_search/common/index.ts index f0569a86ca39a..00e50e9d8214a 100644 --- a/src/plugins/saved_search/common/index.ts +++ b/src/plugins/saved_search/common/index.ts @@ -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'; diff --git a/src/plugins/saved_search/common/saved_searches_utils.ts b/src/plugins/saved_search/common/saved_searches_utils.ts index d8a1dbcd4cafa..232d5286ba32a 100644 --- a/src/plugins/saved_search/common/saved_searches_utils.ts +++ b/src/plugins/saved_search/common/saved_searches_utils.ts @@ -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); diff --git a/src/plugins/saved_search/common/service/get_saved_searches.ts b/src/plugins/saved_search/common/service/get_saved_searches.ts index d81830e76d519..82b3de95c47ef 100644 --- a/src/plugins/saved_search/common/service/get_saved_searches.ts +++ b/src/plugins/saved_search/common/service/get_saved_searches.ts @@ -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 => { 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 => { 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; }; /** diff --git a/src/plugins/saved_search/common/service/saved_searches_utils.ts b/src/plugins/saved_search/common/service/saved_searches_utils.ts index 11a848f8baaf8..866b9876d4dd4 100644 --- a/src/plugins/saved_search/common/service/saved_searches_utils.ts +++ b/src/plugins/saved_search/common/service/saved_searches_utils.ts @@ -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, }); diff --git a/src/plugins/saved_search/common/types.ts b/src/plugins/saved_search/common/types.ts index 34ada26b0c1a4..fe41fd66042fe 100644 --- a/src/plugins/saved_search/common/types.ts +++ b/src/plugins/saved_search/common/types.ts @@ -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 & { 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 & { + serializedSearchSource?: SerializedSearchSourceFields; +}; diff --git a/src/plugins/saved_search/public/index.ts b/src/plugins/saved_search/public/index.ts index eb7342633894c..ad3c3ab0d0ca0 100644 --- a/src/plugins/saved_search/public/index.ts +++ b/src/plugins/saved_search/public/index.ts @@ -12,10 +12,7 @@ export type { SortOrder } from '../common/types'; export type { SavedSearch, SaveSavedSearchOptions, - SearchByReferenceInput, - SearchByValueInput, SavedSearchByValueAttributes, - SavedSearchAttributeService, SavedSearchUnwrapMetaInfo, SavedSearchUnwrapResult, } from './services/saved_searches'; diff --git a/src/plugins/saved_search/public/mocks.ts b/src/plugins/saved_search/public/mocks.ts index 970ed9692aed0..3717a5be4f7ba 100644 --- a/src/plugins/saved_search/public/mocks.ts +++ b/src/plugins/saved_search/public/mocks.ts @@ -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 = { diff --git a/src/plugins/saved_search/public/plugin.ts b/src/plugins/saved_search/public/plugin.ts index 8f9ffc459e72a..2193af94be861 100644 --- a/src/plugins/saved_search/public/plugin.ts +++ b/src/plugins/saved_search/public/plugin.ts @@ -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 { 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 { - getSavedSearch, - saveSavedSearch, - SaveSavedSearchOptions, getNewSavedSearch, SavedSearchUnwrapResult, - SearchByValueInput, + saveSavedSearch, + SaveSavedSearchOptions, + byValueToSavedSearch, } from './services/saved_searches'; -import { SavedSearch, SavedSearchAttributes } from '../common/types'; -import { SavedSearchType, LATEST_VERSION } from '../common'; +import { checkForDuplicateTitle } from './services/saved_searches/check_for_duplicate_title'; import { SavedSearchesService } from './services/saved_searches/saved_searches_service'; -import { kibanaContext } from '../common/expressions'; -import { getKibanaContext } from './expressions/kibana_context'; -import { - type SavedSearchAttributeService, - getSavedSearchAttributeService, - toSavedSearch, -} from './services/saved_searches'; -import { savedObjectToEmbeddableAttributes } from './services/saved_searches/saved_search_attribute_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; + get: ( + savedSearchId: string, + serialized?: Serialized + ) => Promise; getNew: () => ReturnType; getAll: () => Promise>>; save: ( savedSearch: SavedSearch, options?: SaveSavedSearchOptions ) => ReturnType; - byValue: { - attributeService: SavedSearchAttributeService; - toSavedSearch: ( - id: string | undefined, - result: SavedSearchUnwrapResult - ) => Promise; - }; + checkForDuplicateTitle: ( + props: Pick + ) => Promise; + byValueToSavedSearch: ( + result: SavedSearchUnwrapResult, + serialized?: Serialized + ) => Promise; } /** @@ -118,19 +116,6 @@ export class SavedSearchPublicPlugin expressions.registerType(kibanaContext); - embeddable.registerSavedObjectToPanelMethod( - 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: ( + savedSearchId: string, + serialized?: Serialized + ): Promise => + 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 + ) => { + 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 => { + return (await byValueToSavedSearch(result, deps, serialized)) as ReturnType; }, }; } diff --git a/src/plugins/saved_search/public/services/saved_searches/check_for_duplicate_title.ts b/src/plugins/saved_search/public/services/saved_searches/check_for_duplicate_title.ts index 49264e24e25ae..d920c4a389b8e 100644 --- a/src/plugins/saved_search/public/services/saved_searches/check_for_duplicate_title.ts +++ b/src/plugins/saved_search/public/services/saved_searches/check_for_duplicate_title.ts @@ -56,5 +56,5 @@ export const checkForDuplicateTitle = async ({ return Promise.reject(new Error(`Saved search title already exists: ${title}`)); } - return true; + return; }; diff --git a/src/plugins/saved_search/public/services/saved_searches/index.ts b/src/plugins/saved_search/public/services/saved_searches/index.ts index add8464cd8d8b..af93cc1d17c49 100644 --- a/src/plugins/saved_search/public/services/saved_searches/index.ts +++ b/src/plugins/saved_search/public/services/saved_searches/index.ts @@ -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'; diff --git a/src/plugins/saved_search/public/services/saved_searches/saved_search_attribute_service.test.ts b/src/plugins/saved_search/public/services/saved_searches/saved_search_attribute_service.test.ts deleted file mode 100644 index b1a030bb6d3ca..0000000000000 --- a/src/plugins/saved_search/public/services/saved_searches/saved_search_attribute_service.test.ts +++ /dev/null @@ -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, - } - `); - }); - }); -}); diff --git a/src/plugins/saved_search/public/services/saved_searches/saved_search_attribute_service.ts b/src/plugins/saved_search/public/services/saved_searches/saved_search_attribute_service.ts deleted file mode 100644 index 726853d26ebe4..0000000000000 --- a/src/plugins/saved_search/public/services/saved_searches/saved_search_attribute_service.ts +++ /dev/null @@ -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 -) => ({ - ...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 => { - 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 ?? '', - }, - }; -}; diff --git a/src/plugins/saved_search/public/services/saved_searches/saved_searches_service.ts b/src/plugins/saved_search/public/services/saved_searches/saved_searches_service.ts index fe08494b10afc..e2c1222dfb4ab 100644 --- a/src/plugins/saved_search/public/services/saved_searches/saved_searches_service.ts +++ b/src/plugins/saved_search/public/services/saved_searches/saved_searches_service.ts @@ -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 = ( + savedSearchId: string, + serialized?: Serialized + ): Promise => { + return getSavedSearch(savedSearchId, createGetSavedSearchDeps(this.deps), serialized); }; getAll = async () => { const { contentManagement } = this.deps; diff --git a/src/plugins/saved_search/public/services/saved_searches/to_saved_search.test.ts b/src/plugins/saved_search/public/services/saved_searches/to_saved_search.test.ts new file mode 100644 index 0000000000000..a319188a49f63 --- /dev/null +++ b/src/plugins/saved_search/public/services/saved_searches/to_saved_search.test.ts @@ -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, + } + `); + }); +}); diff --git a/src/plugins/saved_search/public/services/saved_searches/to_saved_search.ts b/src/plugins/saved_search/public/services/saved_searches/to_saved_search.ts new file mode 100644 index 0000000000000..4383041149f5a --- /dev/null +++ b/src/plugins/saved_search/public/services/saved_searches/to_saved_search.ts @@ -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 ?? '', + }, + }; +}; diff --git a/src/plugins/saved_search/public/services/saved_searches/types.ts b/src/plugins/saved_search/public/services/saved_searches/types.ts index d2e4e6ad5de51..ed85ca5ea4d01 100644 --- a/src/plugins/saved_search/public/services/saved_searches/types.ts +++ b/src/plugins/saved_search/public/services/saved_searches/types.ts @@ -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 & { description?: string; references: Reference[]; }; - -export type SearchByValueInput = { - attributes: SavedSearchByValueAttributes; -} & SearchBaseInput; - -export type SearchByReferenceInput = SavedObjectEmbeddableInput & SearchBaseInput; diff --git a/src/plugins/saved_search/tsconfig.json b/src/plugins/saved_search/tsconfig.json index 0da728fdc6581..7100affb2e0c3 100644 --- a/src/plugins/saved_search/tsconfig.json +++ b/src/plugins/saved_search/tsconfig.json @@ -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/**/*"] } diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_search.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_search.ts index 86c289a27c70d..445bcdda3bffe 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_search.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_search.ts @@ -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 & { id: SearchInput['id'] }>; +type Output = EmbeddableExpression< + Partial & { + id: string; + filters?: Filter[]; + viewMode?: ViewMode; + } +>; export function savedSearch(): ExpressionFunctionDefinition< 'savedSearch', diff --git a/x-pack/plugins/reporting/kibana.jsonc b/x-pack/plugins/reporting/kibana.jsonc index e2907c1439f34..8c9e97a1f6291 100644 --- a/x-pack/plugins/reporting/kibana.jsonc +++ b/x-pack/plugins/reporting/kibana.jsonc @@ -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", - ], - "optionalPlugins": [ - "security", - "spaces", - "usageCollection", - "screenshotting", + "features" ], - "requiredBundles": [ - "embeddable", - "esUiShared", - "kibanaReact" - ] + "optionalPlugins": ["security", "spaces", "usageCollection", "screenshotting"], + "requiredBundles": ["embeddable", "esUiShared", "kibanaReact"] } } diff --git a/x-pack/test/functional/apps/dashboard/group2/dashboard_search_by_value.ts b/x-pack/test/functional/apps/dashboard/group2/dashboard_search_by_value.ts index 803844999a60d..e82c73dddc294 100644 --- a/x-pack/test/functional/apps/dashboard/group2/dashboard_search_by_value.ts +++ b/x-pack/test/functional/apps/dashboard/group2/dashboard_search_by_value.ts @@ -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);