diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index ffedac575acfc..e2359a59dcbd1 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -134,7 +134,7 @@ pageLoadAssetSize: savedObjectsManagement: 22426 savedObjectsTagging: 23144 savedObjectsTaggingOss: 2163 - savedSearch: 12662 + savedSearch: 11000 screenshotMode: 2351 screenshotting: 8417 searchAssistant: 6150 diff --git a/src/platform/plugins/shared/dashboard/public/services/dashboard_content_management_service/lib/load_dashboard_state.ts b/src/platform/plugins/shared/dashboard/public/services/dashboard_content_management_service/lib/load_dashboard_state.ts index 5aa942ac1fa1c..1043a02e85b7a 100644 --- a/src/platform/plugins/shared/dashboard/public/services/dashboard_content_management_service/lib/load_dashboard_state.ts +++ b/src/platform/plugins/shared/dashboard/public/services/dashboard_content_management_service/lib/load_dashboard_state.ts @@ -57,7 +57,7 @@ export const loadDashboardState = async ({ }) .catch((e) => { if (e.response?.status === 404) { - throw new SavedObjectNotFound(DASHBOARD_CONTENT_ID, id); + throw new SavedObjectNotFound({ type: DASHBOARD_CONTENT_ID, id }); } const message = (e.body as { message?: string })?.message ?? e.message; throw new Error(message); diff --git a/src/platform/plugins/shared/data/common/search/search_source/extract_references.test.ts b/src/platform/plugins/shared/data/common/search/search_source/extract_references.test.ts new file mode 100644 index 0000000000000..196095ef670d1 --- /dev/null +++ b/src/platform/plugins/shared/data/common/search/search_source/extract_references.test.ts @@ -0,0 +1,121 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { extractReferences } from './extract_references'; +import type { SerializedSearchSourceFields } from './types'; + +describe('extractReferences', () => { + it('should extract reference for data view ID', () => { + const searchSource: SerializedSearchSourceFields = { + index: 'test-index', + }; + const result = extractReferences(searchSource); + expect(result).toEqual([ + { + indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.index', + }, + [ + { + id: 'test-index', + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + }, + ], + ]); + }); + + it('should not extract reference for data view spec', () => { + const searchSource: SerializedSearchSourceFields = { + index: { + id: 'test-id', + title: 'test-title', + }, + }; + const result = extractReferences(searchSource); + expect(result).toEqual([ + { + index: { + id: 'test-id', + title: 'test-title', + }, + }, + [], + ]); + }); + + it('should extract reference for filter', () => { + const searchSource: SerializedSearchSourceFields = { + filter: [ + { + meta: { + index: 'test-index', + }, + }, + ], + }; + const result = extractReferences(searchSource); + expect(result).toEqual([ + { + filter: [ + { + meta: { + indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index', + }, + }, + ], + }, + [ + { + id: 'test-index', + name: 'kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index', + type: 'index-pattern', + }, + ], + ]); + }); + + it('should apply prefix to references', () => { + const searchSource: SerializedSearchSourceFields = { + index: 'test-index', + filter: [ + { + meta: { + index: 'test-index', + }, + }, + ], + }; + const result = extractReferences(searchSource, { refNamePrefix: 'testPrefix' }); + expect(result).toEqual([ + { + indexRefName: 'testPrefix.kibanaSavedObjectMeta.searchSourceJSON.index', + filter: [ + { + meta: { + indexRefName: + 'testPrefix.kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index', + }, + }, + ], + }, + [ + { + id: 'test-index', + name: 'testPrefix.kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + }, + { + id: 'test-index', + name: 'testPrefix.kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index', + type: 'index-pattern', + }, + ], + ]); + }); +}); diff --git a/src/platform/plugins/shared/data/common/search/search_source/extract_references.ts b/src/platform/plugins/shared/data/common/search/search_source/extract_references.ts index 5c3b328574fc7..fc24fd5ec03ee 100644 --- a/src/platform/plugins/shared/data/common/search/search_source/extract_references.ts +++ b/src/platform/plugins/shared/data/common/search/search_source/extract_references.ts @@ -14,14 +14,16 @@ import type { SerializedSearchSourceFields } from './types'; import { DATA_VIEW_SAVED_OBJECT_TYPE } from '../..'; export const extractReferences = ( - state: SerializedSearchSourceFields + state: SerializedSearchSourceFields, + options?: { refNamePrefix?: string } ): [SerializedSearchSourceFields, SavedObjectReference[]] => { + const refNamePrefix = options?.refNamePrefix ? `${options.refNamePrefix}.` : ''; let searchSourceFields: SerializedSearchSourceFields & { indexRefName?: string } = { ...state }; const references: SavedObjectReference[] = []; if (searchSourceFields.index) { if (typeof searchSourceFields.index === 'string') { const indexId = searchSourceFields.index; - const refName = 'kibanaSavedObjectMeta.searchSourceJSON.index'; + const refName = `${refNamePrefix}kibanaSavedObjectMeta.searchSourceJSON.index`; references.push({ name: refName, type: DATA_VIEW_SAVED_OBJECT_TYPE, @@ -47,7 +49,7 @@ export const extractReferences = ( if (!filterRow.meta || !filterRow.meta.index) { return filterRow; } - const refName = `kibanaSavedObjectMeta.searchSourceJSON.filter[${i}].meta.index`; + const refName = `${refNamePrefix}kibanaSavedObjectMeta.searchSourceJSON.filter[${i}].meta.index`; references.push({ name: refName, type: DATA_VIEW_SAVED_OBJECT_TYPE, diff --git a/src/platform/plugins/shared/data_views/common/data_views/data_views.test.ts b/src/platform/plugins/shared/data_views/common/data_views/data_views.test.ts index 6861a93b2e6b2..8dfa55288522f 100644 --- a/src/platform/plugins/shared/data_views/common/data_views/data_views.test.ts +++ b/src/platform/plugins/shared/data_views/common/data_views/data_views.test.ts @@ -419,7 +419,7 @@ describe('IndexPatterns', () => { test('savedObjectCache pre-fetches title, type, typeMeta', async () => { expect(await indexPatterns.getIds()).toEqual(['id']); expect(savedObjectsClient.find).toHaveBeenCalledWith({ - fields: ['title', 'type', 'typeMeta', 'name'], + fields: ['title', 'type', 'typeMeta', 'name', 'timeFieldName'], perPage: 10000, }); }); diff --git a/src/platform/plugins/shared/data_views/common/data_views/data_views.ts b/src/platform/plugins/shared/data_views/common/data_views/data_views.ts index 1eca1304bd126..03bf350a2bb55 100644 --- a/src/platform/plugins/shared/data_views/common/data_views/data_views.ts +++ b/src/platform/plugins/shared/data_views/common/data_views/data_views.ts @@ -55,12 +55,7 @@ const createFetchFieldErrorTitle = ({ id, title }: { id?: string; title?: string */ export type DataViewSavedObjectAttrs = Pick< DataViewAttributes, - 'title' | 'type' | 'typeMeta' | 'name' ->; - -export type IndexPatternListSavedObjectAttrs = Pick< - DataViewAttributes, - 'title' | 'type' | 'typeMeta' | 'name' + 'title' | 'type' | 'typeMeta' | 'name' | 'timeFieldName' >; /** @@ -87,7 +82,14 @@ export interface DataViewListItem { * Data view type meta */ typeMeta?: TypeMeta; + /** + * Human-readable name + */ name?: string; + /** + * Time field name if applicable + */ + timeFieldName?: string; } /** @@ -404,7 +406,7 @@ export class DataViewsService { */ private async refreshSavedObjectsCache() { const so = await this.savedObjectsClient.find({ - fields: ['title', 'type', 'typeMeta', 'name'], + fields: ['title', 'type', 'typeMeta', 'name', 'timeFieldName'], perPage: 10000, }); this.savedObjectsCache = so; @@ -494,6 +496,7 @@ export class DataViewsService { type: obj?.attributes?.type, typeMeta: obj?.attributes?.typeMeta && JSON.parse(obj?.attributes?.typeMeta), name: obj?.attributes?.name, + timeFieldName: obj?.attributes?.timeFieldName, })); }; @@ -1269,7 +1272,7 @@ export class DataViewsService { })) as SavedObject; if (this.savedObjectsCache) { - this.savedObjectsCache.push(response as SavedObject); + this.savedObjectsCache.push(response); } dataView.version = response.version; dataView.namespaces = response.namespaces || []; diff --git a/src/platform/plugins/shared/data_views/public/content_management_wrapper.ts b/src/platform/plugins/shared/data_views/public/content_management_wrapper.ts index c5c0b5777da2a..b69d181c4c07c 100644 --- a/src/platform/plugins/shared/data_views/public/content_management_wrapper.ts +++ b/src/platform/plugins/shared/data_views/public/content_management_wrapper.ts @@ -58,7 +58,12 @@ export class ContentMagementWrapper implements PersistenceAPI { }); } catch (e) { if (e.body?.statusCode === 404) { - throw new SavedObjectNotFound('data view', id, 'management/kibana/dataViews'); + throw new SavedObjectNotFound({ + type: DataViewSOType, + typeDisplayName: 'data view', + id, + link: '/app/management/kibana/dataViews', + }); } else { throw e; } diff --git a/src/platform/plugins/shared/discover/common/data_sources/utils.test.ts b/src/platform/plugins/shared/discover/common/data_sources/utils.test.ts index 8cc858612d579..c46c86e1bf144 100644 --- a/src/platform/plugins/shared/discover/common/data_sources/utils.test.ts +++ b/src/platform/plugins/shared/discover/common/data_sources/utils.test.ts @@ -32,4 +32,10 @@ describe('createDataSource', () => { const result = createDataSource({ dataView, query }); expect(result).toEqual(undefined); }); + + it('should return data view source when not ES|QL query and passed a dataView id directly', () => { + const query = { language: 'kql', query: 'test' }; + const result = createDataSource({ dataView: 'test', query }); + expect(result).toEqual(createDataViewDataSource({ dataViewId: 'test' })); + }); }); diff --git a/src/platform/plugins/shared/discover/common/data_sources/utils.ts b/src/platform/plugins/shared/discover/common/data_sources/utils.ts index 70e9546f86943..b244511ed84d2 100644 --- a/src/platform/plugins/shared/discover/common/data_sources/utils.ts +++ b/src/platform/plugins/shared/discover/common/data_sources/utils.ts @@ -8,7 +8,7 @@ */ import { isOfAggregateQueryType, type AggregateQuery, type Query } from '@kbn/es-query'; -import type { DataView } from '@kbn/data-views-plugin/common'; +import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/common'; import { DataSourceType, type DataViewDataSource, @@ -33,14 +33,19 @@ export const createDataSource = ({ dataView, query, }: { - dataView: DataView | undefined; + dataView: DataView | DataViewSpec | string | undefined; query: Query | AggregateQuery | undefined; }) => { - return isOfAggregateQueryType(query) - ? createEsqlDataSource() - : dataView?.id - ? createDataViewDataSource({ dataViewId: dataView.id }) - : undefined; + if (isOfAggregateQueryType(query)) { + return createEsqlDataSource(); + } + if (typeof dataView === 'string') { + return createDataViewDataSource({ dataViewId: dataView }); + } + if (dataView?.id) { + return createDataViewDataSource({ dataViewId: dataView.id }); + } + return undefined; }; export const isDataSourceType = ( diff --git a/src/platform/plugins/shared/discover/public/__mocks__/discover_state.mock.ts b/src/platform/plugins/shared/discover/public/__mocks__/discover_state.mock.ts index a43f9cc3e1a76..67ad61b47af0a 100644 --- a/src/platform/plugins/shared/discover/public/__mocks__/discover_state.mock.ts +++ b/src/platform/plugins/shared/discover/public/__mocks__/discover_state.mock.ts @@ -17,6 +17,7 @@ import type { RuntimeStateManager } from '../application/main/state_management/r import { createInternalStateStore, createRuntimeStateManager, + fromSavedSearchToSavedObjectTab, selectTabRuntimeState, } from '../application/main/state_management/redux'; import type { DiscoverServices, HistoryLocationState } from '../build_services'; @@ -27,10 +28,13 @@ import type { DiscoverCustomizationContext } from '../customizations'; import { createCustomizationService } from '../customizations/customization_service'; import { createTabsStorageManager } from '../application/main/state_management/tabs_storage_manager'; import { internalStateActions } from '../application/main/state_management/redux'; +import { DEFAULT_TAB_STATE } from '../application/main/state_management/redux'; +import type { DiscoverSession, DiscoverSessionTab } from '@kbn/saved-search-plugin/common'; export function getDiscoverStateMock({ isTimeBased = true, savedSearch, + additionalPersistedTabs = [], stateStorageContainer, runtimeStateManager, history, @@ -39,6 +43,7 @@ export function getDiscoverStateMock({ }: { isTimeBased?: boolean; savedSearch?: SavedSearch | false; + additionalPersistedTabs?: DiscoverSessionTab[]; runtimeStateManager?: RuntimeStateManager; stateStorageContainer?: IKbnUrlStateStorage; history?: History; @@ -52,15 +57,13 @@ export function getDiscoverStateMock({ const services = { ...originalServices, history }; const storeInSessionStorage = services.uiSettings.get('state:storeInSessionStorage'); const toasts = services.core.notifications.toasts; - stateStorageContainer = - stateStorageContainer ?? - createKbnUrlStateStorage({ - useHash: storeInSessionStorage, - history: services.history, - useHashQuery: customizationContext.displayMode !== 'embedded', - ...(toasts && withNotifyOnErrors(toasts)), - }); - runtimeStateManager = runtimeStateManager ?? createRuntimeStateManager(); + stateStorageContainer ??= createKbnUrlStateStorage({ + useHash: storeInSessionStorage, + history: services.history, + useHashQuery: customizationContext.displayMode !== 'embedded', + ...(toasts && withNotifyOnErrors(toasts)), + }); + runtimeStateManager ??= createRuntimeStateManager(); const tabsStorageManager = createTabsStorageManager({ urlStateStorage: stateStorageContainer, storage: services.storage, @@ -72,8 +75,48 @@ export function getDiscoverStateMock({ urlStateStorage: stateStorageContainer, tabsStorageManager, }); + const finalSavedSearch = + savedSearch === false + ? undefined + : savedSearch ?? (isTimeBased ? savedSearchMockWithTimeField : savedSearchMock); + const persistedDiscoverSession: DiscoverSession | undefined = finalSavedSearch + ? { + ...finalSavedSearch, + id: finalSavedSearch.id ?? 'test-id', + title: finalSavedSearch.title ?? 'title', + description: finalSavedSearch.description ?? 'description', + tabs: [ + fromSavedSearchToSavedObjectTab({ + tab: { + id: finalSavedSearch.id ?? '', + label: finalSavedSearch.title ?? '', + }, + savedSearch: finalSavedSearch, + services, + }), + ...additionalPersistedTabs, + ], + } + : undefined; + const mockUserId = 'mockUserId'; + const mockSpaceId = 'mockSpaceId'; + const initialTabsState = tabsStorageManager.loadLocally({ + userId: mockUserId, + spaceId: mockSpaceId, + persistedDiscoverSession, + defaultTabState: DEFAULT_TAB_STATE, + }); + internalState.dispatch(internalStateActions.setTabs(initialTabsState)); internalState.dispatch( - internalStateActions.initializeTabs({ userId: 'mockUserId', spaceId: 'mockSpaceId' }) + internalStateActions.initializeTabs.fulfilled( + { + userId: mockUserId, + spaceId: mockSpaceId, + persistedDiscoverSession, + }, + 'requestId', + { discoverSessionId: finalSavedSearch?.id } + ) ); const container = getDiscoverStateContainer({ tabId: internalState.getState().tabs.unsafeCurrentId, @@ -92,10 +135,8 @@ export function getDiscoverStateMock({ cleanup: async () => {}, }); tabRuntimeState.stateContainer$.next(container); - if (savedSearch !== false) { - container.savedSearchState.set( - savedSearch ? savedSearch : isTimeBased ? savedSearchMockWithTimeField : savedSearchMock - ); + if (finalSavedSearch) { + container.savedSearchState.set(finalSavedSearch); } return container; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/session_view/branded_loading_indicator.tsx b/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/branded_loading_indicator.tsx similarity index 100% rename from src/platform/plugins/shared/discover/public/application/main/components/session_view/branded_loading_indicator.tsx rename to src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/branded_loading_indicator.tsx diff --git a/src/platform/plugins/shared/discover/public/application/main/components/session_view/index.ts b/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/index.ts similarity index 80% rename from src/platform/plugins/shared/discover/public/application/main/components/session_view/index.ts rename to src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/index.ts index 7785f6e2f0535..48793e2eb3f33 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/session_view/index.ts +++ b/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/index.ts @@ -9,4 +9,5 @@ export { BrandedLoadingIndicator } from './branded_loading_indicator'; export { NoDataPage } from './no_data_page'; -export { DiscoverSessionView, type DiscoverSessionViewProps } from './session_view'; +export { InitializationError } from './initialization_error'; +export { SingleTabView, type SingleTabViewProps } from './single_tab_view'; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/session_view/redirect_not_found.tsx b/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/initialization_error.tsx similarity index 65% rename from src/platform/plugins/shared/discover/public/application/main/components/session_view/redirect_not_found.tsx rename to src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/initialization_error.tsx index 1e12260e1bbfb..719d4d23e35fe 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/session_view/redirect_not_found.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/initialization_error.tsx @@ -7,20 +7,24 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/public'; +import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/public'; import useMount from 'react-use/lib/useMount'; import { redirectWhenMissing } from '@kbn/kibana-utils-plugin/public'; import React from 'react'; import { useDiscoverServices } from '../../../../hooks/use_discover_services'; import { BrandedLoadingIndicator } from './branded_loading_indicator'; +import { useInternalStateSelector } from '../../state_management/redux'; +import { DiscoverError } from '../../../../components/common/error_alert'; -export const RedirectWhenSavedObjectNotFound = ({ - error, - discoverSessionId, -}: { - error: SavedObjectNotFound; - discoverSessionId: string | undefined; -}) => { +export const InitializationError = ({ error }: { error: Error }) => { + if (error instanceof SavedObjectNotFound) { + return ; + } + + return ; +}; + +const RedirectWhenSavedObjectNotFound = ({ error }: { error: SavedObjectNotFound }) => { const { application: { navigateToApp }, core, @@ -29,6 +33,7 @@ export const RedirectWhenSavedObjectNotFound = ({ toastNotifications, urlTracker, } = useDiscoverServices(); + const discoverSessionId = useInternalStateSelector((state) => state.persistedDiscoverSession?.id); useMount(() => { const redirect = redirectWhenMissing({ @@ -39,7 +44,7 @@ export const RedirectWhenSavedObjectNotFound = ({ search: '/', 'index-pattern': { app: 'management', - path: `kibana/objects/savedSearches/${discoverSessionId}`, + path: `kibana/objects/search/${discoverSessionId}`, }, }, toastNotifications, diff --git a/src/platform/plugins/shared/discover/public/application/main/components/session_view/main_app.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/main_app.test.tsx similarity index 100% rename from src/platform/plugins/shared/discover/public/application/main/components/session_view/main_app.test.tsx rename to src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/main_app.test.tsx diff --git a/src/platform/plugins/shared/discover/public/application/main/components/session_view/main_app.tsx b/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/main_app.tsx similarity index 75% rename from src/platform/plugins/shared/discover/public/application/main/components/session_view/main_app.tsx rename to src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/main_app.tsx index 801f6698bb8c5..c69511802eb13 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/session_view/main_app.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/main_app.tsx @@ -11,7 +11,6 @@ import React, { useEffect } from 'react'; import { RootDragDropProvider } from '@kbn/dom-drag-drop'; import type { DiscoverStateContainer } from '../../state_management/discover_state'; import { DiscoverLayout } from '../layout'; -import { setBreadcrumbs } from '../../../../utils/breadcrumbs'; import { addHelpMenuToAppChrome } from '../../../../components/help_menu/help_menu_util'; import { useDiscoverServices } from '../../../../hooks/use_discover_services'; import { useSavedSearchAliasMatchRedirect } from '../../../../hooks/saved_search_alias_match_redirect'; @@ -37,24 +36,6 @@ export function DiscoverMainApp({ stateContainer }: DiscoverMainProps) { */ useAdHocDataViews(); - /** - * SavedSearch dependent initializing - */ - useEffect(() => { - // TODO: This can be moved to Discover session initialization, some of the logic is already duplicated - if (stateContainer.customizationContext.displayMode === 'standalone') { - const pageTitleSuffix = savedSearch.id && savedSearch.title ? `: ${savedSearch.title}` : ''; - chrome.docTitle.change(`Discover${pageTitleSuffix}`); - setBreadcrumbs({ titleBreadcrumbText: savedSearch.title, services }); - } - }, [ - chrome.docTitle, - savedSearch.id, - savedSearch.title, - services, - stateContainer.customizationContext.displayMode, - ]); - // TODO: Move this higher up in the component tree useEffect(() => { addHelpMenuToAppChrome(chrome, docLinks); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/session_view/no_data_page.tsx b/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/no_data_page.tsx similarity index 100% rename from src/platform/plugins/shared/discover/public/application/main/components/session_view/no_data_page.tsx rename to src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/no_data_page.tsx diff --git a/src/platform/plugins/shared/discover/public/application/main/components/session_view/session_view.tsx b/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/single_tab_view.tsx similarity index 69% rename from src/platform/plugins/shared/discover/public/application/main/components/session_view/session_view.tsx rename to src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/single_tab_view.tsx index d956f3a3852b2..b0ddb2ce53709 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/session_view/session_view.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/single_tab_view.tsx @@ -8,16 +8,9 @@ */ import React from 'react'; -import type { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; -import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/public'; -import { useParams } from 'react-router-dom'; -import useLatest from 'react-use/lib/useLatest'; +import { type IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/common'; -import { useExecutionContext } from '@kbn/kibana-react-plugin/public'; import useMount from 'react-use/lib/useMount'; -import useUpdateEffect from 'react-use/lib/useUpdateEffect'; -import { useUrl } from '../../hooks/use_url'; -import { useAlertResultsToast } from '../../hooks/use_alert_results_toast'; import { createDataViewDataSource } from '../../../../../common/data_sources'; import type { MainHistoryLocationState } from '../../../../../common'; import { useDiscoverServices } from '../../../../hooks/use_discover_services'; @@ -42,17 +35,16 @@ import { DiscoverCustomizationProvider, getConnectedCustomizationService, } from '../../../../customizations'; -import { DiscoverError } from '../../../../components/common/error_alert'; import { NoDataPage } from './no_data_page'; import { DiscoverMainProvider } from '../../state_management/discover_state_provider'; import { BrandedLoadingIndicator } from './branded_loading_indicator'; -import { RedirectWhenSavedObjectNotFound } from './redirect_not_found'; import { DiscoverMainApp } from './main_app'; import { useAsyncFunction } from '../../hooks/use_async_function'; import { ScopedServicesProvider } from '../../../../components/scoped_services_provider'; import { HideTabsBar } from '../tabs_view/hide_tabs_bar'; +import { InitializationError } from './initialization_error'; -export interface DiscoverSessionViewProps { +export interface SingleTabViewProps { customizationContext: DiscoverCustomizationContext; customizationCallbacks: CustomizationCallback[]; urlStateStorage: IKbnUrlStateStorage; @@ -64,23 +56,22 @@ interface SessionInitializationState { showNoDataPage: boolean; } -type InitializeSession = (options?: { +type InitializeSingleSession = (options?: { dataViewSpec?: DataViewSpec | undefined; defaultUrlState?: DiscoverAppState; - shouldClearAllTabs?: boolean; }) => Promise; -export const DiscoverSessionView = ({ +export const SingleTabView = ({ customizationContext, customizationCallbacks, urlStateStorage, internalState, runtimeStateManager, -}: DiscoverSessionViewProps) => { +}: SingleTabViewProps) => { const dispatch = useInternalStateDispatch(); const services = useDiscoverServices(); - const { core, history, getScopedHistory } = services; - const { id: discoverSessionId } = useParams<{ id?: string }>(); + + const initializationState = useInternalStateSelector((state) => state.initializationState); const currentTabId = useCurrentTabSelector((tab) => tab.id); const currentStateContainer = useCurrentTabRuntimeState( runtimeStateManager, @@ -90,9 +81,23 @@ export const DiscoverSessionView = ({ runtimeStateManager, (tab) => tab.customizationService$ ); - const initializeSessionAction = useCurrentTabAction(internalStateActions.initializeSession); - const [initializeSessionState, initializeSession] = useAsyncFunction( - async ({ dataViewSpec, defaultUrlState, shouldClearAllTabs = false } = {}) => { + const scopedProfilesManager = useCurrentTabRuntimeState( + runtimeStateManager, + (tab) => tab.scopedProfilesManager$ + ); + const scopedEbtManager = useCurrentTabRuntimeState( + runtimeStateManager, + (tab) => tab.scopedEbtManager$ + ); + const currentDataView = useCurrentTabRuntimeState( + runtimeStateManager, + (tab) => tab.currentDataView$ + ); + const adHocDataViews = useRuntimeState(runtimeStateManager.adHocDataViews$); + + const initializeSingleTab = useCurrentTabAction(internalStateActions.initializeSingleTab); + const [initializeTabState, initializeTab] = useAsyncFunction( + async ({ dataViewSpec, defaultUrlState } = {}) => { const stateContainer = getDiscoverStateContainer({ tabId: currentTabId, services, @@ -107,14 +112,12 @@ export const DiscoverSessionView = ({ }); return dispatch( - initializeSessionAction({ - initializeSessionParams: { + initializeSingleTab({ + initializeSingleTabParams: { stateContainer, customizationService, - discoverSessionId, dataViewSpec, defaultUrlState, - shouldClearAllTabs, }, }) ); @@ -123,77 +126,29 @@ export const DiscoverSessionView = ({ ? { loading: false, value: { showNoDataPage: false } } : { loading: true } ); - const initializeSessionWithDefaultLocationState = useLatest( - (options?: { shouldClearAllTabs?: boolean }) => { - const historyLocationState = getScopedHistory< + + useMount(() => { + if (!currentStateContainer || !currentCustomizationService) { + const historyLocationState = services.getScopedHistory< MainHistoryLocationState & { defaultState?: DiscoverAppState } >()?.location.state; - initializeSession({ + + initializeTab({ dataViewSpec: historyLocationState?.dataViewSpec, defaultUrlState: historyLocationState?.defaultState, - shouldClearAllTabs: options?.shouldClearAllTabs, }); } - ); - const initializationState = useInternalStateSelector((state) => state.initializationState); - const scopedProfilesManager = useCurrentTabRuntimeState( - runtimeStateManager, - (tab) => tab.scopedProfilesManager$ - ); - const scopedEbtManager = useCurrentTabRuntimeState( - runtimeStateManager, - (tab) => tab.scopedEbtManager$ - ); - const currentDataView = useCurrentTabRuntimeState( - runtimeStateManager, - (tab) => tab.currentDataView$ - ); - const adHocDataViews = useRuntimeState(runtimeStateManager.adHocDataViews$); - - useMount(() => { - if (!currentStateContainer || !currentCustomizationService) { - initializeSessionWithDefaultLocationState.current(); - } - }); - - useUpdateEffect(() => { - initializeSessionWithDefaultLocationState.current(); - }, [discoverSessionId, initializeSessionWithDefaultLocationState]); - - useUrl({ - history, - savedSearchId: discoverSessionId, - onNewUrl: () => { - initializeSessionWithDefaultLocationState.current({ shouldClearAllTabs: true }); - }, - }); - - useAlertResultsToast(); - - useExecutionContext(core.executionContext, { - type: 'application', - page: 'app', - id: discoverSessionId || 'new', }); - if (initializeSessionState.loading) { + if (initializeTabState.loading) { return ; } - if (initializeSessionState.error) { - if (initializeSessionState.error instanceof SavedObjectNotFound) { - return ( - - ); - } - - return ; + if (initializeTabState.error) { + return ; } - if (initializeSessionState.value.showNoDataPage) { + if (initializeTabState.value.showNoDataPage) { return ( { - initializeSession(); + initializeTab(); }} /> diff --git a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx index 5bc93957a0203..160f54111590b 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx @@ -9,7 +9,7 @@ import { UnifiedTabs, type UnifiedTabsProps } from '@kbn/unified-tabs'; import React, { useCallback } from 'react'; -import { DiscoverSessionView, type DiscoverSessionViewProps } from '../session_view'; +import { SingleTabView, type SingleTabViewProps } from '../single_tab_view'; import { createTabItem, internalStateActions, @@ -22,7 +22,9 @@ import { import { useDiscoverServices } from '../../../../hooks/use_discover_services'; import { usePreviewData } from './use_preview_data'; -export const TabsView = (props: DiscoverSessionViewProps) => { +const MAX_TABS_COUNT = 25; + +export const TabsView = (props: SingleTabViewProps) => { const services = useDiscoverServices(); const dispatch = useInternalStateDispatch(); const items = useInternalStateSelector(selectAllTabs); @@ -42,7 +44,7 @@ export const TabsView = (props: DiscoverSessionViewProps) => { ); const renderContent: UnifiedTabsProps['renderContent'] = useCallback( - () => , + () => , [currentTabId, props] ); @@ -52,6 +54,7 @@ export const TabsView = (props: DiscoverSessionViewProps) => { items={items} selectedItemId={currentTabId} recentlyClosedItems={recentlyClosedItems} + maxItemsCount={MAX_TABS_COUNT} hideTabsBar={hideTabsBar} createItem={createItem} getPreviewData={getPreviewData} diff --git a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/use_preview_data.ts b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/use_preview_data.ts index da3ce65914de5..e5b546684d8f0 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/use_preview_data.ts +++ b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/use_preview_data.ts @@ -82,9 +82,11 @@ const getPreviewQuery = (query: TabPreviewData['query'] | undefined): TabPreview }; } + const trimmedQuery = typeof query.query === 'string' ? query.query.trim() : query.query; + return { ...query, - query: query.query.trim() || DEFAULT_PREVIEW_QUERY.query, + query: trimmedQuery || DEFAULT_PREVIEW_QUERY.query, }; }; @@ -111,7 +113,7 @@ const getPreviewDataObservable = ( appState.state$.pipe(startWith(appState.get())), ]).pipe( map(([{ fetchStatus }, { query }]) => ({ fetchStatus, query })), - distinctUntilChanged(isEqual), + distinctUntilChanged((prev, curr) => isEqual(prev, curr)), map(({ fetchStatus, query }) => ({ status: getPreviewStatus(fetchStatus), query: getPreviewQuery(query), diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.tsx index 9541a4df05b8b..1b9d08e998499 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/discover_topnav.tsx @@ -17,7 +17,6 @@ import { useSavedSearchInitial } from '../../state_management/discover_state_pro import { ESQL_TRANSITION_MODAL_KEY } from '../../../../../common/constants'; import { useDiscoverServices } from '../../../../hooks/use_discover_services'; import type { DiscoverStateContainer } from '../../state_management/discover_state'; -import { onSaveSearch } from './on_save_search'; import { useDiscoverCustomization } from '../../../../customizations'; import { useAppStateSelector } from '../../state_management/discover_app_state_container'; import { useDiscoverTopNav } from './use_discover_topnav'; @@ -33,6 +32,7 @@ import { useInternalStateSelector, } from '../../state_management/redux'; import { TABS_ENABLED_FEATURE_FLAG_KEY } from '../../../../constants'; +import { onSaveDiscoverSession } from './save_discover_session'; export interface DiscoverTopNavProps { savedQuery?: string; @@ -151,8 +151,7 @@ export const DiscoverTopNav = ({ return; } if (needsSave) { - onSaveSearch({ - savedSearch: stateContainer.savedSearchState.getState(), + onSaveDiscoverSession({ services, state: stateContainer, onClose: () => diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/get_top_nav_badges.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/get_top_nav_badges.tsx index e5cd51f1c649f..dcd335e2232d6 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/get_top_nav_badges.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/get_top_nav_badges.tsx @@ -15,9 +15,9 @@ import { i18n } from '@kbn/i18n'; import { dismissFlyouts, DiscoverFlyouts } from '@kbn/discover-utils'; import type { DiscoverStateContainer } from '../../state_management/discover_state'; import type { TopNavCustomization } from '../../../../customizations'; -import { onSaveSearch } from './on_save_search'; import type { DiscoverServices } from '../../../../build_services'; import { SolutionsViewBadge } from './solutions_view_badge'; +import { onSaveDiscoverSession } from './save_discover_session'; /** * Helper function to build the top nav badges @@ -33,10 +33,9 @@ export const getTopNavBadges = ({ services: DiscoverServices; topNavCustomization: TopNavCustomization | undefined; }): TopNavMenuBadgeProps[] => { - const saveSearch = (initialCopyOnSave?: boolean) => - onSaveSearch({ + const saveDiscoverSession = (initialCopyOnSave?: boolean) => + onSaveDiscoverSession({ initialCopyOnSave, - savedSearch: stateContainer.savedSearchState.getState(), services, state: stateContainer, }); @@ -65,12 +64,12 @@ export const getTopNavBadges = ({ onSave: services.capabilities.discover_v2.save && !isManaged ? async () => { - await saveSearch(); + await saveDiscoverSession(); } : undefined, onSaveAs: services.capabilities.discover_v2.save ? async () => { - await saveSearch(true); + await saveDiscoverSession(true); } : undefined, }) diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/on_save_search.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/on_save_search.test.tsx deleted file mode 100644 index df5f88436d734..0000000000000 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/on_save_search.test.tsx +++ /dev/null @@ -1,151 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import * as savedObjectsPlugin from '@kbn/saved-objects-plugin/public'; -jest.mock('@kbn/saved-objects-plugin/public'); -import type { DataView } from '@kbn/data-views-plugin/common'; -import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; -import { dataViewWithTimefieldMock } from '../../../../__mocks__/data_view_with_timefield'; -import { onSaveSearch } from './on_save_search'; -import { savedSearchMock } from '../../../../__mocks__/saved_search'; -import type { ReactElement } from 'react'; -import { discoverServiceMock } from '../../../../__mocks__/services'; -import type { SavedSearch } from '@kbn/saved-search-plugin/public'; -import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock'; - -function getStateContainer({ dataView }: { dataView?: DataView } = {}) { - const savedSearch = savedSearchMock; - const stateContainer = getDiscoverStateMock({}); - stateContainer.savedSearchState.set(savedSearch); - stateContainer.appState.getState = jest.fn(() => ({ - rowsPerPage: 250, - })); - if (dataView) { - stateContainer.actions.setDataView(dataView); - } - return stateContainer; -} - -describe('onSaveSearch', () => { - it('should call showSaveModal', async () => { - await onSaveSearch({ - savedSearch: savedSearchMock, - services: discoverServiceMock, - state: getStateContainer(), - }); - - expect(savedObjectsPlugin.showSaveModal).toHaveBeenCalled(); - }); - - it('should consider whether a data view is time based', async () => { - let saveModal: ReactElement | undefined; - jest.spyOn(savedObjectsPlugin, 'showSaveModal').mockImplementation((modal) => { - saveModal = modal; - }); - - await onSaveSearch({ - savedSearch: savedSearchMock, - services: discoverServiceMock, - state: getStateContainer({ dataView: dataViewMock }), - }); - - expect(saveModal?.props.isTimeBased).toBe(false); - - await onSaveSearch({ - savedSearch: savedSearchMock, - services: discoverServiceMock, - state: getStateContainer({ dataView: dataViewWithTimefieldMock }), - }); - - expect(saveModal?.props.isTimeBased).toBe(true); - }); - - it('should pass tags to the save modal', async () => { - let saveModal: ReactElement | undefined; - jest.spyOn(savedObjectsPlugin, 'showSaveModal').mockImplementationOnce((modal) => { - saveModal = modal; - }); - await onSaveSearch({ - savedSearch: { - ...savedSearchMock, - tags: ['tag1', 'tag2'], - }, - services: discoverServiceMock, - state: getStateContainer(), - }); - expect(saveModal?.props.tags).toEqual(['tag1', 'tag2']); - }); - - it('should update the saved search tags', async () => { - let saveModal: ReactElement | undefined; - jest.spyOn(savedObjectsPlugin, 'showSaveModal').mockImplementationOnce((modal) => { - saveModal = modal; - }); - let savedSearch: SavedSearch = { - ...savedSearchMock, - tags: ['tag1', 'tag2'], - }; - const state = getStateContainer(); - await onSaveSearch({ - savedSearch, - services: discoverServiceMock, - state, - }); - expect(savedSearch.tags).toEqual(['tag1', 'tag2']); - - state.savedSearchState.persist = jest.fn().mockImplementationOnce((newSavedSearch, _) => { - savedSearch = newSavedSearch; - return Promise.resolve(newSavedSearch.id); - }); - saveModal?.props.onSave({ - newTitle: savedSearch.title, - newCopyOnSave: false, - newDescription: savedSearch.description, - newTags: ['tag3', 'tag4'], - isTitleDuplicateConfirmed: false, - onTitleDuplicate: jest.fn(), - }); - expect(savedSearch.tags).toEqual(['tag3', 'tag4']); - }); - - it('should not update tags if savedObjectsTagging is undefined', async () => { - const serviceMock = discoverServiceMock; - let saveModal: ReactElement | undefined; - jest.spyOn(savedObjectsPlugin, 'showSaveModal').mockImplementationOnce((modal) => { - saveModal = modal; - }); - let savedSearch: SavedSearch = { - ...savedSearchMock, - tags: ['tag1', 'tag2'], - }; - const state = getStateContainer(); - await onSaveSearch({ - savedSearch, - services: { - ...serviceMock, - savedObjectsTagging: undefined, - }, - state, - }); - expect(savedSearch.tags).toEqual(['tag1', 'tag2']); - state.savedSearchState.persist = jest.fn().mockImplementationOnce((newSavedSearch, _) => { - savedSearch = newSavedSearch; - return Promise.resolve(newSavedSearch.id); - }); - saveModal?.props.onSave({ - newTitle: savedSearch.title, - newCopyOnSave: false, - newDescription: savedSearch.description, - newTags: ['tag3', 'tag4'], - isTitleDuplicateConfirmed: false, - onTitleDuplicate: jest.fn(), - }); - expect(savedSearch.tags).toEqual(['tag1', 'tag2']); - }); -}); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/on_save_search.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/on_save_search.tsx deleted file mode 100644 index 130f1f1dbe40a..0000000000000 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/on_save_search.tsx +++ /dev/null @@ -1,312 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import React, { useState } from 'react'; -import { i18n } from '@kbn/i18n'; -import { EuiFormRow, EuiSwitch } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; -import type { OnSaveProps } from '@kbn/saved-objects-plugin/public'; -import { SavedObjectSaveModal, showSaveModal } from '@kbn/saved-objects-plugin/public'; -import type { SavedSearch, SaveSavedSearchOptions } from '@kbn/saved-search-plugin/public'; -import type { DiscoverServices } from '../../../../build_services'; -import type { DiscoverStateContainer } from '../../state_management/discover_state'; -import { getAllowedSampleSize } from '../../../../utils/get_allowed_sample_size'; -import { internalStateActions } from '../../state_management/redux'; - -async function saveDataSource({ - savedSearch, - saveOptions, - services, - state, - navigateOrReloadSavedSearch, -}: { - savedSearch: SavedSearch; - saveOptions: SaveSavedSearchOptions; - services: DiscoverServices; - state: DiscoverStateContainer; - navigateOrReloadSavedSearch: boolean; -}) { - const prevSavedSearchId = savedSearch.id; - function onSuccess(id: string) { - if (id) { - services.toastNotifications.addSuccess({ - title: i18n.translate('discover.notifications.savedSearchTitle', { - defaultMessage: `Discover session ''{savedSearchTitle}'' was saved`, - values: { - savedSearchTitle: savedSearch.title, - }, - }), - 'data-test-subj': 'saveSearchSuccess', - }); - if (navigateOrReloadSavedSearch) { - if (id !== prevSavedSearchId) { - services.locator.navigate({ savedSearchId: id }); - } else { - // Update defaults so that "reload saved query" functions correctly - state.actions.undoSavedSearchChanges(); - } - } - } - } - - function onError(error: Error) { - services.toastNotifications.addDanger({ - title: i18n.translate('discover.notifications.notSavedSearchTitle', { - defaultMessage: `Discover session ''{savedSearchTitle}'' was not saved.`, - values: { - savedSearchTitle: savedSearch.title, - }, - }), - text: error.message, - }); - } - - try { - const response = await state.savedSearchState.persist(savedSearch, saveOptions); - if (response?.id) { - onSuccess(response.id!); - } - return response; - } catch (error) { - onError(error); - } -} - -export async function onSaveSearch({ - savedSearch, - services, - state, - initialCopyOnSave, - onClose, - onSaveCb, -}: { - savedSearch: SavedSearch; - services: DiscoverServices; - state: DiscoverStateContainer; - initialCopyOnSave?: boolean; - onClose?: () => void; - onSaveCb?: () => void; -}) { - const { uiSettings, savedObjectsTagging } = services; - const dataView = savedSearch.searchSource.getField('index'); - const currentTab = state.getCurrentTab(); - const overriddenVisContextAfterInvalidation = currentTab.overriddenVisContextAfterInvalidation; - - const onSave = async ({ - newTitle, - newCopyOnSave, - newTimeRestore, - newDescription, - newTags, - isTitleDuplicateConfirmed, - onTitleDuplicate, - }: { - newTitle: string; - newTimeRestore: boolean; - newCopyOnSave: boolean; - newDescription: string; - newTags: string[]; - isTitleDuplicateConfirmed: boolean; - onTitleDuplicate: () => void; - }) => { - const appState = state.appState.getState(); - const currentTitle = savedSearch.title; - const currentTimeRestore = savedSearch.timeRestore; - const currentRowsPerPage = savedSearch.rowsPerPage; - const currentSampleSize = savedSearch.sampleSize; - const currentDescription = savedSearch.description; - const currentTags = savedSearch.tags; - const currentVisContext = savedSearch.visContext; - - savedSearch.title = newTitle; - savedSearch.description = newDescription; - savedSearch.timeRestore = newTimeRestore; - savedSearch.rowsPerPage = appState.rowsPerPage; - - // save the custom value or reset it if it's invalid - const appStateSampleSize = appState.sampleSize; - const allowedSampleSize = getAllowedSampleSize(appStateSampleSize, uiSettings); - savedSearch.sampleSize = - appStateSampleSize && allowedSampleSize === appStateSampleSize - ? appStateSampleSize - : undefined; - - if (savedObjectsTagging) { - savedSearch.tags = newTags; - } - - if (overriddenVisContextAfterInvalidation) { - savedSearch.visContext = overriddenVisContextAfterInvalidation; - } - - const saveOptions: SaveSavedSearchOptions = { - onTitleDuplicate, - copyOnSave: newCopyOnSave, - isTitleDuplicateConfirmed, - }; - - if (newCopyOnSave) { - await state.actions.updateAdHocDataViewId(); - } - - const navigateOrReloadSavedSearch = !Boolean(onSaveCb); - const response = await saveDataSource({ - saveOptions, - services, - savedSearch, - state, - navigateOrReloadSavedSearch, - }); - - // If the save wasn't successful, put the original values back. - if (!response) { - savedSearch.title = currentTitle; - savedSearch.timeRestore = currentTimeRestore; - savedSearch.rowsPerPage = currentRowsPerPage; - savedSearch.sampleSize = currentSampleSize; - savedSearch.description = currentDescription; - savedSearch.visContext = currentVisContext; - if (savedObjectsTagging) { - savedSearch.tags = currentTags; - } - } else { - state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.resetOnSavedSearchChange)() - ); - state.appState.resetInitialState(); - } - - onSaveCb?.(); - - return response; - }; - - const saveModal = ( - {})} - /> - ); - - showSaveModal(saveModal); -} - -const SaveSearchObjectModal: React.FC<{ - isTimeBased: boolean; - services: DiscoverServices; - title: string; - showCopyOnSave: boolean; - initialCopyOnSave?: boolean; - description?: string; - timeRestore?: boolean; - tags: string[]; - onSave: (props: OnSaveProps & { newTimeRestore: boolean; newTags: string[] }) => void; - onClose: () => void; - managed: boolean; -}> = ({ - isTimeBased, - services, - title, - description, - tags, - showCopyOnSave, - initialCopyOnSave, - timeRestore: savedTimeRestore, - onSave, - onClose, - managed, -}) => { - const { savedObjectsTagging } = services; - const [timeRestore, setTimeRestore] = useState( - (isTimeBased && savedTimeRestore) || false - ); - const [currentTags, setCurrentTags] = useState(tags); - - const onModalSave = (params: OnSaveProps) => { - onSave({ - ...params, - newTimeRestore: timeRestore, - newTags: currentTags, - }); - }; - - const tagSelector = savedObjectsTagging ? ( - { - setCurrentTags(newTags); - }} - /> - ) : undefined; - - const timeSwitch = isTimeBased ? ( - - } - > - setTimeRestore(event.target.checked)} - label={ - - } - /> - - ) : null; - - const options = tagSelector ? ( - <> - {tagSelector} - {timeSwitch} - - ) : ( - timeSwitch - ); - - return ( - - ); -}; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/index.ts b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/index.ts new file mode 100644 index 0000000000000..353b226b1b981 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/index.ts @@ -0,0 +1,10 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { onSaveDiscoverSession } from './on_save_discover_session'; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/on_save_discover_session.test.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/on_save_discover_session.test.tsx new file mode 100644 index 0000000000000..952ee2749ff4e --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/on_save_discover_session.test.tsx @@ -0,0 +1,362 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { + savedSearchMock, + savedSearchMockWithTimeField, +} from '../../../../../__mocks__/saved_search'; +import type { + DiscoverSessionSaveModalOnSaveCallback, + DiscoverSessionSaveModalProps, +} from './save_modal'; +import type { OnSaveDiscoverSessionParams } from './on_save_discover_session'; +import { onSaveDiscoverSession } from './on_save_discover_session'; +import { getDiscoverStateMock } from '../../../../../__mocks__/discover_state.mock'; +import { createDiscoverServicesMock } from '../../../../../__mocks__/services'; +import type { SavedSearch, SavedSearchPublicPluginStart } from '@kbn/saved-search-plugin/public'; +import type { ReactElement } from 'react'; +import type { DiscoverServices } from '../../../../../build_services'; +import { showSaveModal } from '@kbn/saved-objects-plugin/public'; +import { + fromTabStateToSavedObjectTab, + internalStateActions, +} from '../../../state_management/redux'; +import type { DiscoverSessionTab } from '@kbn/saved-search-plugin/common'; +import { getTabStateMock } from '../../../state_management/redux/__mocks__/internal_state.mocks'; +import type { DataView, DataViewListItem } from '@kbn/data-views-plugin/common'; +import { dataViewMock, dataViewMockWithTimeField } from '@kbn/discover-utils/src/__mocks__'; + +const mockShowSaveModal = jest.mocked(showSaveModal); + +jest.mock('@kbn/saved-objects-plugin/public', () => ({ + ...jest.requireActual('@kbn/saved-objects-plugin/public'), + showSaveModal: jest.fn(), +})); + +type OnSaveProps = Parameters[0]; + +const getOnSaveProps = (props?: Partial): OnSaveProps => ({ + newTitle: 'title', + newCopyOnSave: false, + newDescription: 'description', + newTimeRestore: false, + newTags: [], + isTitleDuplicateConfirmed: false, + onTitleDuplicate: jest.fn(), + ...props, +}); + +const setup = async ({ + savedSearch = savedSearchMock, + additionalPersistedTabs, + dataViewsList, + services = createDiscoverServicesMock(), + mockSaveDiscoverSession = (discoverSession) => { + return Promise.resolve({ + ...discoverSession, + id: discoverSession.id ?? 'new-session', + managed: false, + }); + }, + onSaveCb, + onClose, +}: { + savedSearch?: SavedSearch | false; + additionalPersistedTabs?: DiscoverSessionTab[]; + dataViewsList?: DataView[]; + services?: DiscoverServices; + mockSaveDiscoverSession?: SavedSearchPublicPluginStart['saveDiscoverSession']; + onSaveCb?: OnSaveDiscoverSessionParams['onSaveCb']; + onClose?: OnSaveDiscoverSessionParams['onClose']; +} = {}) => { + jest + .spyOn(services.savedSearch, 'saveDiscoverSession') + .mockImplementation(mockSaveDiscoverSession); + const stateContainer = getDiscoverStateMock({ + savedSearch, + additionalPersistedTabs, + services, + }); + if (savedSearch) { + stateContainer.actions.setDataView(savedSearch.searchSource.getField('index')!); + } + if (dataViewsList) { + stateContainer.internalState.dispatch( + internalStateActions.loadDataViewList.fulfilled( + dataViewsList as DataViewListItem[], + 'requestId' + ) + ); + } + let saveModal: ReactElement | undefined; + mockShowSaveModal.mockImplementation((modal) => { + saveModal = modal as ReactElement; + }); + await onSaveDiscoverSession({ + services, + state: stateContainer, + onSaveCb, + onClose, + }); + return { stateContainer, saveModal }; +}; + +describe('onSaveDiscoverSession', () => { + beforeEach(() => { + mockShowSaveModal.mockReset(); + }); + + it('should call showSaveModal and set expected props', async () => { + const services = createDiscoverServicesMock(); + const { saveModal } = await setup({ + savedSearch: { + ...savedSearchMock, + managed: true, + }, + services, + }); + expect(saveModal).toBeDefined(); + expect(saveModal?.props).toEqual({ + isTimeBased: false, + services, + title: 'A saved search', + showCopyOnSave: true, + description: 'description', + timeRestore: false, + tags: [], + onSave: expect.any(Function), + onClose: expect.any(Function), + managed: true, + }); + }); + + describe('isTimeBased checks', () => { + const services = createDiscoverServicesMock(); + const dataViewNoTimeFieldTab = fromTabStateToSavedObjectTab({ + tab: getTabStateMock({ + id: 'dataViewNoTimeFieldTab', + initialInternalState: { + serializedSearchSource: { index: dataViewMock.id }, + }, + }), + timeRestore: false, + services, + }); + const dataViewWithTimeFieldTab = fromTabStateToSavedObjectTab({ + tab: getTabStateMock({ + id: 'dataViewWithTimeFieldTab', + initialInternalState: { + serializedSearchSource: { index: dataViewMockWithTimeField.id }, + }, + }), + timeRestore: false, + services, + }); + const adHocDataViewNoTimeFieldTab = fromTabStateToSavedObjectTab({ + tab: getTabStateMock({ + id: 'adHocDataViewNoTimeFieldTab', + initialInternalState: { + serializedSearchSource: { index: { title: 'adhoc' } }, + }, + }), + timeRestore: false, + services, + }); + const adHocDataViewWithTimeFieldTab = fromTabStateToSavedObjectTab({ + tab: getTabStateMock({ + id: 'adHocDataViewWithTimeFieldTab', + initialInternalState: { + serializedSearchSource: { index: { title: 'adhoc', timeFieldName: 'timestamp' } }, + }, + }), + timeRestore: true, + services, + }); + + it("should set isTimeBased to false if no tab's data view is time based", async () => { + const { saveModal } = await setup({ + additionalPersistedTabs: [dataViewNoTimeFieldTab, adHocDataViewNoTimeFieldTab], + dataViewsList: [dataViewMock], + services, + }); + expect(saveModal?.props.isTimeBased).toBe(false); + }); + + it.each([ + [ + 'initialized tab', + { + savedSearch: savedSearchMockWithTimeField, + additionalPersistedTabs: [dataViewNoTimeFieldTab, adHocDataViewNoTimeFieldTab], + dataViewsList: [dataViewMock], + }, + ], + [ + 'uninitialized tab with persisted data view', + { + additionalPersistedTabs: [dataViewWithTimeFieldTab, adHocDataViewNoTimeFieldTab], + dataViewsList: [dataViewMockWithTimeField], + }, + ], + [ + 'uninitialized tab with ad hoc data view', + { + additionalPersistedTabs: [dataViewNoTimeFieldTab, adHocDataViewWithTimeFieldTab], + dataViewsList: [dataViewMock], + }, + ], + ])( + "should set isTimeBased to true if any tab's data view is time based - %s", + async (_, setupArgs) => { + const { saveModal } = await setup({ ...setupArgs, services }); + expect(saveModal?.props.isTimeBased).toBe(true); + } + ); + }); + + it("should set timeRestore to true if any tab's timeRestore is true", async () => { + const services = createDiscoverServicesMock(); + const noTimeRestoreTab = fromTabStateToSavedObjectTab({ + tab: getTabStateMock({ id: 'noTimeRestoreTab' }), + timeRestore: false, + services, + }); + const timeRestoreTab = fromTabStateToSavedObjectTab({ + tab: getTabStateMock({ id: 'timeRestoreTab' }), + timeRestore: true, + services, + }); + let { saveModal } = await setup({ + additionalPersistedTabs: [noTimeRestoreTab], + services, + }); + expect(saveModal?.props.timeRestore).toBe(false); + ({ saveModal } = await setup({ + additionalPersistedTabs: [timeRestoreTab], + services, + })); + expect(saveModal?.props.timeRestore).toBe(true); + }); + + it('should set showCopyOnSave to true if a persisted Discover session exists', async () => { + const services = createDiscoverServicesMock(); + let { saveModal } = await setup({ services, savedSearch: false }); + expect(saveModal?.props.showCopyOnSave).toBe(false); + ({ saveModal } = await setup({ services })); + expect(saveModal?.props.showCopyOnSave).toBe(true); + }); + + it('should pass tags to the save modal', async () => { + const { saveModal } = await setup({ + savedSearch: { + ...savedSearchMock, + tags: ['tag1', 'tag2'], + }, + }); + expect(saveModal?.props.tags).toEqual(['tag1', 'tag2']); + }); + + it('should update tags if savedObjectsTagging is defined', async () => { + const savedSearch: SavedSearch = { + ...savedSearchMock, + tags: ['tag1', 'tag2'], + }; + const { stateContainer, saveModal } = await setup({ savedSearch }); + await saveModal?.props.onSave(getOnSaveProps({ newTags: ['tag3', 'tag4'] })); + expect(stateContainer.savedSearchState.getCurrent$().getValue().tags).toEqual(['tag3', 'tag4']); + }); + + it('should not update tags if savedObjectsTagging is undefined', async () => { + const savedSearch: SavedSearch = { + ...savedSearchMock, + tags: ['tag1', 'tag2'], + }; + const { stateContainer, saveModal } = await setup({ + savedSearch, + services: { ...createDiscoverServicesMock(), savedObjectsTagging: undefined }, + }); + await saveModal?.props.onSave(getOnSaveProps({ newTags: ['tag3', 'tag4'] })); + expect(stateContainer.savedSearchState.getCurrent$().getValue().tags).toEqual(['tag1', 'tag2']); + }); + + it('should return the Discover session ID on save', async () => { + const { saveModal } = await setup({ savedSearch: savedSearchMock }); + const result = await saveModal?.props.onSave(getOnSaveProps()); + expect(result).toEqual({ id: savedSearchMock.id }); + }); + + it('should call onSaveCb on save if provided', async () => { + const onSaveCb = jest.fn(); + let { saveModal } = await setup({ savedSearch: savedSearchMock, onSaveCb }); + await saveModal?.props.onSave(getOnSaveProps()); + expect(onSaveCb).toHaveBeenCalledTimes(1); + ({ saveModal } = await setup({ savedSearch: false, onSaveCb })); + await saveModal?.props.onSave(getOnSaveProps()); + expect(onSaveCb).toHaveBeenCalledTimes(2); + }); + + it('should navigate to new Discover session on save if onSaveCb is not provided', async () => { + const services = createDiscoverServicesMock(); + const navigateSpy = jest.spyOn(services.locator, 'navigate'); + let { saveModal } = await setup({ services }); + await saveModal?.props.onSave(getOnSaveProps()); + expect(navigateSpy).not.toHaveBeenCalled(); + ({ saveModal } = await setup({ + savedSearch: false, + services, + })); + await saveModal?.props.onSave(getOnSaveProps()); + expect(navigateSpy).toHaveBeenCalledWith({ savedSearchId: 'new-session' }); + }); + + it('should show a success toast on save', async () => { + const services = createDiscoverServicesMock(); + const successSpy = jest.spyOn(services.toastNotifications, 'addSuccess'); + const { saveModal } = await setup({ services }); + await saveModal?.props.onSave(getOnSaveProps()); + expect(successSpy).toHaveBeenCalledWith({ + title: "Discover session 'title' was saved", + 'data-test-subj': 'saveSearchSuccess', + }); + }); + + it('should show a danger toast on error', async () => { + const services = createDiscoverServicesMock(); + const dangerSpy = jest.spyOn(services.toastNotifications, 'addDanger'); + const { saveModal } = await setup({ + services, + mockSaveDiscoverSession: () => Promise.reject(new Error('Save error')), + }); + await saveModal?.props.onSave(getOnSaveProps()); + expect(dangerSpy).toHaveBeenCalledWith({ + title: "Discover session 'title' was not saved", + text: 'Save error', + }); + }); + + it('should show no toast when save is unsuccessful without an error', async () => { + const services = createDiscoverServicesMock(); + const successSpy = jest.spyOn(services.toastNotifications, 'addSuccess'); + const dangerSpy = jest.spyOn(services.toastNotifications, 'addDanger'); + const { saveModal } = await setup({ + services, + mockSaveDiscoverSession: () => Promise.resolve(undefined), + }); + await saveModal?.props.onSave(getOnSaveProps()); + expect(successSpy).not.toHaveBeenCalled(); + expect(dangerSpy).not.toHaveBeenCalled(); + }); + + it('should call onClose when closed if provided', async () => { + const onClose = jest.fn(); + const { saveModal } = await setup({ onClose }); + saveModal?.props.onClose(); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/on_save_discover_session.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/on_save_discover_session.tsx new file mode 100644 index 0000000000000..d6e38baab23d3 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/on_save_discover_session.tsx @@ -0,0 +1,145 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { showSaveModal } from '@kbn/saved-objects-plugin/public'; +import { isObject } from 'lodash'; +import type { DiscoverSession } from '@kbn/saved-search-plugin/common'; +import type { DiscoverServices } from '../../../../../build_services'; +import type { DiscoverStateContainer } from '../../../state_management/discover_state'; +import { + internalStateActions, + selectAllTabs, + selectTabRuntimeState, +} from '../../../state_management/redux'; +import type { DiscoverSessionSaveModalOnSaveCallback } from './save_modal'; +import { DiscoverSessionSaveModal } from './save_modal'; + +export interface OnSaveDiscoverSessionParams { + services: DiscoverServices; + state: DiscoverStateContainer; + initialCopyOnSave?: boolean; + onClose?: () => void; + onSaveCb?: () => void; +} + +export const onSaveDiscoverSession = async ({ + services, + state, + initialCopyOnSave, + onClose, + onSaveCb, +}: OnSaveDiscoverSessionParams) => { + const internalState = state.internalState.getState(); + const persistedDiscoverSession = internalState.persistedDiscoverSession; + const allTabs = selectAllTabs(internalState); + + const timeRestore = persistedDiscoverSession?.tabs.some((tab) => tab.timeRestore) ?? false; + const isTimeBased = allTabs.some((tab) => { + const tabRuntimeState = selectTabRuntimeState(state.runtimeStateManager, tab.id); + const tabDataView = tabRuntimeState.currentDataView$.getValue(); + + if (tabDataView) { + return tabDataView.isTimeBased(); + } + + const tabDataViewIdOrSpec = tab.initialInternalState?.serializedSearchSource?.index; + + if (!tabDataViewIdOrSpec) { + return false; + } + + if (isObject(tabDataViewIdOrSpec)) { + return Boolean(tabDataViewIdOrSpec.timeFieldName); + } + + const dataViewListItem = internalState.savedDataViews.find( + (item) => item.id === tabDataViewIdOrSpec + ); + + return Boolean(dataViewListItem?.timeFieldName); + }); + + const onSave: DiscoverSessionSaveModalOnSaveCallback = async ({ + newTitle, + newCopyOnSave, + newTimeRestore, + newDescription, + newTags, + isTitleDuplicateConfirmed, + onTitleDuplicate, + }) => { + let response: { discoverSession: DiscoverSession | undefined } = { discoverSession: undefined }; + + try { + response = await state.internalState + .dispatch( + internalStateActions.saveDiscoverSession({ + newTitle, + newTimeRestore, + newCopyOnSave, + newDescription, + newTags, + isTitleDuplicateConfirmed, + onTitleDuplicate, + }) + ) + .unwrap(); + } catch (error) { + services.toastNotifications.addDanger({ + title: i18n.translate('discover.notifications.notSavedSearchTitle', { + defaultMessage: `Discover session ''{savedSearchTitle}'' was not saved`, + values: { + savedSearchTitle: newTitle, + }, + }), + text: error.message, + }); + } + + if (response.discoverSession) { + services.toastNotifications.addSuccess({ + title: i18n.translate('discover.notifications.savedSearchTitle', { + defaultMessage: `Discover session ''{savedSearchTitle}'' was saved`, + values: { + savedSearchTitle: newTitle, + }, + }), + 'data-test-subj': 'saveSearchSuccess', + }); + + if (onSaveCb) { + onSaveCb(); + } else if (response.discoverSession.id !== persistedDiscoverSession?.id) { + services.locator.navigate({ savedSearchId: response.discoverSession.id }); + } + } + + return { id: response.discoverSession?.id }; + }; + + const saveModal = ( + {})} + /> + ); + + showSaveModal(saveModal); +}; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/save_modal.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/save_modal.tsx new file mode 100644 index 0000000000000..47b61272fbd5f --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/save_discover_session/save_modal.tsx @@ -0,0 +1,122 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { useState } from 'react'; +import { EuiFormRow, EuiSwitch } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { OnSaveProps, SaveResult } from '@kbn/saved-objects-plugin/public'; +import { SavedObjectSaveModal } from '@kbn/saved-objects-plugin/public'; +import type { DiscoverServices } from '../../../../../build_services'; + +export type DiscoverSessionSaveModalOnSaveCallback = ( + props: OnSaveProps & { newTimeRestore: boolean; newTags: string[] } +) => Promise; + +export interface DiscoverSessionSaveModalProps { + isTimeBased: boolean; + services: DiscoverServices; + title: string; + showCopyOnSave: boolean; + initialCopyOnSave?: boolean; + description?: string; + timeRestore?: boolean; + tags: string[]; + onSave: DiscoverSessionSaveModalOnSaveCallback; + onClose: () => void; + managed: boolean; +} + +export const DiscoverSessionSaveModal: React.FC = ({ + isTimeBased, + services: { savedObjectsTagging }, + title, + description, + tags, + showCopyOnSave, + initialCopyOnSave, + timeRestore: savedTimeRestore, + onSave, + onClose, + managed, +}) => { + const [timeRestore, setTimeRestore] = useState(Boolean(isTimeBased && savedTimeRestore)); + const [currentTags, setCurrentTags] = useState(tags); + + const onModalSave = (params: OnSaveProps) => { + onSave({ + ...params, + newTimeRestore: timeRestore, + newTags: currentTags, + }); + }; + + const tagSelector = savedObjectsTagging ? ( + { + setCurrentTags(newTags); + }} + /> + ) : null; + + const timeSwitch = isTimeBased ? ( + + } + > + setTimeRestore(event.target.checked)} + label={ + + } + /> + + ) : null; + + const options = ( + <> + {tagSelector} + {timeSwitch} + + ); + + return ( + + ); +}; diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx index 6d11a1d26beca..6ff5b6dd9dbc1 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/use_top_nav_links.tsx @@ -25,7 +25,6 @@ import { useGetRuleTypesPermissions } from '@kbn/alerts-ui-shared'; import { createDataViewDataSource } from '../../../../../common/data_sources'; import { ESQL_TRANSITION_MODAL_KEY } from '../../../../../common/constants'; import type { DiscoverServices } from '../../../../build_services'; -import { onSaveSearch } from './on_save_search'; import type { DiscoverStateContainer } from '../../state_management/discover_state'; import type { AppMenuDiscoverParams } from './app_menu_actions'; import { @@ -45,6 +44,7 @@ import { } from '../../state_management/redux'; import type { DiscoverAppLocatorParams } from '../../../../../common'; import type { DiscoverAppState } from '../../state_management/discover_app_state_container'; +import { onSaveDiscoverSession } from './save_discover_session'; /** * Helper function to build the top nav links @@ -259,8 +259,7 @@ export const useTopNavLinks = ({ iconType: 'save', emphasize: true, run: (anchorElement: HTMLElement) => { - onSaveSearch({ - savedSearch: state.savedSearchState.getState(), + onSaveDiscoverSession({ services, state, onClose: () => { diff --git a/src/platform/plugins/shared/discover/public/application/main/discover_main_route.test.tsx b/src/platform/plugins/shared/discover/public/application/main/discover_main_route.test.tsx index 8274880adce2e..e12b73788afc7 100644 --- a/src/platform/plugins/shared/discover/public/application/main/discover_main_route.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/discover_main_route.test.tsx @@ -44,7 +44,7 @@ jest.mock('../../customizations', () => { }; }); -jest.mock('./components/session_view/main_app', () => { +jest.mock('./components/single_tab_view/main_app', () => { return { DiscoverMainApp: jest.fn(() =>
), }; diff --git a/src/platform/plugins/shared/discover/public/application/main/discover_main_route.tsx b/src/platform/plugins/shared/discover/public/application/main/discover_main_route.tsx index 03c884b4875f0..771c04aa02f78 100644 --- a/src/platform/plugins/shared/discover/public/application/main/discover_main_route.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/discover_main_route.tsx @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { useHistory } from 'react-router-dom'; +import { useHistory, useParams } from 'react-router-dom'; import type { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; import { createKbnUrlStateStorage, withNotifyOnErrors } from '@kbn/kibana-utils-plugin/public'; import { useEffect, useState } from 'react'; @@ -15,28 +15,35 @@ import React from 'react'; import useUnmount from 'react-use/lib/useUnmount'; import type { AppMountParameters } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; +import { useExecutionContext } from '@kbn/kibana-react-plugin/public'; +import useLatest from 'react-use/lib/useLatest'; import { useDiscoverServices } from '../../hooks/use_discover_services'; import type { CustomizationCallback, DiscoverCustomizationContext } from '../../customizations'; import { type DiscoverInternalState, InternalStateProvider, internalStateActions, + useInternalStateDispatch, + useInternalStateSelector, + selectTabRuntimeState, } from './state_management/redux'; import type { RootProfileState } from '../../context_awareness'; import { useRootProfile, useDefaultAdHocDataViews } from '../../context_awareness'; -import { DiscoverError } from '../../components/common/error_alert'; -import type { DiscoverSessionViewProps } from './components/session_view'; +import type { SingleTabViewProps } from './components/single_tab_view'; import { BrandedLoadingIndicator, - DiscoverSessionView, + SingleTabView, NoDataPage, -} from './components/session_view'; + InitializationError, +} from './components/single_tab_view'; import { useAsyncFunction } from './hooks/use_async_function'; import { TabsView } from './components/tabs_view'; import { TABS_ENABLED_FEATURE_FLAG_KEY } from '../../constants'; import { ChartPortalsRenderer } from './components/chart'; import { useStateManagers } from './state_management/hooks/use_state_managers'; -import { getUserAndSpaceIds } from './utils/get_user_and_space_ids'; +import { useUrl } from './hooks/use_url'; +import { useAlertResultsToast } from './hooks/use_alert_results_toast'; +import { setBreadcrumbs } from '../../utils/breadcrumbs'; export interface MainRouteProps { customizationContext: DiscoverCustomizationContext; @@ -58,11 +65,6 @@ export const DiscoverMainRoute = ({ onAppLeave, }: MainRouteProps) => { const services = useDiscoverServices(); - const tabsEnabled = services.core.featureFlags.getBooleanValue( - TABS_ENABLED_FEATURE_FLAG_KEY, - false - ); - const rootProfileState = useRootProfile(); const history = useHistory(); const [urlStateStorage] = useState( () => @@ -74,44 +76,11 @@ export const DiscoverMainRoute = ({ ...withNotifyOnErrors(services.core.notifications.toasts), }) ); - const { internalState, runtimeStateManager } = useStateManagers({ services, urlStateStorage, customizationContext, }); - const { initializeProfileDataViews } = useDefaultAdHocDataViews({ internalState }); - const [mainRouteInitializationState, initializeMainRoute] = useAsyncFunction( - async (loadedRootProfileState) => { - const { dataViews } = services; - const [hasESData, hasUserDataView, defaultDataViewExists, userAndSpaceIds] = - await Promise.all([ - dataViews.hasData.hasESData().catch(() => false), - dataViews.hasData.hasUserDataView().catch(() => false), - dataViews.defaultDataViewExists().catch(() => false), - getUserAndSpaceIds(services), - internalState.dispatch(internalStateActions.loadDataViewList()).catch(() => {}), - initializeProfileDataViews(loadedRootProfileState).catch(() => {}), - ]); - - internalState.dispatch(internalStateActions.initializeTabs(userAndSpaceIds)); - - const initializationState: DiscoverInternalState['initializationState'] = { - hasESData, - hasUserDataView: hasUserDataView && defaultDataViewExists, - }; - - internalState.dispatch(internalStateActions.setInitializationState(initializationState)); - - return initializationState; - } - ); - - useEffect(() => { - if (!rootProfileState.rootProfileLoading) { - initializeMainRoute(rootProfileState); - } - }, [initializeMainRoute, rootProfileState]); useEffect(() => { onAppLeave?.((actions) => { @@ -147,18 +116,151 @@ export const DiscoverMainRoute = ({ }); }, [onAppLeave, runtimeStateManager]); + return ( + + + + ); +}; + +const DiscoverMainRouteContent = (props: SingleTabViewProps) => { + const { customizationContext, runtimeStateManager } = props; + const services = useDiscoverServices(); + const { core, dataViews, chrome } = services; + const history = useHistory(); + const dispatch = useInternalStateDispatch(); + const rootProfileState = useRootProfile(); + const tabsEnabled = core.featureFlags.getBooleanValue(TABS_ENABLED_FEATURE_FLAG_KEY, false); + + const { initializeProfileDataViews } = useDefaultAdHocDataViews(); + const [mainRouteInitializationState, initializeMainRoute] = useAsyncFunction( + async (loadedRootProfileState) => { + const [hasESData, hasUserDataView, defaultDataViewExists] = await Promise.all([ + dataViews.hasData.hasESData().catch(() => false), + dataViews.hasData.hasUserDataView().catch(() => false), + dataViews.defaultDataViewExists().catch(() => false), + dispatch(internalStateActions.loadDataViewList()).catch(() => {}), + initializeProfileDataViews(loadedRootProfileState).catch(() => {}), + ]); + const initializationState: DiscoverInternalState['initializationState'] = { + hasESData, + hasUserDataView: hasUserDataView && defaultDataViewExists, + }; + + dispatch(internalStateActions.setInitializationState(initializationState)); + + return initializationState; + } + ); + + const [tabsInitializationState, initializeTabs] = useAsyncFunction( + async ({ + discoverSessionId, + shouldClearAllTabs, + }: { + discoverSessionId?: string; + shouldClearAllTabs?: boolean; + }) => { + await dispatch( + internalStateActions.initializeTabs({ discoverSessionId, shouldClearAllTabs }) + ).unwrap(); + } + ); + + const { id: currentDiscoverSessionId } = useParams<{ id?: string }>(); + const persistedDiscoverSession = useInternalStateSelector( + (state) => state.persistedDiscoverSession + ); + const currentTabId = useInternalStateSelector((state) => state.tabs.unsafeCurrentId); + const initializeDiscoverSession = useLatest( + ({ nextDiscoverSessionId }: { nextDiscoverSessionId: string | undefined }) => { + const persistedDiscoverSessionId = persistedDiscoverSession?.id; + const isSwitchingSession = Boolean( + persistedDiscoverSessionId && persistedDiscoverSessionId !== nextDiscoverSessionId + ); + + if (!persistedDiscoverSessionId || isSwitchingSession) { + initializeTabs({ + discoverSessionId: nextDiscoverSessionId, + shouldClearAllTabs: isSwitchingSession, + }); + } else { + const currentTabRuntimeState = selectTabRuntimeState(runtimeStateManager, currentTabId); + const currentTabStateContainer = currentTabRuntimeState.stateContainer$.getValue(); + + currentTabStateContainer?.appState.updateUrlWithCurrentState(); + } + } + ); + + useEffect(() => { + if (!rootProfileState.rootProfileLoading) { + initializeMainRoute(rootProfileState); + } + }, [initializeMainRoute, rootProfileState]); + + useEffect(() => { + initializeDiscoverSession.current({ nextDiscoverSessionId: currentDiscoverSessionId }); + }, [currentDiscoverSessionId, initializeDiscoverSession]); + useUnmount(() => { for (const tabId of Object.keys(runtimeStateManager.tabs.byId)) { - internalState.dispatch(internalStateActions.disconnectTab({ tabId })); + dispatch(internalStateActions.disconnectTab({ tabId })); } }); - if (rootProfileState.rootProfileLoading || mainRouteInitializationState.loading) { + useUrl({ + history, + savedSearchId: currentDiscoverSessionId, + onNewUrl: () => { + initializeTabs({ shouldClearAllTabs: true }); + }, + }); + + useAlertResultsToast(); + + useExecutionContext(core.executionContext, { + type: 'application', + page: 'app', + id: currentDiscoverSessionId || 'new', + }); + + useEffect(() => { + if (customizationContext.displayMode === 'standalone') { + const pageTitleSuffix = persistedDiscoverSession?.title + ? `: ${persistedDiscoverSession.title}` + : ''; + chrome.docTitle.change(`Discover${pageTitleSuffix}`); + setBreadcrumbs({ titleBreadcrumbText: persistedDiscoverSession?.title, services }); + } + }, [ + chrome.docTitle, + persistedDiscoverSession?.title, + customizationContext.displayMode, + services, + ]); + + const areTabsInitializing = useInternalStateSelector((state) => state.tabs.areInitializing); + const isLoading = + rootProfileState.rootProfileLoading || + mainRouteInitializationState.loading || + tabsInitializationState.loading || + areTabsInitializing; + + if (isLoading) { return ; } - if (mainRouteInitializationState.error) { - return ; + const error = mainRouteInitializationState.error || tabsInitializationState.error; + + if (error) { + return ; } if ( @@ -175,25 +277,11 @@ export const DiscoverMainRoute = ({ ); } - const sessionViewProps: DiscoverSessionViewProps = { - customizationContext, - customizationCallbacks, - urlStateStorage, - internalState, - runtimeStateManager, - }; - return ( - - - - {tabsEnabled ? ( - - ) : ( - - )} - - - + + + {tabsEnabled ? : } + + ); }; diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/discover_app_state_container.test.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/discover_app_state_container.test.ts index e66289b56a4a0..756c51ee18347 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/discover_app_state_container.test.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/discover_app_state_container.test.ts @@ -20,7 +20,6 @@ import { VIEW_MODE } from '@kbn/saved-search-plugin/common'; import { createDataViewDataSource } from '../../../../common/data_sources'; import type { DiscoverSavedSearchContainer } from './discover_saved_search_container'; import { getSavedSearchContainer } from './discover_saved_search_container'; -import { getDiscoverGlobalStateContainer } from './discover_global_state_container'; import { omit } from 'lodash'; import type { InternalStateStore, TabState } from './redux'; import { @@ -60,14 +59,14 @@ describe('Test discover app state container', () => { urlStateStorage: stateStorage, tabsStorageManager, }); - internalState.dispatch( - internalStateActions.initializeTabs({ userId: 'mockUserId', spaceId: 'mockSpaceId' }) - ); savedSearchState = getSavedSearchContainer({ services: discoverServiceMock, - globalStateContainer: getDiscoverGlobalStateContainer(stateStorage), internalState, + getCurrentTab: () => getCurrentTab(), }); + await internalState.dispatch( + internalStateActions.initializeTabs({ discoverSessionId: savedSearchState.getState()?.id }) + ); getCurrentTab = () => selectTab(internalState.getState(), internalState.getState().tabs.unsafeCurrentId); }); diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/discover_app_state_container.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/discover_app_state_container.ts index c6e2c524a610b..d76f2437507aa 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/discover_app_state_container.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/discover_app_state_container.ts @@ -20,13 +20,17 @@ import { isOfAggregateQueryType, } from '@kbn/es-query'; import type { SavedSearch, VIEW_MODE } from '@kbn/saved-search-plugin/public'; -import type { IKbnUrlStateStorage, ISyncStateRef } from '@kbn/kibana-utils-plugin/public'; +import type { + IKbnUrlStateStorage, + INullableBaseStateContainer, +} from '@kbn/kibana-utils-plugin/public'; import { syncState } from '@kbn/kibana-utils-plugin/public'; import { isEqual, omit } from 'lodash'; -import { connectToQueryState, syncGlobalQueryStateWithUrl } from '@kbn/data-plugin/public'; +import { type GlobalQueryStateFromUrl, connectToQueryState } from '@kbn/data-plugin/public'; import type { DiscoverGridSettings } from '@kbn/saved-search-plugin/common'; import type { DataGridDensity } from '@kbn/unified-data-table'; import type { DataView } from '@kbn/data-views-plugin/common'; +import { distinctUntilChanged, from, map } from 'rxjs'; import type { DiscoverServices } from '../../../build_services'; import { addLog } from '../../../utils/add_log'; import { cleanupUrlState } from './utils/cleanup_url_state'; @@ -41,9 +45,10 @@ import { isEsqlSource, } from '../../../../common/data_sources'; import type { DiscoverSavedSearchContainer } from './discover_saved_search_container'; -import type { InternalStateStore, TabActionInjector } from './redux'; -import { internalStateActions } from './redux'; +import type { DiscoverInternalState, InternalStateStore, TabActionInjector } from './redux'; +import { internalStateActions, selectTab } from './redux'; import { APP_STATE_URL_KEY } from '../../../../common'; +import { GLOBAL_STATE_URL_KEY } from '../../../../common/constants'; export interface DiscoverAppStateContainer extends ReduxLikeStateContainer { /** @@ -58,6 +63,10 @@ export interface DiscoverAppStateContainer extends ReduxLikeStateContainer () => void; + /** + * Updates the URL with the current app and global state without pushing to history (e.g. on initialization) + */ + updateUrlWithCurrentState: () => Promise; /** * Replaces the current state in URL with the given state * @param newState @@ -72,10 +81,6 @@ export interface DiscoverAppStateContainer extends ReduxLikeStateContainer void; - /** - * Start syncing the state with the URL - */ - syncState: () => ISyncStateRef; /** * Updates the state, if replace is true, a history.replace is performed instead of history.push * @param newPartial @@ -84,7 +89,6 @@ export interface DiscoverAppStateContainer extends ReduxLikeStateContainer void; /* * Get updated AppState when given a saved search - * */ getAppStateFromSavedSearch: (newSavedSearch: SavedSearch) => DiscoverAppState; } @@ -255,16 +259,43 @@ export const getDiscoverAppStateContainer = ({ } }; - const startAppStateUrlSync = () => { - addLog('[appState] start syncing state with URL'); - return syncState({ - storageKey: APP_STATE_URL_KEY, - stateContainer: enhancedAppContainer, - stateStorage, - }); + const getGlobalState = (state: DiscoverInternalState): GlobalQueryStateFromUrl => { + const tabState = selectTab(state, tabId); + const { timeRange: time, refreshInterval, filters } = tabState.globalState; + + return { time, refreshInterval, filters }; + }; + + const globalStateContainer: INullableBaseStateContainer = { + get: () => getGlobalState(internalState.getState()), + set: (state) => { + if (!state) { + return; + } + + const { time: timeRange, refreshInterval, filters } = state; + + internalState.dispatch( + injectCurrentTab(internalStateActions.setGlobalState)({ + globalState: { + timeRange, + refreshInterval, + filters, + }, + }) + ); + }, + state$: from(internalState).pipe(map(getGlobalState), distinctUntilChanged(isEqual)), }; - const initializeAndSync = () => { + const updateUrlWithCurrentState = async () => { + await Promise.all([ + stateStorage.set(GLOBAL_STATE_URL_KEY, globalStateContainer.get(), { replace: true }), + replaceUrlState({}), + ]); + }; + + const initAndSync = () => { const currentSavedSearch = savedSearchContainer.getState(); addLog('[appState] initialize state and sync with URL', currentSavedSearch); @@ -317,21 +348,41 @@ export const getDiscoverAppStateContainer = ({ } ); + const { start: startSyncingAppStateWithUrl, stop: stopSyncingAppStateWithUrl } = syncState({ + storageKey: APP_STATE_URL_KEY, + stateContainer: enhancedAppContainer, + stateStorage, + }); + // syncs `_g` portion of url with query services - const { stop: stopSyncingGlobalStateWithUrl } = syncGlobalQueryStateWithUrl( + const stopSyncingQueryGlobalStateWithStateContainer = connectToQueryState( data.query, - stateStorage + globalStateContainer, + { + refreshInterval: true, + time: true, + filters: FilterStateStore.GLOBAL_STATE, + } ); - const { start, stop } = startAppStateUrlSync(); + const { start: startSyncingGlobalStateWithUrl, stop: stopSyncingGlobalStateWithUrl } = + syncState({ + storageKey: GLOBAL_STATE_URL_KEY, + stateContainer: globalStateContainer, + stateStorage, + }); - // current state need to be pushed to url - replaceUrlState({}).then(() => start()); + // current state needs to be pushed to url + updateUrlWithCurrentState().then(() => { + startSyncingAppStateWithUrl(); + startSyncingGlobalStateWithUrl(); + }); return () => { stopSyncingQueryAppStateWithStateContainer(); + stopSyncingQueryGlobalStateWithStateContainer(); + stopSyncingAppStateWithUrl(); stopSyncingGlobalStateWithUrl(); - stop(); }; }; @@ -351,11 +402,11 @@ export const getDiscoverAppStateContainer = ({ ...enhancedAppContainer, getPrevious, hasChanged, - initAndSync: initializeAndSync, + initAndSync, resetToState, resetInitialState, + updateUrlWithCurrentState, replaceUrlState, - syncState: startAppStateUrlSync, update, getAppStateFromSavedSearch, }; diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/discover_global_state_container.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/discover_global_state_container.ts deleted file mode 100644 index 0ddf3fc701e8c..0000000000000 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/discover_global_state_container.ts +++ /dev/null @@ -1,26 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { QueryState } from '@kbn/data-plugin/common'; -import type { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; -import { GLOBAL_STATE_URL_KEY } from '../../../../common/constants'; - -export interface DiscoverGlobalStateContainer { - get: () => QueryState | null; - set: (state: QueryState) => Promise; -} - -export const getDiscoverGlobalStateContainer = ( - stateStorage: IKbnUrlStateStorage -): DiscoverGlobalStateContainer => ({ - get: () => stateStorage.get(GLOBAL_STATE_URL_KEY), - set: async (state: QueryState) => { - await stateStorage.set(GLOBAL_STATE_URL_KEY, state, { replace: true }); - }, -}); diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/discover_saved_search_container.test.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/discover_saved_search_container.test.ts index 25fc34c6bff96..c07de973417d9 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/discover_saved_search_container.test.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/discover_saved_search_container.test.ts @@ -14,15 +14,18 @@ import { createSavedSearchAdHocMock, createSavedSearchMock, savedSearchMock, - savedSearchMockWithTimeField, } from '../../../__mocks__/saved_search'; import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; import { dataViewComplexMock } from '../../../__mocks__/data_view_complex'; -import { getDiscoverGlobalStateContainer } from './discover_global_state_container'; import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; import { VIEW_MODE } from '../../../../common/constants'; import { createSearchSourceMock } from '@kbn/data-plugin/common/search/search_source/mocks'; -import { createInternalStateStore, createRuntimeStateManager, internalStateActions } from './redux'; +import { + createInternalStateStore, + createRuntimeStateManager, + internalStateActions, + selectTab, +} from './redux'; import { mockCustomizationContext } from '../../../customizations/__mocks__/customization_context'; import { omit } from 'lodash'; import { createTabsStorageManager } from './tabs_storage_manager'; @@ -31,7 +34,6 @@ describe('DiscoverSavedSearchContainer', () => { const savedSearch = savedSearchMock; const services = discoverServiceMock; const urlStateStorage = createKbnUrlStateStorage(); - const globalStateContainer = getDiscoverGlobalStateContainer(urlStateStorage); const tabsStorageManager = createTabsStorageManager({ urlStateStorage, storage: services.storage, @@ -43,16 +45,21 @@ describe('DiscoverSavedSearchContainer', () => { urlStateStorage, tabsStorageManager, }); - internalState.dispatch( - internalStateActions.initializeTabs({ userId: 'mockUserId', spaceId: 'mockSpaceId' }) - ); + const getCurrentTab = () => + selectTab(internalState.getState(), internalState.getState().tabs.unsafeCurrentId); + + beforeAll(async () => { + await internalState.dispatch( + internalStateActions.initializeTabs({ discoverSessionId: savedSearch?.id }) + ); + }); describe('getTitle', () => { it('returns undefined for new saved searches', () => { const container = getSavedSearchContainer({ services, - globalStateContainer, internalState, + getCurrentTab, }); expect(container.getTitle()).toBe(undefined); }); @@ -60,8 +67,8 @@ describe('DiscoverSavedSearchContainer', () => { it('returns the title of a persisted saved searches', () => { const container = getSavedSearchContainer({ services, - globalStateContainer, internalState, + getCurrentTab, }); container.set(savedSearch); expect(container.getTitle()).toBe(savedSearch.title); @@ -72,8 +79,8 @@ describe('DiscoverSavedSearchContainer', () => { it('should update the current and initial state of the saved search', () => { const container = getSavedSearchContainer({ services, - globalStateContainer, internalState, + getCurrentTab, }); const newSavedSearch: SavedSearch = { ...savedSearch, title: 'New title' }; const result = container.set(newSavedSearch); @@ -89,8 +96,8 @@ describe('DiscoverSavedSearchContainer', () => { it('should reset hasChanged$ to false', () => { const container = getSavedSearchContainer({ services, - globalStateContainer, internalState, + getCurrentTab, }); const newSavedSearch: SavedSearch = { ...savedSearch, title: 'New title' }; @@ -99,120 +106,12 @@ describe('DiscoverSavedSearchContainer', () => { }); }); - describe('persist', () => { - const saveOptions = { confirmOverwrite: false }; - - it('calls saveSavedSearch with the given saved search and save options', async () => { - const savedSearchContainer = getSavedSearchContainer({ - services: discoverServiceMock, - globalStateContainer, - internalState, - }); - const savedSearchToPersist = { - ...savedSearchMockWithTimeField, - title: 'My updated saved search', - }; - - await savedSearchContainer.persist(savedSearchToPersist, saveOptions); - expect(discoverServiceMock.savedSearch.save).toHaveBeenCalledWith( - savedSearchToPersist, - saveOptions - ); - }); - - it('sets the initial and current saved search to the persisted saved search', async () => { - const title = 'My updated saved search'; - const persistedSavedSearch = { - ...savedSearch, - title, - }; - - discoverServiceMock.savedSearch.save = jest.fn().mockResolvedValue('123'); - - const savedSearchContainer = getSavedSearchContainer({ - services: discoverServiceMock, - globalStateContainer, - internalState, - }); - - const result = await savedSearchContainer.persist(persistedSavedSearch, saveOptions); - expect(savedSearchContainer.getInitial$().getValue().title).toBe(title); - expect(savedSearchContainer.getCurrent$().getValue().title).toBe(title); - expect(result).toEqual({ id: '123' }); - }); - - it('emits false to the hasChanged$ BehaviorSubject', async () => { - const savedSearchContainer = getSavedSearchContainer({ - services: discoverServiceMock, - globalStateContainer, - internalState, - }); - const savedSearchToPersist = { - ...savedSearchMockWithTimeField, - title: 'My updated saved search', - }; - - await savedSearchContainer.persist(savedSearchToPersist, saveOptions); - expect(savedSearchContainer.getHasChanged$().getValue()).toBe(false); - }); - - it('takes care of persisting timeRestore correctly ', async () => { - discoverServiceMock.timefilter.getTime = jest.fn(() => ({ from: 'now-15m', to: 'now' })); - discoverServiceMock.timefilter.getRefreshInterval = jest.fn(() => ({ - value: 0, - pause: true, - })); - const savedSearchContainer = getSavedSearchContainer({ - services: discoverServiceMock, - globalStateContainer, - internalState, - }); - const savedSearchToPersist = { - ...savedSearchMockWithTimeField, - title: 'My updated saved search', - timeRestore: true, - }; - await savedSearchContainer.persist(savedSearchToPersist, saveOptions); - expect(discoverServiceMock.timefilter.getTime).toHaveBeenCalled(); - expect(discoverServiceMock.timefilter.getRefreshInterval).toHaveBeenCalled(); - expect(savedSearchToPersist.timeRange).toEqual({ from: 'now-15m', to: 'now' }); - expect(savedSearchToPersist.refreshInterval).toEqual({ - value: 0, - pause: true, - }); - }); - - it('Error thrown on persistence layer bubbling up, no changes to the initial saved search ', async () => { - discoverServiceMock.savedSearch.save = jest.fn().mockImplementation(() => { - throw new Error('oh-noes'); - }); - - const savedSearchContainer = getSavedSearchContainer({ - services: discoverServiceMock, - globalStateContainer, - internalState, - }); - savedSearchContainer.set(savedSearch); - savedSearchContainer.update({ nextState: { hideChart: true } }); - expect(savedSearchContainer.getHasChanged$().getValue()).toBe(true); - try { - await savedSearchContainer.persist(savedSearch, saveOptions); - } catch (e) { - // intentional error - } - expect(savedSearchContainer.getHasChanged$().getValue()).toBe(true); - expect(savedSearchContainer.getInitial$().getValue().title).not.toBe( - 'My updated saved search' - ); - }); - }); - describe('update', () => { it('updates a saved search by app state providing hideChart', async () => { const savedSearchContainer = getSavedSearchContainer({ services: discoverServiceMock, - globalStateContainer, internalState, + getCurrentTab, }); savedSearchContainer.set(savedSearch); const updated = savedSearchContainer.update({ nextState: { hideChart: true } }); @@ -227,8 +126,8 @@ describe('DiscoverSavedSearchContainer', () => { it('updates a saved search by data view', async () => { const savedSearchContainer = getSavedSearchContainer({ services: discoverServiceMock, - globalStateContainer, internalState, + getCurrentTab, }); const updated = savedSearchContainer.update({ nextDataView: dataViewMock }); expect(savedSearchContainer.getHasChanged$().getValue()).toBe(true); @@ -298,8 +197,8 @@ describe('DiscoverSavedSearchContainer', () => { it('should enable URL tracking for a persisted data view', () => { const savedSearchContainer = getSavedSearchContainer({ services: discoverServiceMock, - globalStateContainer, internalState, + getCurrentTab, }); const unsubscribe = savedSearchContainer.initUrlTracking(); jest.spyOn(services.urlTracker, 'setTrackingEnabled').mockClear(); @@ -313,8 +212,8 @@ describe('DiscoverSavedSearchContainer', () => { it('should disable URL tracking for an ad hoc data view', () => { const savedSearchContainer = getSavedSearchContainer({ services: discoverServiceMock, - globalStateContainer, internalState, + getCurrentTab, }); const unsubscribe = savedSearchContainer.initUrlTracking(); jest.spyOn(services.urlTracker, 'setTrackingEnabled').mockClear(); @@ -328,8 +227,8 @@ describe('DiscoverSavedSearchContainer', () => { it('should enable URL tracking if the ad hoc data view is a default profile data view', () => { const savedSearchContainer = getSavedSearchContainer({ services: discoverServiceMock, - globalStateContainer, internalState, + getCurrentTab, }); const unsubscribe = savedSearchContainer.initUrlTracking(); jest.spyOn(services.urlTracker, 'setTrackingEnabled').mockClear(); @@ -348,8 +247,8 @@ describe('DiscoverSavedSearchContainer', () => { it('should enable URL tracking with an ad hoc data view if in ES|QL mode', () => { const savedSearchContainer = getSavedSearchContainer({ services: discoverServiceMock, - globalStateContainer, internalState, + getCurrentTab, }); const unsubscribe = savedSearchContainer.initUrlTracking(); jest.spyOn(services.urlTracker, 'setTrackingEnabled').mockClear(); @@ -364,8 +263,8 @@ describe('DiscoverSavedSearchContainer', () => { it('should enable URL tracking with an ad hoc data view if the saved search has an ID (persisted)', () => { const savedSearchContainer = getSavedSearchContainer({ services: discoverServiceMock, - globalStateContainer, internalState, + getCurrentTab, }); const unsubscribe = savedSearchContainer.initUrlTracking(); jest.spyOn(services.urlTracker, 'setTrackingEnabled').mockClear(); diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/discover_saved_search_container.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/discover_saved_search_container.ts index 1a590cec5b689..d489e25a34a47 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/discover_saved_search_container.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/discover_saved_search_container.ts @@ -7,27 +7,23 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { v4 as uuidv4 } from 'uuid'; import type { SavedSearch } from '@kbn/saved-search-plugin/public'; import { BehaviorSubject } from 'rxjs'; import { cloneDeep } from 'lodash'; import type { FilterCompareOptions } from '@kbn/es-query'; -import { COMPARE_ALL_OPTIONS, isOfAggregateQueryType, updateFilterReferences } from '@kbn/es-query'; +import { COMPARE_ALL_OPTIONS, isOfAggregateQueryType } from '@kbn/es-query'; import type { SearchSourceFields } from '@kbn/data-plugin/common'; -import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/common'; +import type { DataView } from '@kbn/data-views-plugin/common'; import type { UnifiedHistogramVisContext } from '@kbn/unified-histogram'; import { canImportVisContext } from '@kbn/unified-histogram'; -import type { SavedObjectSaveOpts } from '@kbn/saved-objects-plugin/public'; import { isEqual, isFunction } from 'lodash'; -import { i18n } from '@kbn/i18n'; import { VIEW_MODE } from '../../../../common/constants'; import { updateSavedSearch } from './utils/update_saved_search'; import { addLog } from '../../../utils/add_log'; import type { DiscoverAppState } from './discover_app_state_container'; import { isEqualFilters } from './discover_app_state_container'; import type { DiscoverServices } from '../../../build_services'; -import type { DiscoverGlobalStateContainer } from './discover_global_state_container'; -import type { InternalStateStore } from './redux'; +import type { InternalStateStore, TabState } from './redux'; const FILTERS_COMPARE_OPTIONS: FilterCompareOptions = { ...COMPARE_ALL_OPTIONS, @@ -89,14 +85,6 @@ export interface DiscoverSavedSearchContainer { * Get the current state of the saved search */ getState: () => SavedSearch; - /** - * Persist the given saved search - * Resets the initial and current state of the saved search - */ - persist: ( - savedSearch: SavedSearch, - saveOptions?: SavedObjectSaveOpts - ) => Promise<{ id: string | undefined } | undefined>; /** * Set the persisted & current state of the saved search * Happens when a saved search is loaded or a new one is created @@ -127,12 +115,12 @@ export interface DiscoverSavedSearchContainer { export function getSavedSearchContainer({ services, - globalStateContainer, internalState, + getCurrentTab, }: { services: DiscoverServices; - globalStateContainer: DiscoverGlobalStateContainer; internalState: InternalStateStore; + getCurrentTab: () => TabState; }): DiscoverSavedSearchContainer { const initialSavedSearch = services.savedSearch.getNew(); const savedSearchInitial$ = new BehaviorSubject(initialSavedSearch); @@ -178,59 +166,6 @@ export function getSavedSearchContainer({ }; }; - const persist = async (nextSavedSearch: SavedSearch, saveOptions?: SavedObjectSaveOpts) => { - addLog('[savedSearch] persist', { nextSavedSearch, saveOptions }); - - const dataView = nextSavedSearch.searchSource.getField('index'); - const profileDataViewIds = internalState.getState().defaultProfileAdHocDataViewIds; - let replacementDataView: DataView | undefined; - - // If the Discover session is using a default profile ad hoc data view, - // we copy it with a new ID to avoid conflicts with the profile defaults - if (dataView?.id && profileDataViewIds.includes(dataView.id)) { - const replacementSpec: DataViewSpec = { - ...dataView.toSpec(), - id: uuidv4(), - name: i18n.translate('discover.savedSearch.defaultProfileDataViewCopyName', { - defaultMessage: '{dataViewName} ({discoverSessionTitle})', - values: { - dataViewName: dataView.name ?? dataView.getIndexPattern(), - discoverSessionTitle: nextSavedSearch.title, - }, - }), - }; - - // Skip field list fetching since the existing data view already has the fields - replacementDataView = await services.dataViews.create(replacementSpec, true); - } - - updateSavedSearch({ - savedSearch: nextSavedSearch, - globalStateContainer, - services, - useFilterAndQueryServices: true, - dataView: replacementDataView, - }); - - const currentFilters = nextSavedSearch.searchSource.getField('filter'); - - // If the data view was replaced, we need to update the filter references - if (dataView?.id && replacementDataView?.id && Array.isArray(currentFilters)) { - nextSavedSearch.searchSource.setField( - 'filter', - updateFilterReferences(currentFilters, dataView.id, replacementDataView.id) - ); - } - - const id = await services.savedSearch.save(nextSavedSearch, saveOptions || {}); - - if (id) { - set(nextSavedSearch); - } - - return { id }; - }; - const assignNextSavedSearch = ({ nextSavedSearch }: { nextSavedSearch: SavedSearch }) => { const hasChanged = !isEqualSavedSearch(savedSearchInitial$.getValue(), nextSavedSearch); hasChanged$.next(hasChanged); @@ -248,8 +183,8 @@ export function getSavedSearchContainer({ const nextSavedSearch = updateSavedSearch({ savedSearch: { ...previousSavedSearch }, dataView, - state: nextState || {}, - globalStateContainer, + appState: nextState || {}, + globalState: getCurrentTab().globalState, services, useFilterAndQueryServices, }); @@ -301,7 +236,6 @@ export function getSavedSearchContainer({ getInitial$, getState, getTitle, - persist, set, assignNextSavedSearch: (nextSavedSearch) => assignNextSavedSearch({ nextSavedSearch }), update, diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/discover_state.test.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/discover_state.test.ts index 7d668a4d3f998..2f33a3043b243 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/discover_state.test.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/discover_state.test.ts @@ -9,7 +9,11 @@ import type { DiscoverStateContainer } from './discover_state'; import { createSearchSessionRestorationDataProvider } from './discover_state'; -import { internalStateActions, selectTabRuntimeState } from './redux'; +import { + fromSavedSearchToSavedObjectTab, + internalStateActions, + selectTabRuntimeState, +} from './redux'; import type { History } from 'history'; import { createBrowserHistory, createMemoryHistory } from 'history'; import { createSearchSourceMock, dataPluginMock } from '@kbn/data-plugin/public/mocks'; @@ -23,7 +27,7 @@ import { } from '../../../__mocks__/saved_search'; import { createDiscoverServicesMock } from '../../../__mocks__/services'; import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; -import { getInitialState, type DiscoverAppStateContainer } from './discover_app_state_container'; +import { getInitialState } from './discover_app_state_container'; import { waitFor } from '@testing-library/react'; import { FetchStatus } from '../../types'; import { dataViewAdHoc, dataViewComplexMock } from '../../../__mocks__/data_view_complex'; @@ -38,18 +42,9 @@ import { getDiscoverStateMock } from '../../../__mocks__/discover_state.mock'; import { updateSavedSearch } from './utils/update_saved_search'; import { getConnectedCustomizationService } from '../../../customizations'; -const startSync = (appState: DiscoverAppStateContainer) => { - const { start, stop } = appState.syncState(); - start(); - return stop; -}; - let mockServices = createDiscoverServicesMock(); -async function getState( - url: string = '/', - { savedSearch, isEmptyUrl }: { savedSearch?: SavedSearch; isEmptyUrl?: boolean } = {} -) { +async function getState(url: string = '/', { savedSearch }: { savedSearch?: SavedSearch } = {}) { const nextHistory = createBrowserHistory(); nextHistory.push(url); mockServices.dataViews.create = jest.fn().mockImplementation((spec) => { @@ -62,30 +57,23 @@ async function getState( ...spec, }); }); + if (savedSearch) { + savedSearch = copySavedSearch(savedSearch); + mockServices.data.search.searchSource.create = jest + .fn() + .mockReturnValue(savedSearch.searchSource); + } const runtimeStateManager = createRuntimeStateManager(); const nextState = getDiscoverStateMock({ - savedSearch: false, + savedSearch: savedSearch ?? false, runtimeStateManager, history: nextHistory, services: mockServices, }); jest.spyOn(nextState.dataState, 'fetch'); - await nextState.internalState.dispatch(internalStateActions.loadDataViewList()); nextState.internalState.dispatch( internalStateActions.setInitializationState({ hasESData: true, hasUserDataView: true }) ); - if (savedSearch) { - jest.spyOn(mockServices.savedSearch, 'get').mockImplementation(() => { - nextState.savedSearchState.set(copySavedSearch(savedSearch)); - return Promise.resolve(savedSearch); - }); - } else { - jest.spyOn(mockServices.savedSearch, 'get').mockImplementation(() => { - nextState.savedSearchState.set(copySavedSearch(savedSearchMockWithTimeFieldNew)); - return Promise.resolve(savedSearchMockWithTimeFieldNew); - }); - } - const getCurrentUrl = () => nextHistory.createHref(nextHistory.location); return { history: nextHistory, @@ -116,7 +104,7 @@ describe('Discover state', () => { state = getDiscoverStateMock({ history }); state.savedSearchState.set(savedSearchMock); state.appState.update({}, true); - stopSync = startSync(state.appState); + stopSync = state.appState.initAndSync(); }); afterEach(() => { @@ -130,20 +118,20 @@ describe('Discover state', () => { }); await new Promise(process.nextTick); expect(getCurrentUrl()).toMatchInlineSnapshot( - `"/#?_a=(columns:!(default_column),dataSource:(dataViewId:modified,type:dataView),interval:auto,sort:!())"` + `"/#?_a=(columns:!(default_column),dataSource:(dataViewId:modified,type:dataView),interval:auto,sort:!())&_g=(refreshInterval:(pause:!t,value:1000),time:(from:now-15m,to:now))"` ); }); test('changing URL to be propagated to appState', async () => { history.push('/#?_a=(dataSource:(dataViewId:modified,type:dataView))'); expect(state.appState.getState()).toMatchInlineSnapshot(` - Object { - "dataSource": Object { - "dataViewId": "modified", - "type": "dataView", - }, - } - `); + Object { + "dataSource": Object { + "dataViewId": "modified", + "type": "dataView", + }, + } + `); }); test('URL navigation to url without _a, state should not change', async () => { @@ -209,7 +197,7 @@ describe('Discover state', () => { state = getDiscoverStateMock({ stateStorageContainer: stateStorage, history }); state.savedSearchState.set(savedSearchMock); state.appState.update({}, true); - stopSync = startSync(state.appState); + stopSync = state.appState.initAndSync(); }); afterEach(() => { @@ -226,7 +214,7 @@ describe('Discover state', () => { await jest.runAllTimersAsync(); expect(history.createHref(history.location)).toMatchInlineSnapshot( - `"/#?_a=(columns:!(default_column),dataSource:(dataViewId:modified,type:dataView),interval:auto,sort:!())"` + `"/#?_a=(columns:!(default_column),dataSource:(dataViewId:modified,type:dataView),interval:auto,sort:!())&_g=(refreshInterval:(pause:!t,value:1000),time:(from:now-15m,to:now))"` ); }); @@ -236,13 +224,13 @@ describe('Discover state', () => { await jest.runAllTimersAsync(); expect(state.appState.getState()).toMatchInlineSnapshot(` - Object { - "dataSource": Object { - "dataViewId": "modified", - "type": "dataView", - }, - } - `); + Object { + "dataSource": Object { + "dataViewId": "modified", + "type": "dataView", + }, + } + `); }); }); @@ -266,14 +254,12 @@ describe('Discover state', () => { }; const { state, customizationService } = await getState('/', { savedSearch: nextSavedSearch }); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: savedSearchMock.id, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); @@ -289,16 +275,16 @@ describe('Discover state', () => { { savedSearch: savedSearchMockWithTimeFieldNew } ); expect(state.appState.getState().query).toMatchInlineSnapshot(` - Object { - "language": "lucene", - "query": Object { - "query_string": Object { - "analyze_wildcard": true, - "query": "type:nice name:\\"yeah\\"", - }, - }, - } - `); + Object { + "language": "lucene", + "query": Object { + "query_string": Object { + "analyze_wildcard": true, + "query": "type:nice name:\\"yeah\\"", + }, + }, + } + `); }); }); @@ -338,9 +324,7 @@ describe('Discover state', () => { test('restoreState has sessionId and initialState has not', async () => { mockSavedSearch = savedSearchMock; const searchSessionId = 'id'; - (mockDataPlugin.search.session.getSessionId as jest.Mock).mockImplementation( - () => searchSessionId - ); + (mockDataPlugin.search.session.getSessionId as jest.Mock).mockReturnValue(searchSessionId); const { initialState, restoreState } = await searchSessionInfoProvider.getLocatorData(); expect(initialState.searchSessionId).toBeUndefined(); expect(restoreState.searchSessionId).toBe(searchSessionId); @@ -350,12 +334,12 @@ describe('Discover state', () => { mockSavedSearch = savedSearchMock; const relativeTime = 'relativeTime'; const absoluteTime = 'absoluteTime'; - (mockDataPlugin.query.timefilter.timefilter.getTime as jest.Mock).mockImplementation( - () => relativeTime + (mockDataPlugin.query.timefilter.timefilter.getTime as jest.Mock).mockReturnValue( + relativeTime + ); + (mockDataPlugin.query.timefilter.timefilter.getAbsoluteTime as jest.Mock).mockReturnValue( + absoluteTime ); - ( - mockDataPlugin.query.timefilter.timefilter.getAbsoluteTime as jest.Mock - ).mockImplementation(() => absoluteTime); const { initialState, restoreState } = await searchSessionInfoProvider.getLocatorData(); expect(initialState.timeRange).toBe(relativeTime); expect(restoreState.timeRange).toBe(absoluteTime); @@ -406,9 +390,6 @@ describe('Discover state', () => { mockServices.data.query.timefilter.timefilter.getRefreshInterval = jest.fn(() => { return { pause: true, value: 1000 }; }); - mockServices.data.search.searchSource.create = jest - .fn() - .mockReturnValue(savedSearchMock.searchSource); }); afterEach(() => { @@ -430,7 +411,6 @@ describe('Discover state', () => { state.getCurrentTab().id ).currentDataView$.getValue() ).toBe(dataViewMock); - expect(state.getCurrentTab().dataViewId).toBe(dataViewMock.id); }); test('fetchData', async () => { @@ -439,14 +419,12 @@ describe('Discover state', () => { await state.internalState.dispatch(internalStateActions.loadDataViewList()); expect(dataState.data$.main$.value.fetchStatus).toBe(FetchStatus.LOADING); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: undefined, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); @@ -461,6 +439,7 @@ describe('Discover state', () => { test('loadDataViewList', async () => { const { state } = await getState(''); + await state.internalState.dispatch(internalStateActions.loadDataViewList()); expect(state.internalState.getState().savedDataViews.length).toBe(3); }); @@ -468,14 +447,12 @@ describe('Discover state', () => { const { state, customizationService, getCurrentUrl } = await getState(''); await state.internalState.dispatch(internalStateActions.loadDataViewList()); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: undefined, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); @@ -488,22 +465,22 @@ describe('Discover state', () => { expect(state.savedSearchState.getHasChanged$().getValue()).toBe(false); const { searchSource, ...savedSearch } = state.savedSearchState.getState(); expect(savedSearch).toMatchInlineSnapshot(` - Object { - "columns": Array [ - "default_column", - ], - "density": undefined, - "headerRowHeight": undefined, - "hideAggregatedPreview": undefined, - "hideChart": undefined, - "refreshInterval": undefined, - "rowHeight": undefined, - "rowsPerPage": undefined, - "sampleSize": undefined, - "sort": Array [], - "timeRange": undefined, - } - `); + Object { + "columns": Array [ + "default_column", + ], + "density": undefined, + "headerRowHeight": undefined, + "hideAggregatedPreview": undefined, + "hideChart": undefined, + "refreshInterval": undefined, + "rowHeight": undefined, + "rowsPerPage": undefined, + "sampleSize": undefined, + "sort": Array [], + "timeRange": undefined, + } + `); expect(searchSource.getField('index')?.id).toEqual('the-data-view-id'); state.actions.stopSyncing(); }); @@ -511,14 +488,12 @@ describe('Discover state', () => { test('loadNewSavedSearch given an empty URL using loadSavedSearch', async () => { const { state, customizationService, getCurrentUrl } = await getState('/'); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: undefined, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); @@ -534,18 +509,15 @@ describe('Discover state', () => { test('loadNewSavedSearch with URL changing interval state', async () => { const { state, customizationService, getCurrentUrl } = await getState( - '/#?_a=(interval:month,columns:!(bytes))&_g=()', - { isEmptyUrl: false } + '/#?_a=(interval:month,columns:!(bytes))&_g=()' ); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: undefined, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); @@ -553,7 +525,7 @@ describe('Discover state', () => { expect(newSavedSearch?.id).toBeUndefined(); await new Promise(process.nextTick); expect(getCurrentUrl()).toMatchInlineSnapshot( - `"/#?_a=(columns:!(bytes),dataSource:(dataViewId:the-data-view-id,type:dataView),interval:month,sort:!())&_g=()"` + `"/#?_a=(columns:!(bytes),dataSource:(dataViewId:the-data-view-id,type:dataView),interval:month,sort:!())&_g=(refreshInterval:(pause:!t,value:1000),time:(from:now-15d,to:now))"` ); expect(state.savedSearchState.getHasChanged$().getValue()).toBe(false); state.actions.stopSyncing(); @@ -561,18 +533,15 @@ describe('Discover state', () => { test('loadSavedSearch with no id, given URL changes state', async () => { const { state, customizationService, getCurrentUrl } = await getState( - '/#?_a=(interval:month,columns:!(bytes))&_g=()', - { isEmptyUrl: false } + '/#?_a=(interval:month,columns:!(bytes))&_g=()' ); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: undefined, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); @@ -580,39 +549,33 @@ describe('Discover state', () => { expect(newSavedSearch?.id).toBeUndefined(); await new Promise(process.nextTick); expect(getCurrentUrl()).toMatchInlineSnapshot( - `"/#?_a=(columns:!(bytes),dataSource:(dataViewId:the-data-view-id,type:dataView),interval:month,sort:!())&_g=()"` + `"/#?_a=(columns:!(bytes),dataSource:(dataViewId:the-data-view-id,type:dataView),interval:month,sort:!())&_g=(refreshInterval:(pause:!t,value:1000),time:(from:now-15d,to:now))"` ); expect(state.savedSearchState.getHasChanged$().getValue()).toBe(false); state.actions.stopSyncing(); }); test('loadSavedSearch given an empty URL, no state changes', async () => { - const { state, customizationService, getCurrentUrl } = await getState('/', { - savedSearch: savedSearchMock, - }); - jest.spyOn(mockServices.savedSearch, 'get').mockImplementationOnce(() => { - const savedSearch = copySavedSearch(savedSearchMock); - const savedSearchWithDefaults = updateSavedSearch({ + const savedSearch = copySavedSearch(savedSearchMock); + const savedSearchWithDefaults = updateSavedSearch({ + savedSearch, + appState: getInitialState({ + initialUrlState: undefined, savedSearch, - state: getInitialState({ - initialUrlState: undefined, - savedSearch, - services: mockServices, - }), - globalStateContainer: state.globalState, services: mockServices, - }); - return Promise.resolve(savedSearchWithDefaults); + }), + services: mockServices, + }); + const { state, customizationService, getCurrentUrl } = await getState('/', { + savedSearch: savedSearchWithDefaults, }); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: 'the-saved-search-id', dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); @@ -620,7 +583,7 @@ describe('Discover state', () => { await new Promise(process.nextTick); expect(newSavedSearch?.id).toBe('the-saved-search-id'); expect(getCurrentUrl()).toMatchInlineSnapshot( - `"/#?_g=(refreshInterval:(pause:!t,value:1000),time:(from:now-15d,to:now))&_a=(columns:!(default_column),dataSource:(dataViewId:the-data-view-id,type:dataView),interval:auto,sort:!())"` + `"/#?_g=(refreshInterval:(pause:!t,value:1000),time:(from:now-15d,to:now))&_a=(columns:!(default_column),dataSource:(dataViewId:the-data-view-id,type:dataView),grid:(),hideChart:!f,interval:auto,sort:!())"` ); expect(state.savedSearchState.getHasChanged$().getValue()).toBe(false); state.actions.stopSyncing(); @@ -630,23 +593,20 @@ describe('Discover state', () => { const url = '/#?_a=(interval:month,columns:!(message))&_g=()'; const { state, customizationService, getCurrentUrl } = await getState(url, { savedSearch: savedSearchMock, - isEmptyUrl: false, }); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: savedSearchMock.id, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); await new Promise(process.nextTick); expect(getCurrentUrl()).toMatchInlineSnapshot( - `"/#?_a=(columns:!(message),dataSource:(dataViewId:the-data-view-id,type:dataView),interval:month,sort:!())&_g=()"` + `"/#?_a=(columns:!(message),dataSource:(dataViewId:the-data-view-id,type:dataView),grid:(),hideChart:!f,interval:month,sort:!())&_g=(refreshInterval:(pause:!t,value:1000),time:(from:now-15d,to:now))"` ); expect(state.savedSearchState.getHasChanged$().getValue()).toBe(true); state.actions.stopSyncing(); @@ -662,17 +622,14 @@ describe('Discover state', () => { }; const { state, customizationService } = await getState(url, { savedSearch, - isEmptyUrl: false, }); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: savedSearchMock.id, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); @@ -695,17 +652,14 @@ describe('Discover state', () => { }; const { state, customizationService } = await getState(url, { savedSearch, - isEmptyUrl: false, }); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: savedSearchMock.id, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); @@ -728,17 +682,14 @@ describe('Discover state', () => { }; const { state, customizationService } = await getState(url, { savedSearch, - isEmptyUrl: false, }); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: savedSearchMock.id, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); @@ -751,14 +702,12 @@ describe('Discover state', () => { const url = '/#?_a=(hideChart:true,columns:!(message))&_g=()'; const { state, customizationService } = await getState(url, { savedSearch: savedSearchMock }); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: undefined, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); @@ -768,19 +717,14 @@ describe('Discover state', () => { test('loadSavedSearch without id ignoring invalid index in URL, adding a warning toast', async () => { const url = '/#?_a=(dataSource:(dataViewId:abc,type:dataView))&_g=()'; - const { state, customizationService } = await getState(url, { - savedSearch: savedSearchMock, - isEmptyUrl: false, - }); + const { state, customizationService } = await getState(url); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: undefined, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); @@ -798,18 +742,18 @@ describe('Discover state', () => { const url = "/#?_a=(dataSource:(dataViewId:abcde,type:dataView),query:(esql:'FROM test'))&_g=()"; const { state, customizationService } = await getState(url, { - savedSearch: savedSearchMock, - isEmptyUrl: false, + savedSearch: { + ...savedSearchMock, + id: undefined, + }, }); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: undefined, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); @@ -821,17 +765,14 @@ describe('Discover state', () => { const url = '/#?_a=(dataSource:(dataViewId:abc,type:dataView))&_g=()'; const { state, customizationService } = await getState(url, { savedSearch: savedSearchMock, - isEmptyUrl: false, }); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: savedSearchMock.id, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); @@ -846,32 +787,26 @@ describe('Discover state', () => { }); test('loadSavedSearch data view handling', async () => { - const { state, customizationService, history } = await getState('/', { - savedSearch: savedSearchMock, - }); - jest.spyOn(mockServices.savedSearch, 'get').mockImplementationOnce(() => { - const savedSearch = copySavedSearch(savedSearchMock); - const savedSearchWithDefaults = updateSavedSearch({ + let savedSearch = copySavedSearch(savedSearchMock); + let savedSearchWithDefaults = updateSavedSearch({ + savedSearch, + appState: getInitialState({ + initialUrlState: undefined, savedSearch, - state: getInitialState({ - initialUrlState: undefined, - savedSearch, - services: mockServices, - }), - globalStateContainer: state.globalState, services: mockServices, - }); - return Promise.resolve(savedSearchWithDefaults); + }), + services: mockServices, + }); + const { state, customizationService, history } = await getState('/', { + savedSearch: savedSearchWithDefaults, }); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: savedSearchMock.id, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); @@ -879,30 +814,47 @@ describe('Discover state', () => { 'the-data-view-id' ); expect(state.savedSearchState.getHasChanged$().getValue()).toBe(false); - jest.spyOn(mockServices.savedSearch, 'get').mockImplementationOnce(() => { - const savedSearch = copySavedSearch(savedSearchMockWithTimeField); - const savedSearchWithDefaults = updateSavedSearch({ + savedSearch = { ...copySavedSearch(savedSearchMockWithTimeField), id: savedSearch.id }; + savedSearchWithDefaults = updateSavedSearch({ + savedSearch, + appState: getInitialState({ + initialUrlState: undefined, savedSearch, - state: getInitialState({ - initialUrlState: undefined, - savedSearch, + services: mockServices, + }), + globalState: state.getCurrentTab().globalState, + services: mockServices, + }); + mockServices.data.search.searchSource.create = jest + .fn() + .mockReturnValue(savedSearchWithDefaults.searchSource); + jest.spyOn(mockServices.savedSearch, 'getDiscoverSession').mockResolvedValueOnce({ + ...savedSearchWithDefaults, + id: savedSearchWithDefaults.id ?? '', + title: savedSearchWithDefaults.title ?? '', + description: savedSearchWithDefaults.description ?? '', + tabs: [ + fromSavedSearchToSavedObjectTab({ + tab: { + id: savedSearchWithDefaults.id ?? '', + label: savedSearchWithDefaults.title ?? '', + }, + savedSearch: savedSearchWithDefaults, services: mockServices, }), - globalStateContainer: state.globalState, - services: mockServices, - }); - return Promise.resolve(savedSearchWithDefaults); + ], }); + await state.internalState.dispatch( + internalStateActions.initializeTabs({ discoverSessionId: savedSearchWithDefaults.id }) + ); history.push('/'); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: 'the-saved-search-id-with-timefield', dataViewSpec: undefined, defaultUrlState: {}, - shouldClearAllTabs: false, }, }) ); @@ -910,33 +862,49 @@ describe('Discover state', () => { 'index-pattern-with-timefield-id' ); expect(state.savedSearchState.getHasChanged$().getValue()).toBe(false); - jest.spyOn(mockServices.savedSearch, 'get').mockImplementationOnce(() => { - const savedSearch = copySavedSearch(savedSearchMock); - const savedSearchWithDefaults = updateSavedSearch({ + savedSearch = copySavedSearch(savedSearchMock); + savedSearchWithDefaults = updateSavedSearch({ + savedSearch, + appState: getInitialState({ + initialUrlState: undefined, savedSearch, - state: getInitialState({ - initialUrlState: undefined, - savedSearch, + services: mockServices, + }), + services: mockServices, + }); + mockServices.data.search.searchSource.create = jest + .fn() + .mockReturnValue(savedSearchWithDefaults.searchSource); + jest.spyOn(mockServices.savedSearch, 'getDiscoverSession').mockResolvedValueOnce({ + ...savedSearchWithDefaults, + id: savedSearchWithDefaults.id ?? '', + title: savedSearchWithDefaults.title ?? '', + description: savedSearchWithDefaults.description ?? '', + tabs: [ + fromSavedSearchToSavedObjectTab({ + tab: { + id: savedSearchWithDefaults.id ?? '', + label: savedSearchWithDefaults.title ?? '', + }, + savedSearch: savedSearchWithDefaults, services: mockServices, }), - globalStateContainer: state.globalState, - services: mockServices, - }); - return Promise.resolve(savedSearchWithDefaults); + ], }); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + internalStateActions.initializeTabs({ discoverSessionId: savedSearchWithDefaults.id }) + ); + await state.internalState.dispatch( + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: savedSearchMock.id, dataViewSpec: undefined, defaultUrlState: { dataSource: createDataViewDataSource({ dataViewId: 'index-pattern-with-timefield-id', }), }, - shouldClearAllTabs: false, }, }) ); @@ -954,20 +922,18 @@ describe('Discover state', () => { timeFieldName: 'mock-time-field-name', }; const dataViewsCreateMock = mockServices.dataViews.create as jest.Mock; - dataViewsCreateMock.mockImplementationOnce(() => ({ + dataViewsCreateMock.mockResolvedValueOnce({ ...dataViewMock, ...dataViewSpecMock, isPersisted: () => false, - })); + }); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: undefined, dataViewSpec: dataViewSpecMock, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); @@ -986,14 +952,12 @@ describe('Discover state', () => { test('loadSavedSearch resetting query & filters of data service', async () => { const { state, customizationService } = await getState('/', { savedSearch: savedSearchMock }); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: savedSearchMock.id, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); @@ -1011,14 +975,12 @@ describe('Discover state', () => { savedSearch: savedSearchWithQueryAndFilters, }); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: savedSearchMock.id, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); @@ -1033,14 +995,12 @@ describe('Discover state', () => { savedSearch: savedSearchAdHocCopy, }); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: savedSearchAdHoc.id, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); @@ -1056,17 +1016,14 @@ describe('Discover state', () => { const url = "/#?_a=(dataSource:(dataViewId:'the-data-view-id',type:dataView))&_g=()"; const { state, customizationService } = await getState(url, { savedSearch: savedSearchMockWithESQLCopy, - isEmptyUrl: false, }); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: savedSearchMockWithESQL.id, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); @@ -1081,13 +1038,17 @@ describe('Discover state', () => { savedSearchWithQuery.searchSource.setField('query', query); savedSearchWithQuery.searchSource.setField('filter', filters); const { state } = await getState('/', { savedSearch: savedSearchWithQuery }); - state.globalState?.set({ filters }); + state.internalState.dispatch( + state.injectCurrentTab(internalStateActions.setGlobalState)({ + globalState: { filters }, + }) + ); state.appState.set({ query }); await state.actions.transitionFromDataViewToESQL(dataViewMock); expect(state.appState.getState().query).toStrictEqual({ esql: 'FROM the-data-view-title | WHERE KQL("""foo: \'bar\'""") | LIMIT 10', }); - expect(state.globalState?.get?.()?.filters).toStrictEqual([]); + expect(state.getCurrentTab().globalState.filters).toStrictEqual([]); expect(state.appState.getState().filters).toStrictEqual([]); }); @@ -1109,14 +1070,12 @@ describe('Discover state', () => { const { actions, savedSearchState, dataState } = state; await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: savedSearchMock.id, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); @@ -1144,22 +1103,27 @@ describe('Discover state', () => { }); test('onDataViewCreated - persisted data view', async () => { - const { state, customizationService } = await getState('/', { savedSearch: savedSearchMock }); + const { state, customizationService, runtimeStateManager } = await getState('/', { + savedSearch: savedSearchMock, + }); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: savedSearchMock.id, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); await state.actions.onDataViewCreated(dataViewComplexMock); await waitFor(() => { - expect(state.getCurrentTab().dataViewId).toBe(dataViewComplexMock.id); + expect( + selectTabRuntimeState( + runtimeStateManager, + state.getCurrentTab().id + ).currentDataView$.getValue() + ).toBe(dataViewComplexMock); }); expect(state.appState.getState().dataSource).toEqual( createDataViewDataSource({ dataViewId: dataViewComplexMock.id! }) @@ -1171,16 +1135,16 @@ describe('Discover state', () => { }); test('onDataViewCreated - ad-hoc data view', async () => { - const { state, customizationService } = await getState('/', { savedSearch: savedSearchMock }); + const { state, customizationService, runtimeStateManager } = await getState('/', { + savedSearch: savedSearchMock, + }); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: savedSearchMock.id, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); @@ -1191,7 +1155,12 @@ describe('Discover state', () => { ); await state.actions.onDataViewCreated(dataViewAdHoc); await waitFor(() => { - expect(state.getCurrentTab().dataViewId).toBe(dataViewAdHoc.id); + expect( + selectTabRuntimeState( + runtimeStateManager, + state.getCurrentTab().id + ).currentDataView$.getValue() + ).toBe(dataViewAdHoc); }); expect(state.appState.getState().dataSource).toEqual( createDataViewDataSource({ dataViewId: dataViewAdHoc.id! }) @@ -1203,36 +1172,46 @@ describe('Discover state', () => { }); test('onDataViewEdited - persisted data view', async () => { - const { state, customizationService } = await getState('/', { savedSearch: savedSearchMock }); + const { state, customizationService, runtimeStateManager } = await getState('/', { + savedSearch: savedSearchMock, + }); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: savedSearchMock.id, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); - const selectedDataViewId = state.getCurrentTab().dataViewId; + + const selectedDataView$ = selectTabRuntimeState( + runtimeStateManager, + state.getCurrentTab().id + ).currentDataView$; + const selectedDataViewId = selectedDataView$.getValue()?.id; expect(selectedDataViewId).toBe(dataViewMock.id); await state.actions.onDataViewEdited(dataViewMock); await waitFor(() => { - expect(state.getCurrentTab().dataViewId).toBe(selectedDataViewId); + expect(selectedDataView$.getValue()?.id).toBe(selectedDataViewId); }); state.actions.stopSyncing(); }); test('onDataViewEdited - ad-hoc data view', async () => { - const { state } = await getState('/', { savedSearch: savedSearchMock }); + const { state, runtimeStateManager } = await getState('/', { savedSearch: savedSearchMock }); state.actions.initializeAndSync(); await state.actions.onDataViewCreated(dataViewAdHoc); const previousId = dataViewAdHoc.id; await state.actions.onDataViewEdited(dataViewAdHoc); await waitFor(() => { - expect(state.getCurrentTab().dataViewId).not.toBe(previousId); + expect( + selectTabRuntimeState( + runtimeStateManager, + state.getCurrentTab().id + ).currentDataView$.getValue()?.id + ).not.toBe(previousId); }); state.actions.stopSyncing(); }); @@ -1240,55 +1219,50 @@ describe('Discover state', () => { test('onOpenSavedSearch - same target id', async () => { const { state, customizationService } = await getState('/', { savedSearch: savedSearchMock }); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: savedSearchMock.id, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); + expect(state.savedSearchState.getState().hideChart).toBe(false); state.savedSearchState.update({ nextState: { hideChart: true } }); expect(state.savedSearchState.getState().hideChart).toBe(true); state.actions.onOpenSavedSearch(savedSearchMock.id!); - expect(state.savedSearchState.getState().hideChart).toBe(undefined); + expect(state.savedSearchState.getState().hideChart).toBe(false); state.actions.stopSyncing(); }); test('onOpenSavedSearch - cleanup of previous filter', async () => { const { state, customizationService, history } = await getState( "/#?_g=(filters:!(),refreshInterval:(pause:!t,value:60000),time:(from:now-15m,to:now))&_a=(columns:!(customer_first_name),filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:ff959d40-b880-11e8-a6d9-e546fe2bba5f,key:customer_first_name,negate:!f,params:(query:Mary),type:phrase),query:(match_phrase:(customer_first_name:Mary)))),hideChart:!f,index:ff959d40-b880-11e8-a6d9-e546fe2bba5f,interval:auto,query:(language:kuery,query:''),sort:!())", - { savedSearch: savedSearchMock, isEmptyUrl: false } + { savedSearch: savedSearchMock } ); jest.spyOn(mockServices.filterManager, 'getAppFilters').mockImplementation(() => { return state.appState.getState().filters!; }); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: savedSearchMock.id, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); expect(state.appState.get().filters).toHaveLength(1); history.push('/'); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: undefined, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); @@ -1298,14 +1272,12 @@ describe('Discover state', () => { test('onCreateDefaultAdHocDataView', async () => { const { state, customizationService } = await getState('/', { savedSearch: savedSearchMock }); await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: savedSearchMock.id, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); @@ -1318,38 +1290,49 @@ describe('Discover state', () => { }); test('undoSavedSearchChanges - when changing data views', async () => { - const { state, customizationService, getCurrentUrl } = await getState('/', { - savedSearch: savedSearchMock, - }); + const { state, customizationService, runtimeStateManager, getCurrentUrl } = await getState( + '/', + { + savedSearch: savedSearchMock, + } + ); // Load a given persisted saved search await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: savedSearchMock.id, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); await new Promise(process.nextTick); const initialUrlState = - '/#?_g=(refreshInterval:(pause:!t,value:1000),time:(from:now-15d,to:now))&_a=(columns:!(default_column),dataSource:(dataViewId:the-data-view-id,type:dataView),interval:auto,sort:!())'; + '/#?_g=(refreshInterval:(pause:!t,value:1000),time:(from:now-15d,to:now))&_a=(columns:!(default_column),dataSource:(dataViewId:the-data-view-id,type:dataView),grid:(),hideChart:!f,interval:auto,sort:!())'; expect(getCurrentUrl()).toBe(initialUrlState); - expect(state.getCurrentTab().dataViewId).toBe(dataViewMock.id!); + expect( + selectTabRuntimeState( + runtimeStateManager, + state.getCurrentTab().id + ).currentDataView$.getValue()?.id + ).toBe(dataViewMock.id); // Change the data view, this should change the URL and trigger a fetch await state.actions.onChangeDataView(dataViewComplexMock.id!); await new Promise(process.nextTick); expect(getCurrentUrl()).toMatchInlineSnapshot( - `"/#?_g=(refreshInterval:(pause:!t,value:1000),time:(from:now-15d,to:now))&_a=(columns:!(),dataSource:(dataViewId:data-view-with-various-field-types-id,type:dataView),interval:auto,sort:!(!(data,desc)))"` + `"/#?_g=(refreshInterval:(pause:!t,value:1000),time:(from:now-15d,to:now))&_a=(columns:!(),dataSource:(dataViewId:data-view-with-various-field-types-id,type:dataView),grid:(),hideChart:!f,interval:auto,sort:!(!(data,desc)))"` ); await waitFor(() => { expect(state.dataState.fetch).toHaveBeenCalledTimes(2); }); - expect(state.getCurrentTab().dataViewId).toBe(dataViewComplexMock.id!); + expect( + selectTabRuntimeState( + runtimeStateManager, + state.getCurrentTab().id + ).currentDataView$.getValue()?.id + ).toBe(dataViewComplexMock.id); // Undo all changes to the saved search, this should trigger a fetch, again await state.actions.undoSavedSearchChanges(); @@ -1358,33 +1341,35 @@ describe('Discover state', () => { await waitFor(() => { expect(state.dataState.fetch).toHaveBeenCalledTimes(3); }); - expect(state.getCurrentTab().dataViewId).toBe(dataViewMock.id!); + expect( + selectTabRuntimeState( + runtimeStateManager, + state.getCurrentTab().id + ).currentDataView$.getValue()?.id + ).toBe(dataViewMock.id); state.actions.stopSyncing(); }); test('undoSavedSearchChanges with timeRestore', async () => { - const { state, customizationService } = await getState('/', { - savedSearch: { - ...savedSearchMockWithTimeField, - timeRestore: true, - refreshInterval: { pause: false, value: 1000 }, - timeRange: { from: 'now-15d', to: 'now-10d' }, - }, - }); + const savedSearch = { + ...savedSearchMockWithTimeField, + timeRestore: true, + refreshInterval: { pause: false, value: 1000 }, + timeRange: { from: 'now-15d', to: 'now-10d' }, + }; + const { state, customizationService } = await getState('/', { savedSearch }); const setTime = jest.fn(); const setRefreshInterval = jest.fn(); mockServices.data.query.timefilter.timefilter.setTime = setTime; mockServices.data.query.timefilter.timefilter.setRefreshInterval = setRefreshInterval; await state.internalState.dispatch( - state.injectCurrentTab(internalStateActions.initializeSession)({ - initializeSessionParams: { + state.injectCurrentTab(internalStateActions.initializeSingleTab)({ + initializeSingleTabParams: { stateContainer: state, customizationService, - discoverSessionId: savedSearchMock.id, dataViewSpec: undefined, defaultUrlState: undefined, - shouldClearAllTabs: false, }, }) ); @@ -1416,7 +1401,7 @@ describe('Discover state', () => { }); state.savedSearchState.set(savedSearchMock); state.appState.update({}, true); - stopSync = startSync(state.appState); + stopSync = state.appState.initAndSync(); }); afterEach(() => { @@ -1430,7 +1415,7 @@ describe('Discover state', () => { }); await new Promise(process.nextTick); expect(getCurrentUrl()).toMatchInlineSnapshot( - `"/?_a=(columns:!(default_column),dataSource:(dataViewId:modified,type:dataView),interval:auto,sort:!())"` + `"/?_a=(columns:!(default_column),dataSource:(dataViewId:modified,type:dataView),interval:auto,sort:!())&_g=(refreshInterval:(pause:!t,value:1000),time:(from:now-15m,to:now))"` ); }); diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/discover_state.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/discover_state.ts index c5bca15d21344..1b139c7026173 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/discover_state.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/discover_state.ts @@ -34,8 +34,6 @@ import { DISCOVER_APP_LOCATOR } from '../../../../common'; import type { DiscoverAppState, DiscoverAppStateContainer } from './discover_app_state_container'; import { getDiscoverAppStateContainer, getInitialState } from './discover_app_state_container'; import { updateFiltersReferences } from './utils/update_filter_references'; -import type { DiscoverGlobalStateContainer } from './discover_global_state_container'; -import { getDiscoverGlobalStateContainer } from './discover_global_state_container'; import type { DiscoverCustomizationContext } from '../../../customizations'; import { createDataViewDataSource, @@ -83,30 +81,7 @@ export interface DiscoverStateContainerParams { runtimeStateManager: RuntimeStateManager; } -export interface LoadParams { - /** - * the id of the saved search to load, if undefined, a new saved search will be created - */ - savedSearchId?: string; - /** - * the data view to use, if undefined, the saved search's data view will be used - */ - dataView?: DataView; - /** - * Custom initial app state for loading a saved search - */ - initialAppState?: DiscoverAppState; - /** - * the data view spec to use, if undefined, the saved search's data view will be used - */ - dataViewSpec?: DataViewSpec; -} - export interface DiscoverStateContainer { - /** - * Global State, the _g part of the URL - */ - globalState: DiscoverGlobalStateContainer; /** * App state, the _a part of the URL */ @@ -119,6 +94,11 @@ export interface DiscoverStateContainer { * Internal shared state that's used at several places in the UI */ internalState: InternalStateStore; + /** + * @deprecated Do not use, this only exists to support + * Timeline which accesses the internal state directly + */ + internalStateActions: typeof internalStateActions; /** * Injects the current tab into a given internalState action */ @@ -254,18 +234,13 @@ export function getDiscoverStateContainer({ session: services.data.search.session, }); - /** - * Global State Container, synced with the _g part URL - */ - const globalStateContainer = getDiscoverGlobalStateContainer(stateStorage); - /** * Saved Search State Container, the persisted saved object of Discover */ const savedSearchContainer = getSavedSearchContainer({ services, - globalStateContainer, internalState, + getCurrentTab, }); /** @@ -282,12 +257,16 @@ export function getDiscoverStateContainer({ const pauseAutoRefreshInterval = async (dataView: DataView) => { if (dataView && (!dataView.isTimeBased() || dataView.type === DataViewType.ROLLUP)) { - const state = globalStateContainer.get(); + const state = selectTab(internalState.getState(), tabId).globalState; if (state?.refreshInterval && !state.refreshInterval.pause) { - await globalStateContainer.set({ - ...state, - refreshInterval: { ...state?.refreshInterval, pause: true }, - }); + internalState.dispatch( + injectCurrentTab(internalStateActions.setGlobalState)({ + globalState: { + ...state, + refreshInterval: { ...state.refreshInterval, pause: true }, + }, + }) + ); } } }; @@ -392,9 +371,17 @@ export function getDiscoverStateContainer({ }, columns: [], }); + // clears pinned filters - const globalState = globalStateContainer.get(); - globalStateContainer.set({ ...globalState, filters: [] }); + const globalState = selectTab(internalState.getState(), tabId).globalState; + internalState.dispatch( + injectCurrentTab(internalStateActions.setGlobalState)({ + globalState: { + ...globalState, + filters: [], + }, + }) + ); }; const onDataViewCreated = async (nextDataView: DataView) => { @@ -433,10 +420,9 @@ export function getDiscoverStateContainer({ * state containers initializing and subscribing to changes triggering e.g. data fetching */ const initializeAndSync = () => { - const updateTabAppStateAndGlobalState = () => - internalState.dispatch( - injectCurrentTab(internalStateActions.updateTabAppStateAndGlobalState)() - ); + const syncLocallyPersistedTabState = () => + internalState.dispatch(injectCurrentTab(internalStateActions.syncLocallyPersistedTabState)()); + // This needs to be the first thing that's wired up because initAndSync is pulling the current state from the URL which // might change the time filter and thus needs to re-check whether the saved search has changed. const timefilerUnsubscribe = merge( @@ -444,7 +430,7 @@ export function getDiscoverStateContainer({ services.timefilter.getRefreshIntervalUpdate$() ).subscribe(() => { savedSearchContainer.updateTimeRange(); - updateTabAppStateAndGlobalState(); + syncLocallyPersistedTabState(); }); // Enable/disable kbn url tracking (That's the URL used when selecting Discover in the side menu) @@ -468,7 +454,7 @@ export function getDiscoverStateContainer({ const savedSearchChangesSubscription = savedSearchContainer .getCurrent$() - .subscribe(updateTabAppStateAndGlobalState); + .subscribe(syncLocallyPersistedTabState); // start subscribing to dataStateContainer, triggering data fetching const unsubscribeData = dataStateContainer.subscribe(); @@ -558,29 +544,37 @@ export function getDiscoverStateContainer({ */ const undoSavedSearchChanges = async () => { addLog('undoSavedSearchChanges'); + const nextSavedSearch = savedSearchContainer.getInitial$().getValue(); - savedSearchContainer.set(nextSavedSearch); - restoreStateFromSavedSearch({ - savedSearch: nextSavedSearch, - timefilter: services.timefilter, - }); + const globalState = selectTab(internalState.getState(), tabId).globalState; + const globalStateUpdate = restoreStateFromSavedSearch({ savedSearch: nextSavedSearch }); + + // a saved search can't have global (pinned) filters so we can reset global filters state + if (globalState.filters) { + globalStateUpdate.filters = []; + } + + if (Object.keys(globalStateUpdate).length > 0) { + internalState.dispatch( + injectCurrentTab(internalStateActions.setGlobalState)({ + globalState: { + ...globalState, + ...globalStateUpdate, + }, + }) + ); + } + const newAppState = getInitialState({ initialUrlState: undefined, savedSearch: nextSavedSearch, services, }); - // a saved search can't have global (pinned) filters so we can reset global filters state - const globalFilters = globalStateContainer.get()?.filters; - if (globalFilters) { - await globalStateContainer.set({ - ...globalStateContainer.get(), - filters: [], - }); - } - internalState.dispatch(injectCurrentTab(internalStateActions.resetOnSavedSearchChange)()); + savedSearchContainer.set(nextSavedSearch); await appStateContainer.replaceUrlState(newAppState); + return nextSavedSearch; }; @@ -608,9 +602,9 @@ export function getDiscoverStateContainer({ }; return { - globalState: globalStateContainer, appState: appStateContainer, internalState, + internalStateActions, injectCurrentTab, getCurrentTab, runtimeStateManager, diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/__mocks__/internal_state.mocks.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/__mocks__/internal_state.mocks.ts new file mode 100644 index 0000000000000..fa9d206aab484 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/__mocks__/internal_state.mocks.ts @@ -0,0 +1,21 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { DEFAULT_TAB_STATE } from '../constants'; +import type { RecentlyClosedTabState, TabState } from '../types'; + +export const getTabStateMock = (partial: Partial & Pick): TabState => ({ + ...DEFAULT_TAB_STATE, + label: 'Untitled', + ...partial, +}); + +export const getRecentlyClosedTabStateMock = ( + partial: Partial & Pick +): RecentlyClosedTabState => ({ ...getTabStateMock(partial), closedAt: partial.closedAt }); diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/data_views.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/data_views.ts index 6b62842b7cc21..82e09eb114fc9 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/data_views.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/data_views.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { DataView } from '@kbn/data-views-plugin/common'; +import type { DataView } from '@kbn/data-views-plugin/public'; import { differenceBy } from 'lodash'; import { internalStateSlice, @@ -27,13 +27,12 @@ export const setDataView: InternalStateThunkActionCreator< > = ({ tabId, dataView }) => (dispatch, _, { runtimeStateManager }) => { - dispatch( - internalStateSlice.actions.setDataViewId({ - tabId, - dataViewId: dataView.id, - }) - ); const { currentDataView$ } = selectTabRuntimeState(runtimeStateManager, tabId); + + if (dataView.id !== currentDataView$.getValue()?.id) { + dispatch(internalStateSlice.actions.setExpandedDoc({ expandedDoc: undefined })); + } + currentDataView$.next(dataView); }; diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/index.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/index.ts index 596b84ea805d6..a06778803173b 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/index.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/index.ts @@ -8,5 +8,6 @@ */ export * from './data_views'; -export * from './initialize_session'; +export * from './initialize_single_tab'; export * from './tabs'; +export * from './save_discover_session'; diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/initialize_session.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/initialize_session.ts deleted file mode 100644 index 9a98ecbfefe69..0000000000000 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/initialize_session.ts +++ /dev/null @@ -1,323 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/common'; -import { isOfAggregateQueryType } from '@kbn/es-query'; -import { getSavedSearchFullPathUrl } from '@kbn/saved-search-plugin/public'; -import { i18n } from '@kbn/i18n'; -import { cloneDeep, isEqual } from 'lodash'; -import { - internalStateSlice, - type TabActionPayload, - type InternalStateThunkActionCreator, -} from '../internal_state'; -import { - getInitialState, - type AppStateUrl, - type DiscoverAppState, -} from '../../discover_app_state_container'; -import type { DiscoverStateContainer } from '../../discover_state'; -import { appendAdHocDataViews, setDataView } from './data_views'; -import { cleanupUrlState } from '../../utils/cleanup_url_state'; -import { setBreadcrumbs } from '../../../../../utils/breadcrumbs'; -import { getEsqlDataView } from '../../utils/get_esql_data_view'; -import { loadAndResolveDataView } from '../../utils/resolve_data_view'; -import { isDataViewSource } from '../../../../../../common/data_sources'; -import { copySavedSearch } from '../../discover_saved_search_container'; -import { isRefreshIntervalValid, isTimeRangeValid } from '../../../../../utils/validate_time'; -import { getValidFilters } from '../../../../../utils/get_valid_filters'; -import { updateSavedSearch } from '../../utils/update_saved_search'; -import { APP_STATE_URL_KEY } from '../../../../../../common'; -import { TABS_ENABLED_FEATURE_FLAG_KEY } from '../../../../../constants'; -import { selectTabRuntimeState } from '../runtime_state'; -import type { ConnectedCustomizationService } from '../../../../../customizations'; -import { disconnectTab, clearAllTabs } from './tabs'; -import { selectTab } from '../selectors'; - -export interface InitializeSessionParams { - stateContainer: DiscoverStateContainer; - customizationService: ConnectedCustomizationService; - discoverSessionId: string | undefined; - dataViewSpec: DataViewSpec | undefined; - defaultUrlState: DiscoverAppState | undefined; - shouldClearAllTabs: boolean | undefined; -} - -export const initializeSession: InternalStateThunkActionCreator< - [TabActionPayload<{ initializeSessionParams: InitializeSessionParams }>], - Promise<{ showNoDataPage: boolean }> -> = - ({ - tabId, - initializeSessionParams: { - stateContainer, - customizationService, - discoverSessionId, - dataViewSpec, - defaultUrlState, - shouldClearAllTabs, - }, - }) => - async ( - dispatch, - getState, - { services, customizationContext, runtimeStateManager, urlStateStorage, tabsStorageManager } - ) => { - const tabsEnabled = services.core.featureFlags.getBooleanValue( - TABS_ENABLED_FEATURE_FLAG_KEY, - false - ); - dispatch(disconnectTab({ tabId })); - dispatch(internalStateSlice.actions.resetOnSavedSearchChange({ tabId })); - - if (tabsEnabled && shouldClearAllTabs) { - dispatch(clearAllTabs()); - } - - const { - currentDataView$, - stateContainer$, - customizationService$, - scopedProfilesManager$, - scopedEbtManager$, - } = selectTabRuntimeState(runtimeStateManager, tabId); - const tabState = selectTab(getState(), tabId); - - let urlState = cleanupUrlState( - defaultUrlState ?? urlStateStorage.get(APP_STATE_URL_KEY), - services.uiSettings - ); - - /** - * New tab initialization with the restored data if available - */ - - const wasTabInitialized = Boolean(stateContainer$.getValue()); - - if (wasTabInitialized) { - // Clear existing runtime state on re-initialization - // to ensure no stale state is used during loading - currentDataView$.next(undefined); - stateContainer$.next(undefined); - customizationService$.next(undefined); - scopedEbtManager$.next(services.ebtManager.createScopedEBTManager()); - scopedProfilesManager$.next( - services.profilesManager.createScopedProfilesManager({ - scopedEbtManager: scopedEbtManager$.getValue(), - }) - ); - } - - if (tabsEnabled && !wasTabInitialized) { - const tabInitialGlobalState = tabState.initialGlobalState; - - if (tabInitialGlobalState?.filters) { - services.filterManager.setGlobalFilters(cloneDeep(tabInitialGlobalState.filters)); - } - - if (tabInitialGlobalState?.timeRange) { - services.timefilter.setTime(tabInitialGlobalState.timeRange); - } - if (tabInitialGlobalState?.refreshInterval) { - services.timefilter.setRefreshInterval(tabInitialGlobalState.refreshInterval); - } - - const tabInitialAppState = tabState.initialAppState; - - if (tabInitialAppState) { - urlState = cloneDeep(tabInitialAppState); - } - } - - const discoverSessionLoadTracker = scopedEbtManager$ - .getValue() - .trackPerformanceEvent('discoverLoadSavedSearch'); - - const persistedDiscoverSession = discoverSessionId - ? await services.savedSearch.get(discoverSessionId) - : undefined; - const initialQuery = - urlState?.query ?? persistedDiscoverSession?.searchSource.getField('query'); - const isEsqlMode = isOfAggregateQueryType(initialQuery); - const discoverSessionDataView = persistedDiscoverSession?.searchSource.getField('index'); - const discoverSessionHasAdHocDataView = Boolean( - discoverSessionDataView && !discoverSessionDataView.isPersisted() - ); - const { initializationState, defaultProfileAdHocDataViewIds } = getState(); - const profileDataViews = runtimeStateManager.adHocDataViews$ - .getValue() - .filter(({ id }) => id && defaultProfileAdHocDataViewIds.includes(id)); - const profileDataViewsExist = profileDataViews.length > 0; - const locationStateHasDataViewSpec = Boolean(dataViewSpec); - const canAccessWithoutPersistedDataView = - isEsqlMode || - discoverSessionHasAdHocDataView || - profileDataViewsExist || - locationStateHasDataViewSpec; - - if (!initializationState.hasUserDataView && !canAccessWithoutPersistedDataView) { - return { showNoDataPage: true }; - } - - /** - * Session initialization - */ - - // TODO: Needs to happen when switching tabs too? - if (customizationContext.displayMode === 'standalone' && persistedDiscoverSession) { - if (persistedDiscoverSession.id) { - services.chrome.recentlyAccessed.add( - getSavedSearchFullPathUrl(persistedDiscoverSession.id), - persistedDiscoverSession.title ?? - i18n.translate('discover.defaultDiscoverSessionTitle', { - defaultMessage: 'Untitled Discover session', - }), - persistedDiscoverSession.id - ); - } - - setBreadcrumbs({ services, titleBreadcrumbText: persistedDiscoverSession.title }); - } - - let dataView: DataView; - - if (isOfAggregateQueryType(initialQuery)) { - // Regardless of what was requested, we always use ad hoc data views for ES|QL - dataView = await getEsqlDataView( - initialQuery, - discoverSessionDataView ?? currentDataView$.getValue(), - services - ); - } else { - // Load the requested data view if one exists, or a fallback otherwise - const result = await loadAndResolveDataView({ - dataViewId: isDataViewSource(urlState?.dataSource) - ? urlState?.dataSource.dataViewId - : discoverSessionDataView?.id, - dataViewSpec, - savedSearch: persistedDiscoverSession, - isEsqlMode, - services, - internalState: stateContainer.internalState, - runtimeStateManager, - }); - - dataView = result.dataView; - } - - dispatch(setDataView({ tabId, dataView })); - - if (!dataView.isPersisted()) { - dispatch(appendAdHocDataViews(dataView)); - } - - // This must be executed before updateSavedSearch since - // it updates the Discover session with timefilter values - if (persistedDiscoverSession?.timeRestore && dataView.isTimeBased()) { - const { timeRange, refreshInterval } = persistedDiscoverSession; - - if (timeRange && isTimeRangeValid(timeRange)) { - services.timefilter.setTime(timeRange); - } - - if (refreshInterval && isRefreshIntervalValid(refreshInterval)) { - services.timefilter.setRefreshInterval(refreshInterval); - } - } - - // Get the initial state based on a combo of the URL and persisted session, - // then get an updated copy of the session with the applied initial state - const initialState = getInitialState({ - initialUrlState: urlState, - savedSearch: persistedDiscoverSession, - overrideDataView: dataView, - services, - }); - const discoverSession = updateSavedSearch({ - savedSearch: persistedDiscoverSession - ? copySavedSearch(persistedDiscoverSession) - : services.savedSearch.getNew(), - dataView, - state: initialState, - globalStateContainer: stateContainer.globalState, - services, - }); - - /** - * Sync global services - */ - - // Cleaning up the previous state - services.filterManager.setAppFilters([]); - services.data.query.queryString.clearQuery(); - - // Sync global filters (coming from URL) to filter manager. - // It needs to be done manually here as `syncGlobalQueryStateWithUrl` is called later. - const globalFilters = stateContainer.globalState?.get()?.filters; - const shouldUpdateWithGlobalFilters = - globalFilters?.length && !services.filterManager.getGlobalFilters()?.length; - if (shouldUpdateWithGlobalFilters) { - services.filterManager.setGlobalFilters(globalFilters); - } - - // set data service filters - if (initialState.filters?.length) { - // Saved search SO persists all filters as app filters - services.data.query.filterManager.setAppFilters(cloneDeep(initialState.filters)); - } - - // some filters may not be valid for this context, so update - // the filter manager with a modified list of valid filters - const currentFilters = services.filterManager.getFilters(); - const validFilters = getValidFilters(dataView, currentFilters); - if (!isEqual(currentFilters, validFilters)) { - services.filterManager.setFilters(validFilters); - } - - // set data service query - if (initialState.query) { - services.data.query.queryString.setQuery(initialState.query); - } - - // Make sure global filters make it to the Discover session - if (!urlState && shouldUpdateWithGlobalFilters) { - discoverSession.searchSource.setField( - 'filter', - cloneDeep(services.filterManager.getFilters()) - ); - } - - /** - * Update state containers - */ - - if (persistedDiscoverSession) { - // Set the persisted session first, then assign the - // updated session to ensure unsaved changes are detected - stateContainer.savedSearchState.set(persistedDiscoverSession); - stateContainer.savedSearchState.assignNextSavedSearch(discoverSession); - } else { - stateContainer.savedSearchState.set(discoverSession); - } - - // Make sure app state container is completely reset - stateContainer.appState.resetToState(initialState); - stateContainer.appState.resetInitialState(); - - // Set runtime state - stateContainer$.next(stateContainer); - customizationService$.next(customizationService); - - // Begin syncing the state and trigger the initial fetch - stateContainer.actions.initializeAndSync(); - stateContainer.actions.fetchData(true); - discoverSessionLoadTracker.reportEvent(); - - return { showNoDataPage: false }; - }; diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/initialize_single_tab.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/initialize_single_tab.ts new file mode 100644 index 0000000000000..0ff1b57a4f71a --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/initialize_single_tab.ts @@ -0,0 +1,296 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/common'; +import { isOfAggregateQueryType } from '@kbn/es-query'; +import { cloneDeep, isEqual, isObject, pick } from 'lodash'; +import type { GlobalQueryStateFromUrl } from '@kbn/data-plugin/public'; +import { + internalStateSlice, + type TabActionPayload, + type InternalStateThunkActionCreator, +} from '../internal_state'; +import { + getInitialState, + type AppStateUrl, + type DiscoverAppState, +} from '../../discover_app_state_container'; +import type { DiscoverStateContainer } from '../../discover_state'; +import { appendAdHocDataViews, setDataView } from './data_views'; +import { cleanupUrlState } from '../../utils/cleanup_url_state'; +import { getEsqlDataView } from '../../utils/get_esql_data_view'; +import { loadAndResolveDataView } from '../../utils/resolve_data_view'; +import { isDataViewSource } from '../../../../../../common/data_sources'; +import { copySavedSearch } from '../../discover_saved_search_container'; +import { isRefreshIntervalValid, isTimeRangeValid } from '../../../../../utils/validate_time'; +import { getValidFilters } from '../../../../../utils/get_valid_filters'; +import { updateSavedSearch } from '../../utils/update_saved_search'; +import { APP_STATE_URL_KEY } from '../../../../../../common'; +import { TABS_ENABLED_FEATURE_FLAG_KEY } from '../../../../../constants'; +import { selectTabRuntimeState } from '../runtime_state'; +import type { ConnectedCustomizationService } from '../../../../../customizations'; +import { disconnectTab } from './tabs'; +import { selectTab } from '../selectors'; +import type { TabState, TabStateGlobalState } from '../types'; +import { GLOBAL_STATE_URL_KEY } from '../../../../../../common/constants'; +import { fromSavedObjectTabToSavedSearch } from '../tab_mapping_utils'; + +export interface InitializeSingleTabsParams { + stateContainer: DiscoverStateContainer; + customizationService: ConnectedCustomizationService; + dataViewSpec: DataViewSpec | undefined; + defaultUrlState: DiscoverAppState | undefined; +} + +export const initializeSingleTab: InternalStateThunkActionCreator< + [TabActionPayload<{ initializeSingleTabParams: InitializeSingleTabsParams }>], + Promise<{ showNoDataPage: boolean }> +> = + ({ + tabId, + initializeSingleTabParams: { + stateContainer, + customizationService, + dataViewSpec, + defaultUrlState, + }, + }) => + async (dispatch, getState, { services, runtimeStateManager, urlStateStorage }) => { + dispatch(disconnectTab({ tabId })); + dispatch(internalStateSlice.actions.resetOnSavedSearchChange({ tabId })); + + const { currentDataView$, stateContainer$, customizationService$, scopedEbtManager$ } = + selectTabRuntimeState(runtimeStateManager, tabId); + + /** + * New tab initialization with the restored data if available + */ + + const tabsEnabled = services.core.featureFlags.getBooleanValue( + TABS_ENABLED_FEATURE_FLAG_KEY, + false + ); + + let tabInitialGlobalState: TabStateGlobalState | undefined; + let tabInitialAppState: DiscoverAppState | undefined; + let tabInitialInternalState: TabState['initialInternalState'] | undefined; + + if (tabsEnabled) { + const tabState = selectTab(getState(), tabId); + + tabInitialGlobalState = cloneDeep(tabState.globalState); + + if (tabState.initialAppState) { + tabInitialAppState = cloneDeep(tabState.initialAppState); + } + + if (tabState.initialInternalState) { + tabInitialInternalState = cloneDeep(tabState.initialInternalState); + } + } + + const discoverTabLoadTracker = scopedEbtManager$ + .getValue() + .trackPerformanceEvent('discoverLoadSavedSearch'); + + const { persistedDiscoverSession } = getState(); + const persistedTab = persistedDiscoverSession?.tabs.find((tab) => tab.id === tabId); + const persistedTabSavedSearch = + persistedDiscoverSession && persistedTab + ? await fromSavedObjectTabToSavedSearch({ + tab: persistedTab, + discoverSession: persistedDiscoverSession, + services, + }) + : undefined; + + const urlAppState = { + ...tabInitialAppState, + ...(defaultUrlState ?? + cleanupUrlState(urlStateStorage.get(APP_STATE_URL_KEY), services.uiSettings)), + }; + + const initialQuery = + urlAppState?.query ?? persistedTabSavedSearch?.searchSource.getField('query'); + const isEsqlMode = isOfAggregateQueryType(initialQuery); + + const initialDataViewIdOrSpec = tabInitialInternalState?.serializedSearchSource?.index; + const initialAdHocDataViewSpec = isObject(initialDataViewIdOrSpec) + ? initialDataViewIdOrSpec + : undefined; + + const persistedTabDataView = persistedTabSavedSearch?.searchSource.getField('index'); + const dataViewId = isDataViewSource(urlAppState?.dataSource) + ? urlAppState?.dataSource.dataViewId + : persistedTabDataView?.id; + + const tabHasInitialAdHocDataViewSpec = + dataViewId && initialAdHocDataViewSpec?.id === dataViewId; + const peristedTabHasAdHocDataView = Boolean( + persistedTabDataView && !persistedTabDataView.isPersisted() + ); + + const { initializationState, defaultProfileAdHocDataViewIds } = getState(); + const profileDataViews = runtimeStateManager.adHocDataViews$ + .getValue() + .filter(({ id }) => id && defaultProfileAdHocDataViewIds.includes(id)); + + const profileDataViewsExist = profileDataViews.length > 0; + const locationStateHasDataViewSpec = Boolean(dataViewSpec); + const canAccessWithoutPersistedDataView = + isEsqlMode || + tabHasInitialAdHocDataViewSpec || + peristedTabHasAdHocDataView || + profileDataViewsExist || + locationStateHasDataViewSpec; + + if (!initializationState.hasUserDataView && !canAccessWithoutPersistedDataView) { + return { showNoDataPage: true }; + } + + /** + * Tab initialization + */ + + let dataView: DataView; + + if (isOfAggregateQueryType(initialQuery)) { + // Regardless of what was requested, we always use ad hoc data views for ES|QL + dataView = await getEsqlDataView( + initialQuery, + persistedTabDataView ?? currentDataView$.getValue(), + services + ); + } else { + // Load the requested data view if one exists, or a fallback otherwise + const result = await loadAndResolveDataView({ + dataViewId, + locationDataViewSpec: dataViewSpec, + initialAdHocDataViewSpec, + savedSearch: persistedTabSavedSearch, + isEsqlMode, + services, + internalState: stateContainer.internalState, + runtimeStateManager, + }); + + dataView = result.dataView; + } + + dispatch(setDataView({ tabId, dataView })); + + if (!dataView.isPersisted()) { + dispatch(appendAdHocDataViews(dataView)); + } + + const initialGlobalState: TabStateGlobalState = { + ...(persistedTabSavedSearch?.timeRestore && dataView.isTimeBased() + ? pick(persistedTabSavedSearch, 'timeRange', 'refreshInterval') + : undefined), + ...tabInitialGlobalState, + }; + const urlGlobalState = urlStateStorage.get(GLOBAL_STATE_URL_KEY); + + if (urlGlobalState?.time) { + initialGlobalState.timeRange = urlGlobalState.time; + } + + if (urlGlobalState?.refreshInterval) { + initialGlobalState.refreshInterval = urlGlobalState.refreshInterval; + } + + if (urlGlobalState?.filters) { + initialGlobalState.filters = urlGlobalState.filters; + } + + // Get the initial app state based on a combo of the URL and persisted tab saved search, + // then get an updated copy of the saved search with the applied initial state + const initialAppState = getInitialState({ + initialUrlState: urlAppState, + savedSearch: persistedTabSavedSearch, + overrideDataView: dataView, + services, + }); + const savedSearch = updateSavedSearch({ + savedSearch: persistedTabSavedSearch + ? copySavedSearch(persistedTabSavedSearch) + : services.savedSearch.getNew(), + dataView, + appState: initialAppState, + globalState: initialGlobalState, + services, + }); + + /** + * Sync global services + */ + + // Cleaning up the previous state + services.filterManager.setAppFilters([]); + services.data.query.queryString.clearQuery(); + + if (initialGlobalState.timeRange && isTimeRangeValid(initialGlobalState.timeRange)) { + services.timefilter.setTime(initialGlobalState.timeRange); + } + + if ( + initialGlobalState.refreshInterval && + isRefreshIntervalValid(initialGlobalState.refreshInterval) + ) { + services.timefilter.setRefreshInterval(initialGlobalState.refreshInterval); + } + + if (initialGlobalState.filters) { + services.filterManager.setGlobalFilters(cloneDeep(initialGlobalState.filters)); + } + + if (initialAppState.filters) { + services.filterManager.setAppFilters(cloneDeep(initialAppState.filters)); + } + + // some filters may not be valid for this context, so update + // the filter manager with a modified list of valid filters + const currentFilters = services.filterManager.getFilters(); + const validFilters = getValidFilters(dataView, currentFilters); + if (!isEqual(currentFilters, validFilters)) { + services.filterManager.setFilters(validFilters); + } + + if (initialAppState.query) { + services.data.query.queryString.setQuery(initialAppState.query); + } + + /** + * Update state containers + */ + + if (persistedTabSavedSearch) { + // Set the persisted tab saved search first, then assign the + // updated saved search to ensure unsaved changes are detected + stateContainer.savedSearchState.set(persistedTabSavedSearch); + stateContainer.savedSearchState.assignNextSavedSearch(savedSearch); + } else { + stateContainer.savedSearchState.set(savedSearch); + } + + // Make sure app state container is completely reset + stateContainer.appState.resetToState(initialAppState); + stateContainer.appState.resetInitialState(); + + // Set runtime state + stateContainer$.next(stateContainer); + customizationService$.next(customizationService); + + // Begin syncing the state and trigger the initial fetch + stateContainer.actions.initializeAndSync(); + stateContainer.actions.fetchData(true); + discoverTabLoadTracker.reportEvent(); + + return { showNoDataPage: false }; + }; diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/save_discover_session.test.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/save_discover_session.test.ts new file mode 100644 index 0000000000000..38e4617fd4841 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/save_discover_session.test.ts @@ -0,0 +1,396 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { createDiscoverServicesMock } from '../../../../../__mocks__/services'; +import { getDiscoverStateMock } from '../../../../../__mocks__/discover_state.mock'; +import type { DiscoverSessionTab } from '@kbn/saved-search-plugin/common'; +import { fromTabStateToSavedObjectTab } from '../tab_mapping_utils'; +import { getTabStateMock } from '../__mocks__/internal_state.mocks'; +import { dataViewMock, dataViewMockWithTimeField } from '@kbn/discover-utils/src/__mocks__'; +import type { DiscoverServices } from '../../../../../build_services'; +import type { SaveDiscoverSessionParams } from '@kbn/saved-search-plugin/public'; +import { internalStateActions } from '..'; +import { savedSearchMock } from '../../../../../__mocks__/saved_search'; +import { ESQL_TYPE } from '@kbn/data-view-utils'; +import type { DataViewSpec } from '@kbn/data-views-plugin/common'; +import { internalStateSlice } from '../internal_state'; +import type { SaveDiscoverSessionThunkParams } from './save_discover_session'; +import * as dataViewsActions from './data_views'; +import { createSearchSourceMock } from '@kbn/data-plugin/public/mocks'; + +jest.mock('uuid', () => ({ v4: jest.fn(() => 'test-uuid') })); + +const getSaveDiscoverSessionParams = ( + overrides: Partial = {} +): SaveDiscoverSessionThunkParams => ({ + newTitle: 'new title', + newCopyOnSave: false, + newTimeRestore: false, + newDescription: 'new description', + newTags: [], + isTitleDuplicateConfirmed: false, + onTitleDuplicate: jest.fn(), + ...overrides, +}); + +const setup = ({ + additionalPersistedTabs, +}: { + additionalPersistedTabs?: (services: DiscoverServices) => DiscoverSessionTab[]; +} = {}) => { + const services = createDiscoverServicesMock(); + const saveDiscoverSessionSpy = jest + .spyOn(services.savedSearch, 'saveDiscoverSession') + .mockImplementation((discoverSession) => + Promise.resolve({ + ...discoverSession, + id: discoverSession.id ?? 'new-session', + managed: false, + }) + ); + const dataViewCreateSpy = jest.spyOn(services.dataViews, 'create'); + const dataViewsClearCacheSpy = jest.spyOn(services.dataViews, 'clearInstanceCache'); + const state = getDiscoverStateMock({ + savedSearch: { ...savedSearchMock, timeRestore: false }, + additionalPersistedTabs: additionalPersistedTabs?.(services), + services, + }); + + return { + state, + services, + saveDiscoverSessionSpy, + dataViewCreateSpy, + dataViewsClearCacheSpy, + }; +}; + +describe('saveDiscoverSession', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call saveDiscoverSession with the expected params', async () => { + const { state, saveDiscoverSessionSpy } = setup({ + additionalPersistedTabs: (services) => [ + fromTabStateToSavedObjectTab({ + tab: getTabStateMock({ + id: 'test-tab', + initialInternalState: { + serializedSearchSource: { index: dataViewMock.id }, + }, + }), + timeRestore: false, + services, + }), + ], + }); + const discoverSession = state.internalState.getState().persistedDiscoverSession; + const onTitleDuplicate = jest.fn(); + + await state.internalState.dispatch( + internalStateActions.saveDiscoverSession( + getSaveDiscoverSessionParams({ newTags: ['tag1', 'tag2'], onTitleDuplicate }) + ) + ); + + const updatedDiscoverSession: SaveDiscoverSessionParams = { + id: discoverSession?.id, + title: 'new title', + description: 'new description', + tabs: discoverSession?.tabs ?? [], + tags: ['tag1', 'tag2'], + }; + + expect(saveDiscoverSessionSpy).toHaveBeenCalledWith(updatedDiscoverSession, { + onTitleDuplicate, + copyOnSave: false, + isTitleDuplicateConfirmed: false, + }); + + expect(state.internalState.getState().persistedDiscoverSession).toEqual({ + ...updatedDiscoverSession, + managed: false, + }); + }); + + it('should update runtime state for applicable tabs', async () => { + const { state, services, saveDiscoverSessionSpy } = setup(); + + state.savedSearchState.assignNextSavedSearch({ + ...state.savedSearchState.getState(), + breakdownField: 'breakdown-test', + }); + + const resetOnSavedSearchChangeSpy = jest.spyOn( + internalStateSlice.actions, + 'resetOnSavedSearchChange' + ); + const setDataViewSpy = jest.spyOn(dataViewsActions, 'setDataView'); + const setSavedSearchSpy = jest.spyOn(state.savedSearchState, 'set'); + const undoSavedSearchChangesSpy = jest.spyOn(state.actions, 'undoSavedSearchChanges'); + const resetInitialStateSpy = jest.spyOn(state.appState, 'resetInitialState'); + const currentTabId = state.getCurrentTab().id; + + jest + .spyOn(services.data.search.searchSource, 'create') + .mockResolvedValue(createSearchSourceMock({ index: dataViewMockWithTimeField })); + + await state.internalState.dispatch( + internalStateActions.saveDiscoverSession(getSaveDiscoverSessionParams()) + ); + + expect(saveDiscoverSessionSpy).toHaveBeenCalled(); + expect(resetOnSavedSearchChangeSpy).toHaveBeenCalledWith({ tabId: currentTabId }); + expect(setDataViewSpy).toHaveBeenCalledWith({ + tabId: currentTabId, + dataView: dataViewMockWithTimeField, + }); + expect(setSavedSearchSpy).toHaveBeenCalledWith( + expect.objectContaining({ breakdownField: 'breakdown-test' }) + ); + expect(undoSavedSearchChangesSpy).toHaveBeenCalled(); + expect(resetInitialStateSpy).toHaveBeenCalled(); + }); + + it('should not update local state if saveDiscoverSession returns undefined', async () => { + const { state, saveDiscoverSessionSpy } = setup(); + const resetOnSavedSearchChangeSpy = jest.spyOn( + internalStateSlice.actions, + 'resetOnSavedSearchChange' + ); + const initialPersisted = state.internalState.getState().persistedDiscoverSession; + + saveDiscoverSessionSpy.mockResolvedValueOnce(undefined); + + await state.internalState.dispatch( + internalStateActions.saveDiscoverSession(getSaveDiscoverSessionParams()) + ); + + expect(state.internalState.getState().persistedDiscoverSession).toBe(initialPersisted); + expect(resetOnSavedSearchChangeSpy).not.toHaveBeenCalled(); + }); + + it('should allow errors thrown at the persistence layer to bubble up and not modify local state', async () => { + const { state, saveDiscoverSessionSpy } = setup(); + const resetOnSavedSearchChangeSpy = jest.spyOn( + internalStateSlice.actions, + 'resetOnSavedSearchChange' + ); + const initialPersisted = state.internalState.getState().persistedDiscoverSession; + + saveDiscoverSessionSpy.mockRejectedValueOnce(new Error('boom')); + + await expect( + state.internalState + .dispatch(internalStateActions.saveDiscoverSession(getSaveDiscoverSessionParams())) + .unwrap() + ).rejects.toHaveProperty('message', 'boom'); + + expect(state.internalState.getState().persistedDiscoverSession).toBe(initialPersisted); + expect(resetOnSavedSearchChangeSpy).not.toHaveBeenCalled(); + }); + + it('should include timeRange and refreshInterval when timeRestore is true', async () => { + const { state, saveDiscoverSessionSpy } = setup({ + additionalPersistedTabs: (services) => [ + fromTabStateToSavedObjectTab({ + tab: getTabStateMock({ + id: 'time-tab', + globalState: { + timeRange: { from: 'now-15m', to: 'now' }, + refreshInterval: { value: 10000, pause: false }, + }, + initialInternalState: { + serializedSearchSource: { index: dataViewMock.id }, + }, + }), + timeRestore: true, + services, + }), + ], + }); + + await state.internalState.dispatch( + internalStateActions.saveDiscoverSession( + getSaveDiscoverSessionParams({ newTimeRestore: true }) + ) + ); + + expect(saveDiscoverSessionSpy).toHaveBeenCalled(); + + const tabs = saveDiscoverSessionSpy.mock.calls[0][0].tabs; + const savedTimeTab = tabs.find((t) => t.id === 'time-tab'); + + expect(savedTimeTab?.timeRestore).toBe(true); + expect(savedTimeTab?.timeRange).toEqual({ from: 'now-15m', to: 'now' }); + expect(savedTimeTab?.refreshInterval).toEqual({ value: 10000, pause: false }); + }); + + it('should replace custom ad hoc data view when copying on save', async () => { + const oldId = 'adhoc-id'; + const filters = [ + { meta: { index: oldId, alias: null, disabled: false }, query: { match_all: {} } }, + ]; + const { state, saveDiscoverSessionSpy, dataViewCreateSpy, dataViewsClearCacheSpy } = setup({ + additionalPersistedTabs: (services) => [ + fromTabStateToSavedObjectTab({ + tab: getTabStateMock({ + id: 'adhoc-replace-tab', + initialInternalState: { + serializedSearchSource: { + index: { id: oldId, title: 'Adhoc', name: 'Adhoc Name' }, + filter: filters, + }, + }, + }), + timeRestore: false, + services, + }), + ], + }); + + await state.internalState.dispatch( + internalStateActions.saveDiscoverSession( + getSaveDiscoverSessionParams({ newCopyOnSave: true }) + ) + ); + + expect(saveDiscoverSessionSpy).toHaveBeenCalled(); + expect(dataViewCreateSpy).toHaveBeenCalled(); + expect(dataViewsClearCacheSpy).toHaveBeenCalledWith(oldId); + + const createdSpec = dataViewCreateSpy.mock.calls[0][0]; + expect(createdSpec.id).toBe('test-uuid'); + expect(createdSpec.name).toBe('Adhoc Name'); + + const tabs = saveDiscoverSessionSpy.mock.calls[0][0].tabs; + const savedTab = tabs.find((t) => t.id === 'adhoc-replace-tab'); + + expect((savedTab?.serializedSearchSource?.index as DataViewSpec).id).toBe('test-uuid'); + expect(savedTab?.serializedSearchSource?.filter?.[0].meta.index).toBe('test-uuid'); + }); + + it('should copy default profile ad hoc data view on save', async () => { + const defaultProfileId = 'default-profile-id'; + const filters = [ + { meta: { index: defaultProfileId, alias: null, disabled: false }, query: { match_all: {} } }, + ]; + const { state, saveDiscoverSessionSpy, dataViewCreateSpy, dataViewsClearCacheSpy } = setup({ + additionalPersistedTabs: (services) => [ + fromTabStateToSavedObjectTab({ + tab: getTabStateMock({ + id: 'adhoc-copy-tab', + initialInternalState: { + serializedSearchSource: { + index: { id: defaultProfileId, title: 'Adhoc', name: 'Adhoc Name' }, + filter: filters, + }, + }, + }), + timeRestore: false, + services, + }), + ], + }); + + state.internalState.dispatch( + internalStateSlice.actions.setDefaultProfileAdHocDataViewIds([defaultProfileId]) + ); + + await state.internalState.dispatch( + internalStateActions.saveDiscoverSession(getSaveDiscoverSessionParams()) + ); + + expect(saveDiscoverSessionSpy).toHaveBeenCalled(); + expect(dataViewCreateSpy).toHaveBeenCalled(); + expect(dataViewsClearCacheSpy).not.toHaveBeenCalled(); + + const createdSpec = dataViewCreateSpy.mock.calls[0][0]; + expect(createdSpec.id).toBe('test-uuid'); + expect(createdSpec.name).toBe('Adhoc Name (new title)'); + + const tabs = saveDiscoverSessionSpy.mock.calls[0][0].tabs; + const savedTab = tabs.find((t) => t.id === 'adhoc-copy-tab'); + + expect((savedTab?.serializedSearchSource?.index as DataViewSpec).id).toBe('test-uuid'); + expect(savedTab?.serializedSearchSource?.filter?.[0].meta.index).toBe('test-uuid'); + }); + + it('should not clone ad hoc ES|QL data views', async () => { + const esqlId = 'adhoc-esql-id'; + const { state, saveDiscoverSessionSpy, dataViewCreateSpy, dataViewsClearCacheSpy } = setup({ + additionalPersistedTabs: (services) => [ + fromTabStateToSavedObjectTab({ + tab: getTabStateMock({ + id: 'esql-tab', + initialInternalState: { + serializedSearchSource: { + index: { id: esqlId, title: 'ES|QL Adhoc', type: ESQL_TYPE }, + }, + }, + }), + timeRestore: false, + services, + }), + ], + }); + + await state.internalState.dispatch( + internalStateActions.saveDiscoverSession( + getSaveDiscoverSessionParams({ newCopyOnSave: true }) + ) + ); + + expect(saveDiscoverSessionSpy).toHaveBeenCalled(); + expect(dataViewCreateSpy).not.toHaveBeenCalled(); + expect(dataViewsClearCacheSpy).not.toHaveBeenCalled(); + + const tabs = saveDiscoverSessionSpy.mock.calls[0][0].tabs; + const savedTab = tabs.find((t) => t.id === 'esql-tab'); + + expect((savedTab?.serializedSearchSource.index as DataViewSpec).id).toBe(esqlId); + }); + + it('should apply overriddenVisContextAfterInvalidation to the saved tab', async () => { + const { state, saveDiscoverSessionSpy } = setup({ + additionalPersistedTabs: (services) => [ + fromTabStateToSavedObjectTab({ + tab: getTabStateMock({ + id: 'vis-context-tab', + initialInternalState: { + serializedSearchSource: { index: dataViewMock.id }, + }, + }), + timeRestore: false, + services, + }), + ], + }); + const visContext = { foo: 'bar' }; + + state.internalState.dispatch( + internalStateActions.setOverriddenVisContextAfterInvalidation({ + tabId: 'vis-context-tab', + overriddenVisContextAfterInvalidation: visContext, + }) + ); + + await state.internalState.dispatch( + internalStateActions.saveDiscoverSession(getSaveDiscoverSessionParams()) + ); + + expect(saveDiscoverSessionSpy).toHaveBeenCalled(); + + const tabs = saveDiscoverSessionSpy.mock.calls[0][0].tabs; + const savedTab = tabs.find((t) => t.id === 'vis-context-tab'); + + expect(savedTab?.visContext).toEqual(visContext); + }); +}); diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/save_discover_session.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/save_discover_session.ts new file mode 100644 index 0000000000000..a297639ed36d5 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/save_discover_session.ts @@ -0,0 +1,255 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { v4 as uuidv4 } from 'uuid'; +import type { DiscoverSessionTab } from '@kbn/saved-search-plugin/common'; +import type { + SaveDiscoverSessionOptions, + SaveDiscoverSessionParams, +} from '@kbn/saved-search-plugin/public'; +import { updateFilterReferences } from '@kbn/es-query'; +import type { DataViewSpec } from '@kbn/data-views-plugin/public'; +import { i18n } from '@kbn/i18n'; +import { cloneDeep, isObject } from 'lodash'; +import { ESQL_TYPE } from '@kbn/data-view-utils'; +import { selectAllTabs, selectRecentlyClosedTabs, selectTab } from '../selectors'; +import { createInternalStateAsyncThunk } from '../utils'; +import { selectTabRuntimeState } from '../runtime_state'; +import { internalStateSlice } from '../internal_state'; +import { + fromSavedObjectTabToSavedSearch, + fromSavedObjectTabToTabState, + fromSavedSearchToSavedObjectTab, + fromTabStateToSavedObjectTab, +} from '../tab_mapping_utils'; +import { appendAdHocDataViews, replaceAdHocDataViewWithId, setDataView } from './data_views'; +import { setTabs } from './tabs'; + +type AdHocDataViewAction = 'copy' | 'replace'; + +export interface SaveDiscoverSessionThunkParams { + newTitle: string; + newTimeRestore: boolean; + newCopyOnSave: boolean; + newDescription: string; + newTags: string[]; + isTitleDuplicateConfirmed: boolean; + onTitleDuplicate: () => void; +} + +export const saveDiscoverSession = createInternalStateAsyncThunk( + 'internalState/saveDiscoverSession', + async ( + { + newTitle, + newCopyOnSave, + newTimeRestore, + newDescription, + newTags, + isTitleDuplicateConfirmed, + onTitleDuplicate, + }: SaveDiscoverSessionThunkParams, + { dispatch, getState, extra: { services, runtimeStateManager } } + ) => { + const state = getState(); + const currentTabs = selectAllTabs(state); + const adHocDataViews = new Map< + string, + { + dataViewSpec: DataViewSpec; + action: AdHocDataViewAction; + tabs: DiscoverSessionTab[]; + } + >(); + + const updatedTabs: DiscoverSessionTab[] = await Promise.all( + currentTabs.map(async (tab) => { + const tabRuntimeState = selectTabRuntimeState(runtimeStateManager, tab.id); + const tabStateContainer = tabRuntimeState.stateContainer$.getValue(); + const overriddenVisContextAfterInvalidation = tab.overriddenVisContextAfterInvalidation; + + let updatedTab: DiscoverSessionTab; + + if (tabStateContainer) { + updatedTab = cloneDeep({ + ...fromSavedSearchToSavedObjectTab({ + tab, + savedSearch: tabStateContainer.savedSearchState.getState(), + services, + }), + timeRestore: newTimeRestore, + timeRange: newTimeRestore ? tab.globalState.timeRange : undefined, + refreshInterval: newTimeRestore ? tab.globalState.refreshInterval : undefined, + }); + } else { + updatedTab = cloneDeep( + fromTabStateToSavedObjectTab({ + tab, + timeRestore: newTimeRestore, + services, + }) + ); + } + + if (overriddenVisContextAfterInvalidation) { + updatedTab.visContext = overriddenVisContextAfterInvalidation; + } + + const dataViewSpec = updatedTab.serializedSearchSource.index; + + // If the data view is a non-ES|QL ad hoc data view, it may need to be cloned + if (isObject(dataViewSpec) && dataViewSpec.id && dataViewSpec.type !== ESQL_TYPE) { + let action: AdHocDataViewAction | undefined; + + if (state.defaultProfileAdHocDataViewIds.includes(dataViewSpec.id)) { + // If the Discover session is using a default profile ad hoc data view, + // we copy it with a new ID to avoid conflicts with the profile defaults + action = 'copy'; + } else if (newCopyOnSave) { + // Otherwise, if we're copying a session with a custom ad hoc data view, + // we replace it with a cloned one to avoid ID conflicts across sessions + action = 'replace'; + } + + if (action) { + const adHocEntry = adHocDataViews.get(dataViewSpec.id) ?? { + dataViewSpec, + action, + tabs: [], + }; + + adHocEntry.tabs.push(updatedTab); + adHocDataViews.set(dataViewSpec.id, adHocEntry); + } + } + + return updatedTab; + }) + ); + + for (const adHocEntry of adHocDataViews.values()) { + const { dataViewSpec, action, tabs } = adHocEntry; + + if (!dataViewSpec.id) { + continue; + } + + let newDataViewSpec: DataViewSpec & Required>; + + if (action === 'copy') { + newDataViewSpec = { + ...dataViewSpec, + id: uuidv4(), + name: i18n.translate('discover.savedSearch.defaultProfileDataViewCopyName', { + defaultMessage: '{dataViewName} ({discoverSessionTitle})', + values: { + dataViewName: dataViewSpec.name ?? dataViewSpec.title, + discoverSessionTitle: newTitle, + }, + }), + }; + + const dataView = await services.dataViews.create(newDataViewSpec); + + // Make sure our state is aware of the copy so it appears in the UI + dispatch(appendAdHocDataViews(dataView)); + } else { + newDataViewSpec = { + ...dataViewSpec, + id: uuidv4(), + }; + + // Clear out the old data view since it's no longer needed + services.dataViews.clearInstanceCache(dataViewSpec.id); + + const dataView = await services.dataViews.create(newDataViewSpec); + + // Make sure our state is aware of the new data view + dispatch(replaceAdHocDataViewWithId(dataViewSpec.id, dataView)); + } + + // Update all applicable tabs to use the new data view spec + for (const tab of tabs) { + tab.serializedSearchSource.index = newDataViewSpec; + + // We also need to update the filter references + if (Array.isArray(tab.serializedSearchSource.filter)) { + tab.serializedSearchSource.filter = updateFilterReferences( + tab.serializedSearchSource.filter, + dataViewSpec.id, + newDataViewSpec.id + ); + } + } + } + + const saveParams: SaveDiscoverSessionParams = { + id: state.persistedDiscoverSession?.id, + title: newTitle, + description: newDescription, + tabs: updatedTabs, + tags: services.savedObjectsTagging ? newTags : state.persistedDiscoverSession?.tags, + }; + + const saveOptions: SaveDiscoverSessionOptions = { + onTitleDuplicate, + copyOnSave: newCopyOnSave, + isTitleDuplicateConfirmed, + }; + + const discoverSession = await services.savedSearch.saveDiscoverSession(saveParams, saveOptions); + + if (discoverSession) { + await Promise.all( + updatedTabs.map(async (tab) => { + dispatch(internalStateSlice.actions.resetOnSavedSearchChange({ tabId: tab.id })); + + const tabRuntimeState = selectTabRuntimeState(runtimeStateManager, tab.id); + const tabStateContainer = tabRuntimeState.stateContainer$.getValue(); + + if (!tabStateContainer) { + return; + } + + const savedSearch = await fromSavedObjectTabToSavedSearch({ + tab, + discoverSession, + services, + }); + const dataView = savedSearch.searchSource.getField('index'); + + if (dataView) { + dispatch(setDataView({ tabId: tab.id, dataView })); + } + + tabStateContainer.savedSearchState.set(savedSearch); + tabStateContainer.actions.undoSavedSearchChanges(); + tabStateContainer.appState.resetInitialState(); + }) + ); + + const allTabs = discoverSession.tabs.map((tab) => + fromSavedObjectTabToTabState({ + tab, + existingTab: selectTab(state, tab.id), + }) + ); + + dispatch( + setTabs({ + allTabs, + selectedTabId: state.tabs.unsafeCurrentId, + recentlyClosedTabs: selectRecentlyClosedTabs(state), + }) + ); + } + + return { discoverSession }; + } +); diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/tabs.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/tabs.ts index dfe5954242fb8..1e632083d8bac 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/tabs.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/tabs.ts @@ -10,13 +10,14 @@ import type { TabbedContentState } from '@kbn/unified-tabs/src/components/tabbed_content/tabbed_content'; import { cloneDeep, differenceBy, omit, pick } from 'lodash'; import type { QueryState } from '@kbn/data-plugin/common'; +import { getSavedSearchFullPathUrl } from '@kbn/saved-search-plugin/public'; +import { i18n } from '@kbn/i18n'; import { isOfAggregateQueryType } from '@kbn/es-query'; import { getInitialESQLQuery } from '@kbn/esql-utils'; import { createDataSource } from '../../../../../../common/data_sources/utils'; import type { TabState } from '../types'; import { selectAllTabs, selectRecentlyClosedTabs, selectTab } from '../selectors'; import { - defaultTabState, internalStateSlice, type TabActionPayload, type InternalStateThunkActionCreator, @@ -25,12 +26,15 @@ import { createTabRuntimeState, selectTabRuntimeState, selectTabRuntimeAppState, - selectTabRuntimeGlobalState, selectRestorableTabRuntimeHistogramLayoutProps, + selectTabRuntimeInternalState, } from '../runtime_state'; import { APP_STATE_URL_KEY, GLOBAL_STATE_URL_KEY } from '../../../../../../common/constants'; import type { DiscoverAppState } from '../../discover_app_state_container'; -import { createTabItem } from '../utils'; +import { createInternalStateAsyncThunk, createTabItem } from '../utils'; +import { setBreadcrumbs } from '../../../../../utils/breadcrumbs'; +import { DEFAULT_TAB_STATE } from '../constants'; +import { TABS_ENABLED_FEATURE_FLAG_KEY } from '../../../../../constants'; export const setTabs: InternalStateThunkActionCreator< [Parameters[0]] @@ -99,7 +103,14 @@ export const updateTabs: InternalStateThunkActionCreator<[TabbedContentState], P const existingTab = selectTab(currentState, item.id); const tab: TabState = { - ...defaultTabState, + ...DEFAULT_TAB_STATE, + ...{ + globalState: { + timeRange: services.timefilter.getTime(), + refreshInterval: services.timefilter.getRefreshInterval(), + filters: services.filterManager.getGlobalFilters(), + }, + }, ...existingTab, ...pick(item, 'id', 'label', 'duplicatedFromId'), }; @@ -113,13 +124,13 @@ export const updateTabs: InternalStateThunkActionCreator<[TabbedContentState], P return tab; } + tab.initialInternalState = + selectTabRuntimeInternalState(runtimeStateManager, item.duplicatedFromId) ?? + cloneDeep(existingTabToDuplicateFrom.initialInternalState); tab.initialAppState = selectTabRuntimeAppState(runtimeStateManager, item.duplicatedFromId) ?? cloneDeep(existingTabToDuplicateFrom.initialAppState); - tab.initialGlobalState = cloneDeep({ - ...existingTabToDuplicateFrom.initialGlobalState, - ...existingTabToDuplicateFrom.lastPersistedGlobalState, - }); + tab.globalState = cloneDeep(existingTabToDuplicateFrom.globalState); tab.uiState = cloneDeep(existingTabToDuplicateFrom.uiState); } else { // the new tab is a fresh one @@ -155,11 +166,7 @@ export const updateTabs: InternalStateThunkActionCreator<[TabbedContentState], P const nextTabStateContainer = nextTabRuntimeState?.stateContainer$.getValue(); if (nextTab && nextTabStateContainer) { - const { - timeRange, - refreshInterval, - filters: globalFilters, - } = nextTab.lastPersistedGlobalState; + const { timeRange, refreshInterval, filters: globalFilters } = nextTab.globalState; const appState = nextTabStateContainer.appState.getState(); const { filters: appFilters, query } = appState; @@ -196,35 +203,79 @@ export const updateTabs: InternalStateThunkActionCreator<[TabbedContentState], P ); }; -export const updateTabAppStateAndGlobalState: InternalStateThunkActionCreator<[TabActionPayload]> = - ({ tabId }) => - (dispatch, _, { runtimeStateManager }) => { - dispatch( - internalStateSlice.actions.setTabAppStateAndGlobalState({ - tabId, - appState: selectTabRuntimeAppState(runtimeStateManager, tabId), - globalState: selectTabRuntimeGlobalState(runtimeStateManager, tabId), - }) +export const initializeTabs = createInternalStateAsyncThunk( + 'internalState/initializeTabs', + async ( + { + discoverSessionId, + shouldClearAllTabs, + }: { discoverSessionId: string | undefined; shouldClearAllTabs?: boolean }, + { dispatch, getState, extra: { services, tabsStorageManager, customizationContext } } + ) => { + const tabsEnabled = services.core.featureFlags.getBooleanValue( + TABS_ENABLED_FEATURE_FLAG_KEY, + false ); - }; -export const initializeTabs: InternalStateThunkActionCreator< - [{ userId: string; spaceId: string }] -> = - ({ userId, spaceId }) => - (dispatch, _, { tabsStorageManager }) => { + if (tabsEnabled && shouldClearAllTabs) { + dispatch(clearAllTabs()); + } + + const { userId: existingUserId, spaceId: existingSpaceId } = getState(); + + const getUserId = async () => { + try { + return (await services.core.security?.authc.getCurrentUser()).profile_uid ?? ''; + } catch { + // ignore as user id might be unavailable for some deployments + return ''; + } + }; + + const getSpaceId = async () => { + try { + return (await services.spaces?.getActiveSpace())?.id ?? ''; + } catch { + // ignore + return ''; + } + }; + + const [userId, spaceId, persistedDiscoverSession] = await Promise.all([ + existingUserId === undefined ? getUserId() : existingUserId, + existingSpaceId === undefined ? getSpaceId() : existingSpaceId, + discoverSessionId ? services.savedSearch.getDiscoverSession(discoverSessionId) : undefined, + ]); + + if (customizationContext.displayMode === 'standalone' && persistedDiscoverSession) { + services.chrome.recentlyAccessed.add( + getSavedSearchFullPathUrl(persistedDiscoverSession.id), + persistedDiscoverSession.title ?? + i18n.translate('discover.defaultDiscoverSessionTitle', { + defaultMessage: 'Untitled Discover session', + }), + persistedDiscoverSession.id + ); + + setBreadcrumbs({ services, titleBreadcrumbText: persistedDiscoverSession.title }); + } + const initialTabsState = tabsStorageManager.loadLocally({ userId, spaceId, - defaultTabState, + persistedDiscoverSession, + defaultTabState: DEFAULT_TAB_STATE, }); dispatch(setTabs(initialTabsState)); - }; + + return { userId, spaceId, persistedDiscoverSession }; + } +); export const clearAllTabs: InternalStateThunkActionCreator = () => (dispatch) => { const defaultTab: TabState = { - ...defaultTabState, + ...DEFAULT_TAB_STATE, ...createTabItem([]), }; diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/constants.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/constants.ts new file mode 100644 index 0000000000000..41dc06cafafd8 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/constants.ts @@ -0,0 +1,30 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { TabItem } from '@kbn/unified-tabs'; +import type { TabState } from './types'; + +export const DEFAULT_TAB_STATE: Omit = { + globalState: {}, + isDataViewLoading: false, + dataRequestParams: { + timeRangeAbsolute: undefined, + timeRangeRelative: undefined, + searchSessionId: undefined, + }, + overriddenVisContextAfterInvalidation: undefined, + resetDefaultProfileState: { + resetId: '', + columns: false, + rowHeight: false, + breakdownField: false, + hideChart: false, + }, + uiState: {}, +}; diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/hooks.tsx b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/hooks.tsx index 645e8f6729bea..a034da956177e 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/hooks.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/hooks.tsx @@ -43,8 +43,9 @@ export const InternalStateProvider = ({ ); -export const useInternalStateDispatch: () => InternalStateDispatch = - createDispatchHook(internalStateContext); +export const useInternalStateDispatch = createDispatchHook( + internalStateContext +) as () => InternalStateDispatch; export const useInternalStateSelector: TypedUseSelectorHook = createSelectorHook(internalStateContext); diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/index.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/index.ts index 5de163e2598be..07e373c3ee102 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/index.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/index.ts @@ -8,11 +8,11 @@ */ import { omit } from 'lodash'; -import { internalStateSlice } from './internal_state'; +import { internalStateSlice, syncLocallyPersistedTabState } from './internal_state'; import { loadDataViewList, appendAdHocDataViews, - initializeSession, + initializeSingleTab, replaceAdHocDataViewWithId, setAdHocDataViews, setDataView, @@ -20,10 +20,10 @@ import { setTabs, updateTabs, disconnectTab, - updateTabAppStateAndGlobalState, restoreTab, clearAllTabs, initializeTabs, + saveDiscoverSession, } from './actions'; export type { @@ -33,6 +33,8 @@ export type { InternalStateDataRequestParams, } from './types'; +export { DEFAULT_TAB_STATE } from './constants'; + export { type InternalStateStore, createInternalStateStore } from './internal_state'; export const internalStateActions = { @@ -51,11 +53,12 @@ export const internalStateActions = { setDefaultProfileAdHocDataViews, appendAdHocDataViews, replaceAdHocDataViewWithId, - initializeSession, - updateTabAppStateAndGlobalState, + initializeSingleTab, + syncLocallyPersistedTabState, restoreTab, clearAllTabs, initializeTabs, + saveDiscoverSession, }; export { @@ -91,3 +94,10 @@ export { } from './runtime_state'; export { type TabActionInjector, createTabActionInjector, createTabItem } from './utils'; + +export { + fromSavedObjectTabToTabState, + fromSavedObjectTabToSavedSearch, + fromTabStateToSavedObjectTab, + fromSavedSearchToSavedObjectTab, +} from './tab_mapping_utils'; diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/internal_state.test.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/internal_state.test.ts index aa1c949f04959..9711f01ba75e5 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/internal_state.test.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/internal_state.test.ts @@ -12,7 +12,6 @@ import { createInternalStateStore, createRuntimeStateManager, internalStateActions, - selectTab, selectTabRuntimeState, } from '.'; import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; @@ -21,7 +20,7 @@ import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; import { createTabsStorageManager } from '../tabs_storage_manager'; describe('InternalStateStore', () => { - it('should set data view', () => { + it('should set data view', async () => { const services = createDiscoverServicesMock(); const urlStateStorage = createKbnUrlStateStorage(); const runtimeStateManager = createRuntimeStateManager(); @@ -36,16 +35,12 @@ describe('InternalStateStore', () => { urlStateStorage, tabsStorageManager, }); - store.dispatch( - internalStateActions.initializeTabs({ userId: 'mockUserId', spaceId: 'mockSpaceId' }) - ); + await store.dispatch(internalStateActions.initializeTabs({ discoverSessionId: undefined })); const tabId = store.getState().tabs.unsafeCurrentId; - expect(selectTab(store.getState(), tabId).dataViewId).toBeUndefined(); expect( selectTabRuntimeState(runtimeStateManager, tabId).currentDataView$.value ).toBeUndefined(); store.dispatch(internalStateActions.setDataView({ tabId, dataView: dataViewMock })); - expect(selectTab(store.getState(), tabId).dataViewId).toBe(dataViewMock.id); expect(selectTabRuntimeState(runtimeStateManager, tabId).currentDataView$.value).toBe( dataViewMock ); diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/internal_state.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/internal_state.ts index 7aee8ee267265..10e1819c19308 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/internal_state.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/internal_state.ts @@ -12,75 +12,56 @@ import { v4 as uuidv4 } from 'uuid'; import { throttle } from 'lodash'; import { type PayloadAction, - configureStore, - createSlice, + type PayloadActionCreator, type ThunkAction, type ThunkDispatch, - type AnyAction, - type Dispatch, + type TypedStartListening, + type ListenerEffect, + configureStore, + createSlice, createListenerMiddleware, + createAction, + isAnyOf, } from '@reduxjs/toolkit'; import type { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; -import type { TabItem } from '@kbn/unified-tabs'; import type { DiscoverCustomizationContext } from '../../../../customizations'; import type { DiscoverServices } from '../../../../build_services'; -import { type RuntimeStateManager, selectTabRuntimeAppState } from './runtime_state'; import { - LoadingStatus, + type RuntimeStateManager, + selectTabRuntimeAppState, + selectTabRuntimeInternalState, +} from './runtime_state'; +import { TabsBarVisibility, type DiscoverInternalState, type InternalStateDataRequestParams, type TabState, type RecentlyClosedTabState, } from './types'; -import { loadDataViewList } from './actions/data_views'; +import { loadDataViewList, initializeTabs, saveDiscoverSession } from './actions'; import { selectTab } from './selectors'; import type { TabsStorageManager } from '../tabs_storage_manager'; -import type { DiscoverAppState } from '../discover_app_state_container'; const MIDDLEWARE_THROTTLE_MS = 300; const MIDDLEWARE_THROTTLE_OPTIONS = { leading: false, trailing: true }; -export const defaultTabState: Omit = { - lastPersistedGlobalState: {}, - dataViewId: undefined, - isDataViewLoading: false, - dataRequestParams: { - timeRangeAbsolute: undefined, - timeRangeRelative: undefined, - searchSessionId: undefined, - }, - overriddenVisContextAfterInvalidation: undefined, - resetDefaultProfileState: { - resetId: '', - columns: false, - rowHeight: false, - breakdownField: false, - hideChart: false, - }, - documentsRequest: { - loadingStatus: LoadingStatus.Uninitialized, - result: [], - }, - totalHitsRequest: { - loadingStatus: LoadingStatus.Uninitialized, - result: 0, - }, - chartRequest: { - loadingStatus: LoadingStatus.Uninitialized, - result: {}, - }, - uiState: {}, -}; - const initialState: DiscoverInternalState = { initializationState: { hasESData: false, hasUserDataView: false }, + userId: undefined, + spaceId: undefined, + persistedDiscoverSession: undefined, defaultProfileAdHocDataViewIds: [], savedDataViews: [], expandedDoc: undefined, isESQLToDataViewTransitionModalVisible: false, tabsBarVisibility: TabsBarVisibility.default, - tabs: { byId: {}, allIds: [], unsafeCurrentId: '', recentlyClosedTabIds: [] }, + tabs: { + areInitializing: false, + byId: {}, + allIds: [], + unsafeCurrentId: '', + recentlyClosedTabIds: [], + }, }; export type TabActionPayload = { tabId: string } & T; @@ -132,15 +113,6 @@ export const internalStateSlice = createSlice({ state.tabs.recentlyClosedTabIds = action.payload.recentlyClosedTabs.map((tab) => tab.id); }, - setDataViewId: (state, action: TabAction<{ dataViewId: string | undefined }>) => - withTab(state, action, (tab) => { - if (action.payload.dataViewId !== tab.dataViewId) { - state.expandedDoc = undefined; - } - - tab.dataViewId = action.payload.dataViewId; - }), - setIsDataViewLoading: (state, action: TabAction<{ isDataViewLoading: boolean }>) => withTab(state, action, (tab) => { tab.isDataViewLoading = action.payload.isDataViewLoading; @@ -173,15 +145,14 @@ export const internalStateSlice = createSlice({ tab.dataRequestParams = action.payload.dataRequestParams; }), - setTabAppStateAndGlobalState: ( + setGlobalState: ( state, action: TabAction<{ - appState: DiscoverAppState | undefined; - globalState: TabState['lastPersistedGlobalState'] | undefined; + globalState: TabState['globalState']; }> ) => withTab(state, action, (tab) => { - tab.lastPersistedGlobalState = action.payload.globalState || {}; + tab.globalState = action.payload.globalState; }), setOverriddenVisContextAfterInvalidation: ( @@ -272,43 +243,99 @@ export const internalStateSlice = createSlice({ builder.addCase(loadDataViewList.fulfilled, (state, action) => { state.savedDataViews = action.payload; }); + + builder.addCase(initializeTabs.pending, (state) => { + state.tabs.areInitializing = true; + }); + + builder.addCase(initializeTabs.fulfilled, (state, action) => { + state.userId = action.payload.userId; + state.spaceId = action.payload.spaceId; + state.persistedDiscoverSession = action.payload.persistedDiscoverSession; + }); + + builder.addCase(saveDiscoverSession.fulfilled, (state, action) => { + if (action.payload.discoverSession) { + state.persistedDiscoverSession = action.payload.discoverSession; + } + }); + + builder.addMatcher(isAnyOf(initializeTabs.fulfilled, initializeTabs.rejected), (state) => { + state.tabs.areInitializing = false; + }); }, }); -const createMiddleware = ({ - tabsStorageManager, - runtimeStateManager, -}: { - tabsStorageManager: TabsStorageManager; - runtimeStateManager: RuntimeStateManager; -}) => { - const listenerMiddleware = createListenerMiddleware(); +export const syncLocallyPersistedTabState = createAction( + 'internalState/syncLocallyPersistedTabState' +); + +type InternalStateListenerEffect< + TActionCreator extends PayloadActionCreator, + TPayload = TActionCreator extends PayloadActionCreator ? T : never +> = ListenerEffect< + ReturnType, + DiscoverInternalState, + InternalStateDispatch, + InternalStateDependencies +>; + +const createMiddleware = (options: InternalStateDependencies) => { + const listenerMiddleware = createListenerMiddleware({ extra: options }); + const startListening = listenerMiddleware.startListening as TypedStartListening< + DiscoverInternalState, + InternalStateDispatch, + InternalStateDependencies + >; - listenerMiddleware.startListening({ + startListening({ actionCreator: internalStateSlice.actions.setTabs, - effect: throttle( - (action) => { + effect: throttle>( + (action, listenerApi) => { + const { runtimeStateManager, tabsStorageManager } = listenerApi.extra; const getTabAppState = (tabId: string) => selectTabRuntimeAppState(runtimeStateManager, tabId); - void tabsStorageManager.persistLocally(action.payload, getTabAppState); + const getTabInternalState = (tabId: string) => + selectTabRuntimeInternalState(runtimeStateManager, tabId); + void tabsStorageManager.persistLocally(action.payload, getTabAppState, getTabInternalState); }, MIDDLEWARE_THROTTLE_MS, MIDDLEWARE_THROTTLE_OPTIONS ), }); - listenerMiddleware.startListening({ - actionCreator: internalStateSlice.actions.setTabAppStateAndGlobalState, - effect: throttle( - (action) => { - tabsStorageManager.updateTabStateLocally(action.payload.tabId, action.payload); + startListening({ + actionCreator: syncLocallyPersistedTabState, + effect: throttle>( + (action, listenerApi) => { + const { runtimeStateManager, tabsStorageManager } = listenerApi.extra; + withTab(listenerApi.getState(), action, (tab) => { + tabsStorageManager.updateTabStateLocally(action.payload.tabId, { + internalState: selectTabRuntimeInternalState(runtimeStateManager, tab.id), + appState: selectTabRuntimeAppState(runtimeStateManager, tab.id), + globalState: tab.globalState, + }); + }); }, MIDDLEWARE_THROTTLE_MS, MIDDLEWARE_THROTTLE_OPTIONS ), }); - return listenerMiddleware; + startListening({ + predicate: (_, currentState, previousState) => { + return ( + currentState.persistedDiscoverSession?.id !== previousState.persistedDiscoverSession?.id + ); + }, + effect: (_, listenerApi) => { + const { tabsStorageManager } = listenerApi.extra; + const { persistedDiscoverSession } = listenerApi.getState(); + tabsStorageManager.updateDiscoverSessionIdLocally(persistedDiscoverSession?.id); + }, + }); + + return listenerMiddleware.middleware; }; export interface InternalStateDependencies { @@ -328,7 +355,7 @@ export const createInternalStateStore = (options: InternalStateDependencies) => getDefaultMiddleware({ thunk: { extraArgument: options }, serializableCheck: !IS_JEST_ENVIRONMENT, - }).prepend(createMiddleware(options).middleware), + }).prepend(createMiddleware(options)), devTools: { name: 'DiscoverInternalState', }, @@ -337,12 +364,7 @@ export const createInternalStateStore = (options: InternalStateDependencies) => export type InternalStateStore = ReturnType; -export type InternalStateDispatch = ThunkDispatch< - DiscoverInternalState, - InternalStateDependencies, - AnyAction -> & - Dispatch; +export type InternalStateDispatch = InternalStateStore['dispatch']; type InternalStateThunkAction = ThunkAction< TReturn, diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/runtime_state.tsx b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/runtime_state.tsx index 7765a5e05aa03..2d0b3d31c47f5 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/runtime_state.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/runtime_state.tsx @@ -97,23 +97,18 @@ export const selectTabRuntimeAppState = ( return tabRuntimeState?.stateContainer$.getValue()?.appState?.getState(); }; -export const selectTabRuntimeGlobalState = ( +export const selectTabRuntimeInternalState = ( runtimeStateManager: RuntimeStateManager, tabId: string -): TabState['lastPersistedGlobalState'] | undefined => { +): TabState['initialInternalState'] | undefined => { const tabRuntimeState = selectTabRuntimeState(runtimeStateManager, tabId); - const globalState = tabRuntimeState?.stateContainer$.getValue()?.globalState?.get(); + const savedSearch = tabRuntimeState?.stateContainer$.getValue()?.savedSearchState.getState(); - if (!globalState) { + if (!savedSearch) { return undefined; } - const { time: timeRange, refreshInterval, filters } = globalState; - return { - timeRange, - refreshInterval, - filters, - }; + return { serializedSearchSource: savedSearch.searchSource.getSerializedFields() }; }; export const selectRestorableTabRuntimeHistogramLayoutProps = ( diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/tab_mapping_utils.test.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/tab_mapping_utils.test.ts new file mode 100644 index 0000000000000..0f77053cd6c27 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/tab_mapping_utils.test.ts @@ -0,0 +1,345 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { savedSearchMock } from '../../../../__mocks__/saved_search'; +import { createDiscoverServicesMock } from '../../../../__mocks__/services'; +import { getTabStateMock } from './__mocks__/internal_state.mocks'; +import { + fromSavedObjectTabToTabState, + fromSavedObjectTabToSavedSearch, + fromTabStateToSavedObjectTab, + fromSavedSearchToSavedObjectTab, +} from './tab_mapping_utils'; +import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock'; + +const services = createDiscoverServicesMock(); +const tab1 = getTabStateMock({ + id: '1', + label: 'Tab 1', + duplicatedFromId: '0', + globalState: { + timeRange: { from: 'now-7d', to: 'now' }, + refreshInterval: { pause: true, value: 500 }, + }, + initialAppState: { columns: ['column1'] }, +}); +const tab2 = getTabStateMock({ + id: '2', + label: 'Tab 2', + globalState: { + timeRange: { from: 'now-15m', to: 'now' }, + refreshInterval: { pause: false, value: 1000 }, + }, + initialAppState: { columns: ['column2'] }, +}); + +describe('tab mapping utils', () => { + describe('fromSavedObjectTabToTabState', () => { + it('should map saved object tab to tab state', () => { + let tabState = fromSavedObjectTabToTabState({ + tab: fromTabStateToSavedObjectTab({ tab: tab2, timeRestore: false, services }), + existingTab: tab1, + }); + expect(tabState).toMatchInlineSnapshot(` + Object { + "dataRequestParams": Object { + "searchSessionId": undefined, + "timeRangeAbsolute": undefined, + "timeRangeRelative": undefined, + }, + "duplicatedFromId": "0", + "globalState": Object { + "refreshInterval": Object { + "pause": true, + "value": 500, + }, + "timeRange": Object { + "from": "now-7d", + "to": "now", + }, + }, + "id": "2", + "initialAppState": Object { + "breakdownField": undefined, + "columns": Array [ + "column2", + ], + "dataSource": undefined, + "density": undefined, + "filters": undefined, + "grid": Object {}, + "headerRowHeight": undefined, + "hideAggregatedPreview": undefined, + "hideChart": false, + "query": undefined, + "rowHeight": undefined, + "rowsPerPage": undefined, + "sampleSize": undefined, + "sort": Array [], + "viewMode": undefined, + }, + "initialInternalState": Object { + "serializedSearchSource": Object {}, + }, + "isDataViewLoading": false, + "label": "Tab 2", + "overriddenVisContextAfterInvalidation": undefined, + "resetDefaultProfileState": Object { + "breakdownField": false, + "columns": false, + "hideChart": false, + "resetId": "", + "rowHeight": false, + }, + "uiState": Object {}, + } + `); + tabState = fromSavedObjectTabToTabState({ + tab: fromTabStateToSavedObjectTab({ tab: tab2, timeRestore: true, services }), + existingTab: tab1, + }); + expect(tabState).toMatchInlineSnapshot(` + Object { + "dataRequestParams": Object { + "searchSessionId": undefined, + "timeRangeAbsolute": undefined, + "timeRangeRelative": undefined, + }, + "duplicatedFromId": "0", + "globalState": Object { + "refreshInterval": Object { + "pause": false, + "value": 1000, + }, + "timeRange": Object { + "from": "now-15m", + "to": "now", + }, + }, + "id": "2", + "initialAppState": Object { + "breakdownField": undefined, + "columns": Array [ + "column2", + ], + "dataSource": undefined, + "density": undefined, + "filters": undefined, + "grid": Object {}, + "headerRowHeight": undefined, + "hideAggregatedPreview": undefined, + "hideChart": false, + "query": undefined, + "rowHeight": undefined, + "rowsPerPage": undefined, + "sampleSize": undefined, + "sort": Array [], + "viewMode": undefined, + }, + "initialInternalState": Object { + "serializedSearchSource": Object {}, + }, + "isDataViewLoading": false, + "label": "Tab 2", + "overriddenVisContextAfterInvalidation": undefined, + "resetDefaultProfileState": Object { + "breakdownField": false, + "columns": false, + "hideChart": false, + "resetId": "", + "rowHeight": false, + }, + "uiState": Object {}, + } + `); + }); + }); + + describe('fromSavedObjectTabToSavedSearch', () => { + it('should map saved object tab to saved search', async () => { + const stateContainer = getDiscoverStateMock({ services }); + const savedSearch = await fromSavedObjectTabToSavedSearch({ + tab: fromTabStateToSavedObjectTab({ + tab: tab1, + timeRestore: false, + services, + }), + discoverSession: stateContainer.internalState.getState().persistedDiscoverSession!, + services, + }); + expect(savedSearch).toMatchInlineSnapshot(` + Object { + "breakdownField": undefined, + "columns": Array [ + "column1", + ], + "density": undefined, + "description": "description", + "grid": Object {}, + "headerRowHeight": undefined, + "hideAggregatedPreview": undefined, + "hideChart": false, + "id": "the-saved-search-id-with-timefield", + "isTextBasedQuery": false, + "managed": undefined, + "references": undefined, + "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 [], + "tags": undefined, + "timeRange": undefined, + "timeRestore": false, + "title": "title", + "usesAdHocDataView": false, + "viewMode": undefined, + "visContext": undefined, + } + `); + }); + }); + + describe('fromTabStateToSavedObjectTab', () => { + it('should map tab state to saved object tab', () => { + let savedObjectTab = fromTabStateToSavedObjectTab({ + tab: tab1, + timeRestore: false, + services, + }); + expect(savedObjectTab).toMatchInlineSnapshot(` + Object { + "breakdownField": undefined, + "columns": Array [ + "column1", + ], + "density": undefined, + "grid": Object {}, + "headerRowHeight": undefined, + "hideAggregatedPreview": undefined, + "hideChart": false, + "id": "1", + "isTextBasedQuery": false, + "label": "Tab 1", + "refreshInterval": undefined, + "rowHeight": undefined, + "rowsPerPage": undefined, + "sampleSize": undefined, + "serializedSearchSource": Object {}, + "sort": Array [], + "timeRange": undefined, + "timeRestore": false, + "usesAdHocDataView": false, + "viewMode": undefined, + "visContext": undefined, + } + `); + savedObjectTab = fromTabStateToSavedObjectTab({ tab: tab1, timeRestore: true, services }); + expect(savedObjectTab).toMatchInlineSnapshot(` + Object { + "breakdownField": undefined, + "columns": Array [ + "column1", + ], + "density": undefined, + "grid": Object {}, + "headerRowHeight": undefined, + "hideAggregatedPreview": undefined, + "hideChart": false, + "id": "1", + "isTextBasedQuery": false, + "label": "Tab 1", + "refreshInterval": Object { + "pause": true, + "value": 500, + }, + "rowHeight": undefined, + "rowsPerPage": undefined, + "sampleSize": undefined, + "serializedSearchSource": Object {}, + "sort": Array [], + "timeRange": Object { + "from": "now-7d", + "to": "now", + }, + "timeRestore": true, + "usesAdHocDataView": false, + "viewMode": undefined, + "visContext": undefined, + } + `); + }); + }); + + describe('fromSavedSearchToSavedObjectTab', () => { + it('should map saved search to saved object tab', () => { + const savedObjectTab = fromSavedSearchToSavedObjectTab({ + tab: tab1, + savedSearch: savedSearchMock, + services, + }); + expect(savedObjectTab).toMatchInlineSnapshot(` + Object { + "breakdownField": undefined, + "columns": Array [ + "default_column", + ], + "density": undefined, + "grid": Object {}, + "headerRowHeight": undefined, + "hideAggregatedPreview": undefined, + "hideChart": false, + "id": "1", + "isTextBasedQuery": false, + "label": "Tab 1", + "refreshInterval": undefined, + "rowHeight": undefined, + "rowsPerPage": undefined, + "sampleSize": undefined, + "serializedSearchSource": Object { + "index": "the-data-view-id", + }, + "sort": Array [], + "timeRange": undefined, + "timeRestore": undefined, + "usesAdHocDataView": undefined, + "viewMode": undefined, + "visContext": undefined, + } + `); + }); + }); +}); diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/tab_mapping_utils.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/tab_mapping_utils.ts new file mode 100644 index 0000000000000..638347273618a --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/tab_mapping_utils.ts @@ -0,0 +1,176 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { DiscoverSession, DiscoverSessionTab } from '@kbn/saved-search-plugin/common'; +import type { SavedSearch, SortOrder } from '@kbn/saved-search-plugin/public'; +import { isOfAggregateQueryType } from '@kbn/es-query'; +import { isObject } from 'lodash'; +import { createDataSource } from '../../../../../common/data_sources'; +import type { DiscoverServices } from '../../../../build_services'; +import type { TabState } from './types'; +import { getAllowedSampleSize } from '../../../../utils/get_allowed_sample_size'; +import { DEFAULT_TAB_STATE } from './constants'; + +export const fromSavedObjectTabToTabState = ({ + tab, + existingTab, +}: { + tab: DiscoverSessionTab; + existingTab?: TabState; +}): TabState => ({ + ...DEFAULT_TAB_STATE, + ...existingTab, + id: tab.id, + label: tab.label, + initialInternalState: { + serializedSearchSource: tab.serializedSearchSource, + }, + initialAppState: { + columns: tab.columns, + filters: tab.serializedSearchSource.filter, + grid: tab.grid, + hideChart: tab.hideChart, + dataSource: createDataSource({ + query: tab.serializedSearchSource.query, + dataView: tab.serializedSearchSource.index, + }), + query: tab.serializedSearchSource.query, + sort: tab.sort, + viewMode: tab.viewMode, + hideAggregatedPreview: tab.hideAggregatedPreview, + rowHeight: tab.rowHeight, + headerRowHeight: tab.headerRowHeight, + rowsPerPage: tab.rowsPerPage, + sampleSize: tab.sampleSize, + breakdownField: tab.breakdownField, + density: tab.density, + }, + globalState: { + timeRange: tab.timeRestore ? tab.timeRange : existingTab?.globalState.timeRange, + refreshInterval: tab.timeRange ? tab.refreshInterval : existingTab?.globalState.refreshInterval, + }, +}); + +export const fromSavedObjectTabToSavedSearch = async ({ + tab, + discoverSession, + services, +}: { + discoverSession: DiscoverSession; + tab: DiscoverSessionTab; + services: DiscoverServices; +}): Promise => ({ + id: discoverSession.id, + title: discoverSession.title, + description: discoverSession.description, + tags: discoverSession.tags, + managed: discoverSession.managed, + references: discoverSession.references, + sharingSavedObjectProps: discoverSession.sharingSavedObjectProps, + sort: tab.sort, + columns: tab.columns, + grid: tab.grid, + hideChart: tab.hideChart, + isTextBasedQuery: tab.isTextBasedQuery, + usesAdHocDataView: tab.usesAdHocDataView, + searchSource: await services.data.search.searchSource.create(tab.serializedSearchSource), + viewMode: tab.viewMode, + hideAggregatedPreview: tab.hideAggregatedPreview, + rowHeight: tab.rowHeight, + headerRowHeight: tab.headerRowHeight, + timeRestore: tab.timeRestore, + timeRange: tab.timeRange, + refreshInterval: tab.refreshInterval, + rowsPerPage: tab.rowsPerPage, + sampleSize: tab.sampleSize, + breakdownField: tab.breakdownField, + density: tab.density, + visContext: tab.visContext, +}); + +export const fromTabStateToSavedObjectTab = ({ + tab, + timeRestore, + services, +}: { + tab: TabState; + timeRestore: boolean; + services: DiscoverServices; +}): DiscoverSessionTab => { + const allowedSampleSize = getAllowedSampleSize( + tab.initialAppState?.sampleSize, + services.uiSettings + ); + + return { + id: tab.id, + label: tab.label, + sort: (tab.initialAppState?.sort ?? []) as SortOrder[], + columns: tab.initialAppState?.columns ?? [], + grid: tab.initialAppState?.grid ?? {}, + hideChart: tab.initialAppState?.hideChart ?? false, + isTextBasedQuery: isOfAggregateQueryType(tab.initialAppState?.query), + usesAdHocDataView: isObject(tab.initialInternalState?.serializedSearchSource?.index), + serializedSearchSource: tab.initialInternalState?.serializedSearchSource ?? {}, + viewMode: tab.initialAppState?.viewMode, + hideAggregatedPreview: tab.initialAppState?.hideAggregatedPreview, + rowHeight: tab.initialAppState?.rowHeight, + headerRowHeight: tab.initialAppState?.headerRowHeight, + timeRestore, + timeRange: timeRestore ? tab.globalState.timeRange : undefined, + refreshInterval: timeRestore ? tab.globalState.refreshInterval : undefined, + rowsPerPage: tab.initialAppState?.rowsPerPage, + sampleSize: + tab.initialAppState?.sampleSize && tab.initialAppState.sampleSize === allowedSampleSize + ? tab.initialAppState.sampleSize + : undefined, + breakdownField: tab.initialAppState?.breakdownField, + density: tab.initialAppState?.density, + visContext: tab.overriddenVisContextAfterInvalidation, + }; +}; + +export const fromSavedSearchToSavedObjectTab = ({ + tab, + savedSearch, + services, +}: { + tab: Pick; + savedSearch: SavedSearch; + services: DiscoverServices; +}): DiscoverSessionTab => { + const allowedSampleSize = getAllowedSampleSize(savedSearch.sampleSize, services.uiSettings); + + return { + id: tab.id, + label: tab.label, + sort: savedSearch.sort ?? [], + columns: savedSearch.columns ?? [], + grid: savedSearch.grid ?? {}, + hideChart: savedSearch.hideChart ?? false, + isTextBasedQuery: savedSearch.isTextBasedQuery ?? false, + usesAdHocDataView: savedSearch.usesAdHocDataView, + serializedSearchSource: savedSearch.searchSource.getSerializedFields() ?? {}, + viewMode: savedSearch.viewMode, + hideAggregatedPreview: savedSearch.hideAggregatedPreview, + rowHeight: savedSearch.rowHeight, + headerRowHeight: savedSearch.headerRowHeight, + timeRestore: savedSearch.timeRestore, + timeRange: savedSearch.timeRange, + refreshInterval: savedSearch.refreshInterval, + rowsPerPage: savedSearch.rowsPerPage, + sampleSize: + savedSearch.sampleSize && savedSearch.sampleSize === allowedSampleSize + ? savedSearch.sampleSize + : undefined, + breakdownField: savedSearch.breakdownField, + density: savedSearch.density, + visContext: savedSearch.visContext, + }; +}; diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/types.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/types.ts index 68a083109f76b..dc8b1fb8fc1f9 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/types.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/types.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { RefreshInterval } from '@kbn/data-plugin/common'; +import type { RefreshInterval, SerializedSearchSourceFields } from '@kbn/data-plugin/common'; import type { DataViewListItem } from '@kbn/data-views-plugin/public'; import type { DataTableRecord } from '@kbn/discover-utils'; import type { Filter, TimeRange } from '@kbn/es-query'; @@ -17,34 +17,10 @@ import type { UnifiedSearchDraft } from '@kbn/unified-search-plugin/public'; import type { UnifiedHistogramVisContext } from '@kbn/unified-histogram'; import type { ESQLEditorRestorableState } from '@kbn/esql-editor'; import type { TabItem } from '@kbn/unified-tabs'; +import type { DiscoverSession } from '@kbn/saved-search-plugin/common'; import type { DiscoverAppState } from '../discover_app_state_container'; import type { DiscoverLayoutRestorableState } from '../../components/layout/discover_layout_restorable_state'; -export enum LoadingStatus { - Uninitialized = 'uninitialized', - Loading = 'loading', - LoadingMore = 'loading_more', - Complete = 'complete', - Error = 'error', -} - -type RequestState< - TResult extends {}, - TLoadingStatus extends LoadingStatus = Exclude -> = - | { - loadingStatus: Exclude; - result: TResult; - } - | { - loadingStatus: LoadingStatus.Error; - error: Error; - }; - -export type DocumentsRequest = RequestState; -export type TotalHitsRequest = RequestState; -export type ChartRequest = RequestState<{}>; - export interface InternalStateDataRequestParams { timeRangeAbsolute: TimeRange | undefined; timeRangeRelative: TimeRange | undefined; @@ -58,13 +34,14 @@ export interface TabStateGlobalState { } export interface TabState extends TabItem { - // Initial app and global state for the tab (provided before the tab is initialized). + // Initial state for the tab (provided before the tab is initialized). + initialInternalState?: { + serializedSearchSource?: SerializedSearchSourceFields; + }; initialAppState?: DiscoverAppState; - initialGlobalState?: TabStateGlobalState; // The following properties are used to manage the tab's state after it has been initialized. - lastPersistedGlobalState: TabStateGlobalState; - dataViewId: string | undefined; + globalState: TabStateGlobalState; isDataViewLoading: boolean; dataRequestParams: InternalStateDataRequestParams; overriddenVisContextAfterInvalidation: UnifiedHistogramVisContext | {} | undefined; // it will be used during saved search saving @@ -75,9 +52,6 @@ export interface TabState extends TabItem { breakdownField: boolean; hideChart: boolean; }; - documentsRequest: DocumentsRequest; - totalHitsRequest: TotalHitsRequest; - chartRequest: ChartRequest; uiState: { esqlEditor?: Partial; dataGrid?: Partial; @@ -98,6 +72,9 @@ export enum TabsBarVisibility { export interface DiscoverInternalState { initializationState: { hasESData: boolean; hasUserDataView: boolean }; + userId: string | undefined; + spaceId: string | undefined; + persistedDiscoverSession: DiscoverSession | undefined; savedDataViews: DataViewListItem[]; defaultProfileAdHocDataViewIds: string[]; expandedDoc: DataTableRecord | undefined; @@ -105,6 +82,7 @@ export interface DiscoverInternalState { isESQLToDataViewTransitionModalVisible: boolean; tabsBarVisibility: TabsBarVisibility; tabs: { + areInitializing: boolean; byId: Record; allIds: string[]; recentlyClosedTabIds: string[]; diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/utils.test.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/utils.test.ts index 1835c09988ec2..b83259da48ae3 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/utils.test.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/utils.test.ts @@ -9,13 +9,9 @@ import { createTabItem } from './utils'; import { type TabState } from './types'; -import { defaultTabState } from './internal_state'; +import { getTabStateMock } from './__mocks__/internal_state.mocks'; -const createMockTabState = (id: string, label: string): TabState => ({ - ...defaultTabState, - id, - label, -}); +const createMockTabState = (id: string, label: string): TabState => getTabStateMock({ id, label }); describe('createTabItem', () => { it('should create a tab with default label when no tabs exist', () => { diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/utils.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/utils.ts index b1e372e7bda0e..bafa61b94093c 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/utils.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/utils.ts @@ -10,7 +10,8 @@ import { v4 as uuid } from 'uuid'; import { i18n } from '@kbn/i18n'; import { getNextTabNumber, type TabItem } from '@kbn/unified-tabs'; -import { createAsyncThunk } from '@reduxjs/toolkit'; +import { createAsyncThunk, miniSerializeError } from '@reduxjs/toolkit'; +import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/common'; import type { DiscoverInternalState, TabState } from './types'; import type { InternalStateDispatch, @@ -28,8 +29,18 @@ type CreateInternalStateAsyncThunk = ReturnType< }> >; -export const createInternalStateAsyncThunk: CreateInternalStateAsyncThunk = - createAsyncThunk.withTypes(); +export const createInternalStateAsyncThunk: CreateInternalStateAsyncThunk = (( + ...[typePrefix, payloadCreator, options]: Parameters +) => { + return createAsyncThunk(typePrefix, payloadCreator, { + ...options, + serializeError: (error) => { + return error instanceof SavedObjectNotFound + ? error + : options?.serializeError?.(error) ?? miniSerializeError(error); + }, + }); +}) as CreateInternalStateAsyncThunk; type WithoutTabId = Omit; type VoidIfEmpty = keyof T extends never ? void : T; diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/tabs_storage_manager.test.tsx b/src/platform/plugins/shared/discover/public/application/main/state_management/tabs_storage_manager.test.tsx index a3fd8e184c5ca..8a0caac4cfa1d 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/tabs_storage_manager.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/tabs_storage_manager.test.tsx @@ -15,9 +15,14 @@ import { TABS_LOCAL_STORAGE_KEY, type TabsInternalStatePayload, } from './tabs_storage_manager'; -import { defaultTabState } from './redux/internal_state'; import type { RecentlyClosedTabState, TabState } from './redux/types'; import { TABS_STATE_URL_KEY } from '../../../../common/constants'; +import { DEFAULT_TAB_STATE, fromSavedSearchToSavedObjectTab } from './redux'; +import { + getRecentlyClosedTabStateMock, + getTabStateMock, +} from './redux/__mocks__/internal_state.mocks'; +import { savedSearchMock } from '../../../__mocks__/saved_search'; const mockUserId = 'testUserId'; const mockSpaceId = 'testSpaceId'; @@ -42,39 +47,38 @@ const mockGetAppState = (tabId: string) => { } }; -const mockTab1: TabState = { - ...defaultTabState, +const mockGetInternalState = () => ({}); + +const mockTab1 = getTabStateMock({ id: 'tab1', label: 'Tab 1', - lastPersistedGlobalState: { + globalState: { timeRange: { from: '2025-04-16T14:07:55.127Z', to: '2025-04-16T14:12:55.127Z' }, filters: [], refreshInterval: { pause: true, value: 1000 }, }, -}; +}); -const mockTab2: TabState = { - ...defaultTabState, +const mockTab2 = getTabStateMock({ id: 'tab2', label: 'Tab 2', - lastPersistedGlobalState: { + globalState: { timeRange: { from: '2025-04-17T03:07:55.127Z', to: '2025-04-17T03:12:55.127Z' }, filters: [], refreshInterval: { pause: true, value: 1000 }, }, -}; +}); -const mockRecentlyClosedTab: RecentlyClosedTabState = { - ...defaultTabState, +const mockRecentlyClosedTab = getRecentlyClosedTabStateMock({ id: 'closedTab1', label: 'Closed tab 1', - lastPersistedGlobalState: { + globalState: { timeRange: { from: '2025-04-07T03:07:55.127Z', to: '2025-04-07T03:12:55.127Z' }, filters: [], refreshInterval: { pause: true, value: 1000 }, }, closedAt: Date.now(), -}; +}); const mockRecentlyClosedTab2: RecentlyClosedTabState = { ...mockRecentlyClosedTab, @@ -109,18 +113,18 @@ describe('TabsStorageManager', () => { const toStoredTab = (tab: TabState | RecentlyClosedTabState) => ({ id: tab.id, label: tab.label, + internalState: mockGetInternalState(), appState: mockGetAppState(tab.id), - globalState: tab.lastPersistedGlobalState, + globalState: tab.globalState, ...('closedAt' in tab ? { closedAt: tab.closedAt } : {}), }); const toRestoredTab = (storedTab: TabState | RecentlyClosedTabState) => ({ - ...defaultTabState, + ...DEFAULT_TAB_STATE, id: storedTab.id, label: storedTab.label, initialAppState: mockGetAppState(storedTab.id), - initialGlobalState: storedTab.lastPersistedGlobalState, - lastPersistedGlobalState: storedTab.lastPersistedGlobalState, + globalState: storedTab.globalState, ...('closedAt' in storedTab ? { closedAt: storedTab.closedAt } : {}), }); @@ -134,7 +138,7 @@ describe('TabsStorageManager', () => { tabsStorageManager.loadLocally({ userId: mockUserId, // register userId and spaceId in tabsStorageManager spaceId: mockSpaceId, - defaultTabState, + defaultTabState: DEFAULT_TAB_STATE, }); jest.spyOn(urlStateStorage, 'set'); @@ -146,7 +150,7 @@ describe('TabsStorageManager', () => { recentlyClosedTabs: [mockRecentlyClosedTab], }; - await tabsStorageManager.persistLocally(props, mockGetAppState); + await tabsStorageManager.persistLocally(props, mockGetAppState, mockGetInternalState); expect(urlStateStorage.set).toHaveBeenCalledWith(TABS_STATE_URL_KEY, { tabId: 'tab1' }); expect(storage.set).toHaveBeenCalledWith(TABS_LOCAL_STORAGE_KEY, { @@ -183,7 +187,7 @@ describe('TabsStorageManager', () => { const loadedProps = tabsStorageManager.loadLocally({ userId: mockUserId, spaceId: mockSpaceId, - defaultTabState, + defaultTabState: DEFAULT_TAB_STATE, }); expect(loadedProps).toEqual({ @@ -230,7 +234,7 @@ describe('TabsStorageManager', () => { const loadedProps = tabsStorageManager.loadLocally({ userId: mockUserId, spaceId: mockSpaceId, - defaultTabState, + defaultTabState: DEFAULT_TAB_STATE, }); expect(loadedProps).toEqual({ @@ -285,7 +289,7 @@ describe('TabsStorageManager', () => { const loadedProps = tabsStorageManager.loadLocally({ userId: 'different', spaceId: mockSpaceId, - defaultTabState, + defaultTabState: DEFAULT_TAB_STATE, }); expect(loadedProps.recentlyClosedTabs).toHaveLength(0); @@ -329,7 +333,7 @@ describe('TabsStorageManager', () => { const loadedProps = tabsStorageManager.loadLocally({ userId: mockUserId, spaceId: mockSpaceId, - defaultTabState, + defaultTabState: DEFAULT_TAB_STATE, }); expect(loadedProps).toEqual( @@ -368,6 +372,7 @@ describe('TabsStorageManager', () => { jest.spyOn(storage, 'set'); const updatedTabState = { + internalState: {}, appState: { columns: ['a', 'b', 'c'], }, @@ -449,4 +454,229 @@ describe('TabsStorageManager', () => { ...newClosedTabs.map((tab) => ({ ...tab, closedAt: newClosedAt })), ]); }); + + it('should update discover session id in local storage', () => { + const { + tabsStorageManager, + services: { storage }, + } = create(); + + storage.set(TABS_LOCAL_STORAGE_KEY, { + userId: mockUserId, + spaceId: mockSpaceId, + openTabs: [toStoredTab(mockTab1), toStoredTab(mockTab2)], + closedTabs: [toStoredTab(mockRecentlyClosedTab)], + discoverSessionId: undefined, + }); + + jest.spyOn(storage, 'set'); + + const newDiscoverSessionId = 'session-123'; + tabsStorageManager.updateDiscoverSessionIdLocally(newDiscoverSessionId); + + expect(storage.set).toHaveBeenCalledWith(TABS_LOCAL_STORAGE_KEY, { + userId: mockUserId, + spaceId: mockSpaceId, + discoverSessionId: newDiscoverSessionId, + openTabs: [toStoredTab(mockTab1), toStoredTab(mockTab2)], + closedTabs: [toStoredTab(mockRecentlyClosedTab)], + }); + }); + + it('should not update discover session id when disabled', () => { + const urlStateStorage = createKbnUrlStateStorage(); + const services = createDiscoverServicesMock(); + services.storage = new Storage(localStorage); + const storage = services.storage; + + const tabsStorageManager = createTabsStorageManager({ + urlStateStorage, + storage, + enabled: false, + }); + + storage.set(TABS_LOCAL_STORAGE_KEY, { + userId: mockUserId, + spaceId: mockSpaceId, + openTabs: [], + closedTabs: [], + }); + + jest.spyOn(storage, 'set'); + + tabsStorageManager.updateDiscoverSessionIdLocally('session-123'); + + expect(storage.set).not.toHaveBeenCalled(); + }); + + it('should load open tabs from storage when persisted discover session id matches stored session id', () => { + const { + tabsStorageManager, + urlStateStorage, + services: { storage }, + } = create(); + + const matchingSessionId = 'session-match'; + + storage.set(TABS_LOCAL_STORAGE_KEY, { + userId: mockUserId, + spaceId: mockSpaceId, + discoverSessionId: matchingSessionId, + openTabs: [toStoredTab(mockTab1), toStoredTab(mockTab2)], + closedTabs: [toStoredTab(mockRecentlyClosedTab)], + }); + + urlStateStorage.set(TABS_STATE_URL_KEY, { + tabId: mockTab2.id, + }); + + const loadedProps = tabsStorageManager.loadLocally({ + userId: mockUserId, + spaceId: mockSpaceId, + persistedDiscoverSession: { + id: matchingSessionId, + title: 'title', + description: 'description', + managed: false, + tabs: [], + }, + defaultTabState: DEFAULT_TAB_STATE, + }); + + expect(loadedProps).toEqual({ + allTabs: [toRestoredTab(mockTab1), toRestoredTab(mockTab2)], + selectedTabId: mockTab2.id, + recentlyClosedTabs: [toRestoredTab(mockRecentlyClosedTab)], + }); + }); + + it('should load persisted tabs when persisted discover session id differs from stored session id', () => { + const { tabsStorageManager, urlStateStorage, services } = create(); + const { storage } = services; + + storage.set(TABS_LOCAL_STORAGE_KEY, { + userId: mockUserId, + spaceId: mockSpaceId, + discoverSessionId: undefined, + openTabs: [toStoredTab(mockTab1)], + closedTabs: [toStoredTab(mockRecentlyClosedTab)], + }); + + urlStateStorage.set(TABS_STATE_URL_KEY, { + tabId: mockTab1.id, + }); + + const persistedTabId = 'persisted-tab'; + const peristedTab = fromSavedSearchToSavedObjectTab({ + tab: { id: persistedTabId, label: 'Persisted tab' }, + savedSearch: savedSearchMock, + services, + }); + const persistedDiscoverSession = { + id: 'persisted-session', + title: 'title', + description: 'description', + managed: false, + tabs: [peristedTab], + }; + + const loadedProps = tabsStorageManager.loadLocally({ + userId: mockUserId, + spaceId: mockSpaceId, + persistedDiscoverSession, + defaultTabState: DEFAULT_TAB_STATE, + }); + + expect(loadedProps.allTabs.map((t) => t.id)).toEqual([persistedTabId]); + expect(loadedProps.selectedTabId).toBe(persistedTabId); + expect(loadedProps.allTabs.find((t) => t.id === mockTab1.id)).toBeUndefined(); + }); + + it('should load persisted tabs when persisted discover session id matches stored session id, but target open tab is not found', () => { + const { tabsStorageManager, urlStateStorage, services } = create(); + const { storage } = services; + + const persistedSessionId = 'persisted-session'; + + storage.set(TABS_LOCAL_STORAGE_KEY, { + userId: mockUserId, + spaceId: mockSpaceId, + discoverSessionId: persistedSessionId, + openTabs: [toStoredTab(mockTab1)], + closedTabs: [toStoredTab(mockRecentlyClosedTab)], + }); + + urlStateStorage.set(TABS_STATE_URL_KEY, { + tabId: 'bad-tab', + }); + + const persistedTabId = 'persisted-tab'; + const peristedTab = fromSavedSearchToSavedObjectTab({ + tab: { id: persistedTabId, label: 'Persisted tab' }, + savedSearch: savedSearchMock, + services, + }); + const persistedDiscoverSession = { + id: persistedSessionId, + title: 'title', + description: 'description', + managed: false, + tabs: [peristedTab], + }; + + const loadedProps = tabsStorageManager.loadLocally({ + userId: mockUserId, + spaceId: mockSpaceId, + persistedDiscoverSession, + defaultTabState: DEFAULT_TAB_STATE, + }); + + expect(loadedProps.allTabs.map((t) => t.id)).toEqual([persistedTabId]); + expect(loadedProps.selectedTabId).toBe(persistedTabId); + expect(loadedProps.allTabs.find((t) => t.id === mockTab1.id)).toBeUndefined(); + }); + + it('should not load from recently closed tabs when a persisted discover session is provided', () => { + const { tabsStorageManager, urlStateStorage, services } = create(); + const { storage } = services; + + const persistedSessionId = 'persisted-session'; + + storage.set(TABS_LOCAL_STORAGE_KEY, { + userId: mockUserId, + spaceId: mockSpaceId, + discoverSessionId: persistedSessionId, + openTabs: [toStoredTab(mockTab1)], + closedTabs: [toStoredTab(mockRecentlyClosedTab)], + }); + + urlStateStorage.set(TABS_STATE_URL_KEY, { + tabId: mockRecentlyClosedTab.id, + }); + + const persistedTabId = 'persisted-tab'; + const peristedTab = fromSavedSearchToSavedObjectTab({ + tab: { id: persistedTabId, label: 'Persisted tab' }, + savedSearch: savedSearchMock, + services, + }); + const persistedDiscoverSession = { + id: persistedSessionId, + title: 'title', + description: 'description', + managed: false, + tabs: [peristedTab], + }; + + const loadedProps = tabsStorageManager.loadLocally({ + userId: mockUserId, + spaceId: mockSpaceId, + persistedDiscoverSession, + defaultTabState: DEFAULT_TAB_STATE, + }); + + expect(loadedProps.allTabs.map((t) => t.id)).toEqual([persistedTabId]); + expect(loadedProps.selectedTabId).toBe(persistedTabId); + expect(loadedProps.allTabs.find((t) => t.id === mockTab1.id)).toBeUndefined(); + }); }); diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/tabs_storage_manager.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/tabs_storage_manager.ts index 86d30e27ca67f..b69a8fa0b1af3 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/tabs_storage_manager.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/tabs_storage_manager.ts @@ -15,17 +15,20 @@ import { } from '@kbn/kibana-utils-plugin/public'; import type { TabItem } from '@kbn/unified-tabs'; import type { Storage } from '@kbn/kibana-utils-plugin/public'; +import type { DiscoverSession } from '@kbn/saved-search-plugin/common'; import { TABS_STATE_URL_KEY } from '../../../../common/constants'; import type { TabState, RecentlyClosedTabState } from './redux/types'; import { createTabItem } from './redux/utils'; import type { DiscoverAppState } from './discover_app_state_container'; +import { fromSavedObjectTabToTabState } from './redux'; export const TABS_LOCAL_STORAGE_KEY = 'discover.tabs'; export const RECENTLY_CLOSED_TABS_LIMIT = 50; -type TabStateInLocalStorage = Pick & { +export type TabStateInLocalStorage = Pick & { + internalState: TabState['initialInternalState'] | undefined; appState: DiscoverAppState | undefined; - globalState: TabState['lastPersistedGlobalState'] | undefined; + globalState: TabState['globalState'] | undefined; }; type RecentlyClosedTabStateInLocalStorage = TabStateInLocalStorage & @@ -34,6 +37,7 @@ type RecentlyClosedTabStateInLocalStorage = TabStateInLocalStorage & interface TabsStateInLocalStorage { userId: string; spaceId: string; + discoverSessionId: string | undefined; openTabs: TabStateInLocalStorage[]; closedTabs: RecentlyClosedTabStateInLocalStorage[]; } @@ -41,6 +45,7 @@ interface TabsStateInLocalStorage { const defaultTabsStateInLocalStorage: TabsStateInLocalStorage = { userId: '', spaceId: '', + discoverSessionId: undefined, openTabs: [], closedTabs: [], }; @@ -62,15 +67,18 @@ export interface TabsStorageManager { startUrlSync: (props: { onChanged?: (nextState: TabsUrlState) => void }) => () => void; persistLocally: ( props: TabsInternalStatePayload, - getAppState: (tabId: string) => DiscoverAppState | undefined + getAppState: (tabId: string) => DiscoverAppState | undefined, + getInternalState: (tabId: string) => TabState['initialInternalState'] | undefined ) => Promise; updateTabStateLocally: ( tabId: string, - tabState: Pick + tabState: Pick ) => void; + updateDiscoverSessionIdLocally: (discoverSessionId: string | undefined) => void; loadLocally: (props: { userId: string; spaceId: string; + persistedDiscoverSession?: DiscoverSession; defaultTabState: Omit; }) => TabsInternalStatePayload; getNRecentlyClosedTabs: ( @@ -89,7 +97,11 @@ export const createTabsStorageManager = ({ enabled?: boolean; }): TabsStorageManager => { const urlStateContainer = createStateContainer({}); - const sessionInfo = { userId: '', spaceId: '' }; + const sessionInfo: Pick = { + userId: '', + spaceId: '', + discoverSessionId: undefined, + }; const startUrlSync: TabsStorageManager['startUrlSync'] = ({ onChanged, // can be called when selectedTabId changes in URL to trigger app state change if needed @@ -141,24 +153,29 @@ export const createTabsStorageManager = ({ const toTabStateInStorage = ( tabState: TabState, - getAppState: (tabId: string) => DiscoverAppState | undefined + getAppState: (tabId: string) => DiscoverAppState | undefined, + getInternalState: (tabId: string) => TabState['initialInternalState'] | undefined ): TabStateInLocalStorage => { + const getInternalStateForTabWithoutRuntimeState = (tabId: string) => + getInternalState(tabId) || tabState.initialInternalState; const getAppStateForTabWithoutRuntimeState = (tabId: string) => getAppState(tabId) || tabState.initialAppState; return { id: tabState.id, label: tabState.label, + internalState: getInternalStateForTabWithoutRuntimeState(tabState.id), appState: getAppStateForTabWithoutRuntimeState(tabState.id), - globalState: tabState.lastPersistedGlobalState || tabState.initialGlobalState, + globalState: tabState.globalState, }; }; const toRecentlyClosedTabStateInStorage = ( tabState: RecentlyClosedTabState, - getAppState: (tabId: string) => DiscoverAppState | undefined + getAppState: (tabId: string) => DiscoverAppState | undefined, + getInternalState: (tabId: string) => TabState['initialInternalState'] | undefined ): RecentlyClosedTabStateInLocalStorage => { - const state = toTabStateInStorage(tabState, getAppState); + const state = toTabStateInStorage(tabState, getAppState, getInternalState); return { ...state, closedAt: tabState.closedAt, @@ -176,16 +193,17 @@ export const createTabsStorageManager = ({ tabStateInStorage: TabStateInLocalStorage, defaultTabState: Omit ): TabState => { + const internalState = getDefinedStateOnly(tabStateInStorage.internalState); const appState = getDefinedStateOnly(tabStateInStorage.appState); const globalState = getDefinedStateOnly( - tabStateInStorage.globalState || defaultTabState.lastPersistedGlobalState + tabStateInStorage.globalState || defaultTabState.globalState ); return { ...defaultTabState, ...pick(tabStateInStorage, 'id', 'label'), + initialInternalState: internalState, initialAppState: appState, - initialGlobalState: globalState, - lastPersistedGlobalState: globalState || {}, + globalState: globalState || {}, }; }; @@ -204,6 +222,7 @@ export const createTabsStorageManager = ({ return { userId: storedTabsState?.userId || '', spaceId: storedTabsState?.spaceId || '', + discoverSessionId: storedTabsState?.discoverSessionId || undefined, openTabs: storedTabsState?.openTabs || [], closedTabs: storedTabsState?.closedTabs || [], }; @@ -249,7 +268,8 @@ export const createTabsStorageManager = ({ const persistLocally: TabsStorageManager['persistLocally'] = async ( { allTabs, selectedTabId, recentlyClosedTabs }, - getAppState + getAppState, + getInternalState ) => { if (!enabled) { return; @@ -260,12 +280,16 @@ export const createTabsStorageManager = ({ const keptTabIds: Record = {}; const openTabs: TabsStateInLocalStorage['openTabs'] = allTabs.map((tab) => { - const tabStateInStorage = toTabStateInStorage(tab, getAppState); + const tabStateInStorage = toTabStateInStorage(tab, getAppState, getInternalState); keptTabIds[tab.id] = true; return tabStateInStorage; }); const closedTabs: TabsStateInLocalStorage['closedTabs'] = recentlyClosedTabs.map((tab) => { - const tabStateInStorage = toRecentlyClosedTabStateInStorage(tab, getAppState); + const tabStateInStorage = toRecentlyClosedTabStateInStorage( + tab, + getAppState, + getInternalState + ); keptTabIds[tab.id] = true; return tabStateInStorage; }); @@ -273,6 +297,7 @@ export const createTabsStorageManager = ({ const nextTabsInStorage: TabsStateInLocalStorage = { userId: sessionInfo.userId, spaceId: sessionInfo.spaceId, + discoverSessionId: sessionInfo.discoverSessionId, openTabs, closedTabs, // wil be used for "Recently closed tabs" feature }; @@ -296,6 +321,7 @@ export const createTabsStorageManager = ({ hasModifications = true; return { ...tab, + internalState: tabStatePartial.internalState, appState: tabStatePartial.appState, globalState: tabStatePartial.globalState, }; @@ -309,7 +335,30 @@ export const createTabsStorageManager = ({ } }; - const loadLocally: TabsStorageManager['loadLocally'] = ({ userId, spaceId, defaultTabState }) => { + const updateDiscoverSessionIdLocally: TabsStorageManager['updateDiscoverSessionIdLocally'] = ( + discoverSessionId + ) => { + if (!enabled) { + return; + } + + sessionInfo.discoverSessionId = discoverSessionId; + + const storedTabsState = readFromLocalStorage(); + const updatedTabsState = { + ...storedTabsState, + discoverSessionId, + }; + + storage.set(TABS_LOCAL_STORAGE_KEY, updatedTabsState); + }; + + const loadLocally: TabsStorageManager['loadLocally'] = ({ + userId, + spaceId, + persistedDiscoverSession, + defaultTabState, + }) => { const selectedTabId = enabled ? getSelectedTabIdFromURL() : undefined; let storedTabsState: TabsStateInLocalStorage = enabled ? readFromLocalStorage() @@ -326,23 +375,30 @@ export const createTabsStorageManager = ({ sessionInfo.userId = userId; sessionInfo.spaceId = spaceId; + sessionInfo.discoverSessionId = persistedDiscoverSession?.id; - const openTabs = storedTabsState.openTabs.map((tab) => toTabState(tab, defaultTabState)); + const persistedTabs = persistedDiscoverSession?.tabs.map((tab) => + fromSavedObjectTabToTabState({ tab }) + ); + const openTabs = + persistedDiscoverSession?.id === storedTabsState.discoverSessionId + ? storedTabsState.openTabs.map((tab) => toTabState(tab, defaultTabState)) + : persistedTabs ?? []; const closedTabs = storedTabsState.closedTabs.map((tab) => toRecentlyClosedTabState(tab, defaultTabState) ); - if (enabled) { - if (selectedTabId) { - // restore previously opened tabs - if (openTabs.find((tab) => tab.id === selectedTabId)) { - return { - allTabs: openTabs, - selectedTabId, - recentlyClosedTabs: closedTabs, - }; - } + if (enabled && selectedTabId) { + // restore previously opened tabs + if (openTabs.find((tab) => tab.id === selectedTabId)) { + return { + allTabs: openTabs, + selectedTabId, + recentlyClosedTabs: closedTabs, + }; + } + if (!persistedDiscoverSession) { const storedClosedTab = storedTabsState.closedTabs.find((tab) => tab.id === selectedTabId); if (storedClosedTab) { @@ -358,13 +414,16 @@ export const createTabsStorageManager = ({ } } - const defaultTab: TabState = { - ...defaultTabState, - ...createTabItem([]), - }; + const defaultTab = persistedTabs + ? persistedTabs[0] + : { + ...defaultTabState, + ...createTabItem([]), + }; + const allTabs = persistedTabs ?? [defaultTab]; return { - allTabs: [defaultTab], + allTabs, selectedTabId: defaultTab.id, recentlyClosedTabs: getNRecentlyClosedTabs(closedTabs, openTabs), }; @@ -374,6 +433,7 @@ export const createTabsStorageManager = ({ startUrlSync, persistLocally, updateTabStateLocally, + updateDiscoverSessionIdLocally, loadLocally, getNRecentlyClosedTabs, }; diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/utils/resolve_data_view.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/utils/resolve_data_view.ts index 4467dba463786..bd07114a28005 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/utils/resolve_data_view.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/utils/resolve_data_view.ts @@ -34,13 +34,15 @@ interface DataViewData { */ export async function loadDataView({ dataViewId, - dataViewSpec, + locationDataViewSpec, + initialAdHocDataViewSpec, services: { dataViews }, savedDataViews, adHocDataViews, }: { dataViewId?: string; - dataViewSpec?: DataViewSpec; + locationDataViewSpec?: DataViewSpec; + initialAdHocDataViewSpec?: DataViewSpec; services: DiscoverServices; savedDataViews: DataViewListItem[]; adHocDataViews: DataView[]; @@ -48,18 +50,20 @@ export async function loadDataView({ let fetchId: string | undefined = dataViewId; // Handle redirect with data view spec provided via history location state - if (dataViewSpec) { - const isPersisted = savedDataViews.find(({ id: currentId }) => currentId === dataViewSpec.id); + if (locationDataViewSpec) { + const isPersisted = savedDataViews.find( + ({ id: currentId }) => currentId === locationDataViewSpec.id + ); if (isPersisted) { // If passed a spec for a persisted data view, reassign the fetchId - fetchId = dataViewSpec.id!; + fetchId = locationDataViewSpec.id!; } else { // If passed an ad hoc data view spec, clear the instance cache // to avoid conflicts, then create and return the data view - if (dataViewSpec.id) { - dataViews.clearInstanceCache(dataViewSpec.id); + if (locationDataViewSpec.id) { + dataViews.clearInstanceCache(locationDataViewSpec.id); } - const createdAdHocDataView = await dataViews.create(dataViewSpec); + const createdAdHocDataView = await dataViews.create(locationDataViewSpec); return { loadedDataView: createdAdHocDataView, requestedDataViewId: createdAdHocDataView.id, @@ -68,6 +72,16 @@ export async function loadDataView({ } } + // If the initial ad hoc data view spec matches the data view id, create and return it + if (dataViewId && initialAdHocDataViewSpec?.id === dataViewId) { + const createdAdHocDataView = await dataViews.create(initialAdHocDataViewSpec); + return { + loadedDataView: createdAdHocDataView, + requestedDataViewId: createdAdHocDataView.id, + requestedDataViewFound: true, + }; + } + // First try to fetch the data view by ID let fetchedDataView: DataView | null = null; try { @@ -171,7 +185,8 @@ function resolveDataView({ export const loadAndResolveDataView = async ({ dataViewId, - dataViewSpec, + locationDataViewSpec, + initialAdHocDataViewSpec, savedSearch, isEsqlMode, internalState, @@ -179,7 +194,8 @@ export const loadAndResolveDataView = async ({ services, }: { dataViewId?: string; - dataViewSpec?: DataViewSpec; + locationDataViewSpec?: DataViewSpec; + initialAdHocDataViewSpec?: DataViewSpec; savedSearch?: SavedSearch; isEsqlMode?: boolean; internalState: InternalStateStore; @@ -193,13 +209,16 @@ export const loadAndResolveDataView = async ({ // Check ad hoc data views first, unless a data view spec is supplied, // then attempt to load one if none is found let fallback = false; - let dataView = dataViewSpec ? undefined : adHocDataViews.find((dv) => dv.id === dataViewId); + let dataView = locationDataViewSpec + ? undefined + : adHocDataViews.find((dv) => dv.id === dataViewId); if (!dataView) { const dataViewData = await loadDataView({ dataViewId, + locationDataViewSpec, + initialAdHocDataViewSpec, services, - dataViewSpec, savedDataViews, adHocDataViews, }); diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/utils/restore_from_saved_search.test.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/utils/restore_from_saved_search.test.ts index a499c35170edb..3564fd2279c36 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/utils/restore_from_saved_search.test.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/utils/restore_from_saved_search.test.ts @@ -7,13 +7,11 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { TimefilterContract } from '@kbn/data-plugin/public'; import type { TimeRange, RefreshInterval } from '@kbn/data-plugin/common'; import { savedSearchMock, savedSearchMockWithTimeField } from '../../../../__mocks__/saved_search'; import { restoreStateFromSavedSearch } from './restore_from_saved_search'; describe('discover restore state from saved search', () => { - let timefilterMock: TimefilterContract; const timeRange: TimeRange = { from: 'now-30m', to: 'now', @@ -23,83 +21,67 @@ describe('discover restore state from saved search', () => { pause: false, }; - beforeEach(() => { - timefilterMock = { - setTime: jest.fn(), - setRefreshInterval: jest.fn(), - } as unknown as TimefilterContract; - }); - test('should not update timefilter if attributes are not set', async () => { - restoreStateFromSavedSearch({ - savedSearch: savedSearchMockWithTimeField, - timefilter: timefilterMock, - }); + const globalState = restoreStateFromSavedSearch({ savedSearch: savedSearchMockWithTimeField }); - expect(timefilterMock.setTime).not.toHaveBeenCalled(); - expect(timefilterMock.setRefreshInterval).not.toHaveBeenCalled(); + expect(globalState).toEqual({}); }); test('should not update timefilter if timeRestore is disabled', async () => { - restoreStateFromSavedSearch({ + const globalState = restoreStateFromSavedSearch({ savedSearch: { ...savedSearchMockWithTimeField, timeRestore: false, timeRange, refreshInterval, }, - timefilter: timefilterMock, }); - expect(timefilterMock.setTime).not.toHaveBeenCalled(); - expect(timefilterMock.setRefreshInterval).not.toHaveBeenCalled(); + expect(globalState).toEqual({}); }); test('should update timefilter if timeRestore is enabled', async () => { - restoreStateFromSavedSearch({ + const globalState = restoreStateFromSavedSearch({ savedSearch: { ...savedSearchMockWithTimeField, timeRestore: true, timeRange, refreshInterval, }, - timefilter: timefilterMock, }); - expect(timefilterMock.setTime).toHaveBeenCalledWith(timeRange); - expect(timefilterMock.setRefreshInterval).toHaveBeenCalledWith(refreshInterval); + expect(globalState).toEqual({ + timeRange, + refreshInterval, + }); }); test('should not update if data view is not time based', async () => { - restoreStateFromSavedSearch({ + const globalState = restoreStateFromSavedSearch({ savedSearch: { ...savedSearchMock, timeRestore: true, timeRange, refreshInterval, }, - timefilter: timefilterMock, }); - expect(timefilterMock.setTime).not.toHaveBeenCalled(); - expect(timefilterMock.setRefreshInterval).not.toHaveBeenCalled(); + expect(globalState).toEqual({}); }); test('should not update timefilter if attributes are missing', async () => { - restoreStateFromSavedSearch({ + const globalState = restoreStateFromSavedSearch({ savedSearch: { ...savedSearchMockWithTimeField, timeRestore: true, }, - timefilter: timefilterMock, }); - expect(timefilterMock.setTime).not.toHaveBeenCalled(); - expect(timefilterMock.setRefreshInterval).not.toHaveBeenCalled(); + expect(globalState).toEqual({}); }); test('should not update timefilter if attributes are invalid', async () => { - restoreStateFromSavedSearch({ + const globalState = restoreStateFromSavedSearch({ savedSearch: { ...savedSearchMockWithTimeField, timeRestore: true, @@ -112,10 +94,8 @@ describe('discover restore state from saved search', () => { value: -500, }, }, - timefilter: timefilterMock, }); - expect(timefilterMock.setTime).not.toHaveBeenCalled(); - expect(timefilterMock.setRefreshInterval).not.toHaveBeenCalled(); + expect(globalState).toEqual({}); }); }); diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/utils/restore_from_saved_search.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/utils/restore_from_saved_search.ts index 40f579666e1b8..5252fb72dbbff 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/utils/restore_from_saved_search.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/utils/restore_from_saved_search.ts @@ -7,35 +7,33 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { TimefilterContract } from '@kbn/data-plugin/public'; import type { SavedSearch } from '@kbn/saved-search-plugin/public'; import { isRefreshIntervalValid, isTimeRangeValid } from '../../../../utils/validate_time'; +import type { TabStateGlobalState } from '../redux'; -export const restoreStateFromSavedSearch = ({ - savedSearch, - timefilter, -}: { - savedSearch: SavedSearch; - timefilter: TimefilterContract; -}) => { +export const restoreStateFromSavedSearch = ({ savedSearch }: { savedSearch: SavedSearch }) => { if (!savedSearch) { - return; + return {}; } const isTimeBased = savedSearch.searchSource.getField('index')?.isTimeBased(); if (!isTimeBased) { - return; + return {}; } + const globalStateUpdate: Partial = {}; + if (savedSearch.timeRestore && savedSearch.timeRange && isTimeRangeValid(savedSearch.timeRange)) { - timefilter.setTime(savedSearch.timeRange); + globalStateUpdate.timeRange = savedSearch.timeRange; } if ( savedSearch.timeRestore && savedSearch.refreshInterval && isRefreshIntervalValid(savedSearch.refreshInterval) ) { - timefilter.setRefreshInterval(savedSearch.refreshInterval); + globalStateUpdate.refreshInterval = savedSearch.refreshInterval; } + + return globalStateUpdate; }; diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/utils/update_saved_search.test.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/utils/update_saved_search.test.ts index fb3369a848156..84bbebc8d87a7 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/utils/update_saved_search.test.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/utils/update_saved_search.test.ts @@ -13,6 +13,7 @@ import type { Filter, Query } from '@kbn/es-query'; import { FilterStateStore } from '@kbn/es-query'; import { updateSavedSearch } from './update_saved_search'; import type { SavedSearch } from '@kbn/saved-search-plugin/public'; +import type { TabStateGlobalState } from '../redux'; describe('updateSavedSearch', () => { const query: Query = { @@ -47,10 +48,7 @@ describe('updateSavedSearch', () => { store: FilterStateStore.GLOBAL_STATE, }, }; - const createGlobalStateContainer = () => ({ - get: jest.fn(() => ({ filters: [globalFilter] })), - set: jest.fn(), - }); + const globalState: TabStateGlobalState = { filters: [globalFilter] }; beforeEach(() => { jest.clearAllMocks(); @@ -65,9 +63,9 @@ describe('updateSavedSearch', () => { expect(savedSearch.searchSource.getField('filter')).toBeUndefined(); updateSavedSearch({ savedSearch, - globalStateContainer: createGlobalStateContainer(), + globalState, services: discoverServiceMock, - state: { + appState: { query, filters: [appFilter], }, @@ -82,15 +80,17 @@ describe('updateSavedSearch', () => { searchSource: savedSearchMock.searchSource.createCopy(), timeRestore: true, }; - (discoverServiceMock.timefilter.getTime as jest.Mock).mockReturnValue({ - from: 'now-666m', - to: 'now', - }); updateSavedSearch({ savedSearch, - globalStateContainer: createGlobalStateContainer(), + globalState: { + ...globalState, + timeRange: { + from: 'now-666m', + to: 'now', + }, + }, services: discoverServiceMock, - state: { + appState: { query, filters: [appFilter], }, @@ -107,15 +107,17 @@ describe('updateSavedSearch', () => { searchSource: savedSearchMock.searchSource.createCopy(), timeRestore: false, }; - (discoverServiceMock.timefilter.getTime as jest.Mock).mockReturnValue({ - from: 'now-666m', - to: 'now', - }); updateSavedSearch({ savedSearch, - globalStateContainer: createGlobalStateContainer(), + globalState: { + ...globalState, + timeRange: { + from: 'now-666m', + to: 'now', + }, + }, services: discoverServiceMock, - state: { + appState: { query, filters: [appFilter], }, @@ -134,9 +136,9 @@ describe('updateSavedSearch', () => { expect(savedSearch.breakdownField).toBeUndefined(); updateSavedSearch({ savedSearch, - globalStateContainer: createGlobalStateContainer(), + globalState, services: discoverServiceMock, - state: { + appState: { breakdownField: 'test', }, }); @@ -151,9 +153,9 @@ describe('updateSavedSearch', () => { }; updateSavedSearch({ savedSearch, - globalStateContainer: createGlobalStateContainer(), + globalState, services: discoverServiceMock, - state: { + appState: { breakdownField: undefined, }, }); @@ -173,7 +175,7 @@ describe('updateSavedSearch', () => { jest.spyOn(discoverServiceMock.data.query.queryString, 'getQuery').mockReturnValue(query); updateSavedSearch({ savedSearch, - globalStateContainer: createGlobalStateContainer(), + globalState, services: discoverServiceMock, useFilterAndQueryServices: true, }); diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/utils/update_saved_search.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/utils/update_saved_search.ts index 79dce94bcbc4d..441592839a19c 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/utils/update_saved_search.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/utils/update_saved_search.ts @@ -12,8 +12,8 @@ import type { DataView } from '@kbn/data-views-plugin/common'; import { cloneDeep } from 'lodash'; import type { DiscoverAppState } from '../discover_app_state_container'; import type { DiscoverServices } from '../../../../build_services'; -import type { DiscoverGlobalStateContainer } from '../discover_global_state_container'; import { DataSourceType, isDataSourceType } from '../../../../../common/data_sources'; +import type { TabStateGlobalState } from '../redux'; /** * Updates the saved search with a given data view & Appstate @@ -29,15 +29,15 @@ import { DataSourceType, isDataSourceType } from '../../../../../common/data_sou export function updateSavedSearch({ savedSearch, dataView, - state, - globalStateContainer, + appState, + globalState, services, useFilterAndQueryServices = false, }: { savedSearch: SavedSearch; dataView?: DataView; - state?: DiscoverAppState; - globalStateContainer: DiscoverGlobalStateContainer; + appState?: DiscoverAppState; + globalState?: TabStateGlobalState; services: DiscoverServices; useFilterAndQueryServices?: boolean; }) { @@ -52,59 +52,59 @@ export function updateSavedSearch({ savedSearch.searchSource .setField('query', services.data.query.queryString.getQuery()) .setField('filter', services.data.query.filterManager.getFilters()); - } else if (state) { - const appFilters = state.filters ? cloneDeep(state.filters) : []; - const globalFilters = globalStateContainer.get()?.filters ?? []; + } else if (appState) { + const appFilters = appState.filters ? cloneDeep(appState.filters) : []; + const globalFilters = globalState?.filters ? cloneDeep(globalState.filters) : []; savedSearch.searchSource - .setField('query', state.query ?? undefined) + .setField('query', appState.query ?? undefined) .setField('filter', [...globalFilters, ...appFilters]); } - if (state) { - savedSearch.columns = state.columns || []; - savedSearch.sort = (state.sort as SortOrder[]) || []; - if (state.grid) { - savedSearch.grid = state.grid; + if (appState) { + savedSearch.columns = appState.columns || []; + savedSearch.sort = (appState.sort as SortOrder[]) || []; + if (appState.grid) { + savedSearch.grid = appState.grid; } - savedSearch.hideChart = state.hideChart; - savedSearch.rowHeight = state.rowHeight; - savedSearch.headerRowHeight = state.headerRowHeight; - savedSearch.rowsPerPage = state.rowsPerPage; - savedSearch.sampleSize = state.sampleSize; - savedSearch.density = state.density; + savedSearch.hideChart = appState.hideChart; + savedSearch.rowHeight = appState.rowHeight; + savedSearch.headerRowHeight = appState.headerRowHeight; + savedSearch.rowsPerPage = appState.rowsPerPage; + savedSearch.sampleSize = appState.sampleSize; + savedSearch.density = appState.density; - if (state.viewMode) { - savedSearch.viewMode = state.viewMode; + if (appState.viewMode) { + savedSearch.viewMode = appState.viewMode; } - if (typeof state.breakdownField !== 'undefined') { - savedSearch.breakdownField = state.breakdownField; + if (typeof appState.breakdownField !== 'undefined') { + savedSearch.breakdownField = appState.breakdownField; } else if (savedSearch.breakdownField) { savedSearch.breakdownField = ''; } - savedSearch.hideAggregatedPreview = state.hideAggregatedPreview; + savedSearch.hideAggregatedPreview = appState.hideAggregatedPreview; // add a flag here to identify ES|QL queries // these should be filtered out from the visualize editor - const isEsqlMode = isDataSourceType(state.dataSource, DataSourceType.Esql); + const isEsqlMode = isDataSourceType(appState.dataSource, DataSourceType.Esql); if (savedSearch.isTextBasedQuery || isEsqlMode) { savedSearch.isTextBasedQuery = isEsqlMode; } } - const { from, to } = services.timefilter.getTime(); - const refreshInterval = services.timefilter.getRefreshInterval(); + const timeRange = globalState?.timeRange; + const refreshInterval = globalState?.refreshInterval; savedSearch.timeRange = - savedSearch.timeRestore || savedSearch.timeRange + timeRange && (savedSearch.timeRestore || savedSearch.timeRange) ? { - from, - to, + from: timeRange.from, + to: timeRange.to, } : undefined; savedSearch.refreshInterval = - savedSearch.timeRestore || savedSearch.refreshInterval + refreshInterval && (savedSearch.timeRestore || savedSearch.refreshInterval) ? { value: refreshInterval.value, pause: refreshInterval.pause } : undefined; diff --git a/src/platform/plugins/shared/discover/public/constants.ts b/src/platform/plugins/shared/discover/public/constants.ts index 5f795ad4b574a..adbd77d54cf4d 100644 --- a/src/platform/plugins/shared/discover/public/constants.ts +++ b/src/platform/plugins/shared/discover/public/constants.ts @@ -10,4 +10,5 @@ export const ADHOC_DATA_VIEW_RENDER_EVENT = 'ad_hoc_data_view'; export const SEARCH_SESSION_ID_QUERY_PARAM = 'searchSessionId'; + export const TABS_ENABLED_FEATURE_FLAG_KEY = 'discover.tabsEnabled'; diff --git a/src/platform/plugins/shared/discover/public/context_awareness/hooks/use_default_ad_hoc_data_views.test.tsx b/src/platform/plugins/shared/discover/public/context_awareness/hooks/use_default_ad_hoc_data_views.test.tsx index 474aaf6ec74d8..80dfff526415c 100644 --- a/src/platform/plugins/shared/discover/public/context_awareness/hooks/use_default_ad_hoc_data_views.test.tsx +++ b/src/platform/plugins/shared/discover/public/context_awareness/hooks/use_default_ad_hoc_data_views.test.tsx @@ -51,9 +51,10 @@ const renderDefaultAdHocDataViewsHook = () => { internalStateActions.setDefaultProfileAdHocDataViews(previousDataViews) ); const { result, unmount } = renderHook(useDefaultAdHocDataViews, { - initialProps: { internalState: stateContainer.internalState }, wrapper: ({ children }) => ( - {children} + + {children} + ), }); return { diff --git a/src/platform/plugins/shared/discover/public/context_awareness/hooks/use_default_ad_hoc_data_views.ts b/src/platform/plugins/shared/discover/public/context_awareness/hooks/use_default_ad_hoc_data_views.ts index 716a2298e1110..005d31aa5df73 100644 --- a/src/platform/plugins/shared/discover/public/context_awareness/hooks/use_default_ad_hoc_data_views.ts +++ b/src/platform/plugins/shared/discover/public/context_awareness/hooks/use_default_ad_hoc_data_views.ts @@ -12,26 +12,29 @@ import useLatest from 'react-use/lib/useLatest'; import useUnmount from 'react-use/lib/useUnmount'; import type { RootProfileState } from './use_root_profile'; import { useDiscoverServices } from '../../hooks/use_discover_services'; -import type { InternalStateStore } from '../../application/main/state_management/redux'; -import { internalStateActions } from '../../application/main/state_management/redux'; +import { + internalStateActions, + useInternalStateDispatch, + useInternalStateSelector, +} from '../../application/main/state_management/redux'; /** * Hook to retrieve and initialize the default profile ad hoc data views * @param Options The options object * @returns An object containing the initialization function */ -export const useDefaultAdHocDataViews = ({ - internalState, -}: { - internalState: InternalStateStore; -}) => { +export const useDefaultAdHocDataViews = () => { const { dataViews } = useDiscoverServices(); + const dispatch = useInternalStateDispatch(); + const defaultProfileAdHocDataViewIds = useInternalStateSelector( + (state) => state.defaultProfileAdHocDataViewIds + ); const initializeDataViews = useLatest( async (rootProfileState: Extract) => { // Clear the cache of old data views before creating // the new ones to avoid cache hits on duplicate IDs - for (const prevId of internalState.getState().defaultProfileAdHocDataViewIds) { + for (const prevId of defaultProfileAdHocDataViewIds) { dataViews.clearInstanceCache(prevId); } @@ -40,9 +43,7 @@ export const useDefaultAdHocDataViews = ({ profileDataViewSpecs.map((spec) => dataViews.create(spec, true)) ); - internalState.dispatch( - internalStateActions.setDefaultProfileAdHocDataViews(profileDataViews) - ); + dispatch(internalStateActions.setDefaultProfileAdHocDataViews(profileDataViews)); } ); @@ -54,11 +55,11 @@ export const useDefaultAdHocDataViews = ({ // Make sure to clean up on unmount useUnmount(() => { - for (const prevId of internalState.getState().defaultProfileAdHocDataViewIds) { + for (const prevId of defaultProfileAdHocDataViewIds) { dataViews.clearInstanceCache(prevId); } - internalState.dispatch(internalStateActions.setDefaultProfileAdHocDataViews([])); + dispatch(internalStateActions.setDefaultProfileAdHocDataViews([])); }); return { initializeProfileDataViews }; diff --git a/src/platform/plugins/shared/discover/tsconfig.json b/src/platform/plugins/shared/discover/tsconfig.json index 0c7254248d297..0d8003983d9cf 100644 --- a/src/platform/plugins/shared/discover/tsconfig.json +++ b/src/platform/plugins/shared/discover/tsconfig.json @@ -114,7 +114,7 @@ "@kbn/core-application-browser", "@kbn/timerange", "@kbn/metrics-experience-plugin", - "@kbn/unified-metrics-grid" + "@kbn/unified-metrics-grid", ], "exclude": ["target/**/*"] } diff --git a/src/platform/plugins/shared/kibana_utils/common/errors/errors.test.ts b/src/platform/plugins/shared/kibana_utils/common/errors/errors.test.ts index f2a8549af0a9f..05e9e0bb3c8cf 100644 --- a/src/platform/plugins/shared/kibana_utils/common/errors/errors.test.ts +++ b/src/platform/plugins/shared/kibana_utils/common/errors/errors.test.ts @@ -11,7 +11,13 @@ import expect from '@kbn/expect'; import { DuplicateField, SavedObjectNotFound, KbnError } from './errors'; describe('errors', () => { - const errors = [new DuplicateField('dupfield'), new SavedObjectNotFound('dashboard', '123')]; + const errors = [ + new DuplicateField('dupfield'), + new SavedObjectNotFound({ + type: 'dashboard', + id: '123', + }), + ]; errors.forEach((error) => { const className = error.constructor.name; diff --git a/src/platform/plugins/shared/kibana_utils/common/errors/errors.ts b/src/platform/plugins/shared/kibana_utils/common/errors/errors.ts index 1906f4cdd169c..28ecd825f5e14 100644 --- a/src/platform/plugins/shared/kibana_utils/common/errors/errors.ts +++ b/src/platform/plugins/shared/kibana_utils/common/errors/errors.ts @@ -43,10 +43,23 @@ export class CharacterNotAllowedInField extends KbnError { */ export class SavedObjectNotFound extends KbnError { public savedObjectType: string; + public savedObjectTypeDisplayName: string; public savedObjectId?: string; - constructor(type: string, id?: string, link?: string, customMessage?: string) { + constructor({ + type, + typeDisplayName = type, + id, + link, + customMessage, + }: { + type: string; + typeDisplayName?: string; + id?: string; + link?: string; + customMessage?: string; + }) { const idMsg = id ? ` (id: ${id})` : ''; - let message = `Could not locate that ${type}${idMsg}`; + let message = `Could not locate that ${typeDisplayName}${idMsg}`; if (link) { message += `, [click here to re-create it](${link})`; @@ -55,6 +68,7 @@ export class SavedObjectNotFound extends KbnError { super(customMessage || message); this.savedObjectType = type; + this.savedObjectTypeDisplayName = typeDisplayName; this.savedObjectId = id; } } diff --git a/src/platform/plugins/shared/kibana_utils/public/history/redirect_when_missing.tsx b/src/platform/plugins/shared/kibana_utils/public/history/redirect_when_missing.tsx index b145f4a88de0f..10eac0544fea1 100644 --- a/src/platform/plugins/shared/kibana_utils/public/history/redirect_when_missing.tsx +++ b/src/platform/plugins/shared/kibana_utils/public/history/redirect_when_missing.tsx @@ -19,10 +19,26 @@ import type { UserProfileService } from '@kbn/core-user-profile-browser'; import type { SavedObjectNotFound } from '..'; import { KibanaThemeProvider } from '../theme'; -const ReactMarkdown = React.lazy(() => import('react-markdown')); -const ErrorRenderer = (props: { children: string }) => ( +type MarkdownRendererProps = { + basePath: HttpStart['basePath']; + children: string; +}; + +const MarkdownRenderer = React.lazy(async () => { + const { default: ReactMarkdown } = await import('react-markdown'); + const WrappedRenderer = ({ basePath, children }: MarkdownRendererProps) => ( + ReactMarkdown.uriTransformer(basePath.prepend(href))} + children={children} + /> + ); + + return { default: WrappedRenderer }; +}); + +const ErrorRenderer = (props: MarkdownRendererProps) => ( }> - + ); @@ -102,7 +118,7 @@ export function redirectWhenMissing({ text: (element: HTMLElement) => { ReactDOM.render( - {error.message} + {error.message} , element ); diff --git a/src/platform/plugins/shared/saved_search/common/index.ts b/src/platform/plugins/shared/saved_search/common/index.ts index 491eaee1135eb..1a2612ff5b2ff 100644 --- a/src/platform/plugins/shared/saved_search/common/index.ts +++ b/src/platform/plugins/shared/saved_search/common/index.ts @@ -7,15 +7,17 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { getSavedSearchUrl, getSavedSearchFullPathUrl } from './saved_searches_url'; +export { getSavedSearchFullPathUrl } from './saved_searches_url'; export { fromSavedSearchAttributes } from './saved_searches_utils'; -export { extractTabs, extractTabsBackfillFn } from './service/extract_tabs'; +export { extractTabs } from './service/extract_tabs'; export type { DiscoverGridSettings, DiscoverGridSettingsColumn, SavedSearch, SavedSearchAttributes, + DiscoverSession, + DiscoverSessionTab, } from './types'; export enum VIEW_MODE { @@ -31,6 +33,5 @@ export { MIN_SAVED_SEARCH_SAMPLE_SIZE, MAX_SAVED_SEARCH_SAMPLE_SIZE, } from './constants'; -export { getKibanaContextFn } from './expressions/kibana_context'; export { toSavedSearchAttributes } from './service/saved_searches_utils'; diff --git a/src/platform/plugins/shared/saved_search/common/service/extract_tabs.test.ts b/src/platform/plugins/shared/saved_search/common/service/extract_tabs.test.ts index f80e181e5d965..a07ddd456e5c2 100644 --- a/src/platform/plugins/shared/saved_search/common/service/extract_tabs.test.ts +++ b/src/platform/plugins/shared/saved_search/common/service/extract_tabs.test.ts @@ -9,7 +9,8 @@ import type { SavedObjectModelTransformationContext } from '@kbn/core-saved-objects-server'; import type { TypeOf } from '@kbn/config-schema'; import type { SCHEMA_SEARCH_MODEL_VERSION_5 } from '../../server/saved_objects/schema'; -import { extractTabs, extractTabsBackfillFn, SavedSearchType, VIEW_MODE } from '..'; +import { extractTabs, extractTabsBackfillFn } from './extract_tabs'; +import { SavedSearchType, VIEW_MODE } from '..'; jest.mock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid'), diff --git a/src/platform/plugins/shared/saved_search/common/service/get_discover_session.ts b/src/platform/plugins/shared/saved_search/common/service/get_discover_session.ts new file mode 100644 index 0000000000000..fb150153bd65f --- /dev/null +++ b/src/platform/plugins/shared/saved_search/common/service/get_discover_session.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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { injectReferences, parseSearchSourceJSON } from '@kbn/data-plugin/common'; +import type { DataGridDensity } from '@kbn/unified-data-table'; +import type { DiscoverSession, SortOrder } from '../types'; +import type { GetSavedSearchDependencies } from './get_saved_searches'; +import { getSearchSavedObject } from './get_saved_searches'; + +export const getDiscoverSession = async ( + discoverSessionId: string, + deps: GetSavedSearchDependencies +): Promise => { + const so = await getSearchSavedObject(discoverSessionId, deps); + const discoverSession: DiscoverSession = { + id: so.item.id, + title: so.item.attributes.title, + description: so.item.attributes.description, + // TODO: so.item.attributes.tabs shouldn't be nullable soon + tabs: so.item.attributes.tabs!.map((tab) => ({ + id: tab.id, + label: tab.label, + sort: tab.attributes.sort as SortOrder[], + columns: tab.attributes.columns, + grid: tab.attributes.grid, + hideChart: tab.attributes.hideChart, + isTextBasedQuery: tab.attributes.isTextBasedQuery, + usesAdHocDataView: tab.attributes.usesAdHocDataView, + serializedSearchSource: injectReferences( + parseSearchSourceJSON(tab.attributes.kibanaSavedObjectMeta?.searchSourceJSON ?? '{}'), + so.item.references + ), + viewMode: tab.attributes.viewMode, + hideAggregatedPreview: tab.attributes.hideAggregatedPreview, + rowHeight: tab.attributes.rowHeight, + headerRowHeight: tab.attributes.headerRowHeight, + timeRestore: tab.attributes.timeRestore, + timeRange: tab.attributes.timeRange, + refreshInterval: tab.attributes.refreshInterval, + rowsPerPage: tab.attributes.rowsPerPage, + sampleSize: tab.attributes.sampleSize, + breakdownField: tab.attributes.breakdownField, + density: tab.attributes.density as DataGridDensity, + visContext: tab.attributes.visContext, + })), + managed: Boolean(so.item.managed), + tags: deps.savedObjectsTagging + ? deps.savedObjectsTagging.ui.getTagIdsFromReferences(so.item.references) + : undefined, + references: so.item.references, + sharingSavedObjectProps: so.meta, + }; + + return discoverSession; +}; diff --git a/src/platform/plugins/shared/saved_search/common/service/get_new_saved_search.ts b/src/platform/plugins/shared/saved_search/common/service/get_new_saved_search.ts new file mode 100644 index 0000000000000..80ec6741379e3 --- /dev/null +++ b/src/platform/plugins/shared/saved_search/common/service/get_new_saved_search.ts @@ -0,0 +1,25 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { ISearchStartSearchSource } from '@kbn/data-plugin/common'; +import type { SavedSearch } from '../types'; + +/** + * Returns a new saved search + * Used when e.g. Discover is opened without a saved search id + * @param search + */ +export const getNewSavedSearch = ({ + searchSource, +}: { + searchSource: ISearchStartSearchSource; +}): SavedSearch => ({ + searchSource: searchSource.createEmpty(), + managed: false, +}); diff --git a/src/platform/plugins/shared/saved_search/common/service/get_saved_searches.test.ts b/src/platform/plugins/shared/saved_search/common/service/get_saved_searches.test.ts index 187912d8d3495..3dc132c9cb74a 100644 --- a/src/platform/plugins/shared/saved_search/common/service/get_saved_searches.test.ts +++ b/src/platform/plugins/shared/saved_search/common/service/get_saved_searches.test.ts @@ -8,9 +8,7 @@ */ import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; - import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; - import { getSavedSearch } from './get_saved_searches'; import type { SavedObjectsTaggingApi } from '@kbn/saved-objects-tagging-oss-plugin/public'; import type { GetSavedSearchDependencies } from './get_saved_searches'; @@ -24,28 +22,6 @@ describe('getSavedSearch', () => { searchSourceCreate = dataPluginMock.createStartContract().search.searchSource.create; }); - test('should throw an error if so not found', async () => { - let errorMessage = 'No error thrown.'; - getSavedSrch = jest.fn().mockReturnValue({ - statusCode: 404, - error: 'Not Found', - message: 'Saved object [ccf1af80-2297-11ec-86e0-1155ffb9c7a7] not found', - }); - - try { - await getSavedSearch('ccf1af80-2297-11ec-86e0-1155ffb9c7a7', { - getSavedSrch, - searchSourceCreate, - }); - } catch (error) { - errorMessage = error.message; - } - - expect(errorMessage).toBe( - 'Could not locate that Discover session (id: ccf1af80-2297-11ec-86e0-1155ffb9c7a7)' - ); - }); - test('should find saved search', async () => { getSavedSrch = jest.fn().mockReturnValue({ item: { diff --git a/src/platform/plugins/shared/saved_search/common/service/get_saved_searches.ts b/src/platform/plugins/shared/saved_search/common/service/get_saved_searches.ts index 497454134302e..8e7dad4dc80b4 100644 --- a/src/platform/plugins/shared/saved_search/common/service/get_saved_searches.ts +++ b/src/platform/plugins/shared/saved_search/common/service/get_saved_searches.ts @@ -22,41 +22,42 @@ import type { SavedSearchCrudTypes } from '../content_management'; export interface GetSavedSearchDependencies { searchSourceCreate: ISearchStartSearchSource['create']; getSavedSrch: (id: string) => Promise; + handleGetSavedSrchError?: (error: unknown, savedSearchId: string) => void; spaces?: SpacesApi; savedObjectsTagging?: SavedObjectsTaggingApi; } const getSavedSearchUrlConflictMessage = async (json: string) => i18n.translate('savedSearch.legacyURLConflict.errorMessage', { - defaultMessage: `This Discover session has the same URL as a legacy alias. Disable the alias to resolve this error : {json}`, + defaultMessage: `This Discover session has the same URL as a legacy alias. Disable the alias to resolve this error: {json}`, values: { json }, }); export const getSearchSavedObject = async ( savedSearchId: string, - { spaces, getSavedSrch }: GetSavedSearchDependencies + { spaces, getSavedSrch, handleGetSavedSrchError }: GetSavedSearchDependencies ) => { - const so = await getSavedSrch(savedSearchId); - - // @ts-expect-error - if (so.error) { - throw new Error(`Could not locate that Discover session (id: ${savedSearchId})`); - } - - if (so.meta.outcome === 'conflict') { - throw new Error( - await getSavedSearchUrlConflictMessage( - JSON.stringify({ - targetType: SAVED_SEARCH_TYPE, - sourceId: savedSearchId, - // front end only - targetSpace: (await spaces?.getActiveSpace())?.id, - }) - ) - ); + try { + const so = await getSavedSrch(savedSearchId); + + if (so.meta.outcome === 'conflict') { + throw new Error( + await getSavedSearchUrlConflictMessage( + JSON.stringify({ + targetType: SAVED_SEARCH_TYPE, + sourceId: savedSearchId, + // front end only + targetSpace: (await spaces?.getActiveSpace())?.id, + }) + ) + ); + } + + return so; + } catch (e) { + handleGetSavedSrchError?.(e, savedSearchId); + throw e; } - - return so; }; export const convertToSavedSearch = async < @@ -135,17 +136,3 @@ export const getSavedSearch = async < return savedSearch as ReturnType; }; - -/** - * Returns a new saved search - * Used when e.g. Discover is opened without a saved search id - * @param search - */ -export const getNewSavedSearch = ({ - searchSource, -}: { - searchSource: ISearchStartSearchSource; -}): SavedSearch => ({ - searchSource: searchSource.createEmpty(), - managed: false, -}); diff --git a/src/platform/plugins/shared/saved_search/common/service/saved_searches_utils.ts b/src/platform/plugins/shared/saved_search/common/service/saved_searches_utils.ts index c7bdb7311aa4d..f0f0b367b77bc 100644 --- a/src/platform/plugins/shared/saved_search/common/service/saved_searches_utils.ts +++ b/src/platform/plugins/shared/saved_search/common/service/saved_searches_utils.ts @@ -10,11 +10,10 @@ import type { SavedObjectReference } from '@kbn/core-saved-objects-server'; import type { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; import { pick } from 'lodash'; -import type { SavedSearch, SavedSearchAttributes } from '..'; -import { extractTabs, fromSavedSearchAttributes as fromSavedSearchAttributesCommon } from '..'; -import type { SerializableSavedSearch } from '../types'; - -export { getSavedSearchFullPathUrl, getSavedSearchUrl } from '..'; +import type { SavedSearch } from '..'; +import { fromSavedSearchAttributes as fromSavedSearchAttributesCommon } from '../saved_searches_utils'; +import type { SavedSearchAttributes, SerializableSavedSearch } from '../types'; +import { extractTabs } from './extract_tabs'; export const fromSavedSearchAttributes = ( id: string | undefined, diff --git a/src/platform/plugins/shared/saved_search/common/types.ts b/src/platform/plugins/shared/saved_search/common/types.ts index 34f26fd2f3c18..775337ef40da2 100644 --- a/src/platform/plugins/shared/saved_search/common/types.ts +++ b/src/platform/plugins/shared/saved_search/common/types.ts @@ -18,7 +18,7 @@ import type { SavedObjectsResolveResponse } from '@kbn/core/server'; import type { SerializableRecord } from '@kbn/utility-types'; import type { DataGridDensity } from '@kbn/unified-data-table'; import type { SortOrder } from '@kbn/discover-utils'; -import type { DiscoverSessionTab } from '../server'; +import type { DiscoverSessionTab as DiscoverSessionTabSchema } from '../server'; import type { VIEW_MODE } from '.'; export interface DiscoverGridSettings extends SerializableRecord { @@ -71,7 +71,7 @@ export interface SavedSearchAttributes { density?: DataGridDensity; visContext?: VisContextUnmapped; - tabs: DiscoverSessionTab[]; + tabs: DiscoverSessionTabSchema[]; } /** @internal **/ @@ -98,3 +98,43 @@ export type SavedSearch = Partial & { export type SerializableSavedSearch = Omit & { serializedSearchSource?: SerializedSearchSourceFields; }; + +export interface DiscoverSessionTab { + id: string; + label: string; + sort: SortOrder[]; + columns: string[]; + grid: DiscoverGridSettings; + hideChart: boolean; + isTextBasedQuery: boolean; + usesAdHocDataView?: boolean; + serializedSearchSource: SerializedSearchSourceFields; + viewMode?: VIEW_MODE; + hideAggregatedPreview?: boolean; + rowHeight?: number; + headerRowHeight?: number; + timeRestore?: boolean; + timeRange?: Pick; + refreshInterval?: RefreshInterval; + rowsPerPage?: number; + sampleSize?: number; + breakdownField?: string; + density?: DataGridDensity; + visContext?: VisContextUnmapped; +} + +export interface DiscoverSession { + id: string; + title: string; + description: string; + tabs: DiscoverSessionTab[]; + managed: boolean; + tags?: string[] | undefined; + references?: SavedObjectReference[]; + sharingSavedObjectProps?: { + outcome?: SavedObjectsResolveResponse['outcome']; + aliasTargetId?: SavedObjectsResolveResponse['alias_target_id']; + aliasPurpose?: SavedObjectsResolveResponse['alias_purpose']; + errorJSON?: string; + }; +} diff --git a/src/platform/plugins/shared/saved_search/kibana.jsonc b/src/platform/plugins/shared/saved_search/kibana.jsonc index 820d42662ff1c..6d02dd0bcb211 100644 --- a/src/platform/plugins/shared/saved_search/kibana.jsonc +++ b/src/platform/plugins/shared/saved_search/kibana.jsonc @@ -21,7 +21,7 @@ "spaces", "savedObjectsTaggingOss" ], - "requiredBundles": [], + "requiredBundles": ["kibanaUtils"], "extraPublicDirs": [ "common" ] diff --git a/src/platform/plugins/shared/saved_search/public/expressions/kibana_context.ts b/src/platform/plugins/shared/saved_search/public/expressions/kibana_context.ts index c67fb5459fdc4..146f8038c11c8 100644 --- a/src/platform/plugins/shared/saved_search/public/expressions/kibana_context.ts +++ b/src/platform/plugins/shared/saved_search/public/expressions/kibana_context.ts @@ -8,7 +8,7 @@ */ import type { StartServicesAccessor } from '@kbn/core/public'; -import { getKibanaContextFn } from '../../common'; +import { getKibanaContextFn } from '../../common/expressions/kibana_context'; import type { SavedSearchPublicPluginStart, SavedSearchPublicStartDependencies } from '../plugin'; /** diff --git a/src/platform/plugins/shared/saved_search/public/index.ts b/src/platform/plugins/shared/saved_search/public/index.ts index 94c75b034ec30..1d70e6ab8e563 100644 --- a/src/platform/plugins/shared/saved_search/public/index.ts +++ b/src/platform/plugins/shared/saved_search/public/index.ts @@ -11,13 +11,13 @@ import { SavedSearchPublicPlugin } from './plugin'; export type { SortOrder } from '../common/types'; export type { - SavedSearch, - SaveSavedSearchOptions, - SavedSearchByValueAttributes, - SavedSearchUnwrapMetaInfo, - SavedSearchUnwrapResult, -} from './services/saved_searches'; -export { getSavedSearchFullPathUrl, getSavedSearchUrl } from './services/saved_searches'; + SaveDiscoverSessionOptions, + SaveDiscoverSessionParams, +} from './service/save_discover_session'; +export type { SaveSavedSearchOptions } from './service/save_saved_searches'; +export type { SavedSearchUnwrapMetaInfo, SavedSearchUnwrapResult } from './service/to_saved_search'; +export type { SavedSearch, SavedSearchByValueAttributes } from './service/types'; +export { getSavedSearchFullPathUrl, getSavedSearchUrl } from '../common/saved_searches_url'; export { VIEW_MODE } from '../common'; export type { SavedSearchPublicPluginStart } from './plugin'; diff --git a/src/platform/plugins/shared/saved_search/public/mocks.ts b/src/platform/plugins/shared/saved_search/public/mocks.ts index 0ad6b9807171a..c9b8e0eb253b0 100644 --- a/src/platform/plugins/shared/saved_search/public/mocks.ts +++ b/src/platform/plugins/shared/saved_search/public/mocks.ts @@ -15,7 +15,7 @@ import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; import type { SavedSearchPublicPluginStart } from './plugin'; import type { SavedSearch } from '../common'; import type { SerializableSavedSearch } from '../common/types'; -import type { SavedSearchUnwrapResult } from './services/saved_searches'; +import type { SavedSearchUnwrapResult } from './service/to_saved_search'; const createEmptySearchSource = jest.fn(() => { const deps = { @@ -60,11 +60,13 @@ const savedSearchStartMock = (): SavedSearchPublicPluginStart => ({ serialized ) ), + getDiscoverSession: jest.fn(), getAll: jest.fn(), getNew: jest.fn().mockImplementation(() => ({ searchSource: createEmptySearchSource(), })), save: jest.fn(), + saveDiscoverSession: jest.fn(), checkForDuplicateTitle: jest.fn(), byValueToSavedSearch: toSavedSearchMock, }); diff --git a/src/platform/plugins/shared/saved_search/public/plugin.ts b/src/platform/plugins/shared/saved_search/public/plugin.ts index 1ce9cfa1c9554..4e43f0aa64d45 100644 --- a/src/platform/plugins/shared/saved_search/public/plugin.ts +++ b/src/platform/plugins/shared/saved_search/public/plugin.ts @@ -20,19 +20,25 @@ import { i18n } from '@kbn/i18n'; import type { 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 { once } from 'lodash'; import { LATEST_VERSION, SavedSearchType } from '../common'; import { kibanaContext } from '../common/expressions'; -import type { SavedSearch, SavedSearchAttributes, SerializableSavedSearch } from '../common/types'; +import type { + DiscoverSession, + SavedSearch, + SavedSearchAttributes, + SerializableSavedSearch, +} from '../common/types'; import { getKibanaContext } from './expressions/kibana_context'; +import type { SavedSearchesServiceDeps } from './service/saved_searches_service'; +import type { SaveSavedSearchOptions, saveSavedSearch } from './service/save_saved_searches'; import type { - getNewSavedSearch, - SavedSearchUnwrapResult, - saveSavedSearch, - SaveSavedSearchOptions, -} from './services/saved_searches'; -import { byValueToSavedSearch } from './services/saved_searches'; -import { checkForDuplicateTitle } from './services/saved_searches/check_for_duplicate_title'; -import { SavedSearchesService } from './services/saved_searches/saved_searches_service'; + SaveDiscoverSessionOptions, + SaveDiscoverSessionParams, + saveDiscoverSession, +} from './service/save_discover_session'; +import type { SavedSearchUnwrapResult } from './service/to_saved_search'; +import { getNewSavedSearch } from '../common/service/get_new_saved_search'; /** * Saved search plugin public Setup contract @@ -41,19 +47,24 @@ import { SavedSearchesService } from './services/saved_searches/saved_searches_s export interface SavedSearchPublicPluginSetup {} /** - * Saved search plugin public Setup contract + * Saved search plugin public Start contract */ export interface SavedSearchPublicPluginStart { get: ( savedSearchId: string, serialized?: Serialized ) => Promise; + getDiscoverSession: (discoverSessionId: string) => Promise; getNew: () => ReturnType; getAll: () => Promise>>; save: ( savedSearch: SavedSearch, options?: SaveSavedSearchOptions ) => ReturnType; + saveDiscoverSession: ( + discoverSession: SaveDiscoverSessionParams, + options?: SaveDiscoverSessionOptions + ) => ReturnType; checkForDuplicateTitle: ( props: Pick ) => Promise; @@ -64,7 +75,7 @@ export interface SavedSearchPublicPluginStart { } /** - * Saved search plugin public Setup contract + * Saved search plugin public Setup dependencies */ export interface SavedSearchPublicSetupDependencies { embeddable: EmbeddableSetup; @@ -73,7 +84,7 @@ export interface SavedSearchPublicSetupDependencies { } /** - * Saved search plugin public Setup contract + * Saved search plugin public Start dependencies */ export interface SavedSearchPublicStartDependencies { data: DataPublicPluginStart; @@ -94,7 +105,7 @@ export class SavedSearchPublicPlugin { public setup( { getStartServices }: CoreSetup, - { contentManagement, expressions, embeddable }: SavedSearchPublicSetupDependencies + { contentManagement, expressions }: SavedSearchPublicSetupDependencies ) { contentManagement.registry.register({ id: SavedSearchType, @@ -127,42 +138,52 @@ export class SavedSearchPublicPlugin spaces, savedObjectsTaggingOss, contentManagement: { client: contentManagement }, - embeddable, }: SavedSearchPublicStartDependencies ): SavedSearchPublicPluginStart { - const deps = { search, spaces, savedObjectsTaggingOss, contentManagement, embeddable }; - const service = new SavedSearchesService(deps); + const deps: SavedSearchesServiceDeps = { + search, + spaces, + savedObjectsTaggingOss, + contentManagement, + }; return { - get: ( - savedSearchId: string, - serialized?: Serialized - ): Promise => - service.get(savedSearchId, serialized), - getAll: () => service.getAll(), - getNew: () => service.getNew(), - save: (savedSearch: SavedSearch, options?: SaveSavedSearchOptions) => { + get: async (savedSearchId, serialized) => { + const service = await getSavedSearchesService(deps); + return service.get(savedSearchId, serialized); + }, + getDiscoverSession: async (discoverSessionId) => { + const service = await getSavedSearchesService(deps); + return service.getDiscoverSession(discoverSessionId); + }, + getAll: async () => { + const service = await getSavedSearchesService(deps); + return service.getAll(); + }, + getNew: () => { + return getNewSavedSearch({ searchSource: search.searchSource }); + }, + save: async (savedSearch, options) => { + const service = await getSavedSearchesService(deps); return service.save(savedSearch, options); }, - checkForDuplicateTitle: ( - props: Pick - ) => { - return checkForDuplicateTitle({ - title: props.newTitle, - isTitleDuplicateConfirmed: props.isTitleDuplicateConfirmed, - onTitleDuplicate: props.onTitleDuplicate, - contentManagement: deps.contentManagement, - }); + saveDiscoverSession: async (discoverSession, options) => { + const service = await getSavedSearchesService(deps); + return service.saveDiscoverSession(discoverSession, options); + }, + checkForDuplicateTitle: async (props) => { + const service = await getSavedSearchesService(deps); + return service.checkForDuplicateTitle(props); }, - 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; + byValueToSavedSearch: async (result, serialized) => { + const service = await getSavedSearchesService(deps); + return service.byValueToSavedSearch(result, serialized); }, }; } } + +const getSavedSearchesService = once(async (deps: SavedSearchesServiceDeps) => { + const { SavedSearchesService } = await import('./service/saved_searches_service'); + return new SavedSearchesService(deps); +}); diff --git a/src/platform/plugins/shared/saved_search/public/services/saved_searches/check_for_duplicate_title.ts b/src/platform/plugins/shared/saved_search/public/service/check_for_duplicate_title.ts similarity index 95% rename from src/platform/plugins/shared/saved_search/public/services/saved_searches/check_for_duplicate_title.ts rename to src/platform/plugins/shared/saved_search/public/service/check_for_duplicate_title.ts index 6eb364afec12f..1f9e8e1e3ed1a 100644 --- a/src/platform/plugins/shared/saved_search/public/services/saved_searches/check_for_duplicate_title.ts +++ b/src/platform/plugins/shared/saved_search/public/service/check_for_duplicate_title.ts @@ -8,7 +8,7 @@ */ import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; -import type { SavedSearchCrudTypes } from '../../../common/content_management'; +import type { SavedSearchCrudTypes } from '../../common/content_management'; import { SAVED_SEARCH_TYPE } from './constants'; const hasDuplicatedTitle = async ( diff --git a/src/platform/plugins/shared/saved_search/public/services/saved_searches/constants.ts b/src/platform/plugins/shared/saved_search/public/service/constants.ts similarity index 100% rename from src/platform/plugins/shared/saved_search/public/services/saved_searches/constants.ts rename to src/platform/plugins/shared/saved_search/public/service/constants.ts diff --git a/src/platform/plugins/shared/saved_search/public/service/create_get_saved_search_deps.test.ts b/src/platform/plugins/shared/saved_search/public/service/create_get_saved_search_deps.test.ts new file mode 100644 index 0000000000000..1e7eabdba0053 --- /dev/null +++ b/src/platform/plugins/shared/saved_search/public/service/create_get_saved_search_deps.test.ts @@ -0,0 +1,46 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { contentManagementMock } from '@kbn/content-management-plugin/public/mocks'; +import { createGetSavedSearchDeps } from './create_get_saved_search_deps'; +import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; +import { HttpFetchError } from '@kbn/core-http-browser-internal/src/http_fetch_error'; +import { getSavedSearch } from '../../common/service/get_saved_searches'; + +describe('createGetSavedSearchDeps', () => { + test('should throw an error if SO not found', async () => { + const getSavedSearchDeps = createGetSavedSearchDeps({ + search: dataPluginMock.createStartContract().search, + contentManagement: contentManagementMock.createStartContract().client, + }); + + jest + .spyOn(getSavedSearchDeps, 'getSavedSrch') + .mockRejectedValue( + new HttpFetchError( + 'Not found', + 'NotFound', + new Request(''), + new Response(undefined, { status: 404 }) + ) + ); + + let errorMessage = 'No error thrown.'; + + try { + await getSavedSearch('ccf1af80-2297-11ec-86e0-1155ffb9c7a7', getSavedSearchDeps); + } catch (error) { + errorMessage = error.message; + } + + expect(errorMessage).toBe( + 'Could not locate that Discover session (id: ccf1af80-2297-11ec-86e0-1155ffb9c7a7)' + ); + }); +}); diff --git a/src/platform/plugins/shared/saved_search/public/services/saved_searches/create_get_saved_search_deps.ts b/src/platform/plugins/shared/saved_search/public/service/create_get_saved_search_deps.ts similarity index 65% rename from src/platform/plugins/shared/saved_search/public/services/saved_searches/create_get_saved_search_deps.ts rename to src/platform/plugins/shared/saved_search/public/service/create_get_saved_search_deps.ts index d45bc89979f16..554c9e25a7977 100644 --- a/src/platform/plugins/shared/saved_search/public/services/saved_searches/create_get_saved_search_deps.ts +++ b/src/platform/plugins/shared/saved_search/public/service/create_get_saved_search_deps.ts @@ -7,9 +7,12 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { type SavedSearchCrudTypes, SavedSearchType } from '../../../common/content_management'; -import type { GetSavedSearchDependencies } from '../../../common/service/get_saved_searches'; +import { isHttpFetchError } from '@kbn/core-http-browser'; +import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/public'; +import { type SavedSearchCrudTypes, SavedSearchType } from '../../common/content_management'; +import type { GetSavedSearchDependencies } from '../../common/service/get_saved_searches'; import type { SavedSearchesServiceDeps } from './saved_searches_service'; +import { SAVED_SEARCH_TYPE } from './constants'; export const createGetSavedSearchDeps = ({ spaces, @@ -26,4 +29,13 @@ export const createGetSavedSearchDeps = ({ id, }); }, + handleGetSavedSrchError: (error, savedSearchId) => { + if (isHttpFetchError(error) && error.response?.status === 404) { + throw new SavedObjectNotFound({ + type: SAVED_SEARCH_TYPE, + typeDisplayName: 'Discover session', + id: savedSearchId, + }); + } + }, }); diff --git a/src/platform/plugins/shared/saved_search/public/service/save_discover_session.ts b/src/platform/plugins/shared/saved_search/public/service/save_discover_session.ts new file mode 100644 index 0000000000000..214468f67b591 --- /dev/null +++ b/src/platform/plugins/shared/saved_search/public/service/save_discover_session.ts @@ -0,0 +1,149 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { SavedObjectsTaggingApi } from '@kbn/saved-objects-tagging-oss-plugin/public'; +import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; +import type { Reference } from '@kbn/content-management-utils'; +import { extractReferences } from '@kbn/data-plugin/common'; +import type { SavedObjectReference } from '@kbn/core/server'; +import type { SortOrder } from '@kbn/discover-utils'; +import type { DataGridDensity } from '@kbn/unified-data-table'; +import { SAVED_SEARCH_TYPE } from './constants'; +import type { SavedSearchCrudTypes } from '../../common/content_management'; +import { checkForDuplicateTitle } from './check_for_duplicate_title'; +import type { DiscoverSession, SavedSearchAttributes } from '../../common'; + +export type SaveDiscoverSessionParams = Pick< + DiscoverSession, + 'title' | 'description' | 'tabs' | 'tags' +> & + Partial>; + +export interface SaveDiscoverSessionOptions { + onTitleDuplicate?: () => void; + isTitleDuplicateConfirmed?: boolean; + copyOnSave?: boolean; +} + +const saveDiscoverSessionSavedObject = async ( + id: string | undefined, + attributes: SavedSearchAttributes, + references: Reference[] | undefined, + contentManagement: ContentManagementPublicStart['client'] +) => { + const resp = id + ? await contentManagement.update< + SavedSearchCrudTypes['UpdateIn'], + SavedSearchCrudTypes['UpdateOut'] + >({ + contentTypeId: SAVED_SEARCH_TYPE, + id, + data: attributes, + options: { + references, + }, + }) + : await contentManagement.create< + SavedSearchCrudTypes['CreateIn'], + SavedSearchCrudTypes['CreateOut'] + >({ + contentTypeId: SAVED_SEARCH_TYPE, + data: attributes, + options: { + references, + }, + }); + + return resp.item.id; +}; + +export const saveDiscoverSession = async ( + discoverSession: SaveDiscoverSessionParams, + options: SaveDiscoverSessionOptions, + contentManagement: ContentManagementPublicStart['client'], + savedObjectsTagging: SavedObjectsTaggingApi | undefined +): Promise => { + const isNew = options.copyOnSave || !discoverSession.id; + + if (isNew) { + try { + await checkForDuplicateTitle({ + title: discoverSession.title, + isTitleDuplicateConfirmed: options.isTitleDuplicateConfirmed, + onTitleDuplicate: options.onTitleDuplicate, + contentManagement, + }); + } catch { + return; + } + } + + const tabReferences: SavedObjectReference[] = []; + + // TODO: SavedSearchAttributes['tabs'] shouldn't be nullable soon + const tabs: NonNullable = discoverSession.tabs.map((tab) => { + const [serializedSearchSource, searchSourceReferences] = extractReferences( + tab.serializedSearchSource, + { refNamePrefix: `tab_${tab.id}` } + ); + + tabReferences.push(...searchSourceReferences); + + return { + id: tab.id, + label: tab.label, + attributes: { + sort: tab.sort, + columns: tab.columns, + grid: tab.grid, + hideChart: tab.hideChart, + isTextBasedQuery: tab.isTextBasedQuery, + usesAdHocDataView: tab.usesAdHocDataView, + kibanaSavedObjectMeta: { + searchSourceJSON: JSON.stringify(serializedSearchSource), + }, + viewMode: tab.viewMode, + hideAggregatedPreview: tab.hideAggregatedPreview, + rowHeight: tab.rowHeight, + headerRowHeight: tab.headerRowHeight, + timeRestore: tab.timeRestore, + timeRange: tab.timeRange, + refreshInterval: tab.refreshInterval, + rowsPerPage: tab.rowsPerPage, + sampleSize: tab.sampleSize, + breakdownField: tab.breakdownField, + density: tab.density, + visContext: tab.visContext, + }, + }; + }); + + const attributes: SavedSearchAttributes = { + title: discoverSession.title, + description: discoverSession.description, + tabs, + // TODO: Spreading the first tab attributes like this shouldn't be necessary soon + ...tabs[0].attributes, + sort: tabs[0].attributes.sort as SortOrder[], + density: tabs[0].attributes.density as DataGridDensity, + }; + + const references = savedObjectsTagging + ? savedObjectsTagging.ui.updateTagsReferences(tabReferences, discoverSession.tags ?? []) + : tabReferences; + + const id = await saveDiscoverSessionSavedObject( + isNew ? undefined : discoverSession.id, + attributes, + references, + contentManagement + ); + + return { ...discoverSession, id, references, managed: false }; +}; diff --git a/src/platform/plugins/shared/saved_search/public/services/saved_searches/save_saved_searches.test.ts b/src/platform/plugins/shared/saved_search/public/service/save_saved_searches.test.ts similarity index 100% rename from src/platform/plugins/shared/saved_search/public/services/saved_searches/save_saved_searches.test.ts rename to src/platform/plugins/shared/saved_search/public/service/save_saved_searches.test.ts diff --git a/src/platform/plugins/shared/saved_search/public/services/saved_searches/save_saved_searches.ts b/src/platform/plugins/shared/saved_search/public/service/save_saved_searches.ts similarity index 92% rename from src/platform/plugins/shared/saved_search/public/services/saved_searches/save_saved_searches.ts rename to src/platform/plugins/shared/saved_search/public/service/save_saved_searches.ts index 91468e8a8a8c1..673d5a32f19ed 100644 --- a/src/platform/plugins/shared/saved_search/public/services/saved_searches/save_saved_searches.ts +++ b/src/platform/plugins/shared/saved_search/public/service/save_saved_searches.ts @@ -10,12 +10,12 @@ import type { SavedObjectsTaggingApi } from '@kbn/saved-objects-tagging-oss-plugin/public'; import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; import type { Reference } from '@kbn/content-management-utils'; -import type { SavedSearchAttributes } from '../../../common'; import type { SavedSearch } from './types'; import { SAVED_SEARCH_TYPE } from './constants'; -import { toSavedSearchAttributes } from '../../../common/service/saved_searches_utils'; -import type { SavedSearchCrudTypes } from '../../../common/content_management'; +import { toSavedSearchAttributes } from '../../common/service/saved_searches_utils'; +import type { SavedSearchCrudTypes } from '../../common/content_management'; import { checkForDuplicateTitle } from './check_for_duplicate_title'; +import type { SavedSearchAttributes } from '../../common'; export interface SaveSavedSearchOptions { onTitleDuplicate?: () => void; diff --git a/src/platform/plugins/shared/saved_search/public/services/saved_searches/saved_searches_service.ts b/src/platform/plugins/shared/saved_search/public/service/saved_searches_service.ts similarity index 55% rename from src/platform/plugins/shared/saved_search/public/services/saved_searches/saved_searches_service.ts rename to src/platform/plugins/shared/saved_search/public/service/saved_searches_service.ts index 3e5405d99c6a5..c8754708ccf1a 100644 --- a/src/platform/plugins/shared/saved_search/public/services/saved_searches/saved_searches_service.ts +++ b/src/platform/plugins/shared/saved_search/public/service/saved_searches_service.ts @@ -11,12 +11,23 @@ import type { ContentManagementPublicStart } from '@kbn/content-management-plugi import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { SavedObjectTaggingOssPluginStart } from '@kbn/saved-objects-tagging-oss-plugin/public'; import type { SpacesApi } from '@kbn/spaces-plugin/public'; -import type { SaveSavedSearchOptions } from '.'; -import { getNewSavedSearch, getSavedSearch, saveSavedSearch } from '.'; -import { SavedSearchType } from '../../../common'; -import type { SavedSearchCrudTypes } from '../../../common/content_management'; -import type { SavedSearch, SerializableSavedSearch } from '../../../common/types'; +import type { OnSaveProps } from '@kbn/saved-objects-plugin/public'; +import { SavedSearchType } from '../../common'; +import type { SavedSearchCrudTypes } from '../../common/content_management'; +import type { SavedSearch, SerializableSavedSearch } from '../../common/types'; import { createGetSavedSearchDeps } from './create_get_saved_search_deps'; +import { getDiscoverSession } from '../../common/service/get_discover_session'; +import { getSavedSearch } from '../../common/service/get_saved_searches'; +import type { SaveSavedSearchOptions } from './save_saved_searches'; +import { saveSavedSearch } from './save_saved_searches'; +import type { + SaveDiscoverSessionOptions, + SaveDiscoverSessionParams, +} from './save_discover_session'; +import { saveDiscoverSession } from './save_discover_session'; +import type { SavedSearchUnwrapResult } from './to_saved_search'; +import { checkForDuplicateTitle } from './check_for_duplicate_title'; +import { byValueToSavedSearch } from './to_saved_search'; export interface SavedSearchesServiceDeps { search: DataPublicPluginStart['search']; @@ -34,6 +45,11 @@ export class SavedSearchesService { ): Promise => { return getSavedSearch(savedSearchId, createGetSavedSearchDeps(this.deps), serialized); }; + + getDiscoverSession = (discoverSessionId: string) => { + return getDiscoverSession(discoverSessionId, createGetSavedSearchDeps(this.deps)); + }; + getAll = async () => { const { contentManagement } = this.deps; const result = await contentManagement.search< @@ -45,7 +61,6 @@ export class SavedSearchesService { }); return result.hits; }; - getNew = () => getNewSavedSearch({ searchSource: this.deps.search.searchSource }); find = async (search: string) => { const { contentManagement } = this.deps; @@ -74,4 +89,35 @@ export class SavedSearchesService { savedObjectsTaggingOss?.getTaggingApi() ); }; + + saveDiscoverSession = ( + discoverSession: SaveDiscoverSessionParams, + options: SaveDiscoverSessionOptions = {} + ) => { + const { contentManagement, savedObjectsTaggingOss } = this.deps; + return saveDiscoverSession( + discoverSession, + options, + contentManagement, + savedObjectsTaggingOss?.getTaggingApi() + ); + }; + + checkForDuplicateTitle = ( + props: Pick + ): Promise => { + return checkForDuplicateTitle({ + title: props.newTitle, + isTitleDuplicateConfirmed: props.isTitleDuplicateConfirmed, + onTitleDuplicate: props.onTitleDuplicate, + contentManagement: this.deps.contentManagement, + }); + }; + + byValueToSavedSearch = ( + result: SavedSearchUnwrapResult, + serialized?: Serialized + ): Promise => { + return byValueToSavedSearch(result, this.deps, serialized); + }; } diff --git a/src/platform/plugins/shared/saved_search/public/services/saved_searches/to_saved_search.test.ts b/src/platform/plugins/shared/saved_search/public/service/to_saved_search.test.ts similarity index 98% rename from src/platform/plugins/shared/saved_search/public/services/saved_searches/to_saved_search.test.ts rename to src/platform/plugins/shared/saved_search/public/service/to_saved_search.test.ts index f1b0eee6355df..2e74df2eaf291 100644 --- a/src/platform/plugins/shared/saved_search/public/services/saved_searches/to_saved_search.test.ts +++ b/src/platform/plugins/shared/saved_search/public/service/to_saved_search.test.ts @@ -10,9 +10,9 @@ import { contentManagementMock } from '@kbn/content-management-plugin/public/mocks'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { spacesPluginMock } from '@kbn/spaces-plugin/public/mocks'; -import type { SavedSearchByValueAttributes } from '.'; -import { byValueToSavedSearch } from '.'; -import type { DiscoverSessionTab } from '../../../server'; +import type { SavedSearchByValueAttributes } from './types'; +import { byValueToSavedSearch } from './to_saved_search'; +import type { DiscoverSessionTab } from '../../server'; const mockServices = { contentManagement: contentManagementMock.createStartContract().client, diff --git a/src/platform/plugins/shared/saved_search/public/services/saved_searches/to_saved_search.ts b/src/platform/plugins/shared/saved_search/public/service/to_saved_search.ts similarity index 81% rename from src/platform/plugins/shared/saved_search/public/services/saved_searches/to_saved_search.ts rename to src/platform/plugins/shared/saved_search/public/service/to_saved_search.ts index 2c2aaca305826..ae202d8f55be0 100644 --- a/src/platform/plugins/shared/saved_search/public/services/saved_searches/to_saved_search.ts +++ b/src/platform/plugins/shared/saved_search/public/service/to_saved_search.ts @@ -7,7 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { convertToSavedSearch } from '../../../common/service/get_saved_searches'; +import type { SerializableSavedSearch } from '../../common/types'; +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'; @@ -22,11 +23,14 @@ export interface SavedSearchUnwrapResult { metaInfo?: SavedSearchUnwrapMetaInfo; } -export const byValueToSavedSearch = async ( +export const byValueToSavedSearch = async < + Serialized extends boolean = false, + ReturnType = Serialized extends true ? SerializableSavedSearch : SavedSearch +>( result: SavedSearchUnwrapResult, services: SavedSearchesServiceDeps, - serializable?: boolean -) => { + serializable?: Serialized +): Promise => { const { sharingSavedObjectProps, managed } = result.metaInfo ?? {}; return await convertToSavedSearch( diff --git a/src/platform/plugins/shared/saved_search/public/services/saved_searches/types.ts b/src/platform/plugins/shared/saved_search/public/service/types.ts similarity index 97% rename from src/platform/plugins/shared/saved_search/public/services/saved_searches/types.ts rename to src/platform/plugins/shared/saved_search/public/service/types.ts index 0d3f748d02b52..2bcb586dd2b9b 100644 --- a/src/platform/plugins/shared/saved_search/public/services/saved_searches/types.ts +++ b/src/platform/plugins/shared/saved_search/public/service/types.ts @@ -9,7 +9,7 @@ import type { Reference } from '@kbn/content-management-utils'; import type { SavedObjectsResolveResponse } from '@kbn/core-saved-objects-api-server'; -import type { SavedSearch as SavedSearchCommon, SavedSearchAttributes } from '../../../common'; +import type { SavedSearch as SavedSearchCommon, SavedSearchAttributes } from '../../common'; /** @public **/ export interface SavedSearch extends SavedSearchCommon { diff --git a/src/platform/plugins/shared/saved_search/public/services/saved_searches/index.ts b/src/platform/plugins/shared/saved_search/public/services/saved_searches/index.ts deleted file mode 100644 index 7e08dfb20b181..0000000000000 --- a/src/platform/plugins/shared/saved_search/public/services/saved_searches/index.ts +++ /dev/null @@ -1,23 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -export { getSavedSearch, getNewSavedSearch } from '../../../common/service/get_saved_searches'; -export { - getSavedSearchUrl, - getSavedSearchFullPathUrl, -} from '../../../common/service/saved_searches_utils'; -export type { SaveSavedSearchOptions } from './save_saved_searches'; -export { saveSavedSearch } from './save_saved_searches'; -export { SAVED_SEARCH_TYPE } from './constants'; -export type { SavedSearch, SavedSearchByValueAttributes } from './types'; -export { - byValueToSavedSearch, - type SavedSearchUnwrapMetaInfo, - type SavedSearchUnwrapResult, -} from './to_saved_search'; diff --git a/src/platform/plugins/shared/saved_search/server/expressions/kibana_context.ts b/src/platform/plugins/shared/saved_search/server/expressions/kibana_context.ts index 7243fbd095458..cc5345db8cb2a 100644 --- a/src/platform/plugins/shared/saved_search/server/expressions/kibana_context.ts +++ b/src/platform/plugins/shared/saved_search/server/expressions/kibana_context.ts @@ -8,7 +8,7 @@ */ import type { StartServicesAccessor } from '@kbn/core/server'; -import { getKibanaContextFn } from '../../common'; +import { getKibanaContextFn } from '../../common/expressions/kibana_context'; import type { SavedSearchServerStartDeps } from '../plugin'; import { getSavedSearch } from '../../common/service/get_saved_searches'; import type { SavedSearchAttributes } from '../../common/types'; diff --git a/src/platform/plugins/shared/saved_search/server/services/saved_searches/get_saved_searches.ts b/src/platform/plugins/shared/saved_search/server/services/saved_searches/get_saved_searches.ts index 09d3353472f0b..ebc9d305ee195 100644 --- a/src/platform/plugins/shared/saved_search/server/services/saved_searches/get_saved_searches.ts +++ b/src/platform/plugins/shared/saved_search/server/services/saved_searches/get_saved_searches.ts @@ -10,8 +10,8 @@ import type { SavedObject, SavedObjectsClientContract } from '@kbn/core/server'; import type { ISearchStartSearchSource } from '@kbn/data-plugin/common'; import { injectReferences, parseSearchSourceJSON } from '@kbn/data-plugin/common'; +import { fromSavedSearchAttributes } from '../../../common/saved_searches_utils'; import type { SavedSearchAttributes } from '../../../common'; -import { fromSavedSearchAttributes } from '../../../common'; interface GetSavedSearchDependencies { savedObjects: SavedObjectsClientContract; diff --git a/src/platform/plugins/shared/saved_search/tsconfig.json b/src/platform/plugins/shared/saved_search/tsconfig.json index 0d4132a2cbfce..ce7c5dbb013e0 100644 --- a/src/platform/plugins/shared/saved_search/tsconfig.json +++ b/src/platform/plugins/shared/saved_search/tsconfig.json @@ -29,6 +29,8 @@ "@kbn/search-types", "@kbn/unified-data-table", "@kbn/core-saved-objects-api-server", + "@kbn/core-http-browser-internal", + "@kbn/core-http-browser", "@kbn/discover-utils", ], "exclude": ["target/**/*"] diff --git a/src/platform/plugins/shared/visualizations/public/utils/saved_visualize_utils.ts b/src/platform/plugins/shared/visualizations/public/utils/saved_visualize_utils.ts index c547125705310..5b69a3337bbf2 100644 --- a/src/platform/plugins/shared/visualizations/public/utils/saved_visualize_utils.ts +++ b/src/platform/plugins/shared/visualizations/public/utils/saved_visualize_utils.ts @@ -18,6 +18,7 @@ import { } from '@kbn/data-plugin/public'; import type { SavedObjectsTaggingApi } from '@kbn/saved-objects-tagging-oss-plugin/public'; import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; +import { DATA_VIEW_SAVED_OBJECT_TYPE } from '@kbn/data-views-plugin/public'; import type { VisualizationSavedObject } from '../../common/content_management'; import { checkForDuplicateTitle, saveWithConfirmation } from './saved_objects_utils'; import type { VisualizationsAppExtension } from '../vis_types/vis_type_alias_registry'; @@ -231,7 +232,7 @@ export async function getSavedVisualization( } = await visualizationsClient.get(id); if (!resp.id) { - throw new SavedObjectNotFound(SAVED_VIS_TYPE, id || ''); + throw new SavedObjectNotFound({ type: SAVED_VIS_TYPE, id: id || '' }); } const attributes = _.cloneDeep(resp.attributes); @@ -417,4 +418,4 @@ export async function saveVisualization( } export const shouldShowMissedDataViewError = (error: Error): error is SavedObjectNotFound => - error instanceof SavedObjectNotFound && error.savedObjectType === 'data view'; + error instanceof SavedObjectNotFound && error.savedObjectType === DATA_VIEW_SAVED_OBJECT_TYPE; diff --git a/src/platform/test/functional/apps/discover/group3/_request_counts.ts b/src/platform/test/functional/apps/discover/group3/_request_counts.ts index 103542621f4eb..3773a0f1759f6 100644 --- a/src/platform/test/functional/apps/discover/group3/_request_counts.ts +++ b/src/platform/test/functional/apps/discover/group3/_request_counts.ts @@ -111,7 +111,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { query2: string; setQuery: (query: string) => Promise; }) => { - it(`should send 2 search requests (documents + chart) on page load`, async () => { + it('should send 2 search requests (documents + chart) on page load', async () => { if (type === 'ese') { await browser.refresh(); } @@ -127,20 +127,20 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { } }); - it(`should send 2 requests (documents + chart) when refreshing`, async () => { + it('should send 2 requests (documents + chart) when refreshing', async () => { await expectSearches(type, 2, async () => { await queryBar.clickQuerySubmitButton(); }); }); - it(`should send 2 requests (documents + chart) when changing the query`, async () => { + it('should send 2 requests (documents + chart) when changing the query', async () => { await expectSearches(type, 2, async () => { await setQuery(query1); await queryBar.clickQuerySubmitButton(); }); }); - it(`should send 2 requests (documents + chart) when changing the time range`, async () => { + it('should send 2 requests (documents + chart) when changing the time range', async () => { await expectSearches(type, 2, async () => { await timePicker.setAbsoluteRange( 'Sep 21, 2015 @ 06:31:44.000', @@ -149,7 +149,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); - it(`should send no requests (documents + chart) when toggling the chart visibility`, async () => { + it('should send no requests (documents + chart) when toggling the chart visibility', async () => { await expectSearches(type, 0, async () => { // hide chart await discover.toggleChartVisibility(); @@ -158,7 +158,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); - it(`should send a request for chart data when toggling the chart visibility after a time range change`, async () => { + it('should send a request for chart data when toggling the chart visibility after a time range change', async () => { // hide chart await discover.toggleChartVisibility(); await timePicker.setAbsoluteRange( @@ -172,7 +172,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); - it(`should send no more than 2 requests for saved search changes`, async () => { + it('should send expected requests for saved search changes', async () => { await setQuery(query1); await queryBar.clickQuerySubmitButton(); await timePicker.setAbsoluteRange( @@ -181,7 +181,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); await waitForLoadingToFinish(); log.debug('Creating saved search'); - await expectSearches(type, 2, async () => { + await expectSearches(type, 0, async () => { await discover.saveSearch(savedSearch); }); log.debug('Resetting saved search'); diff --git a/src/platform/test/functional/apps/discover/group7/_search_on_page_load.ts b/src/platform/test/functional/apps/discover/group7/_search_on_page_load.ts index 34b5eec651579..71810faf0a497 100644 --- a/src/platform/test/functional/apps/discover/group7/_search_on_page_load.ts +++ b/src/platform/test/functional/apps/discover/group7/_search_on_page_load.ts @@ -140,7 +140,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await unifiedFieldList.doesSidebarShowFields()).to.be(true); }); - it('should fetch data when a search is saved', async function () { + it('should not fetch data when a search is saved', async function () { await discover.selectIndexPattern('logstash-*'); await retry.waitFor('number of fetches to be 0', waitForFetches(0)); @@ -148,8 +148,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await discover.saveSearch(savedSearchName); - await retry.waitFor('number of fetches to be 1', waitForFetches(1)); - expect(await unifiedFieldList.doesSidebarShowFields()).to.be(true); + await retry.waitFor('number of fetches to be 0', waitForFetches(0)); + expect(await unifiedFieldList.doesSidebarShowFields()).to.be(false); }); it('should reset state after opening a saved search and pressing New', async function () { diff --git a/src/platform/test/functional/apps/discover/tabs/_save_and_load.ts b/src/platform/test/functional/apps/discover/tabs/_save_and_load.ts new file mode 100644 index 0000000000000..f3f0c23dd6326 --- /dev/null +++ b/src/platform/test/functional/apps/discover/tabs/_save_and_load.ts @@ -0,0 +1,549 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import expect from '@kbn/expect'; +import type { FtrProviderContext } from '../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const { discover, timePicker, unifiedFieldList, unifiedTabs, common } = getPageObjects([ + 'discover', + 'timePicker', + 'unifiedFieldList', + 'unifiedTabs', + 'common', + ]); + const dataViews = getService('dataViews'); + const monacoEditor = getService('monacoEditor'); + const queryBar = getService('queryBar'); + const testSubjects = getService('testSubjects'); + const browser = getService('browser'); + + describe('tabs saving and loading', function () { + describe('legacy Discover sessions', () => { + const legacySessionName = 'A Saved Search'; + const updatedSessionName = 'Updated legacy session'; + + it('should load a legacy Discover session into a single untitled tab', async () => { + // Load legacy session + await discover.loadSavedSearch(legacySessionName); + await discover.waitUntilTabIsLoaded(); + + // Validate loaded session + expect(await discover.getSavedSearchTitle()).to.be(legacySessionName); + expect(await unifiedTabs.getTabLabels()).to.eql(['Untitled']); + expect(await discover.getHitCount()).to.be.eql('14,004'); + }); + + it('should allow adding additional tabs to a legacy session and saving as a new session', async () => { + // Load legacy session + await discover.loadSavedSearch(legacySessionName); + await discover.waitUntilTabIsLoaded(); + expect(await discover.getSavedSearchTitle()).to.be(legacySessionName); + expect(await unifiedTabs.getTabLabels()).to.eql(['Untitled']); + + // Create a second tab + await unifiedTabs.createNewTab(); + await discover.waitUntilTabIsLoaded(); + expect(await unifiedTabs.getTabLabels()).to.eql(['Untitled', 'Untitled 2']); + + // Save as new session + await discover.saveSearch(updatedSessionName, true); + expect(await discover.getSavedSearchTitle()).to.be(updatedSessionName); + expect(await unifiedTabs.getTabLabels()).to.eql(['Untitled', 'Untitled 2']); + + // Load legacy session again + await discover.loadSavedSearch(legacySessionName); + await discover.waitUntilTabIsLoaded(); + expect(await discover.getSavedSearchTitle()).to.be(legacySessionName); + expect(await unifiedTabs.getTabLabels()).to.eql(['Untitled']); + + // Load updated session again + await discover.loadSavedSearch(updatedSessionName); + await discover.waitUntilTabIsLoaded(); + expect(await discover.getSavedSearchTitle()).to.be(updatedSessionName); + expect(await unifiedTabs.getTabLabels()).to.eql(['Untitled', 'Untitled 2']); + }); + }); + + describe('multi-tab Discover sessions', () => { + const sessionName = 'Multi-tab Discover session'; + + const persistedTabLabel = 'Persisted data view'; + const persistedTabQuery = 'test'; + const persistedTabTime = { + start: 'Sep 20, 2015 @ 00:00:00.000', + end: 'Sep 22, 2015 @ 00:00:00.000', + }; + const persistedTabDataView = 'logstash-*'; + const persistedTabHitCount = '9'; + + const adHocTabLabel = 'Ad hoc data view'; + const adHocTabQuery = 'extension : jpg'; + const adHocTabTime = { + start: 'Sep 20, 2015 @ 06:00:00.000', + end: 'Sep 22, 2015 @ 06:00:00.000', + }; + const adHocTabDataView = 'logs*'; + const adHocTabHitCount = '6,045'; + + const esqlTabLabel = 'ES|QL'; + const esqlTabQuery = 'FROM logstash-* | SORT @timestamp DESC | LIMIT 50'; + const esqlTabTime = { + start: 'Sep 20, 2015 @ 12:00:00.000', + end: 'Sep 22, 2015 @ 12:00:00.000', + }; + const esqlTabHitCount = '50'; + + it('should support saving a multi-tab Discover session', async () => { + // Persisted data view tab + await timePicker.setAbsoluteRange(persistedTabTime.start, persistedTabTime.end); + await queryBar.setQuery(persistedTabQuery); + await queryBar.submitQuery(); + await discover.waitUntilTabIsLoaded(); + await unifiedFieldList.clickFieldListItemAdd('referer'); + await unifiedTabs.editTabLabel(0, persistedTabLabel); + expect(await discover.getHitCount()).to.be(persistedTabHitCount); + + // Ad hoc data view tab + await unifiedTabs.createNewTab(); + await discover.waitUntilTabIsLoaded(); + await timePicker.setAbsoluteRange(adHocTabTime.start, adHocTabTime.end); + await dataViews.createFromSearchBar({ + name: 'logs', + adHoc: true, + hasTimeField: true, + }); + await discover.waitUntilTabIsLoaded(); + await queryBar.setQuery(adHocTabQuery); + await queryBar.submitQuery(); + await discover.waitUntilTabIsLoaded(); + await unifiedFieldList.clickFieldListItemAdd('geo.src'); + await unifiedTabs.editTabLabel(1, adHocTabLabel); + expect(await discover.getHitCount()).to.be(adHocTabHitCount); + + // ES|QL tab + await unifiedTabs.createNewTab(); + await discover.waitUntilTabIsLoaded(); + await timePicker.setAbsoluteRange(esqlTabTime.start, esqlTabTime.end); + await discover.selectTextBaseLang(); + await monacoEditor.setCodeEditorValue(esqlTabQuery); + await queryBar.clickQuerySubmitButton(); + await discover.waitUntilTabIsLoaded(); + await unifiedTabs.editTabLabel(2, esqlTabLabel); + expect(await discover.getHitCount()).to.be(esqlTabHitCount); + + // Switch back to first tab and refresh + await unifiedTabs.selectTab(0); + await browser.refresh(); + await discover.waitUntilTabIsLoaded(); + + // Validate tab labels + expect(await unifiedTabs.getTabLabels()).to.eql([ + persistedTabLabel, + adHocTabLabel, + esqlTabLabel, + ]); + + // Validate persisted tab + expect(await discover.getHitCount()).to.be(persistedTabHitCount); + expect(await queryBar.getQueryString()).to.be(persistedTabQuery); + expect(await unifiedFieldList.getSidebarSectionFieldNames('selected')).to.eql(['referer']); + expect(await dataViews.getSelectedName()).to.be(persistedTabDataView); + expect(await timePicker.getTimeConfig()).to.eql(persistedTabTime); + expect(await timePicker.getTimeConfig()).to.eql(persistedTabTime); + + // Validate ad hoc tab + await unifiedTabs.selectTab(1); + await discover.waitUntilTabIsLoaded(); + expect(await discover.getHitCount()).to.be(adHocTabHitCount); + expect(await queryBar.getQueryString()).to.be(adHocTabQuery); + expect(await unifiedFieldList.getSidebarSectionFieldNames('selected')).to.eql(['geo.src']); + expect(await dataViews.getSelectedName()).to.be(adHocTabDataView); + expect(await timePicker.getTimeConfig()).to.eql(adHocTabTime); + + // Validate ES|QL tab + await unifiedTabs.selectTab(2); + await discover.waitUntilTabIsLoaded(); + expect(await discover.getHitCount()).to.be(esqlTabHitCount); + expect(await monacoEditor.getCodeEditorValue()).to.be(esqlTabQuery); + expect(await timePicker.getTimeConfig()).to.eql(esqlTabTime); + + // Switch back to first tab and refresh + await unifiedTabs.selectTab(0); + await browser.refresh(); + await discover.waitUntilTabIsLoaded(); + + // Validate after refresh + expect(await unifiedTabs.getTabLabels()).to.eql([ + persistedTabLabel, + adHocTabLabel, + esqlTabLabel, + ]); + + // Save the Discover session + await discover.saveSearch(sessionName, undefined, { storeTimeRange: true }); + expect(await discover.getSavedSearchTitle()).to.be(sessionName); + + // Confirm no unsaved changes badge after saving + await testSubjects.missingOrFail('unsavedChangesBadge'); + + // Validate persisted tab + expect(await discover.getHitCount()).to.be(persistedTabHitCount); + expect(await queryBar.getQueryString()).to.be(persistedTabQuery); + expect(await unifiedFieldList.getSidebarSectionFieldNames('selected')).to.eql(['referer']); + expect(await dataViews.getSelectedName()).to.be(persistedTabDataView); + expect(await timePicker.getTimeConfig()).to.eql(persistedTabTime); + + // Validate ad hoc tab + await unifiedTabs.selectTab(1); + await discover.waitUntilTabIsLoaded(); + expect(await discover.getHitCount()).to.be(adHocTabHitCount); + expect(await queryBar.getQueryString()).to.be(adHocTabQuery); + expect(await unifiedFieldList.getSidebarSectionFieldNames('selected')).to.eql(['geo.src']); + expect(await dataViews.getSelectedName()).to.be(adHocTabDataView); + expect(await timePicker.getTimeConfig()).to.eql(adHocTabTime); + + // Validate ES|QL tab + await unifiedTabs.selectTab(2); + await discover.waitUntilTabIsLoaded(); + expect(await discover.getHitCount()).to.be(esqlTabHitCount); + expect(await monacoEditor.getCodeEditorValue()).to.be(esqlTabQuery); + expect(await timePicker.getTimeConfig()).to.eql(esqlTabTime); + }); + + it('should support loading a multi-tab Discover session', async () => { + // Load the Discover session + await discover.loadSavedSearch(sessionName); + await discover.waitUntilTabIsLoaded(); + + // Validate loaded session + expect(await discover.getSavedSearchTitle()).to.be(sessionName); + expect(await unifiedTabs.getTabLabels()).to.eql([ + persistedTabLabel, + adHocTabLabel, + esqlTabLabel, + ]); + + // Confirm no unsaved changes badge after loading + await testSubjects.missingOrFail('unsavedChangesBadge'); + + // Validate persisted tab + expect(await discover.getHitCount()).to.be(persistedTabHitCount); + expect(await queryBar.getQueryString()).to.be(persistedTabQuery); + expect(await unifiedFieldList.getSidebarSectionFieldNames('selected')).to.eql(['referer']); + expect(await dataViews.getSelectedName()).to.be(persistedTabDataView); + expect(await timePicker.getTimeConfig()).to.eql(persistedTabTime); + + // Validate ad hoc tab + await unifiedTabs.selectTab(1); + await discover.waitUntilTabIsLoaded(); + expect(await discover.getHitCount()).to.be(adHocTabHitCount); + expect(await queryBar.getQueryString()).to.be(adHocTabQuery); + expect(await unifiedFieldList.getSidebarSectionFieldNames('selected')).to.eql(['geo.src']); + expect(await dataViews.getSelectedName()).to.be(adHocTabDataView); + expect(await timePicker.getTimeConfig()).to.eql(adHocTabTime); + + // Validate ES|QL tab + await unifiedTabs.selectTab(2); + await discover.waitUntilTabIsLoaded(); + expect(await discover.getHitCount()).to.be(esqlTabHitCount); + expect(await monacoEditor.getCodeEditorValue()).to.be(esqlTabQuery); + expect(await timePicker.getTimeConfig()).to.eql(esqlTabTime); + }); + + it('should locally persist unsaved changes to a multi-tab Discover session', async () => { + // Load the Discover session + await discover.loadSavedSearch(sessionName); + await discover.waitUntilTabIsLoaded(); + + // Prepare unsaved changes per tab + const persistedUnsaved = { + time: { start: 'Sep 20, 2015 @ 01:00:00.000', end: 'Sep 22, 2015 @ 01:00:00.000' }, + query: 'test and extension : png', + columns: ['referer', 'bytes'], + }; + const adHocUnsaved = { + time: { start: 'Sep 20, 2015 @ 07:00:00.000', end: 'Sep 22, 2015 @ 07:00:00.000' }, + query: 'extension : png', + columns: ['geo.src', 'bytes'], + }; + const esqlUnsaved = { + time: { start: 'Sep 20, 2015 @ 13:00:00.000', end: 'Sep 22, 2015 @ 13:00:00.000' }, + query: 'FROM logstash-* | SORT @timestamp DESC | LIMIT 25', + }; + + // Persisted data view tab + await timePicker.setAbsoluteRange(persistedUnsaved.time.start, persistedUnsaved.time.end); + await queryBar.setQuery(persistedUnsaved.query); + await queryBar.submitQuery(); + await discover.waitUntilTabIsLoaded(); + await unifiedFieldList.clickFieldListItemAdd('bytes'); + const persistedUnsavedCount = await discover.getHitCount(); + + // Ad hoc data view tab + await unifiedTabs.selectTab(1); + await discover.waitUntilTabIsLoaded(); + await timePicker.setAbsoluteRange(adHocUnsaved.time.start, adHocUnsaved.time.end); + await queryBar.setQuery(adHocUnsaved.query); + await queryBar.submitQuery(); + await discover.waitUntilTabIsLoaded(); + await unifiedFieldList.clickFieldListItemAdd('bytes'); + const adHocUnsavedCount = await discover.getHitCount(); + + // ES|QL tab + await unifiedTabs.selectTab(2); + await discover.waitUntilTabIsLoaded(); + await timePicker.setAbsoluteRange(esqlUnsaved.time.start, esqlUnsaved.time.end); + await monacoEditor.setCodeEditorValue(esqlUnsaved.query); + await queryBar.clickQuerySubmitButton(); + await discover.waitUntilTabIsLoaded(); + const esqlUnsavedCount = await discover.getHitCount(); + + // Unsaved changes badge should be visible after making changes + await testSubjects.existOrFail('unsavedChangesBadge'); + + // Refresh and ensure the unsaved changes are restored + await browser.refresh(); + await discover.waitUntilTabIsLoaded(); + + // Validate persisted data view tab + await unifiedTabs.selectTab(0); + await discover.waitUntilTabIsLoaded(); + expect(await queryBar.getQueryString()).to.be(persistedUnsaved.query); + expect(await timePicker.getTimeConfig()).to.eql(persistedUnsaved.time); + expect(await unifiedFieldList.getSidebarSectionFieldNames('selected')).to.eql( + persistedUnsaved.columns + ); + expect(await discover.getHitCount()).to.be(persistedUnsavedCount); + + // Validate ad hoc data view tab + await unifiedTabs.selectTab(1); + await discover.waitUntilTabIsLoaded(); + expect(await queryBar.getQueryString()).to.be(adHocUnsaved.query); + expect(await timePicker.getTimeConfig()).to.eql(adHocUnsaved.time); + expect(await unifiedFieldList.getSidebarSectionFieldNames('selected')).to.eql( + adHocUnsaved.columns + ); + expect(await discover.getHitCount()).to.be(adHocUnsavedCount); + + // Validate ES|QL tab + await unifiedTabs.selectTab(2); + await discover.waitUntilTabIsLoaded(); + expect(await monacoEditor.getCodeEditorValue()).to.be(esqlUnsaved.query); + expect(await timePicker.getTimeConfig()).to.eql(esqlUnsaved.time); + expect(await discover.getHitCount()).to.be(esqlUnsavedCount); + + // Unsaved badge should still be visible after refresh + await testSubjects.existOrFail('unsavedChangesBadge'); + }); + + it('should clear all tabs when starting a new session', async () => { + // Load the Discover session + await discover.loadSavedSearch(sessionName); + await discover.waitUntilTabIsLoaded(); + + // Validate loaded session + expect(await discover.getSavedSearchTitle()).to.be(sessionName); + expect(await unifiedTabs.getTabLabels()).to.eql([ + persistedTabLabel, + adHocTabLabel, + esqlTabLabel, + ]); + + // Clear loaded session + await discover.clickNewSearchButton(); + await discover.waitUntilTabIsLoaded(); + + // Validate cleared session + expect(await discover.getSavedSearchTitle()).to.be(undefined); + expect(await unifiedTabs.getTabLabels()).to.eql(['Untitled']); + + // Add a second unsaved tab + await unifiedTabs.createNewTab(); + await discover.waitUntilTabIsLoaded(); + expect(await unifiedTabs.getTabLabels()).to.eql(['Untitled', 'Untitled 2']); + + // Clear unsaved tabs + await discover.clickNewSearchButton(); + await discover.waitUntilTabIsLoaded(); + + // Validate cleared tabs + expect(await unifiedTabs.getTabLabels()).to.eql(['Untitled']); + }); + }); + + describe('time based tabs', () => { + const adHocWithTimeRange = 'log'; + const adHocWithoutTimeRange = 'logs'; + const persistedWithoutTimeRange = 'logst*'; + + before(async () => { + // Create saved data view without time range + await common.navigateToApp('discover'); + await discover.waitUntilTabIsLoaded(); + await dataViews.createFromSearchBar({ + name: 'logst', + adHoc: false, + hasTimeField: false, + }); + }); + + it('should show time range switch when saving if any tab is time based', async () => { + const expectTimeSwitchVisible = async () => { + await discover.clickSaveSearchButton(); + await testSubjects.existOrFail('storeTimeWithSearch'); + await browser.pressKeys(browser.keys.ESCAPE); + await testSubjects.missingOrFail('confirmSaveSavedObjectButton'); + }; + + // Case A: Only persisted data view tab is time based; ad hoc data view is non-time-based; ES|QL is non-time-based + // Initial tab uses default time-based data view + + // Tab 2: ad hoc data view without time field + await unifiedTabs.createNewTab(); + await discover.waitUntilTabIsLoaded(); + await dataViews.createFromSearchBar({ + name: adHocWithoutTimeRange, + adHoc: true, + hasTimeField: false, + }); + await discover.waitUntilTabIsLoaded(); + + // Tab 3: ES|QL non-time-based + await unifiedTabs.createNewTab(); + await discover.waitUntilTabIsLoaded(); + await discover.selectTextBaseLang(); + await monacoEditor.setCodeEditorValue('FROM without-timefield'); + await queryBar.clickQuerySubmitButton(); + await discover.waitUntilTabIsLoaded(); + + // Visit the time-based tab (persisted data view) and check + await unifiedTabs.selectTab(0); + await discover.waitUntilTabIsLoaded(); + await expectTimeSwitchVisible(); + + // Switch away, refresh so time-based tab is unvisited, then check + await unifiedTabs.selectTab(1); + await browser.refresh(); + await discover.waitUntilTabIsLoaded(); + await expectTimeSwitchVisible(); + + // Case B: Only ad hoc data view tab is time based; persisted data view is non-time-based; ES|QL is non-time-based + // Reset to a fresh session, then make Tab 1 persisted data view non-time-based + await discover.clickNewSearchButton(); + await discover.waitUntilTabIsLoaded(); + await dataViews.switchToAndValidate(persistedWithoutTimeRange); + await discover.waitUntilTabIsLoaded(); + + // Tab 2: ad hoc data view with time field (time-based) + await unifiedTabs.createNewTab(); + await discover.waitUntilTabIsLoaded(); + await dataViews.createFromSearchBar({ + name: adHocWithTimeRange, + adHoc: true, + hasTimeField: true, + }); + await discover.waitUntilTabIsLoaded(); + + // Tab 3: ES|QL non-time-based + await unifiedTabs.createNewTab(); + await discover.waitUntilTabIsLoaded(); + await discover.selectTextBaseLang(); + await monacoEditor.setCodeEditorValue('FROM without-timefield'); + await queryBar.clickQuerySubmitButton(); + await discover.waitUntilTabIsLoaded(); + + // Visit the time-based tab (ad hoc data view) and check + await unifiedTabs.selectTab(1); + await discover.waitUntilTabIsLoaded(); + await expectTimeSwitchVisible(); + + // Switch away, refresh, then check + await unifiedTabs.selectTab(0); + await browser.refresh(); + await discover.waitUntilTabIsLoaded(); + await expectTimeSwitchVisible(); + + // Case C: Only ES|QL tab is time based; both data view tabs non-time-based + // Reset and build non-time-based data view tabs + await discover.clickNewSearchButton(); + await discover.waitUntilTabIsLoaded(); + await dataViews.switchToAndValidate(persistedWithoutTimeRange); + await discover.waitUntilTabIsLoaded(); + + await unifiedTabs.createNewTab(); + await discover.waitUntilTabIsLoaded(); + await dataViews.createFromSearchBar({ + name: adHocWithoutTimeRange, + adHoc: true, + hasTimeField: false, + }); + await discover.waitUntilTabIsLoaded(); + + // Tab 3: ES|QL time-based + await unifiedTabs.createNewTab(); + await discover.waitUntilTabIsLoaded(); + await discover.selectTextBaseLang(); + await monacoEditor.setCodeEditorValue('FROM logstash-* | SORT @timestamp DESC | LIMIT 10'); + await queryBar.clickQuerySubmitButton(); + await discover.waitUntilTabIsLoaded(); + + // Visit ES|QL time-based tab and check + await unifiedTabs.selectTab(2); + await discover.waitUntilTabIsLoaded(); + await expectTimeSwitchVisible(); + + // Switch away, refresh, then check + await unifiedTabs.selectTab(1); + await browser.refresh(); + await discover.waitUntilTabIsLoaded(); + await expectTimeSwitchVisible(); + }); + + it('should not show time range switch when saving if no tab is time based', async () => { + const expectTimeSwitchMissing = async () => { + await discover.clickSaveSearchButton(); + await testSubjects.missingOrFail('storeTimeWithSearch'); + await browser.pressKeys(browser.keys.ESCAPE); + await testSubjects.missingOrFail('confirmSaveSavedObjectButton'); + }; + + // Tab 1: persisted data view without time field + await dataViews.switchToAndValidate(persistedWithoutTimeRange); + await discover.waitUntilTabIsLoaded(); + + // Tab 2: ad hoc data view without time field + await unifiedTabs.createNewTab(); + await discover.waitUntilTabIsLoaded(); + await dataViews.createFromSearchBar({ + name: adHocWithoutTimeRange, + adHoc: true, + hasTimeField: false, + }); + await discover.waitUntilTabIsLoaded(); + + // Tab 3: ES|QL non-time-based + await unifiedTabs.createNewTab(); + await discover.waitUntilTabIsLoaded(); + await discover.selectTextBaseLang(); + await monacoEditor.setCodeEditorValue('FROM without-timefield'); + await queryBar.clickQuerySubmitButton(); + await discover.waitUntilTabIsLoaded(); + + // Perform check + await expectTimeSwitchMissing(); + + // Refresh, then check again + await browser.refresh(); + await discover.waitUntilTabIsLoaded(); + await expectTimeSwitchMissing(); + }); + }); + }); +} diff --git a/src/platform/test/functional/apps/discover/tabs/index.ts b/src/platform/test/functional/apps/discover/tabs/index.ts index 8cf90f434c932..028422389b1b3 100644 --- a/src/platform/test/functional/apps/discover/tabs/index.ts +++ b/src/platform/test/functional/apps/discover/tabs/index.ts @@ -26,6 +26,9 @@ export default function ({ getService, getPageObjects, loadTestFile }: FtrProvid await esArchiver.loadIfNeeded( 'src/platform/test/functional/fixtures/es_archiver/logstash_functional' ); + await esArchiver.loadIfNeeded( + 'src/platform/test/functional/fixtures/es_archiver/index_pattern_without_timefield' + ); await kibanaServer.uiSettings.replace({ defaultIndex: 'logstash-*' }); await timePicker.setDefaultAbsoluteRangeViaUiSettings(); }); @@ -42,6 +45,9 @@ export default function ({ getService, getPageObjects, loadTestFile }: FtrProvid await esArchiver.unload( 'src/platform/test/functional/fixtures/es_archiver/logstash_functional' ); + await esArchiver.unload( + 'src/platform/test/functional/fixtures/es_archiver/index_pattern_without_timefield' + ); await kibanaServer.uiSettings.unset('defaultIndex'); await kibanaServer.savedObjects.cleanStandardList(); }); @@ -50,5 +56,6 @@ export default function ({ getService, getPageObjects, loadTestFile }: FtrProvid loadTestFile(require.resolve('./_new_tab')); loadTestFile(require.resolve('./_no_data')); loadTestFile(require.resolve('./_restorable_state')); + loadTestFile(require.resolve('./_save_and_load')); }); } diff --git a/src/platform/test/functional/page_objects/discover_page.ts b/src/platform/test/functional/page_objects/discover_page.ts index 0f9792e134e9a..2ab4fb1d3a229 100644 --- a/src/platform/test/functional/page_objects/discover_page.ts +++ b/src/platform/test/functional/page_objects/discover_page.ts @@ -43,7 +43,7 @@ export class DiscoverPageObject extends FtrService { public async saveSearch( searchName: string, saveAsNew?: boolean, - options: { tags: string[] } = { tags: [] } + { tags = [], storeTimeRange }: { tags?: string[]; storeTimeRange?: boolean } = {} ) { await this.clickSaveSearchButton(); // preventing an occasional flakiness when the saved object wasn't set and the form can't be submitted @@ -56,14 +56,26 @@ export class DiscoverPageObject extends FtrService { } ); - if (options.tags.length) { + if (tags.length) { await this.testSubjects.click('savedObjectTagSelector'); - for (const tagName of options.tags) { + for (const tagName of tags) { await this.testSubjects.click(`tagSelectorOption-${tagName.replace(' ', '_')}`); } await this.testSubjects.click('savedObjectTitle'); } + if (storeTimeRange !== undefined) { + await this.retry.waitFor(`store time range switch is set`, async () => { + await this.testSubjects.setEuiSwitch( + 'storeTimeWithSearch', + storeTimeRange ? 'check' : 'uncheck' + ); + return ( + (await this.testSubjects.isEuiSwitchChecked('storeTimeWithSearch')) === storeTimeRange + ); + }); + } + if (saveAsNew !== undefined) { await this.retry.waitFor(`save as new switch is set`, async () => { await this.testSubjects.setEuiSwitch('saveAsNewCheckbox', saveAsNew ? 'check' : 'uncheck'); @@ -140,8 +152,10 @@ export class DiscoverPageObject extends FtrService { } public async getSavedSearchTitle() { - const breadcrumb = await this.find.byCssSelector('[data-test-subj="breadcrumb last"]'); - return await breadcrumb.getVisibleText(); + if (await this.testSubjects.exists('breadcrumb last')) { + const breadcrumb = await this.testSubjects.find('breadcrumb last'); + return await breadcrumb.getVisibleText(); + } } public async loadSavedSearch(searchName: string) { diff --git a/x-pack/platform/plugins/private/graph/public/helpers/saved_workspace_utils.ts b/x-pack/platform/plugins/private/graph/public/helpers/saved_workspace_utils.ts index e728d9de24e0f..6b9963def5132 100644 --- a/x-pack/platform/plugins/private/graph/public/helpers/saved_workspace_utils.ts +++ b/x-pack/platform/plugins/private/graph/public/helpers/saved_workspace_utils.ts @@ -109,7 +109,7 @@ export async function getSavedWorkspace(contentClient: ContentClient, id: string const resp = resolveResult.item; if (!resp.attributes) { - throw new SavedObjectNotFound(savedWorkspaceType, id || ''); + throw new SavedObjectNotFound({ type: savedWorkspaceType, id: id || '' }); } const savedObject = { diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index d52de0dd24fb0..bbe0f14d61708 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -6481,7 +6481,7 @@ "savedSearch.kibana_context.q.help": "Spécifier une recherche en texte libre Kibana", "savedSearch.kibana_context.savedSearchId.help": "Spécifier l'ID de recherche enregistrée à utiliser pour les requêtes et les filtres", "savedSearch.kibana_context.timeRange.help": "Spécifier le filtre de plage temporelle Kibana", - "savedSearch.legacyURLConflict.errorMessage": "Cette session Discover a la même URL qu'un alias hérité. Désactiver l'alias pour résoudre cette erreur : {json}", + "savedSearch.legacyURLConflict.errorMessage": "Cette session Discover a la même URL qu'un alias hérité. Désactiver l'alias pour résoudre cette erreur : {json}", "searchApiKeysComponents.apiKeyForm.createButton": "Créer une clé d'API", "searchApiKeysComponents.apiKeyForm.noUserPrivileges": "Vous n'avez pas accès à la gestion des clés d'API", "searchApiKeysComponents.apiKeyForm.showApiKey": "Afficher la clé d'API", diff --git a/x-pack/platform/test/serverless/functional/test_suites/discover/group3/_request_counts.ts b/x-pack/platform/test/serverless/functional/test_suites/discover/group3/_request_counts.ts index 48fd043f6dcc3..8eeec50480f00 100644 --- a/x-pack/platform/test/serverless/functional/test_suites/discover/group3/_request_counts.ts +++ b/x-pack/platform/test/serverless/functional/test_suites/discover/group3/_request_counts.ts @@ -11,7 +11,7 @@ import type { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); - const PageObjects = getPageObjects([ + const { common, svlCommonPage, discover, timePicker, header } = getPageObjects([ 'common', 'svlCommonPage', 'discover', @@ -20,14 +20,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ]); const testSubjects = getService('testSubjects'); const browser = getService('browser'); + const monacoEditor = getService('monacoEditor'); const filterBar = getService('filterBar'); const queryBar = getService('queryBar'); const elasticChart = getService('elasticChart'); - const dataViews = getService('dataViews'); + const log = getService('log'); + const retry = getService('retry'); describe('discover request counts', function describeIndexTests() { before(async function () { - await PageObjects.svlCommonPage.loginAsAdmin(); + await svlCommonPage.loginAsAdmin(); await esArchiver.loadIfNeeded( 'src/platform/test/functional/fixtures/es_archiver/logstash_functional' ); @@ -42,10 +44,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); await kibanaServer.uiSettings.replace({ defaultIndex: 'logstash-*', - // 'bfetch:disable': true, // bfetch is already disabled in serverless - // TODO: Removed ES|QL setting since ES|QL isn't supported in Serverless }); - await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); + await timePicker.setDefaultAbsoluteRangeViaUiSettings(); + await common.navigateToApp('discover'); }); after(async () => { @@ -56,36 +57,45 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await kibanaServer.uiSettings.replace({}); }); - beforeEach(async () => { - await PageObjects.common.navigateToApp('discover'); - await PageObjects.header.waitUntilLoadingHasFinished(); - }); - - const getSearchCount = async (type: 'ese' | 'esql') => { - const requests = await browser.execute(() => - performance - .getEntries() - .filter((entry: any) => ['fetch', 'xmlhttprequest'].includes(entry.initiatorType)) + const expectSearchCount = async (type: 'ese' | 'esql', searchCount: number) => { + await retry.tryWithRetries( + `expect ${type} request to match count ${searchCount}`, + async () => { + if (searchCount === 0) { + await browser.execute(async () => { + performance.clearResourceTimings(); + }); + } + await waitForLoadingToFinish(); + const endpoint = type === 'esql' ? `${type}_async` : type; + const requests = await browser.execute(() => + performance + .getEntries() + .filter((entry: any) => ['fetch', 'xmlhttprequest'].includes(entry.initiatorType)) + ); + const result = requests.filter((entry) => + entry.name.endsWith(`/internal/search/${endpoint}`) + ); + const count = result.length; + if (count !== searchCount) { + log.warning('Request count differs:', result); + } + expect(count).to.be(searchCount); + }, + { retryCount: 5, retryDelay: 500 } ); - return requests.filter((entry) => entry.name.endsWith(`/internal/search/${type}`)).length; - }; - - const waitForLoadingToFinish = async () => { - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.discover.waitForDocTableLoadingComplete(); - await elasticChart.canvasExists(); }; const expectSearches = async (type: 'ese' | 'esql', expected: number, cb: Function) => { - await browser.execute(async () => { - performance.clearResourceTimings(); - }); - let searchCount = await getSearchCount(type); - expect(searchCount).to.be(0); + await expectSearchCount(type, 0); await cb(); - await waitForLoadingToFinish(); - searchCount = await getSearchCount(type); - expect(searchCount).to.be(expected); + await expectSearchCount(type, expected); + }; + + const waitForLoadingToFinish = async () => { + await header.waitUntilLoadingHasFinished(); + await discover.waitForDocTableLoadingComplete(); + await elasticChart.canvasExists(); }; const getSharedTests = ({ @@ -93,100 +103,105 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { savedSearch, query1, query2, - savedSearchesRequests, setQuery, }: { type: 'ese' | 'esql'; savedSearch: string; query1: string; query2: string; - savedSearchesRequests?: number; setQuery: (query: string) => Promise; }) => { - it('should send no more than 2 search requests (documents + chart) on page load', async () => { - await browser.refresh(); + it('should send 2 search requests (documents + chart) on page load', async () => { + if (type === 'ese') { + await browser.refresh(); + } await browser.execute(async () => { performance.setResourceTimingBufferSize(Number.MAX_SAFE_INTEGER); }); - await waitForLoadingToFinish(); - const searchCount = await getSearchCount(type); - expect(searchCount).to.be(2); + if (type === 'esql') { + await expectSearches(type, 2, async () => { + await queryBar.clickQuerySubmitButton(); + }); + } else { + await expectSearchCount(type, 2); + } }); - it('should send no more than 2 requests (documents + chart) when refreshing', async () => { + it('should send 2 requests (documents + chart) when refreshing', async () => { await expectSearches(type, 2, async () => { await queryBar.clickQuerySubmitButton(); }); }); - it('should send no more than 2 requests (documents + chart) when changing the query', async () => { + it('should send 2 requests (documents + chart) when changing the query', async () => { await expectSearches(type, 2, async () => { await setQuery(query1); await queryBar.clickQuerySubmitButton(); }); }); - it('should send no more than 2 requests (documents + chart) when changing the time range', async () => { + it('should send 2 requests (documents + chart) when changing the time range', async () => { await expectSearches(type, 2, async () => { - await PageObjects.timePicker.setAbsoluteRange( + await timePicker.setAbsoluteRange( 'Sep 21, 2015 @ 06:31:44.000', 'Sep 23, 2015 @ 00:00:00.000' ); }); }); - it(`should send no requests (documents + chart) when toggling the chart visibility`, async () => { + it('should send no requests (documents + chart) when toggling the chart visibility', async () => { await expectSearches(type, 0, async () => { // hide chart - await PageObjects.discover.toggleChartVisibility(); + await discover.toggleChartVisibility(); // show chart - await PageObjects.discover.toggleChartVisibility(); + await discover.toggleChartVisibility(); }); }); - it(`should send a request for chart data when toggling the chart visibility after a time range change`, async () => { + + it('should send a request for chart data when toggling the chart visibility after a time range change', async () => { // hide chart - await PageObjects.discover.toggleChartVisibility(); - await PageObjects.timePicker.setAbsoluteRange( + await discover.toggleChartVisibility(); + await timePicker.setAbsoluteRange( 'Sep 21, 2015 @ 06:31:44.000', 'Sep 24, 2015 @ 00:00:00.000' ); await waitForLoadingToFinish(); await expectSearches(type, 1, async () => { // show chart, we expect a request for the chart data, since the time range changed - await PageObjects.discover.toggleChartVisibility(); + await discover.toggleChartVisibility(); }); }); - it('should send no more than 2 requests for saved search changes', async () => { + it('should send expected requests for saved search changes', async () => { await setQuery(query1); await queryBar.clickQuerySubmitButton(); - await PageObjects.timePicker.setAbsoluteRange( + await timePicker.setAbsoluteRange( 'Sep 21, 2015 @ 06:31:44.000', 'Sep 23, 2015 @ 00:00:00.000' ); await waitForLoadingToFinish(); - // TODO: Check why the request happens 4 times in case of opening a saved search - // https://github.com/elastic/kibana/issues/165192 - // creating the saved search - await expectSearches(type, savedSearchesRequests ?? 2, async () => { - await PageObjects.discover.saveSearch(savedSearch); + log.debug('Creating saved search'); + await expectSearches(type, 0, async () => { + await discover.saveSearch(savedSearch); }); - // resetting the saved search + log.debug('Resetting saved search'); await setQuery(query2); await queryBar.clickQuerySubmitButton(); await waitForLoadingToFinish(); await expectSearches(type, 2, async () => { - await PageObjects.discover.revertUnsavedChanges(); + await discover.revertUnsavedChanges(); }); - // clearing the saved search - await expectSearches('ese', 2, async () => { + log.debug('Clearing saved search'); + await expectSearches(type, 2, async () => { await testSubjects.click('discoverNewButton'); + if (type === 'esql') { + await queryBar.clickQuerySubmitButton(); + } await waitForLoadingToFinish(); }); - // loading the saved search - // TODO: https://github.com/elastic/kibana/issues/165192 - await expectSearches(type, savedSearchesRequests ?? 2, async () => { - await PageObjects.discover.loadSavedSearch(savedSearch); + log.debug('Loading saved search'); + await expectSearches(type, 2, async () => { + await discover.loadSavedSearch(savedSearch); }); }); }; @@ -194,6 +209,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('data view mode', () => { const type = 'ese'; + beforeEach(async () => { + await common.navigateToApp('discover'); + await header.waitUntilLoadingHasFinished(); + }); + getSharedTests({ type, savedSearch: 'data view test', @@ -202,7 +222,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { setQuery: (query) => queryBar.setQuery(query), }); - it('should send no more than 2 requests (documents + chart) when adding a filter', async () => { + it('should send 2 requests (documents + chart) when adding a filter', async () => { await expectSearches(type, 2, async () => { await filterBar.addFilter({ field: 'extension', @@ -212,37 +232,60 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); - it('should send no more than 2 requests (documents + chart) when sorting', async () => { + it('should send 2 requests (documents + chart) when sorting', async () => { await expectSearches(type, 2, async () => { - await PageObjects.discover.clickFieldSort('@timestamp', 'Sort Old-New'); + await discover.clickFieldSort('@timestamp', 'Sort Old-New'); }); }); - it('should send no more than 2 requests (documents + chart) when changing to a breakdown field without an other bucket', async () => { + it('should send 2 requests (documents + chart) when changing to a breakdown field without an other bucket', async () => { await expectSearches(type, 2, async () => { - await PageObjects.discover.chooseBreakdownField('type'); + await discover.chooseBreakdownField('type'); }); }); - it('should send no more than 3 requests (documents + chart + other bucket) when changing to a breakdown field with an other bucket', async () => { + it('should send 3 requests (documents + chart + other bucket) when changing to a breakdown field with an other bucket', async () => { + await testSubjects.click('discoverNewButton'); await expectSearches(type, 3, async () => { - await PageObjects.discover.chooseBreakdownField('extension.raw'); + await discover.chooseBreakdownField('extension.raw'); }); }); - it('should send no more than 2 requests (documents + chart) when changing the chart interval', async () => { + it('should send 2 requests (documents + chart) when changing the chart interval', async () => { await expectSearches(type, 2, async () => { - await PageObjects.discover.setChartInterval('Day'); + await discover.setChartInterval('Day'); }); }); - it('should send no more than 2 requests (documents + chart) when changing the data view', async () => { + it('should send 2 requests (documents + chart) when changing the data view', async () => { await expectSearches(type, 2, async () => { - await dataViews.switchToAndValidate('long-window-logstash-*'); + await discover.selectIndexPattern('long-window-logstash-*'); }); }); }); - // TODO: ES|QL tests removed since ES|QL isn't supported in Serverless + describe('ES|QL mode', () => { + const type = 'esql'; + + before(async () => { + await kibanaServer.uiSettings.update({ + 'discover:searchOnPageLoad': false, + }); + await common.navigateToApp('discover'); + await discover.selectTextBaseLang(); + }); + + beforeEach(async () => { + await monacoEditor.setCodeEditorValue('from logstash-* | where bytes > 1000 '); + }); + + getSharedTests({ + type, + savedSearch: 'esql test', + query1: 'from logstash-* | where bytes > 1000 ', + query2: 'from logstash-* | where bytes < 2000 ', + setQuery: (query) => monacoEditor.setCodeEditorValue(query), + }); + }); }); } diff --git a/x-pack/solutions/observability/plugins/exploratory_view/public/utils/observability_data_views/observability_data_views.test.ts b/x-pack/solutions/observability/plugins/exploratory_view/public/utils/observability_data_views/observability_data_views.test.ts index 4bd40762a98fb..9725a314a3200 100644 --- a/x-pack/solutions/observability/plugins/exploratory_view/public/utils/observability_data_views/observability_data_views.test.ts +++ b/x-pack/solutions/observability/plugins/exploratory_view/public/utils/observability_data_views/observability_data_views.test.ts @@ -89,7 +89,7 @@ describe('ObservabilityDataViews', function () { it('should creates missing index pattern', async function () { dataViews!.get = jest.fn().mockImplementation(() => { - throw new SavedObjectNotFound('index_pattern'); + throw new SavedObjectNotFound({ type: 'index_pattern' }); }); dataViews!.createAndSave = jest.fn().mockReturnValue({ id: dataViewList.ux }); diff --git a/x-pack/solutions/observability/plugins/infra/public/pages/logs/shared/page_log_view_error.tsx b/x-pack/solutions/observability/plugins/infra/public/pages/logs/shared/page_log_view_error.tsx index d4086e810b700..1eae4c30cf3f2 100644 --- a/x-pack/solutions/observability/plugins/infra/public/pages/logs/shared/page_log_view_error.tsx +++ b/x-pack/solutions/observability/plugins/infra/public/pages/logs/shared/page_log_view_error.tsx @@ -104,7 +104,7 @@ const LogSourceErrorMessage: React.FC<{ error: Error }> = ({ error }) => { id="xpack.infra.logSourceErrorPage.savedObjectNotFoundErrorMessage" defaultMessage="Failed to locate that {savedObjectType}: {savedObjectId}" values={{ - savedObjectType: error.cause.savedObjectType, + savedObjectType: error.cause.savedObjectTypeDisplayName, savedObjectId: error.cause.savedObjectId, }} /> diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_actions.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_actions.test.tsx index 339662b2fefb2..158312cb3cd83 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_actions.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_actions.test.tsx @@ -186,8 +186,8 @@ describe('useDiscoverInTimelineActions', () => { const { result } = renderTestHook(); await result.current.resetDiscoverAppState(); await waitFor(() => { - const globalState = mockDiscoverStateContainerRef.current.globalState.get(); - expect(globalState).toMatchObject({ time: { from: 'now-15m', to: 'now' } }); + const globalState = mockDiscoverStateContainerRef.current.getCurrentTab().globalState; + expect(globalState).toMatchObject({ timeRange: { from: 'now-15m', to: 'now' } }); }); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_actions.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_actions.tsx index 992f7d4e25391..4d5e89b83f375 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_actions.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_actions.tsx @@ -128,10 +128,15 @@ export const useDiscoverInTimelineActions = ( savedSearchState?.appState ?? {} ); setDiscoverAppState(savedSearchState?.appState ?? defaultDiscoverAppState()); - discoverStateContainer.current?.globalState.set({ - ...discoverStateContainer.current?.globalState.get(), - time: savedSearch.timeRange ?? defaultDiscoverTimeRange, - }); + const discoverState = discoverStateContainer.current; + discoverState?.internalState.dispatch( + discoverState.injectCurrentTab(discoverState.internalStateActions.setGlobalState)({ + globalState: { + ...discoverState.getCurrentTab().globalState, + timeRange: savedSearch.timeRange ?? defaultDiscoverTimeRange, + }, + }) + ); } catch (e) { /* empty */ } @@ -140,10 +145,15 @@ export const useDiscoverInTimelineActions = ( discoverStateContainer.current?.appState.resetToState(defaultState); await discoverStateContainer.current?.appState.replaceUrlState({}); setDiscoverAppState(defaultState); - discoverStateContainer.current?.globalState.set({ - ...discoverStateContainer.current?.globalState.get(), - time: defaultDiscoverTimeRange, - }); + const discoverState = discoverStateContainer.current; + discoverState?.internalState.dispatch( + discoverState.injectCurrentTab(discoverState.internalStateActions.setGlobalState)({ + globalState: { + ...discoverState.getCurrentTab().globalState, + timeRange: defaultDiscoverTimeRange, + }, + }) + ); } }, [discoverStateContainer, getAppStateFromSavedSearch, savedSearchService, setDiscoverAppState] diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/esql/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/esql/index.tsx index 0781530c490d7..ccae915d735ae 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/esql/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/tabs/esql/index.tsx @@ -121,7 +121,7 @@ export const DiscoverTabContent: FC = ({ timelineId }) return { ...(discoverStateContainer.current?.savedSearchState.getState() ?? discoverSavedSearchState), timeRange: discoverDataService.query.timefilter.timefilter.getTime(), - refreshInterval: discoverStateContainer.current?.globalState.get()?.refreshInterval, + refreshInterval: discoverStateContainer.current?.getCurrentTab().globalState.refreshInterval, breakdownField: discoverStateContainer.current?.appState.getState().breakdownField, rowsPerPage: discoverStateContainer.current?.appState.getState().rowsPerPage, title: GET_TIMELINE_DISCOVER_SAVED_SEARCH_TITLE(title), @@ -214,10 +214,14 @@ export const DiscoverTabContent: FC = ({ timelineId }) if (!stateContainer.stateStorage.get(APP_STATE_URL_KEY) || !hasESQLUrlState) { if (savedSearchAppState?.savedSearch.timeRange) { - stateContainer.globalState.set({ - ...stateContainer.globalState.get(), - time: savedSearchAppState?.savedSearch.timeRange, - }); + stateContainer.internalState.dispatch( + stateContainer.injectCurrentTab(stateContainer.internalStateActions.setGlobalState)({ + globalState: { + ...stateContainer.getCurrentTab().globalState, + timeRange: savedSearchAppState.savedSearch.timeRange, + }, + }) + ); } stateContainer.appState.set(finalAppState); await stateContainer.appState.replaceUrlState(finalAppState);