diff --git a/src/plugins/discover/kibana.json b/src/plugins/discover/kibana.json index 46eeb5af1470d5..3d5fdefd276d34 100644 --- a/src/plugins/discover/kibana.json +++ b/src/plugins/discover/kibana.json @@ -15,8 +15,8 @@ "savedObjects", "indexPatternFieldEditor" ], - "optionalPlugins": ["home", "share", "usageCollection"], - "requiredBundles": ["kibanaUtils", "home", "kibanaReact", "fieldFormats"], + "optionalPlugins": ["home", "share", "usageCollection", "spaces"], + "requiredBundles": ["kibanaUtils", "home", "kibanaReact", "fieldFormats", "dataViews"], "extraPublicDirs": ["common"], "owner": { "name": "Data Discovery", diff --git a/src/plugins/discover/public/__mocks__/saved_search.ts b/src/plugins/discover/public/__mocks__/saved_search.ts index ebe65a57703562..a488fe7e04c503 100644 --- a/src/plugins/discover/public/__mocks__/saved_search.ts +++ b/src/plugins/discover/public/__mocks__/saved_search.ts @@ -13,44 +13,10 @@ import { indexPatternWithTimefieldMock } from './index_pattern_with_timefield'; export const savedSearchMock = { id: 'the-saved-search-id', - type: 'search', - attributes: { - title: 'the-saved-search-title', - kibanaSavedObjectMeta: { - searchSourceJSON: - '{"highlightAll":true,"version":true,"query":{"query":"foo : \\"bar\\" ","language":"kuery"},"filter":[],"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.index"}', - }, - }, - references: [ - { - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - type: 'index-pattern', - id: 'the-index-pattern-id', - }, - ], - migrationVersion: { search: '7.5.0' }, - error: undefined, searchSource: createSearchSourceMock({ index: indexPatternMock }), } as unknown as SavedSearch; export const savedSearchMockWithTimeField = { id: 'the-saved-search-id-with-timefield', - type: 'search', - attributes: { - title: 'the-saved-search-title', - kibanaSavedObjectMeta: { - searchSourceJSON: - '{"highlightAll":true,"version":true,"query":{"query":"foo : \\"bar\\" ","language":"kuery"},"filter":[],"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.index"}', - }, - }, - references: [ - { - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - type: 'index-pattern', - id: 'the-index-pattern-id', - }, - ], - migrationVersion: { search: '7.5.0' }, - error: undefined, searchSource: createSearchSourceMock({ index: indexPatternWithTimefieldMock }), } as unknown as SavedSearch; diff --git a/src/plugins/discover/public/__mocks__/services.ts b/src/plugins/discover/public/__mocks__/services.ts index 30d66b113e5288..8cc5ccf5aa121e 100644 --- a/src/plugins/discover/public/__mocks__/services.ts +++ b/src/plugins/discover/public/__mocks__/services.ts @@ -16,7 +16,6 @@ import { SAMPLE_SIZE_SETTING, SORT_DEFAULT_ORDER_SETTING, } from '../../common'; -import { savedSearchMock } from './saved_search'; import { UI_SETTINGS } from '../../../data/common'; import { TopNavMenu } from '../../../navigation/public'; import { FORMATS_UI_SETTINGS } from 'src/plugins/field_formats/common'; @@ -78,7 +77,6 @@ export const discoverServiceMock = { editIndexPattern: jest.fn(), }, }, - getSavedSearchById: (id?: string) => Promise.resolve(savedSearchMock), navigation: { ui: { TopNavMenu }, }, diff --git a/src/plugins/discover/public/application/apps/context/services/context.ts b/src/plugins/discover/public/application/apps/context/services/context.ts index b76b5ac648c22b..257ae2dcce834a 100644 --- a/src/plugins/discover/public/application/apps/context/services/context.ts +++ b/src/plugins/discover/public/application/apps/context/services/context.ts @@ -5,7 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { Filter, IndexPattern, SearchSource } from 'src/plugins/data/public'; +import { Filter, IndexPattern, ISearchSource } from 'src/plugins/data/public'; import { reverseSortDir, SortDirection } from './utils/sorting'; import { convertIsoToMillis, extractNanos } from './utils/date_conversion'; import { fetchHitsInInterval } from './utils/fetch_hits_in_interval'; @@ -53,7 +53,7 @@ export async function fetchSurroundingDocs( } const { data } = getServices(); const timeField = indexPattern.timeFieldName!; - const searchSource = data.search.searchSource.createEmpty() as SearchSource; + const searchSource = data.search.searchSource.createEmpty(); updateSearchSource(searchSource, indexPattern, filters, Boolean(useNewFieldsApi)); const sortDirToApply = type === SurrDocType.SUCCESSORS ? sortDir : reverseSortDir(sortDir); @@ -104,7 +104,7 @@ export async function fetchSurroundingDocs( } export function updateSearchSource( - searchSource: SearchSource, + searchSource: ISearchSource, indexPattern: IndexPattern, filters: Filter[], useNewFieldsApi: boolean diff --git a/src/plugins/discover/public/application/apps/main/components/layout/discover_documents.tsx b/src/plugins/discover/public/application/apps/main/components/layout/discover_documents.tsx index e0e0c9c6f8831c..d6ede9aa7fe5fe 100644 --- a/src/plugins/discover/public/application/apps/main/components/layout/discover_documents.tsx +++ b/src/plugins/discover/public/application/apps/main/components/layout/discover_documents.tsx @@ -134,7 +134,7 @@ function DiscoverDocumentsComponent({ sort={state.sort || []} isLoading={isLoading} searchDescription={savedSearch.description} - sharedItemTitle={savedSearch.lastSavedTitle} + sharedItemTitle={savedSearch.title} onAddColumn={onAddColumn} onFilter={onAddFilter as DocViewFilterFn} onMoveColumn={onMoveColumn} @@ -156,7 +156,7 @@ function DiscoverDocumentsComponent({ sort={(state.sort as SortPairArr[]) || []} sampleSize={sampleSize} searchDescription={savedSearch.description} - searchTitle={savedSearch.lastSavedTitle} + searchTitle={savedSearch.title} setExpandedDoc={setExpandedDoc} showTimeCol={showTimeCol} services={services} diff --git a/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.tsx b/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.tsx index 4bbef32dcbaddd..6c6393daf30a3d 100644 --- a/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.tsx +++ b/src/plugins/discover/public/application/apps/main/components/layout/discover_layout.tsx @@ -39,6 +39,10 @@ import { useDataGridColumns } from '../../../../helpers/use_data_grid_columns'; import { DiscoverDocuments } from './discover_documents'; import { FetchStatus } from '../../../../types'; import { useDataState } from '../../utils/use_data_state'; +import { + SavedSearchURLConflictCallout, + useSavedSearchAliasMatchRedirect, +} from '../../../../../saved_searches'; /** * Local storage key for sidebar persistence state @@ -65,10 +69,18 @@ export function DiscoverLayout({ state, stateContainer, }: DiscoverLayoutProps) { - const { trackUiMetric, capabilities, indexPatterns, data, uiSettings, filterManager, storage } = - services; + const { + trackUiMetric, + capabilities, + indexPatterns, + data, + uiSettings, + filterManager, + storage, + history, + spaces, + } = services; const { main$, charts$, totalHits$ } = savedSearchData$; - const [expandedDoc, setExpandedDoc] = useState(undefined); const [inspectorSession, setInspectorSession] = useState(undefined); const fetchCounter = useRef(0); @@ -80,6 +92,8 @@ export function DiscoverLayout({ } }, [dataState.fetchStatus]); + useSavedSearchAliasMatchRedirect({ savedSearch, spaces, history }); + const timeField = useMemo(() => { return indexPattern.type !== 'rollup' ? indexPattern.timeFieldName : undefined; }, [indexPattern]); @@ -174,6 +188,11 @@ export function DiscoverLayout({ resetSavedSearch={resetSavedSearch} /> +

{savedSearch.title}

diff --git a/src/plugins/discover/public/application/apps/main/components/top_nav/on_save_search.tsx b/src/plugins/discover/public/application/apps/main/components/top_nav/on_save_search.tsx index c68b1ab7b844c0..18766b5df7f33d 100644 --- a/src/plugins/discover/public/application/apps/main/components/top_nav/on_save_search.tsx +++ b/src/plugins/discover/public/application/apps/main/components/top_nav/on_save_search.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { SavedObjectSaveModal, showSaveModal } from '../../../../../../../saved_objects/public'; -import { SavedSearch } from '../../../../../saved_searches'; +import { SavedSearch, SaveSavedSearchOptions } from '../../../../../saved_searches'; import { IndexPattern } from '../../../../../../../data/common'; import { DiscoverServices } from '../../../../../build_services'; import { GetStateReturn } from '../../services/discover_state'; @@ -27,11 +27,7 @@ async function saveDataSource({ indexPattern: IndexPattern; navigateTo: (url: string) => void; savedSearch: SavedSearch; - saveOptions: { - confirmOverwrite: boolean; - isTitleDuplicateConfirmed: boolean; - onTitleDuplicate: () => void; - }; + saveOptions: SaveSavedSearchOptions; services: DiscoverServices; state: GetStateReturn; }) { @@ -47,14 +43,20 @@ async function saveDataSource({ }), 'data-test-subj': 'saveSearchSuccess', }); - - if (savedSearch.id !== prevSavedSearchId) { - navigateTo(`/view/${encodeURIComponent(savedSearch.id)}`); + if (id !== prevSavedSearchId) { + navigateTo(`/view/${encodeURIComponent(id)}`); } else { // Update defaults so that "reload saved query" functions correctly state.resetAppState(); - services.chrome.docTitle.change(savedSearch.lastSavedTitle!); - setBreadcrumbsTitle(savedSearch, services.chrome); + services.chrome.docTitle.change(savedSearch.title!); + + setBreadcrumbsTitle( + { + ...savedSearch, + id: prevSavedSearchId ?? id, + }, + services.chrome + ); } } } @@ -106,11 +108,10 @@ export async function onSaveSearch({ }) => { const currentTitle = savedSearch.title; savedSearch.title = newTitle; - savedSearch.copyOnSave = newCopyOnSave; - const saveOptions = { - confirmOverwrite: false, - isTitleDuplicateConfirmed, + const saveOptions: SaveSavedSearchOptions = { onTitleDuplicate, + copyOnSave: newCopyOnSave, + isTitleDuplicateConfirmed, }; const response = await saveDataSource({ indexPattern, @@ -133,7 +134,7 @@ export async function onSaveSearch({ {}} - title={savedSearch.title} + title={savedSearch.title ?? ''} showCopyOnSave={!!savedSearch.id} objectType={i18n.translate('discover.localMenu.saveSaveSearchObjectType', { defaultMessage: 'search', diff --git a/src/plugins/discover/public/application/apps/main/discover_main_route.tsx b/src/plugins/discover/public/application/apps/main/discover_main_route.tsx index a95668642558c8..b674bfd6568acb 100644 --- a/src/plugins/discover/public/application/apps/main/discover_main_route.tsx +++ b/src/plugins/discover/public/application/apps/main/discover_main_route.tsx @@ -8,15 +8,18 @@ import React, { useEffect, useState, memo } from 'react'; import { History } from 'history'; import { useParams } from 'react-router-dom'; -import type { SavedObject as SavedObjectDeprecated } from 'src/plugins/saved_objects/public'; -import { IndexPatternAttributes, SavedObject } from 'src/plugins/data/common'; +import { i18n } from '@kbn/i18n'; +import { EuiEmptyPrompt } from '@elastic/eui'; + +import { IndexPatternAttributes, ISearchSource, SavedObject } from 'src/plugins/data/common'; import { DiscoverServices } from '../../../build_services'; -import { SavedSearch } from '../../../saved_searches'; +import { SavedSearch, getSavedSearch, getSavedSearchFullPathUrl } from '../../../saved_searches'; import { getState } from './services/discover_state'; import { loadIndexPattern, resolveIndexPattern } from './utils/resolve_index_pattern'; import { DiscoverMainApp } from './discover_main_app'; import { getRootBreadcrumbs, getSavedSearchBreadcrumbs } from '../../helpers/breadcrumbs'; import { redirectWhenMissing } from '../../../../../kibana_utils/public'; +import { DataViewSavedObjectConflictError } from '../../../../../data_views/common'; import { getUrlTracker } from '../../../kibana_services'; import { LoadingIndicator } from '../../components/common/loading_indicator'; @@ -37,6 +40,21 @@ interface DiscoverLandingParams { id: string; } +const DiscoverError = ({ error }: { error: Error }) => ( + + {i18n.translate('discover.discoverError.title', { + defaultMessage: 'Error loading Discover', + })} + + } + body={

{error.message}

} + /> +); + export function DiscoverMainRoute({ services, history }: DiscoverMainProps) { const { core, @@ -46,7 +64,7 @@ export function DiscoverMainRoute({ services, history }: DiscoverMainProps) { toastNotifications, http: { basePath }, } = services; - + const [error, setError] = useState(); const [savedSearch, setSavedSearch] = useState(); const indexPattern = savedSearch?.searchSource?.getField('index'); const [indexPatternList, setIndexPatternList] = useState< @@ -58,58 +76,76 @@ export function DiscoverMainRoute({ services, history }: DiscoverMainProps) { useEffect(() => { const savedSearchId = id; - async function loadDefaultOrCurrentIndexPattern(usedSavedSearch: SavedSearch) { - await data.indexPatterns.ensureDefaultDataView(); - const { appStateContainer } = getState({ history, uiSettings: config }); - const { index } = appStateContainer.getState(); - const ip = await loadIndexPattern(index || '', data.indexPatterns, config); - const ipList = ip.list as Array>; - const indexPatternData = await resolveIndexPattern( - ip, - usedSavedSearch.searchSource, - toastNotifications - ); - setIndexPatternList(ipList); - return indexPatternData; + async function loadDefaultOrCurrentIndexPattern(searchSource: ISearchSource) { + try { + await data.indexPatterns.ensureDefaultDataView(); + const { appStateContainer } = getState({ history, uiSettings: config }); + const { index } = appStateContainer.getState(); + const ip = await loadIndexPattern(index || '', data.indexPatterns, config); + + const ipList = ip.list as Array>; + const indexPatternData = await resolveIndexPattern(ip, searchSource, toastNotifications); + + setIndexPatternList(ipList); + + return indexPatternData; + } catch (e) { + setError(e); + } } async function loadSavedSearch() { try { - const loadedSavedSearch = await services.getSavedSearchById(savedSearchId); - const loadedIndexPattern = await loadDefaultOrCurrentIndexPattern(loadedSavedSearch); - if (loadedSavedSearch && !loadedSavedSearch?.searchSource.getField('index')) { - loadedSavedSearch.searchSource.setField('index', loadedIndexPattern); + const currentSavedSearch = await getSavedSearch(savedSearchId, { + search: services.data.search, + savedObjectsClient: core.savedObjects.client, + spaces: services.spaces, + }); + + const loadedIndexPattern = await loadDefaultOrCurrentIndexPattern( + currentSavedSearch.searchSource + ); + + if (!currentSavedSearch.searchSource.getField('index')) { + currentSavedSearch.searchSource.setField('index', loadedIndexPattern); } - setSavedSearch(loadedSavedSearch); - if (savedSearchId) { + + setSavedSearch(currentSavedSearch); + + if (currentSavedSearch.id) { chrome.recentlyAccessed.add( - (loadedSavedSearch as unknown as SavedObjectDeprecated).getFullPath(), - loadedSavedSearch.title, - loadedSavedSearch.id + getSavedSearchFullPathUrl(currentSavedSearch.id), + currentSavedSearch.title ?? '', + currentSavedSearch.id ); } } catch (e) { - redirectWhenMissing({ - history, - navigateToApp: core.application.navigateToApp, - basePath, - mapping: { - search: '/', - 'index-pattern': { - app: 'management', - path: `kibana/objects/savedSearches/${id}`, + if (e instanceof DataViewSavedObjectConflictError) { + setError(e); + } else { + redirectWhenMissing({ + history, + navigateToApp: core.application.navigateToApp, + basePath, + mapping: { + search: '/', + 'index-pattern': { + app: 'management', + path: `kibana/objects/savedSearches/${id}`, + }, }, - }, - toastNotifications, - onBeforeRedirect() { - getUrlTracker().setTrackedUrl('/'); - }, - })(e); + toastNotifications, + onBeforeRedirect() { + getUrlTracker().setTrackedUrl('/'); + }, + })(e); + } } } loadSavedSearch(); }, [ + core.savedObjects.client, basePath, chrome.recentlyAccessed, config, @@ -129,6 +165,10 @@ export function DiscoverMainRoute({ services, history }: DiscoverMainProps) { ); }, [chrome, savedSearch]); + if (error) { + return ; + } + if (!indexPattern || !savedSearch) { return ; } diff --git a/src/plugins/discover/public/application/apps/main/services/discover_state.test.ts b/src/plugins/discover/public/application/apps/main/services/discover_state.test.ts index 905d81a6fc7160..9968ca6f1f63fe 100644 --- a/src/plugins/discover/public/application/apps/main/services/discover_state.test.ts +++ b/src/plugins/discover/public/application/apps/main/services/discover_state.test.ts @@ -14,7 +14,7 @@ import { } from './discover_state'; import { createBrowserHistory, History } from 'history'; import { dataPluginMock } from '../../../../../../data/public/mocks'; -import { SavedSearch } from '../../../../saved_searches'; +import type { SavedSearch } from '../../../../saved_searches'; import { SEARCH_FIELDS_FROM_SOURCE } from '../../../../../common'; let history: History; diff --git a/src/plugins/discover/public/application/apps/main/services/use_discover_state.test.ts b/src/plugins/discover/public/application/apps/main/services/use_discover_state.test.ts index 28f5f96acc1441..c719f83980aa0c 100644 --- a/src/plugins/discover/public/application/apps/main/services/use_discover_state.test.ts +++ b/src/plugins/discover/public/application/apps/main/services/use_discover_state.test.ts @@ -15,6 +15,20 @@ import { indexPatternMock } from '../../../../__mocks__/index_pattern'; import { SearchSource } from '../../../../../../data/common'; describe('test useDiscoverState', () => { + const originalSavedObjectsClient = discoverServiceMock.core.savedObjects.client; + + beforeAll(() => { + discoverServiceMock.core.savedObjects.client.resolve = jest.fn().mockReturnValue({ + saved_object: { + attributes: {}, + }, + }); + }); + + afterAll(() => { + discoverServiceMock.core.savedObjects.client = originalSavedObjectsClient; + }); + test('return is valid', async () => { const { history } = createSearchSessionMock(); diff --git a/src/plugins/discover/public/application/apps/main/services/use_discover_state.ts b/src/plugins/discover/public/application/apps/main/services/use_discover_state.ts index 223d896b16cd1b..ce30c0749b938b 100644 --- a/src/plugins/discover/public/application/apps/main/services/use_discover_state.ts +++ b/src/plugins/discover/public/application/apps/main/services/use_discover_state.ts @@ -11,7 +11,7 @@ import { History } from 'history'; import { getState } from './discover_state'; import { getStateDefaults } from '../utils/get_state_defaults'; import { DiscoverServices } from '../../../../build_services'; -import { SavedSearch } from '../../../../saved_searches'; +import { SavedSearch, getSavedSearch } from '../../../../saved_searches'; import { loadIndexPattern } from '../utils/resolve_index_pattern'; import { useSavedSearch as useSavedSearchData } from './use_saved_search'; import { @@ -148,7 +148,12 @@ export function useDiscoverState({ */ const resetSavedSearch = useCallback( async (id?: string) => { - const newSavedSearch = await services.getSavedSearchById(id); + const newSavedSearch = await getSavedSearch(id, { + search: services.data.search, + savedObjectsClient: services.core.savedObjects.client, + spaces: services.spaces, + }); + const newIndexPattern = newSavedSearch.searchSource.getField('index') || indexPattern; newSavedSearch.searchSource.setField('index', newIndexPattern); const newAppState = getStateDefaults({ diff --git a/src/plugins/discover/public/application/apps/main/services/use_saved_search.ts b/src/plugins/discover/public/application/apps/main/services/use_saved_search.ts index d11c76283fedd0..6cadfbb89acfbd 100644 --- a/src/plugins/discover/public/application/apps/main/services/use_saved_search.ts +++ b/src/plugins/discover/public/application/apps/main/services/use_saved_search.ts @@ -9,7 +9,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import { BehaviorSubject, Subject } from 'rxjs'; import { DiscoverServices } from '../../../../build_services'; import { DiscoverSearchSessionManager } from './discover_search_session'; -import { SearchSource } from '../../../../../../data/common'; +import { ISearchSource } from '../../../../../../data/common'; import { GetStateReturn } from './discover_state'; import { ElasticSearchHit } from '../../../doc_views/doc_views_types'; import { RequestAdapter } from '../../../../../../inspector/public'; @@ -91,7 +91,7 @@ export const useSavedSearch = ({ }: { initialFetchStatus: FetchStatus; searchSessionManager: DiscoverSearchSessionManager; - searchSource: SearchSource; + searchSource: ISearchSource; services: DiscoverServices; stateContainer: GetStateReturn; useNewFieldsApi: boolean; diff --git a/src/plugins/discover/public/application/apps/main/utils/fetch_all.ts b/src/plugins/discover/public/application/apps/main/utils/fetch_all.ts index 53d13ee547b0ff..e9d9335abcda05 100644 --- a/src/plugins/discover/public/application/apps/main/utils/fetch_all.ts +++ b/src/plugins/discover/public/application/apps/main/utils/fetch_all.ts @@ -14,11 +14,11 @@ import { sendResetMsg, } from '../services/use_saved_search_messages'; import { updateSearchSource } from './update_search_source'; -import { SortOrder } from '../../../../saved_searches/types'; +import type { SortOrder } from '../../../../saved_searches'; import { fetchDocuments } from './fetch_documents'; import { fetchTotalHits } from './fetch_total_hits'; import { fetchChart } from './fetch_chart'; -import { SearchSource } from '../../../../../../data/common'; +import { ISearchSource } from '../../../../../../data/common'; import { Adapters } from '../../../../../../inspector'; import { AppState } from '../services/discover_state'; import { FetchStatus } from '../../../types'; @@ -29,7 +29,7 @@ import { ReduxLikeStateContainer } from '../../../../../../kibana_utils/common'; export function fetchAll( dataSubjects: SavedSearchData, - searchSource: SearchSource, + searchSource: ISearchSource, reset = false, fetchDeps: { abortController: AbortController; diff --git a/src/plugins/discover/public/application/apps/main/utils/fetch_chart.ts b/src/plugins/discover/public/application/apps/main/utils/fetch_chart.ts index 67f34c7503c59c..50f3a1b8bfea76 100644 --- a/src/plugins/discover/public/application/apps/main/utils/fetch_chart.ts +++ b/src/plugins/discover/public/application/apps/main/utils/fetch_chart.ts @@ -11,7 +11,7 @@ import { DataPublicPluginStart, isCompleteResponse, search, - SearchSource, + ISearchSource, } from '../../../../../../data/public'; import { Adapters } from '../../../../../../inspector'; import { getChartAggConfigs, getDimensions } from './index'; @@ -25,7 +25,7 @@ import { sendErrorMsg, sendLoadingMsg } from '../services/use_saved_search_messa export function fetchChart( data$: SavedSearchData, - searchSource: SearchSource, + searchSource: ISearchSource, { abortController, appStateContainer, @@ -114,7 +114,7 @@ export function fetchChart( } export function updateSearchSource( - searchSource: SearchSource, + searchSource: ISearchSource, interval: string, data: DataPublicPluginStart ) { diff --git a/src/plugins/discover/public/application/apps/main/utils/fetch_documents.ts b/src/plugins/discover/public/application/apps/main/utils/fetch_documents.ts index 2f06a9dbbb3db3..6c5eff7cff7022 100644 --- a/src/plugins/discover/public/application/apps/main/utils/fetch_documents.ts +++ b/src/plugins/discover/public/application/apps/main/utils/fetch_documents.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import { filter } from 'rxjs/operators'; import { Adapters } from '../../../../../../inspector/common'; -import { isCompleteResponse, SearchSource } from '../../../../../../data/common'; +import { isCompleteResponse, ISearchSource } from '../../../../../../data/common'; import { FetchStatus } from '../../../types'; import { SavedSearchData } from '../services/use_saved_search'; import { sendErrorMsg, sendLoadingMsg } from '../services/use_saved_search_messages'; @@ -17,7 +17,7 @@ import { DiscoverServices } from '../../../../build_services'; export const fetchDocuments = ( data$: SavedSearchData, - searchSource: SearchSource, + searchSource: ISearchSource, { abortController, inspectorAdapters, diff --git a/src/plugins/discover/public/application/apps/main/utils/fetch_total_hits.ts b/src/plugins/discover/public/application/apps/main/utils/fetch_total_hits.ts index 9688f5ddd614d4..cfab0d17fcd549 100644 --- a/src/plugins/discover/public/application/apps/main/utils/fetch_total_hits.ts +++ b/src/plugins/discover/public/application/apps/main/utils/fetch_total_hits.ts @@ -11,7 +11,7 @@ import { filter } from 'rxjs/operators'; import { DataPublicPluginStart, isCompleteResponse, - SearchSource, + ISearchSource, } from '../../../../../../data/public'; import { Adapters } from '../../../../../../inspector/common'; import { FetchStatus } from '../../../types'; @@ -20,7 +20,7 @@ import { sendErrorMsg, sendLoadingMsg } from '../services/use_saved_search_messa export function fetchTotalHits( data$: SavedSearchData, - searchSource: SearchSource, + searchSource: ISearchSource, { abortController, data, diff --git a/src/plugins/discover/public/application/apps/main/utils/get_chart_agg_config.test.ts b/src/plugins/discover/public/application/apps/main/utils/get_chart_agg_config.test.ts index 3eef49fe6ddcb0..515565f0062c95 100644 --- a/src/plugins/discover/public/application/apps/main/utils/get_chart_agg_config.test.ts +++ b/src/plugins/discover/public/application/apps/main/utils/get_chart_agg_config.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ import { indexPatternWithTimefieldMock } from '../../../../__mocks__/index_pattern_with_timefield'; -import { SearchSource } from '../../../../../../data/public'; +import { ISearchSource } from '../../../../../../data/public'; import { dataPluginMock } from '../../../../../../data/public/mocks'; import { getChartAggConfigs } from './get_chart_agg_configs'; @@ -22,7 +22,7 @@ describe('getChartAggConfigs', () => { } }, removeField: jest.fn(), - } as unknown as SearchSource; + } as unknown as ISearchSource; const dataMock = dataPluginMock.createStartContract(); diff --git a/src/plugins/discover/public/application/apps/main/utils/get_chart_agg_configs.ts b/src/plugins/discover/public/application/apps/main/utils/get_chart_agg_configs.ts index 2665254027fd91..65f98f72beec05 100644 --- a/src/plugins/discover/public/application/apps/main/utils/get_chart_agg_configs.ts +++ b/src/plugins/discover/public/application/apps/main/utils/get_chart_agg_configs.ts @@ -5,7 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { SearchSource } from '../../../../../../data/common'; +import { ISearchSource } from '../../../../../../data/common'; import { DataPublicPluginStart } from '../../../../../../data/public'; /** @@ -13,7 +13,7 @@ import { DataPublicPluginStart } from '../../../../../../data/public'; * for Discover's histogram vis */ export function getChartAggConfigs( - searchSource: SearchSource, + searchSource: ISearchSource, histogramInterval: string, data: DataPublicPluginStart ) { diff --git a/src/plugins/discover/public/application/apps/main/utils/get_dimensions.test.ts b/src/plugins/discover/public/application/apps/main/utils/get_dimensions.test.ts index b98662f2db3b5b..35a6e955fe5b23 100644 --- a/src/plugins/discover/public/application/apps/main/utils/get_dimensions.test.ts +++ b/src/plugins/discover/public/application/apps/main/utils/get_dimensions.test.ts @@ -9,7 +9,7 @@ import { dataPluginMock } from '../../../../../../data/public/mocks'; import { getDimensions } from './get_dimensions'; import { indexPatternWithTimefieldMock } from '../../../../__mocks__/index_pattern_with_timefield'; -import { SearchSource, calculateBounds } from '../../../../../../data/common'; +import { ISearchSource, calculateBounds } from '../../../../../../data/common'; import { getChartAggConfigs } from './get_chart_agg_configs'; test('getDimensions', () => { @@ -23,7 +23,7 @@ test('getDimensions', () => { return indexPattern; } }, - } as unknown as SearchSource; + } as unknown as ISearchSource; const dataMock = dataPluginMock.createStartContract(); dataMock.query.timefilter.timefilter.getTime = () => { diff --git a/src/plugins/discover/public/application/apps/main/utils/get_fetch_observable.ts b/src/plugins/discover/public/application/apps/main/utils/get_fetch_observable.ts index 528f0e74d3ed6e..de79a9425f17ca 100644 --- a/src/plugins/discover/public/application/apps/main/utils/get_fetch_observable.ts +++ b/src/plugins/discover/public/application/apps/main/utils/get_fetch_observable.ts @@ -12,7 +12,7 @@ import { FetchStatus } from '../../../types'; import type { AutoRefreshDoneFn, DataPublicPluginStart, - SearchSource, + ISearchSource, } from '../../../../../../data/public'; import { DataMain$, DataRefetch$ } from '../services/use_saved_search'; import { DiscoverSearchSessionManager } from '../services/discover_search_session'; @@ -33,7 +33,7 @@ export function getFetch$({ main$: DataMain$; refetch$: DataRefetch$; searchSessionManager: DiscoverSearchSessionManager; - searchSource: SearchSource; + searchSource: ISearchSource; initialFetchStatus: FetchStatus; }) { const { timefilter } = data.query.timefilter; diff --git a/src/plugins/discover/public/application/apps/main/utils/get_sharing_data.ts b/src/plugins/discover/public/application/apps/main/utils/get_sharing_data.ts index 21292fabdd13f9..437d4fda666fc6 100644 --- a/src/plugins/discover/public/application/apps/main/utils/get_sharing_data.ts +++ b/src/plugins/discover/public/application/apps/main/utils/get_sharing_data.ts @@ -11,7 +11,7 @@ import type { IUiSettingsClient } from 'src/core/public'; import type { DataPublicPluginStart } from 'src/plugins/data/public'; import type { ISearchSource, SearchSourceFields } from 'src/plugins/data/common'; import { DOC_HIDE_TIME_COLUMN_SETTING, SORT_DEFAULT_ORDER_SETTING } from '../../../../../common'; -import type { SavedSearch, SortOrder } from '../../../../saved_searches/types'; +import type { SavedSearch, SortOrder } from '../../../../saved_searches'; import { getSortForSearchSource } from '../components/doc_table'; import { AppState } from '../services/discover_state'; diff --git a/src/plugins/discover/public/application/apps/main/utils/get_state_defaults.ts b/src/plugins/discover/public/application/apps/main/utils/get_state_defaults.ts index cd23d520223745..11ebf0ecf9af48 100644 --- a/src/plugins/discover/public/application/apps/main/utils/get_state_defaults.ts +++ b/src/plugins/discover/public/application/apps/main/utils/get_state_defaults.ts @@ -31,10 +31,11 @@ export function getStateDefaults({ data: DataPublicPluginStart; savedSearch: SavedSearch; }) { - const searchSource = savedSearch.searchSource; - const indexPattern = savedSearch.searchSource.getField('index'); + const { searchSource } = savedSearch; + const indexPattern = searchSource.getField('index'); + const query = searchSource.getField('query') || data.query.queryString.getDefaultQuery(); - const sort = getSortArray(savedSearch.sort, indexPattern!); + const sort = getSortArray(savedSearch.sort ?? [], indexPattern!); const columns = getDefaultColumns(savedSearch, config); const defaultState = { @@ -43,7 +44,7 @@ export function getStateDefaults({ ? getDefaultSort(indexPattern, config.get(SORT_DEFAULT_ORDER_SETTING, 'desc')) : sort, columns, - index: indexPattern!.id, + index: indexPattern?.id, interval: 'auto', filters: cloneDeep(searchSource.getOwnField('filter')), hideChart: undefined, diff --git a/src/plugins/discover/public/application/apps/main/utils/persist_saved_search.ts b/src/plugins/discover/public/application/apps/main/utils/persist_saved_search.ts index a5e1e2bb6c2ea6..584fbe14cb59ed 100644 --- a/src/plugins/discover/public/application/apps/main/utils/persist_saved_search.ts +++ b/src/plugins/discover/public/application/apps/main/utils/persist_saved_search.ts @@ -10,9 +10,10 @@ import { updateSearchSource } from './update_search_source'; import { IndexPattern } from '../../../../../../data/public'; import { SavedSearch } from '../../../../saved_searches'; import { AppState } from '../services/discover_state'; -import { SortOrder } from '../../../../saved_searches/types'; +import type { SortOrder } from '../../../../saved_searches'; import { SavedObjectSaveOpts } from '../../../../../../saved_objects/public'; import { DiscoverServices } from '../../../../build_services'; +import { saveSavedSearch } from '../../../../saved_searches'; /** * Helper function to update and persist the given savedSearch @@ -52,8 +53,10 @@ export async function persistSavedSearch( } try { - const id = await savedSearch.save(saveOptions); - onSuccess(id); + const id = await saveSavedSearch(savedSearch, saveOptions, services.core.savedObjects.client); + if (id) { + onSuccess(id); + } return { id }; } catch (saveError) { onError(saveError, savedSearch); diff --git a/src/plugins/discover/public/application/apps/main/utils/resolve_index_pattern.ts b/src/plugins/discover/public/application/apps/main/utils/resolve_index_pattern.ts index d30b67db311865..613615446ee08c 100644 --- a/src/plugins/discover/public/application/apps/main/utils/resolve_index_pattern.ts +++ b/src/plugins/discover/public/application/apps/main/utils/resolve_index_pattern.ts @@ -7,7 +7,7 @@ */ import { i18n } from '@kbn/i18n'; -import type { IndexPattern, IndexPatternsContract, SearchSource } from 'src/plugins/data/common'; +import type { IndexPattern, IndexPatternsContract, ISearchSource } from 'src/plugins/data/common'; import type { IUiSettingsClient, SavedObject, ToastsStart } from 'kibana/public'; export type IndexPatternSavedObject = SavedObject & { title: string }; @@ -95,7 +95,7 @@ export async function loadIndexPattern( */ export function resolveIndexPattern( ip: IndexPatternData, - searchSource: SearchSource, + searchSource: ISearchSource, toastNotifications: ToastsStart ) { const { loaded: loadedIndexPattern, stateVal, stateValFound } = ip; diff --git a/src/plugins/discover/public/application/apps/main/utils/update_search_source.test.ts b/src/plugins/discover/public/application/apps/main/utils/update_search_source.test.ts index 945140e0586abf..22f3b6ad86f6c0 100644 --- a/src/plugins/discover/public/application/apps/main/utils/update_search_source.test.ts +++ b/src/plugins/discover/public/application/apps/main/utils/update_search_source.test.ts @@ -9,7 +9,7 @@ import { updateSearchSource } from './update_search_source'; import { createSearchSourceMock } from '../../../../../../data/common/search/search_source/mocks'; import { indexPatternMock } from '../../../../__mocks__/index_pattern'; -import { SortOrder } from '../../../../saved_searches/types'; +import type { SortOrder } from '../../../../saved_searches'; import { discoverServiceMock } from '../../../../__mocks__/services'; describe('updateSearchSource', () => { diff --git a/src/plugins/discover/public/application/apps/main/utils/update_search_source.ts b/src/plugins/discover/public/application/apps/main/utils/update_search_source.ts index 4dfcbc7b79712f..6d592e176afe50 100644 --- a/src/plugins/discover/public/application/apps/main/utils/update_search_source.ts +++ b/src/plugins/discover/public/application/apps/main/utils/update_search_source.ts @@ -8,7 +8,7 @@ import { SORT_DEFAULT_ORDER_SETTING } from '../../../../../common'; import { IndexPattern, ISearchSource } from '../../../../../../data/common'; -import { SortOrder } from '../../../../saved_searches/types'; +import type { SortOrder } from '../../../../saved_searches'; import { DiscoverServices } from '../../../../build_services'; import { getSortForSearchSource } from '../components/doc_table'; diff --git a/src/plugins/discover/public/application/embeddable/helpers/update_search_source.test.ts b/src/plugins/discover/public/application/embeddable/helpers/update_search_source.test.ts index f3edc523f44646..f09131cb5c926c 100644 --- a/src/plugins/discover/public/application/embeddable/helpers/update_search_source.test.ts +++ b/src/plugins/discover/public/application/embeddable/helpers/update_search_source.test.ts @@ -8,7 +8,7 @@ import { createSearchSourceMock } from '../../../../../data/common/search/search_source/mocks'; import { updateSearchSource } from './update_search_source'; import { indexPatternMock } from '../../../__mocks__/index_pattern'; -import { SortOrder } from '../../../saved_searches/types'; +import type { SortOrder } from '../../../saved_searches'; describe('updateSearchSource', () => { const defaults = { diff --git a/src/plugins/discover/public/application/embeddable/saved_search_embeddable.tsx b/src/plugins/discover/public/application/embeddable/saved_search_embeddable.tsx index ef8670f9766724..8849806cf5959d 100644 --- a/src/plugins/discover/public/application/embeddable/saved_search_embeddable.tsx +++ b/src/plugins/discover/public/application/embeddable/saved_search_embeddable.tsx @@ -165,7 +165,7 @@ export class SavedSearchEmbeddable const executionContext = { type: this.type, name: 'discover', - id: this.savedSearch.id, + id: this.savedSearch.id!, description: this.output.title || this.output.defaultTitle || '', url: this.output.editUrl, parent: this.input.executionContext, @@ -232,7 +232,7 @@ export class SavedSearchEmbeddable searchDescription: this.savedSearch.description, description: this.savedSearch.description, inspectorAdapters: this.inspectorAdapters, - searchTitle: this.savedSearch.lastSavedTitle, + searchTitle: this.savedSearch.title, services: this.services, onAddColumn: (columnName: string) => { if (!props.columns) { @@ -404,7 +404,6 @@ export class SavedSearchEmbeddable public destroy() { super.destroy(); - this.savedSearch.destroy(); if (this.searchProps) { delete this.searchProps; } diff --git a/src/plugins/discover/public/application/embeddable/search_embeddable_factory.ts b/src/plugins/discover/public/application/embeddable/search_embeddable_factory.ts index 95f5b2d3ce2845..a8b492d3687684 100644 --- a/src/plugins/discover/public/application/embeddable/search_embeddable_factory.ts +++ b/src/plugins/discover/public/application/embeddable/search_embeddable_factory.ts @@ -20,6 +20,11 @@ import { TimeRange } from '../../../../data/public'; import { SearchInput, SearchOutput } from './types'; import { SEARCH_EMBEDDABLE_TYPE } from './constants'; import { SavedSearchEmbeddable } from './saved_search_embeddable'; +import { + getSavedSearch, + getSavedSearchUrl, + throwErrorOnSavedSearchUrlConflict, +} from '../../saved_searches'; interface StartServices { executeTriggerActions: UiActionsStart['executeTriggerActions']; @@ -59,20 +64,27 @@ export class SearchEmbeddableFactory input: Partial & { id: string; timeRange: TimeRange }, parent?: Container ): Promise => { - const filterManager = getServices().filterManager; - - const url = await getServices().getSavedSearchUrlById(savedObjectId); - const editUrl = getServices().addBasePath(`/app/discover${url}`); + const services = getServices(); + const filterManager = services.filterManager; + const url = getSavedSearchUrl(savedObjectId); + const editUrl = services.addBasePath(`/app/discover${url}`); try { - const savedObject = await getServices().getSavedSearchById(savedObjectId); - const indexPattern = savedObject.searchSource.getField('index'); + const savedSearch = await getSavedSearch(savedObjectId, { + search: services.data.search, + savedObjectsClient: services.core.savedObjects.client, + spaces: services.spaces, + }); + + await throwErrorOnSavedSearchUrlConflict(savedSearch); + + const indexPattern = savedSearch.searchSource.getField('index'); const { executeTriggerActions } = await this.getStartServices(); const { SavedSearchEmbeddable: SavedSearchEmbeddableClass } = await import( './saved_search_embeddable' ); return new SavedSearchEmbeddableClass( { - savedSearch: savedObject, + savedSearch, editUrl, editPath: url, filterManager, diff --git a/src/plugins/discover/public/application/embeddable/types.ts b/src/plugins/discover/public/application/embeddable/types.ts index 5a08534918d4f5..de109e3fa78794 100644 --- a/src/plugins/discover/public/application/embeddable/types.ts +++ b/src/plugins/discover/public/application/embeddable/types.ts @@ -13,7 +13,7 @@ import { IEmbeddable, } from 'src/plugins/embeddable/public'; import { Filter, IndexPattern, TimeRange, Query } from '../../../../data/public'; -import { SavedSearch } from '../..'; +import { SavedSearch } from '../../saved_searches'; import { SortOrder } from '../apps/main/components/doc_table/components/table_header/helpers'; export interface SearchInput extends EmbeddableInput { diff --git a/src/plugins/discover/public/application/embeddable/view_saved_search_action.ts b/src/plugins/discover/public/application/embeddable/view_saved_search_action.ts index 69c273f326c617..e4b97d011ff645 100644 --- a/src/plugins/discover/public/application/embeddable/view_saved_search_action.ts +++ b/src/plugins/discover/public/application/embeddable/view_saved_search_action.ts @@ -12,6 +12,7 @@ import { IEmbeddable, ViewMode } from '../../../../embeddable/public'; import { Action } from '../../../../ui_actions/public'; import { SavedSearchEmbeddable } from './saved_search_embeddable'; import { SEARCH_EMBEDDABLE_TYPE } from '../../../common'; +import { getSavedSearchUrl } from '../../saved_searches'; export const ACTION_VIEW_SAVED_SEARCH = 'ACTION_VIEW_SAVED_SEARCH'; @@ -28,7 +29,7 @@ export class ViewSavedSearchAction implements Action { async execute(context: ActionExecutionContext): Promise { const { embeddable } = context; const savedSearchId = (embeddable as SavedSearchEmbeddable).getSavedSearch().id; - const path = `#/view/${encodeURIComponent(savedSearchId)}`; + const path = getSavedSearchUrl(savedSearchId); const app = embeddable ? embeddable.getOutput().editApp : undefined; await this.application.navigateToApp(app ? app : 'discover', { path }); } diff --git a/src/plugins/discover/public/build_services.ts b/src/plugins/discover/public/build_services.ts index e88f00fadcbf18..ab2484abee8923 100644 --- a/src/plugins/discover/public/build_services.ts +++ b/src/plugins/discover/public/build_services.ts @@ -31,13 +31,14 @@ import { UiCounterMetricType } from '@kbn/analytics'; import { Storage } from '../../kibana_utils/public'; import { DiscoverStartPlugins } from './plugin'; -import { createSavedSearchesLoader, SavedSearch } from './saved_searches'; import { getHistory } from './kibana_services'; import { KibanaLegacyStart } from '../../kibana_legacy/public'; import { UrlForwardingStart } from '../../url_forwarding/public'; import { NavigationPublicPluginStart } from '../../navigation/public'; import { IndexPatternFieldEditorStart } from '../../index_pattern_field_editor/public'; +import type { SpacesApi } from '../../../../x-pack/plugins/spaces/public'; + export interface DiscoverServices { addBasePath: (path: string) => string; capabilities: Capabilities; @@ -57,13 +58,12 @@ export interface DiscoverServices { urlForwarding: UrlForwardingStart; timefilter: TimefilterContract; toastNotifications: ToastsStart; - getSavedSearchById: (id?: string) => Promise; - getSavedSearchUrlById: (id: string) => Promise; uiSettings: IUiSettingsClient; trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void; indexPatternFieldEditor: IndexPatternFieldEditorStart; http: HttpStart; storage: Storage; + spaces?: SpacesApi; } export function buildServices( @@ -71,11 +71,6 @@ export function buildServices( plugins: DiscoverStartPlugins, context: PluginInitializerContext ): DiscoverServices { - const services = { - savedObjectsClient: core.savedObjects.client, - savedObjects: plugins.savedObjects, - }; - const savedObjectService = createSavedSearchesLoader(services); const { usageCollection } = plugins; const storage = new Storage(localStorage); @@ -88,8 +83,6 @@ export function buildServices( docLinks: core.docLinks, theme: plugins.charts.theme, filterManager: plugins.data.query.filterManager, - getSavedSearchById: async (id?: string) => savedObjectService.get(id), - getSavedSearchUrlById: async (id: string) => savedObjectService.urlFor(id), history: getHistory, indexPatterns: plugins.data.indexPatterns, inspector: plugins.inspector, @@ -107,5 +100,6 @@ export function buildServices( trackUiMetric: usageCollection?.reportUiCounter.bind(usageCollection, 'discover'), indexPatternFieldEditor: plugins.indexPatternFieldEditor, http: core.http, + spaces: plugins.spaces, }; } diff --git a/src/plugins/discover/public/index.ts b/src/plugins/discover/public/index.ts index 3840df4353faf8..f6cd687c962c34 100644 --- a/src/plugins/discover/public/index.ts +++ b/src/plugins/discover/public/index.ts @@ -9,12 +9,23 @@ import { PluginInitializerContext } from 'kibana/public'; import { DiscoverPlugin } from './plugin'; +export { + getSavedSearch, + getSavedSearchFullPathUrl, + getSavedSearchUrl, + getSavedSearchUrlConflictMessage, + throwErrorOnSavedSearchUrlConflict, + SavedSearch, + LegacySavedSearch, + SavedSearchLoader, + __LEGACY, +} from './saved_searches'; + export { DiscoverSetup, DiscoverStart } from './plugin'; export function plugin(initializerContext: PluginInitializerContext) { return new DiscoverPlugin(initializerContext); } -export { SavedSearch, SavedSearchLoader, createSavedSearchesLoader } from './saved_searches'; export { ISearchEmbeddable, SEARCH_EMBEDDABLE_TYPE, SearchInput } from './application/embeddable'; export { loadSharingDataHelpers } from './shared'; diff --git a/src/plugins/discover/public/mocks.ts b/src/plugins/discover/public/mocks.ts index 71de630132b0ac..6a3c703ea0da87 100644 --- a/src/plugins/discover/public/mocks.ts +++ b/src/plugins/discover/public/mocks.ts @@ -24,7 +24,9 @@ const createSetupContract = (): Setup => { const createStartContract = (): Start => { const startContract: Start = { - savedSearchLoader: {} as DiscoverStart['savedSearchLoader'], + __LEGACY: { + savedSearchLoader: {} as DiscoverStart['__LEGACY']['savedSearchLoader'], + }, urlGenerator: { createUrl: jest.fn(), } as unknown as DiscoverStart['urlGenerator'], diff --git a/src/plugins/discover/public/plugin.tsx b/src/plugins/discover/public/plugin.tsx index afb83d6cbd6678..e34e7644caa251 100644 --- a/src/plugins/discover/public/plugin.tsx +++ b/src/plugins/discover/public/plugin.tsx @@ -45,7 +45,7 @@ import { getScopedHistory, syncHistoryLocations, } from './kibana_services'; -import { createSavedSearchesLoader } from './saved_searches'; +import { __LEGACY } from './saved_searches'; import { registerFeature } from './register_feature'; import { buildServices } from './build_services'; import { @@ -61,6 +61,7 @@ import { replaceUrlHashQuery } from '../../kibana_utils/public/'; import { IndexPatternFieldEditorStart } from '../../../plugins/index_pattern_field_editor/public'; import { DeferredSpinner } from './shared'; import { ViewSavedSearchAction } from './application/embeddable/view_saved_search_action'; +import type { SpacesPluginStart } from '../../../../x-pack/plugins/spaces/public'; declare module '../../share/public' { export interface UrlGeneratorStateMapping { @@ -120,7 +121,9 @@ export interface DiscoverSetup { } export interface DiscoverStart { - savedSearchLoader: SavedObjectLoader; + __LEGACY: { + savedSearchLoader: SavedObjectLoader; + }; /** * @deprecated Use URL locator instead. URL generator will be removed. @@ -189,6 +192,7 @@ export interface DiscoverStartPlugins { savedObjects: SavedObjectsStart; usageCollection?: UsageCollectionSetup; indexPatternFieldEditor: IndexPatternFieldEditorStart; + spaces?: SpacesPluginStart; } /** @@ -410,10 +414,12 @@ export class DiscoverPlugin return { urlGenerator: this.urlGenerator, locator: this.locator, - savedSearchLoader: createSavedSearchesLoader({ - savedObjectsClient: core.savedObjects.client, - savedObjects: plugins.savedObjects, - }), + __LEGACY: { + savedSearchLoader: __LEGACY.createSavedSearchesLoader({ + savedObjectsClient: core.savedObjects.client, + savedObjects: plugins.savedObjects, + }), + }, }; } diff --git a/src/plugins/discover/public/saved_searches/constants.ts b/src/plugins/discover/public/saved_searches/constants.ts new file mode 100644 index 00000000000000..f8e191c263bd72 --- /dev/null +++ b/src/plugins/discover/public/saved_searches/constants.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** @internal **/ +export const SAVED_SEARCH_TYPE = 'search'; diff --git a/src/plugins/discover/public/saved_searches/get_saved_searches.test.ts b/src/plugins/discover/public/saved_searches/get_saved_searches.test.ts new file mode 100644 index 00000000000000..755831e7009ed9 --- /dev/null +++ b/src/plugins/discover/public/saved_searches/get_saved_searches.test.ts @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { SavedObjectsStart } from '../../../../core/public'; +import type { DataPublicPluginStart } from '../../../data/public'; + +import { savedObjectsServiceMock } from '../../../../core/public/mocks'; +import { dataPluginMock } from '../../../data/public/mocks'; + +import { getSavedSearch } from './get_saved_searches'; + +describe('getSavedSearch', () => { + let search: DataPublicPluginStart['search']; + let savedObjectsClient: SavedObjectsStart['client']; + + beforeEach(() => { + savedObjectsClient = savedObjectsServiceMock.createStartContract().client; + search = dataPluginMock.createStartContract().search; + }); + + test('should return empty saved search in case of no id', async () => { + const savedSearch = await getSavedSearch(undefined, { savedObjectsClient, search }); + + expect(search.searchSource.createEmpty).toHaveBeenCalled(); + expect(savedSearch).toHaveProperty('searchSource'); + }); + + test('should throw an error if so not found', async () => { + let errorMessage = 'No error thrown.'; + savedObjectsClient.resolve = jest.fn().mockReturnValue({ + saved_object: { + attributes: {}, + error: { + statusCode: 404, + error: 'Not Found', + message: 'Saved object [search/ccf1af80-2297-11ec-86e0-1155ffb9c7a7] not found', + }, + id: 'ccf1af80-2297-11ec-86e0-1155ffb9c7a7', + type: 'search', + references: [], + }, + }); + + try { + await getSavedSearch('ccf1af80-2297-11ec-86e0-1155ffb9c7a7', { + savedObjectsClient, + search, + }); + } catch (error) { + errorMessage = error.message; + } + + expect(errorMessage).toBe( + 'Could not locate that search (id: ccf1af80-2297-11ec-86e0-1155ffb9c7a7)' + ); + }); + + test('should find saved search', async () => { + savedObjectsClient.resolve = jest.fn().mockReturnValue({ + saved_object: { + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: + '{"query":{"query":"","language":"kuery"},"filter":[],"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.index"}', + }, + title: 'test1', + sort: [['order_date', 'desc']], + columns: ['_source'], + description: 'description', + grid: {}, + hideChart: false, + }, + id: 'ccf1af80-2297-11ec-86e0-1155ffb9c7a7', + type: 'search', + references: [ + { + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + type: 'index-pattern', + }, + ], + namespaces: ['default'], + }, + outcome: 'exactMatch', + }); + + const savedSearch = await getSavedSearch('ccf1af80-2297-11ec-86e0-1155ffb9c7a7', { + savedObjectsClient, + search, + }); + + expect(savedObjectsClient.resolve).toHaveBeenCalled(); + expect(savedSearch).toMatchInlineSnapshot(` + Object { + "columns": Array [ + "_source", + ], + "description": "description", + "grid": Object {}, + "hideChart": false, + "id": "ccf1af80-2297-11ec-86e0-1155ffb9c7a7", + "searchSource": Object { + "create": [MockFunction], + "createChild": [MockFunction], + "createCopy": [MockFunction], + "destroy": [MockFunction], + "fetch": [MockFunction], + "fetch$": [MockFunction], + "getField": [MockFunction], + "getFields": [MockFunction], + "getId": [MockFunction], + "getOwnField": [MockFunction], + "getParent": [MockFunction], + "getSearchRequestBody": [MockFunction], + "getSerializedFields": [MockFunction], + "history": Array [], + "onRequestStart": [MockFunction], + "removeField": [MockFunction], + "serialize": [MockFunction], + "setField": [MockFunction], + "setFields": [MockFunction], + "setParent": [MockFunction], + "setPreferredSearchStrategyId": [MockFunction], + }, + "sharingSavedObjectProps": Object { + "aliasTargetId": undefined, + "errorJSON": undefined, + "outcome": "exactMatch", + }, + "sort": Array [ + Array [ + "order_date", + "desc", + ], + ], + "title": "test1", + } + `); + }); +}); diff --git a/src/plugins/discover/public/saved_searches/get_saved_searches.ts b/src/plugins/discover/public/saved_searches/get_saved_searches.ts new file mode 100644 index 00000000000000..32c50f691fe42b --- /dev/null +++ b/src/plugins/discover/public/saved_searches/get_saved_searches.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SavedObjectsStart } from '../../../../core/public'; +import type { DataPublicPluginStart } from '../../../data/public'; +import type { SavedSearchAttributes, SavedSearch } from './types'; + +import { SAVED_SEARCH_TYPE } from './constants'; +import { fromSavedSearchAttributes } from './saved_searches_utils'; +import { injectSearchSourceReferences, parseSearchSourceJSON } from '../../../data/public'; +import { SavedObjectNotFound } from '../../../kibana_utils/public'; + +import type { SpacesApi } from '../../../../../x-pack/plugins/spaces/public'; + +interface GetSavedSearchDependencies { + search: DataPublicPluginStart['search']; + savedObjectsClient: SavedObjectsStart['client']; + spaces?: SpacesApi; +} + +const getEmptySavedSearch = ({ + search, +}: { + search: DataPublicPluginStart['search']; +}): SavedSearch => ({ + searchSource: search.searchSource.createEmpty(), +}); + +const findSavedSearch = async ( + savedSearchId: string, + { search, savedObjectsClient, spaces }: GetSavedSearchDependencies +) => { + const so = await savedObjectsClient.resolve( + SAVED_SEARCH_TYPE, + savedSearchId + ); + + if (!so.saved_object || so.saved_object.error) { + throw new SavedObjectNotFound(SAVED_SEARCH_TYPE, savedSearchId); + } + + const savedSearch = so.saved_object; + + const parsedSearchSourceJSON = parseSearchSourceJSON( + savedSearch.attributes.kibanaSavedObjectMeta?.searchSourceJSON ?? '{}' + ); + + const searchSourceValues = injectSearchSourceReferences( + parsedSearchSourceJSON as Parameters[0], + savedSearch.references + ); + + return fromSavedSearchAttributes( + savedSearchId, + savedSearch.attributes, + await search.searchSource.create(searchSourceValues), + { + outcome: so.outcome, + aliasTargetId: so.alias_target_id, + errorJSON: + so.outcome === 'conflict' && spaces + ? JSON.stringify({ + targetType: SAVED_SEARCH_TYPE, + sourceId: savedSearchId, + targetSpace: (await spaces.getActiveSpace()).id, + }) + : undefined, + } + ); +}; + +/** @public **/ +export const getSavedSearch = async ( + savedSearchId: string | undefined, + dependencies: GetSavedSearchDependencies +) => { + return savedSearchId + ? findSavedSearch(savedSearchId, dependencies) + : getEmptySavedSearch(dependencies); +}; diff --git a/src/plugins/discover/public/saved_searches/index.ts b/src/plugins/discover/public/saved_searches/index.ts index 86564cc5d6eca3..6870fa5e6d617f 100644 --- a/src/plugins/discover/public/saved_searches/index.ts +++ b/src/plugins/discover/public/saved_searches/index.ts @@ -6,5 +6,25 @@ * Side Public License, v 1. */ -export { createSavedSearchesLoader } from './saved_searches'; -export { SavedSearch, SavedSearchLoader } from './types'; +import { createSavedSearchesLoader } from './legacy/saved_searches'; + +export { getSavedSearch } from './get_saved_searches'; +export { + getSavedSearchUrl, + getSavedSearchFullPathUrl, + getSavedSearchUrlConflictMessage, + throwErrorOnSavedSearchUrlConflict, +} from './saved_searches_utils'; +export { useSavedSearchAliasMatchRedirect } from './saved_search_alias_match_redirect'; +export { SavedSearchURLConflictCallout } from './saved_search_url_conflict_callout'; +export { saveSavedSearch, SaveSavedSearchOptions } from './save_saved_searches'; + +export { SAVED_SEARCH_TYPE } from './constants'; + +export type { SavedSearch } from './types'; +export type { LegacySavedSearch, SavedSearchLoader, SortOrder } from './legacy/types'; + +/** @deprecated __LEGACY object will be removed in v8**/ +export const __LEGACY = { + createSavedSearchesLoader, +}; diff --git a/src/plugins/discover/public/saved_searches/_saved_search.ts b/src/plugins/discover/public/saved_searches/legacy/_saved_search.ts similarity index 80% rename from src/plugins/discover/public/saved_searches/_saved_search.ts rename to src/plugins/discover/public/saved_searches/legacy/_saved_search.ts index 56533ed20b31e2..154f91f5582b3f 100644 --- a/src/plugins/discover/public/saved_searches/_saved_search.ts +++ b/src/plugins/discover/public/saved_searches/legacy/_saved_search.ts @@ -6,11 +6,14 @@ * Side Public License, v 1. */ -import { SavedObject, SavedObjectsStart } from '../../../saved_objects/public'; +import type { SavedObject, SavedObjectsStart } from '../../../../saved_objects/public'; +import { SAVED_SEARCH_TYPE } from '../constants'; +import { getSavedSearchFullPathUrl } from '../saved_searches_utils'; +/** @deprecated **/ export function createSavedSearchClass(savedObjects: SavedObjectsStart) { class SavedSearch extends savedObjects.SavedObjectClass { - public static type: string = 'search'; + public static type: string = SAVED_SEARCH_TYPE; public static mapping = { title: 'text', description: 'text', @@ -31,7 +34,7 @@ export function createSavedSearchClass(savedObjects: SavedObjectsStart) { constructor(id: string) { super({ id, - type: 'search', + type: SAVED_SEARCH_TYPE, mapping: { title: 'text', description: 'text', @@ -54,7 +57,7 @@ export function createSavedSearchClass(savedObjects: SavedObjectsStart) { }); this.showInRecentlyAccessed = true; this.id = id; - this.getFullPath = () => `/app/discover#/view/${String(id)}`; + this.getFullPath = () => getSavedSearchFullPathUrl(String(id)); } } diff --git a/src/plugins/discover/public/saved_searches/legacy/index.ts b/src/plugins/discover/public/saved_searches/legacy/index.ts new file mode 100644 index 00000000000000..0bfed6f57b9f5c --- /dev/null +++ b/src/plugins/discover/public/saved_searches/legacy/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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { createSavedSearchesLoader } from './saved_searches'; +export { LegacySavedSearch, SavedSearchLoader } from './types'; diff --git a/src/plugins/discover/public/saved_searches/saved_searches.ts b/src/plugins/discover/public/saved_searches/legacy/saved_searches.ts similarity index 78% rename from src/plugins/discover/public/saved_searches/saved_searches.ts rename to src/plugins/discover/public/saved_searches/legacy/saved_searches.ts index 0fe693dcd2ebe6..58bed080d0249c 100644 --- a/src/plugins/discover/public/saved_searches/saved_searches.ts +++ b/src/plugins/discover/public/saved_searches/legacy/saved_searches.ts @@ -6,15 +6,17 @@ * Side Public License, v 1. */ -import { SavedObjectsClientContract } from 'kibana/public'; -import { SavedObjectLoader, SavedObjectsStart } from '../../../saved_objects/public'; +import type { SavedObjectsClientContract } from 'kibana/public'; +import { SavedObjectLoader, SavedObjectsStart } from '../../../../saved_objects/public'; import { createSavedSearchClass } from './_saved_search'; +import { getSavedSearchUrl } from '../saved_searches_utils'; interface Services { savedObjectsClient: SavedObjectsClientContract; savedObjects: SavedObjectsStart; } +/** @deprecated **/ export function createSavedSearchesLoader({ savedObjectsClient, savedObjects }: Services) { const SavedSearchClass = createSavedSearchClass(savedObjects); const savedSearchLoader = new SavedObjectLoader(SavedSearchClass, savedObjectsClient); @@ -25,7 +27,7 @@ export function createSavedSearchesLoader({ savedObjectsClient, savedObjects }: nouns: 'saved searches', }; - savedSearchLoader.urlFor = (id: string) => (id ? `#/view/${encodeURIComponent(id)}` : '#/'); + savedSearchLoader.urlFor = getSavedSearchUrl; return savedSearchLoader; } diff --git a/src/plugins/discover/public/saved_searches/legacy/types.ts b/src/plugins/discover/public/saved_searches/legacy/types.ts new file mode 100644 index 00000000000000..e55422ff26a7be --- /dev/null +++ b/src/plugins/discover/public/saved_searches/legacy/types.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ISearchSource } from '../../../../data/public'; +import type { SavedObjectSaveOpts } from '../../../../saved_objects/public'; +import type { DiscoverGridSettings } from '../../application/components/discover_grid/types'; + +export type SortOrder = [string, string]; + +/** @deprecated **/ +export interface LegacySavedSearch { + readonly id: string; + title: string; + searchSource: ISearchSource; + description?: string; + columns: string[]; + sort: SortOrder[]; + grid: DiscoverGridSettings; + destroy: () => void; + save: (saveOptions: SavedObjectSaveOpts) => Promise; + copyOnSave?: boolean; + hideChart?: boolean; +} + +/** @deprecated **/ +export interface SavedSearchLoader { + get: (id: string) => Promise; + urlFor: (id: string) => string; +} diff --git a/src/plugins/discover/public/saved_searches/save_saved_searches.test.ts b/src/plugins/discover/public/saved_searches/save_saved_searches.test.ts new file mode 100644 index 00000000000000..eabbfe7f9419f2 --- /dev/null +++ b/src/plugins/discover/public/saved_searches/save_saved_searches.test.ts @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SavedObjectsStart } from '../../../../core/public'; + +import { savedObjectsServiceMock } from '../../../../core/public/mocks'; +import { dataPluginMock } from '../../../data/public/mocks'; + +import { saveSavedSearch } from './save_saved_searches'; +import type { SavedSearch } from './types'; + +describe('saveSavedSearch', () => { + let savedObjectsClient: SavedObjectsStart['client']; + let savedSearch: SavedSearch; + + beforeEach(() => { + savedObjectsClient = savedObjectsServiceMock.createStartContract().client; + const searchSource = dataPluginMock.createStartContract().search.searchSource.createEmpty(); + + savedSearch = { + id: 'id', + title: 'title', + searchSource: { + ...searchSource, + serialize: () => ({ + searchSourceJSON: '{}', + references: [], + }), + }, + sharingSavedObjectProps: { + outcome: 'aliasMatch', + }, + } as SavedSearch; + }); + + describe('onTitleDuplicate', () => { + test('should check for title duplicating', async () => { + savedObjectsClient.find = jest.fn().mockReturnValue({ + savedObjects: [{ get: () => 'title' }], + }); + const onTitleDuplicate = jest.fn(); + + await saveSavedSearch( + savedSearch, + { + onTitleDuplicate, + copyOnSave: true, + }, + savedObjectsClient + ); + + expect(onTitleDuplicate).toHaveBeenCalled(); + }); + + test('should not check for title duplicating for saving existing search', async () => { + savedObjectsClient.find = jest.fn().mockReturnValue({ + savedObjects: [{ get: () => 'title' }], + }); + const onTitleDuplicate = jest.fn(); + + await saveSavedSearch( + savedSearch, + { + onTitleDuplicate, + copyOnSave: false, + }, + savedObjectsClient + ); + + expect(onTitleDuplicate).not.toHaveBeenCalled(); + }); + }); + + test('should call savedObjectsClient.create for saving new search', async () => { + delete savedSearch.id; + + await saveSavedSearch(savedSearch, {}, savedObjectsClient); + + expect(savedObjectsClient.create).toHaveBeenCalledWith( + 'search', + { + columns: [], + description: '', + grid: {}, + hideChart: false, + kibanaSavedObjectMeta: { searchSourceJSON: '{}' }, + sort: [], + title: 'title', + }, + { references: [] } + ); + }); + + test('should call savedObjectsClient.update for saving existing search', async () => { + await saveSavedSearch(savedSearch, {}, savedObjectsClient); + + expect(savedObjectsClient.update).toHaveBeenCalledWith( + 'search', + 'id', + { + columns: [], + description: '', + grid: {}, + hideChart: false, + kibanaSavedObjectMeta: { searchSourceJSON: '{}' }, + sort: [], + title: 'title', + }, + { references: [] } + ); + }); +}); diff --git a/src/plugins/discover/public/saved_searches/save_saved_searches.ts b/src/plugins/discover/public/saved_searches/save_saved_searches.ts new file mode 100644 index 00000000000000..c3440bdb7696a4 --- /dev/null +++ b/src/plugins/discover/public/saved_searches/save_saved_searches.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { SavedObjectsStart } from 'kibana/public'; +import type { SavedSearch, SavedSearchAttributes } from './types'; + +import { SAVED_SEARCH_TYPE } from './constants'; +import { toSavedSearchAttributes } from './saved_searches_utils'; + +export interface SaveSavedSearchOptions { + onTitleDuplicate?: () => void; + isTitleDuplicateConfirmed?: boolean; + copyOnSave?: boolean; +} + +const hasDuplicatedTitle = async ( + title: string, + savedObjectsClient: SavedObjectsStart['client'] +): Promise => { + if (!title) { + return; + } + + const response = await savedObjectsClient.find({ + type: SAVED_SEARCH_TYPE, + perPage: 10, + search: `"${title}"`, + searchFields: ['title'], + fields: ['title'], + }); + + return response.savedObjects.some( + (obj) => obj.get('title').toLowerCase() === title.toLowerCase() + ); +}; + +/** @internal **/ +export const saveSavedSearch = async ( + savedSearch: SavedSearch, + options: SaveSavedSearchOptions, + savedObjectsClient: SavedObjectsStart['client'] +): Promise => { + const isNew = options.copyOnSave || !savedSearch.id; + + if (savedSearch.title) { + if ( + isNew && + !options.isTitleDuplicateConfirmed && + options.onTitleDuplicate && + (await hasDuplicatedTitle(savedSearch.title, savedObjectsClient)) + ) { + options.onTitleDuplicate(); + return; + } + } + + const { searchSourceJSON, references } = savedSearch.searchSource.serialize(); + const resp = isNew + ? await savedObjectsClient.create( + SAVED_SEARCH_TYPE, + toSavedSearchAttributes(savedSearch, searchSourceJSON), + { + references, + } + ) + : await savedObjectsClient.update( + SAVED_SEARCH_TYPE, + savedSearch.id!, + toSavedSearchAttributes(savedSearch, searchSourceJSON), + { + references, + } + ); + + return resp?.id; +}; diff --git a/src/plugins/discover/public/saved_searches/saved_search_alias_match_redirect.test.ts b/src/plugins/discover/public/saved_searches/saved_search_alias_match_redirect.test.ts new file mode 100644 index 00000000000000..0a871061d2b19a --- /dev/null +++ b/src/plugins/discover/public/saved_searches/saved_search_alias_match_redirect.test.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import type { History } from 'history'; + +import { useSavedSearchAliasMatchRedirect } from './saved_search_alias_match_redirect'; +import type { SavedSearch } from './types'; + +import { spacesPluginMock } from '../../../../../x-pack/plugins/spaces/public/mocks'; + +describe('useSavedSearchAliasMatchRedirect', () => { + let spaces: ReturnType; + let history: () => History; + + beforeEach(() => { + spaces = spacesPluginMock.createStartContract(); + history = () => + ({ + location: { + search: '?_g=foo', + }, + } as History); + }); + + test('should redirect in case of aliasMatch', () => { + const savedSearch = { + id: 'id', + sharingSavedObjectProps: { + outcome: 'aliasMatch', + aliasTargetId: 'aliasTargetId', + }, + } as SavedSearch; + + renderHook(() => useSavedSearchAliasMatchRedirect({ spaces, savedSearch, history })); + + expect(spaces.ui.redirectLegacyUrl).toHaveBeenCalledWith( + '#/view/aliasTargetId?_g=foo', + ' search' + ); + }); + + test('should not redirect if outcome !== aliasMatch', () => { + const savedSearch = { + id: 'id', + sharingSavedObjectProps: { + outcome: 'exactMatch', + }, + } as SavedSearch; + + renderHook(() => useSavedSearchAliasMatchRedirect({ spaces, savedSearch, history })); + + expect(spaces.ui.redirectLegacyUrl).not.toHaveBeenCalled(); + }); + + test('should not redirect if aliasTargetId is not defined', () => { + const savedSearch = { + id: 'id', + sharingSavedObjectProps: { + outcome: 'aliasMatch', + }, + } as SavedSearch; + + renderHook(() => useSavedSearchAliasMatchRedirect({ spaces, savedSearch, history })); + + expect(spaces.ui.redirectLegacyUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/discover/public/saved_searches/saved_search_alias_match_redirect.ts b/src/plugins/discover/public/saved_searches/saved_search_alias_match_redirect.ts new file mode 100644 index 00000000000000..3a88c1a2b1989f --- /dev/null +++ b/src/plugins/discover/public/saved_searches/saved_search_alias_match_redirect.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { History } from 'history'; +import { useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { getSavedSearchUrl } from './saved_searches_utils'; + +import type { SavedSearch } from './types'; +import type { SpacesApi } from '../../../../../x-pack/plugins/spaces/public'; + +interface SavedSearchAliasMatchRedirectProps { + savedSearch?: SavedSearch; + spaces?: SpacesApi; + history: () => History; +} + +export const useSavedSearchAliasMatchRedirect = ({ + savedSearch, + spaces, + history, +}: SavedSearchAliasMatchRedirectProps) => { + useEffect(() => { + async function aliasMatchRedirect() { + if (savedSearch) { + const { aliasTargetId, outcome } = savedSearch.sharingSavedObjectProps ?? {}; + + if (spaces && aliasTargetId && outcome === 'aliasMatch') { + await spaces.ui.redirectLegacyUrl( + `${getSavedSearchUrl(aliasTargetId)}${history().location.search}`, + i18n.translate('discover.savedSearchAliasMatchRedirect.objectNoun', { + defaultMessage: '{savedSearch} search', + values: { + savedSearch: savedSearch.title, + }, + }) + ); + } + } + } + + aliasMatchRedirect(); + }, [savedSearch, spaces, history]); +}; diff --git a/src/plugins/discover/public/saved_searches/saved_search_url_conflict_callout.test.tsx b/src/plugins/discover/public/saved_searches/saved_search_url_conflict_callout.test.tsx new file mode 100644 index 00000000000000..c92c15e771f64e --- /dev/null +++ b/src/plugins/discover/public/saved_searches/saved_search_url_conflict_callout.test.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import type { History } from 'history'; + +import { mountWithIntl } from '@kbn/test/jest'; +import { SavedSearchURLConflictCallout } from './saved_search_url_conflict_callout'; +import type { SavedSearch } from './types'; + +import { spacesPluginMock } from '../../../../../x-pack/plugins/spaces/public/mocks'; + +describe('SavedSearchURLConflictCallout', () => { + let spaces: ReturnType; + let history: () => History; + + beforeEach(() => { + spaces = spacesPluginMock.createStartContract(); + spaces.ui.components.getLegacyUrlConflict = jest.fn().mockReturnValue('callout'); + history = () => + ({ + location: { + search: '?_g=foo', + }, + } as History); + }); + + test("should render URLConflictCallout in case of id's conflicts", () => { + const savedSearch = { + id: 'id', + sharingSavedObjectProps: { + outcome: 'conflict', + aliasTargetId: 'aliasTargetId', + }, + } as SavedSearch; + + const component = mountWithIntl( + + ); + + expect(component.children()).toMatchInlineSnapshot(`"callout"`); + }); + + test('should not render URLConflictCallout in case of no conflicts', () => { + const savedSearch = { + id: 'id', + sharingSavedObjectProps: {}, + } as SavedSearch; + + const component = mountWithIntl( + + ); + + expect(component.children()).toMatchInlineSnapshot(`null`); + }); +}); diff --git a/src/plugins/discover/public/saved_searches/saved_search_url_conflict_callout.ts b/src/plugins/discover/public/saved_searches/saved_search_url_conflict_callout.ts new file mode 100644 index 00000000000000..fd07126c496cf9 --- /dev/null +++ b/src/plugins/discover/public/saved_searches/saved_search_url_conflict_callout.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import type { History } from 'history'; +import { getSavedSearchUrl } from './saved_searches_utils'; + +import type { SavedSearch } from './types'; +import type { SpacesApi } from '../../../../../x-pack/plugins/spaces/public'; + +interface SavedSearchURLConflictCalloutProps { + savedSearch?: SavedSearch; + spaces?: SpacesApi; + history: () => History; +} + +export const SavedSearchURLConflictCallout = ({ + savedSearch, + spaces, + history, +}: SavedSearchURLConflictCalloutProps) => { + if (spaces && savedSearch?.id && savedSearch?.sharingSavedObjectProps?.outcome === 'conflict') { + const otherObjectId = savedSearch.sharingSavedObjectProps?.aliasTargetId; + + if (otherObjectId) { + return spaces.ui.components.getLegacyUrlConflict({ + objectNoun: i18n.translate('discover.savedSearchURLConflictCallout.objectNoun', { + defaultMessage: '{savedSearch} search', + values: { + savedSearch: savedSearch.title, + }, + }), + currentObjectId: savedSearch.id, + otherObjectPath: `${getSavedSearchUrl(otherObjectId)}${history().location.search}`, + otherObjectId, + }); + } + } + + return null; +}; diff --git a/src/plugins/discover/public/saved_searches/saved_searches_utils.test.ts b/src/plugins/discover/public/saved_searches/saved_searches_utils.test.ts new file mode 100644 index 00000000000000..12c73e86b3dc4d --- /dev/null +++ b/src/plugins/discover/public/saved_searches/saved_searches_utils.test.ts @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + getSavedSearchUrl, + getSavedSearchFullPathUrl, + fromSavedSearchAttributes, + toSavedSearchAttributes, + throwErrorOnSavedSearchUrlConflict, +} from './saved_searches_utils'; + +import { createSearchSourceMock } from '../../../data/public/mocks'; + +import type { SavedSearchAttributes, SavedSearch } from './types'; + +describe('saved_searches_utils', () => { + describe('getSavedSearchUrl', () => { + test('should return valid saved search url', () => { + expect(getSavedSearchUrl()).toBe('#/'); + expect(getSavedSearchUrl('id')).toBe('#/view/id'); + }); + }); + + describe('getSavedSearchFullPathUrl', () => { + test('should return valid full path url', () => { + expect(getSavedSearchFullPathUrl()).toBe('/app/discover#/'); + expect(getSavedSearchFullPathUrl('id')).toBe('/app/discover#/view/id'); + }); + }); + + describe('fromSavedSearchAttributes', () => { + test('should convert attributes into SavedSearch', () => { + const attributes: SavedSearchAttributes = { + kibanaSavedObjectMeta: { searchSourceJSON: '{}' }, + title: 'saved search', + sort: [], + columns: ['a', 'b'], + description: 'foo', + grid: {}, + hideChart: true, + }; + + expect(fromSavedSearchAttributes('id', attributes, createSearchSourceMock(), {})) + .toMatchInlineSnapshot(` + Object { + "columns": Array [ + "a", + "b", + ], + "description": "foo", + "grid": Object {}, + "hideChart": true, + "id": "id", + "searchSource": SearchSource { + "dependencies": Object { + "getConfig": [MockFunction], + "onResponse": [MockFunction], + "search": [MockFunction], + }, + "fields": Object {}, + "getFieldName": [Function], + "history": Array [], + "id": "data_source1", + "inheritOptions": Object {}, + "parent": undefined, + "requestStartHandlers": Array [], + "searchStrategyId": undefined, + }, + "sharingSavedObjectProps": Object {}, + "sort": Array [], + "title": "saved search", + } + `); + }); + }); + + describe('throwErrorOnSavedSearchUrlConflict', () => { + test('should throw an error on url conflict', async () => { + let error = 'no error'; + + try { + await throwErrorOnSavedSearchUrlConflict({ + id: 'id', + sharingSavedObjectProps: { + outcome: 'conflict', + errorJSON: '{}', + }, + } as SavedSearch); + } catch (e) { + error = e.message; + } + + expect(error).toBe( + 'This search has the same URL as a legacy alias. Disable the alias to resolve this error : {}' + ); + }); + }); + + describe('toSavedSearchAttributes', () => { + test('should serialize SavedSearch attributes', () => { + const savedSearch: SavedSearch = { + id: 'id', + searchSource: createSearchSourceMock(), + title: 'title', + sort: [['a', 'asc']], + columns: ['c', 'd'], + description: 'description', + grid: {}, + hideChart: true, + }; + + expect(toSavedSearchAttributes(savedSearch, '{}')).toMatchInlineSnapshot(` + Object { + "columns": Array [ + "c", + "d", + ], + "description": "description", + "grid": Object {}, + "hideChart": true, + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": "{}", + }, + "sort": Array [ + Array [ + "a", + "asc", + ], + ], + "title": "title", + } + `); + }); + }); +}); diff --git a/src/plugins/discover/public/saved_searches/saved_searches_utils.ts b/src/plugins/discover/public/saved_searches/saved_searches_utils.ts new file mode 100644 index 00000000000000..98ab2267a875ec --- /dev/null +++ b/src/plugins/discover/public/saved_searches/saved_searches_utils.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { i18n } from '@kbn/i18n'; +import type { SavedSearchAttributes, SavedSearch } from './types'; + +export const getSavedSearchUrl = (id?: string) => (id ? `#/view/${encodeURIComponent(id)}` : '#/'); + +export const getSavedSearchFullPathUrl = (id?: string) => `/app/discover${getSavedSearchUrl(id)}`; + +export const getSavedSearchUrlConflictMessage = async (savedSearch: SavedSearch) => + i18n.translate('discover.savedSearchEmbeddable.legacyURLConflict.errorMessage', { + defaultMessage: `This search has the same URL as a legacy alias. Disable the alias to resolve this error : {json}`, + values: { + json: savedSearch.sharingSavedObjectProps?.errorJSON, + }, + }); + +export const throwErrorOnSavedSearchUrlConflict = async (savedSearch: SavedSearch) => { + if (savedSearch.sharingSavedObjectProps?.errorJSON) { + throw new Error(await getSavedSearchUrlConflictMessage(savedSearch)); + } +}; + +export const fromSavedSearchAttributes = ( + id: string, + attributes: SavedSearchAttributes, + searchSource: SavedSearch['searchSource'], + sharingSavedObjectProps: SavedSearch['sharingSavedObjectProps'] +): SavedSearch => ({ + id, + searchSource, + sharingSavedObjectProps, + title: attributes.title, + sort: attributes.sort, + columns: attributes.columns, + description: attributes.description, + grid: attributes.grid, + hideChart: attributes.hideChart, +}); + +export const toSavedSearchAttributes = ( + savedSearch: SavedSearch, + searchSourceJSON: string +): SavedSearchAttributes => ({ + kibanaSavedObjectMeta: { searchSourceJSON }, + title: savedSearch.title ?? '', + sort: savedSearch.sort ?? [], + columns: savedSearch.columns ?? [], + description: savedSearch.description ?? '', + grid: savedSearch.grid ?? {}, + hideChart: savedSearch.hideChart ?? false, +}); diff --git a/src/plugins/discover/public/saved_searches/types.ts b/src/plugins/discover/public/saved_searches/types.ts index b1c7b48d696b34..645ada901d5e55 100644 --- a/src/plugins/discover/public/saved_searches/types.ts +++ b/src/plugins/discover/public/saved_searches/types.ts @@ -6,26 +6,39 @@ * Side Public License, v 1. */ -import { SearchSource } from '../../../data/public'; -import { SavedObjectSaveOpts } from '../../../saved_objects/public'; -import { DiscoverGridSettings } from '../application/components/discover_grid/types'; +import type { ISearchSource } from '../../../data/public'; +import { DiscoverGridSettingsColumn } from '../application/components/discover_grid/types'; -export type SortOrder = [string, string]; -export interface SavedSearch { - readonly id: string; +/** @internal **/ +export interface SavedSearchAttributes { title: string; - searchSource: SearchSource; - description?: string; + sort: Array<[string, string]>; columns: string[]; - sort: SortOrder[]; - grid: DiscoverGridSettings; - destroy: () => void; - save: (saveOptions: SavedObjectSaveOpts) => Promise; - lastSavedTitle?: string; - copyOnSave?: boolean; - hideChart?: boolean; + description: string; + grid: { + columns?: Record; + }; + hideChart: boolean; + kibanaSavedObjectMeta: { + searchSourceJSON: string; + }; } -export interface SavedSearchLoader { - get: (id: string) => Promise; - urlFor: (id: string) => string; + +/** @public **/ +export interface SavedSearch { + searchSource: ISearchSource; + id?: string; + title?: string; + sort?: Array<[string, string]>; + columns?: string[]; + description?: string; + grid?: { + columns?: Record; + }; + hideChart?: boolean; + sharingSavedObjectProps?: { + outcome?: 'aliasMatch' | 'exactMatch' | 'conflict'; + aliasTargetId?: string; + errorJSON?: string; + }; } diff --git a/src/plugins/discover/tsconfig.json b/src/plugins/discover/tsconfig.json index b3f1ad5d0bc1e8..eb739e673cacde 100644 --- a/src/plugins/discover/tsconfig.json +++ b/src/plugins/discover/tsconfig.json @@ -24,6 +24,8 @@ { "path": "../kibana_react/tsconfig.json" }, { "path": "../kibana_legacy/tsconfig.json" }, { "path": "../index_pattern_field_editor/tsconfig.json"}, - { "path": "../field_formats/tsconfig.json" } + { "path": "../field_formats/tsconfig.json" }, + { "path": "../data_views/tsconfig.json" }, + { "path": "../../../x-pack/plugins/spaces/tsconfig.json" } ] } diff --git a/src/plugins/vis_default_editor/kibana.json b/src/plugins/vis_default_editor/kibana.json index e85c5713eb82c3..efed1eab1e4948 100644 --- a/src/plugins/vis_default_editor/kibana.json +++ b/src/plugins/vis_default_editor/kibana.json @@ -3,7 +3,7 @@ "version": "kibana", "ui": true, "optionalPlugins": ["visualize"], - "requiredBundles": ["kibanaUtils", "kibanaReact", "data", "fieldFormats"], + "requiredBundles": ["kibanaUtils", "kibanaReact", "data", "fieldFormats", "discover"], "owner": { "name": "Vis Editors", "githubTeam": "kibana-vis-editors" diff --git a/src/plugins/vis_default_editor/public/components/sidebar/sidebar.tsx b/src/plugins/vis_default_editor/public/components/sidebar/sidebar.tsx index dab982e5a8070f..f1eebbbdf21161 100644 --- a/src/plugins/vis_default_editor/public/components/sidebar/sidebar.tsx +++ b/src/plugins/vis_default_editor/public/components/sidebar/sidebar.tsx @@ -26,7 +26,7 @@ import { } from 'src/plugins/visualizations/public'; import type { Schema } from 'src/plugins/visualizations/public'; import { TimeRange } from 'src/plugins/data/public'; -import { SavedObject } from 'src/plugins/saved_objects/public'; +import { SavedSearch } from 'src/plugins/discover/public'; import { DefaultEditorNavBar } from './navbar'; import { DefaultEditorControls } from './controls'; import { setStateParamValue, useEditorReducer, useEditorFormState, discardChanges } from './state'; @@ -42,7 +42,7 @@ interface DefaultEditorSideBarProps { vis: Vis; isLinkedSearch: boolean; eventEmitter: EventEmitter; - savedSearch?: SavedObject; + savedSearch?: SavedSearch; timeRange: TimeRange; } diff --git a/src/plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx b/src/plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx index cab27d53b827de..2740f4ff50b4e2 100644 --- a/src/plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx +++ b/src/plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx @@ -25,18 +25,18 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { Vis } from 'src/plugins/visualizations/public'; -import { SavedObject } from 'src/plugins/saved_objects/public'; +import { SavedSearch, getSavedSearchUrl } from '../../../../discover/public'; import { ApplicationStart } from '../../../../../core/public'; import { useKibana } from '../../../../kibana_react/public'; interface LinkedSearchProps { - savedSearch: SavedObject; + savedSearch: SavedSearch; eventEmitter: EventEmitter; } interface SidebarTitleProps { isLinkedSearch: boolean; - savedSearch?: SavedObject; + savedSearch?: SavedSearch; vis: Vis; eventEmitter: EventEmitter; } @@ -55,7 +55,7 @@ export function LinkedSearch({ savedSearch, eventEmitter }: LinkedSearchProps) { }, [eventEmitter]); const onClickViewInDiscover = useCallback(() => { application.navigateToApp('discover', { - path: `#/view/${savedSearch.id}`, + path: getSavedSearchUrl(savedSearch.id), }); }, [application, savedSearch.id]); diff --git a/src/plugins/visualizations/public/plugin.ts b/src/plugins/visualizations/public/plugin.ts index 47f544ce2f5d3e..87095f5c389edf 100644 --- a/src/plugins/visualizations/public/plugin.ts +++ b/src/plugins/visualizations/public/plugin.ts @@ -23,9 +23,9 @@ import { setAggs, setChrome, setOverlays, - setSavedSearchLoader, setEmbeddable, setDocLinks, + setSpaces, } from './services'; import { VISUALIZE_EMBEDDABLE_TYPE, @@ -51,8 +51,6 @@ import { findListItems, } from './utils/saved_visualize_utils'; -import { createSavedSearchesLoader } from '../../discover/public'; - import type { PluginInitializerContext, CoreSetup, @@ -191,6 +189,11 @@ export class VisualizationsPlugin setAggs(data.search.aggs); setOverlays(core.overlays); setChrome(core.chrome); + + if (spaces) { + setSpaces(spaces); + } + const savedVisualizationsLoader = createSavedVisLoader({ savedObjectsClient: core.savedObjects.client, indexPatterns: data.indexPatterns, @@ -198,11 +201,7 @@ export class VisualizationsPlugin visualizationTypes: types, }); setSavedVisualizationsLoader(savedVisualizationsLoader); - const savedSearchLoader = createSavedSearchesLoader({ - savedObjectsClient: core.savedObjects.client, - savedObjects, - }); - setSavedSearchLoader(savedSearchLoader); + return { ...types, showNewVisModal, diff --git a/src/plugins/visualizations/public/saved_visualizations/_saved_vis.ts b/src/plugins/visualizations/public/saved_visualizations/_saved_vis.ts index fbd8e414c27387..aa8183eb8da399 100644 --- a/src/plugins/visualizations/public/saved_visualizations/_saved_vis.ts +++ b/src/plugins/visualizations/public/saved_visualizations/_saved_vis.ts @@ -16,8 +16,8 @@ import type { SavedObjectsStart, SavedObject } from '../../../../plugins/saved_objects/public'; // @ts-ignore import { updateOldState } from '../legacy/vis_update_state'; +import { __LEGACY } from '../../../discover/public'; import { extractReferences, injectReferences } from '../utils/saved_visualization_references'; -import { createSavedSearchesLoader } from '../../../discover/public'; import type { SavedObjectsClientContract } from '../../../../core/public'; import type { IndexPatternsContract } from '../../../../plugins/data/public'; import type { ISavedVis } from '../types'; @@ -30,7 +30,7 @@ export interface SavedVisServices { /** @deprecated **/ export function createSavedVisClass(services: SavedVisServices) { - const savedSearch = createSavedSearchesLoader(services); + const savedSearch = __LEGACY.createSavedSearchesLoader(services); class SavedVis extends services.savedObjects.SavedObjectClass { public static type: string = 'visualization'; diff --git a/src/plugins/visualizations/public/services.ts b/src/plugins/visualizations/public/services.ts index f1ab9077cd207b..b5db56e61ebe49 100644 --- a/src/plugins/visualizations/public/services.ts +++ b/src/plugins/visualizations/public/services.ts @@ -18,13 +18,14 @@ import type { } from '../../../core/public'; import type { TypesStart } from './vis_types'; import { createGetterSetter } from '../../../plugins/kibana_utils/public'; -import { DataPublicPluginStart, TimefilterContract } from '../../../plugins/data/public'; -import { UsageCollectionSetup } from '../../../plugins/usage_collection/public'; -import { ExpressionsStart } from '../../../plugins/expressions/public'; -import { UiActionsStart } from '../../../plugins/ui_actions/public'; -import { SavedVisualizationsLoader } from './saved_visualizations'; -import { SavedObjectLoader } from '../../saved_objects/public'; -import { EmbeddableStart } from '../../embeddable/public'; +import type { DataPublicPluginStart, TimefilterContract } from '../../../plugins/data/public'; +import type { UsageCollectionSetup } from '../../../plugins/usage_collection/public'; +import type { ExpressionsStart } from '../../../plugins/expressions/public'; +import type { UiActionsStart } from '../../../plugins/ui_actions/public'; +import type { SavedVisualizationsLoader } from './saved_visualizations'; +import type { EmbeddableStart } from '../../embeddable/public'; + +import type { SpacesPluginStart } from '../../../../x-pack/plugins/spaces/public'; export const [getUISettings, setUISettings] = createGetterSetter('UISettings'); @@ -64,5 +65,4 @@ export const [getOverlays, setOverlays] = createGetterSetter('Over export const [getChrome, setChrome] = createGetterSetter('Chrome'); -export const [getSavedSearchLoader, setSavedSearchLoader] = - createGetterSetter('savedSearchLoader'); +export const [getSpaces, setSpaces] = createGetterSetter('Spaces', false); diff --git a/src/plugins/visualizations/public/vis.ts b/src/plugins/visualizations/public/vis.ts index dfab4ecfc3cd81..2a1e7f2c8c6738 100644 --- a/src/plugins/visualizations/public/vis.ts +++ b/src/plugins/visualizations/public/vis.ts @@ -21,17 +21,19 @@ import { Assign } from '@kbn/utility-types'; import { i18n } from '@kbn/i18n'; import { PersistedState } from './persisted_state'; -import { getTypes, getAggs, getSearch, getSavedSearchLoader } from './services'; +import { getTypes, getAggs, getSearch, getSavedObjects, getSpaces } from './services'; import { IAggConfigs, IndexPattern, ISearchSource, AggConfigSerialized, SearchSourceFields, -} from '../../../plugins/data/public'; +} from '../../data/public'; import { BaseVisType } from './vis_types'; import { VisParams } from '../common/types'; +import { getSavedSearch, throwErrorOnSavedSearchUrlConflict } from '../../discover/public'; + export interface SerializedVisData { expression?: string; aggs: AggConfigSerialized[]; @@ -58,14 +60,20 @@ export interface VisData { } const getSearchSource = async (inputSearchSource: ISearchSource, savedSearchId?: string) => { - const searchSource = inputSearchSource.createCopy(); if (savedSearchId) { - const savedSearch = await getSavedSearchLoader().get(savedSearchId); + const savedSearch = await getSavedSearch(savedSearchId, { + search: getSearch(), + savedObjectsClient: getSavedObjects().client, + spaces: getSpaces(), + }); + + await throwErrorOnSavedSearchUrlConflict(savedSearch); - searchSource.setParent(savedSearch.searchSource); + if (savedSearch?.searchSource) { + inputSearchSource.setParent(savedSearch.searchSource); + } } - searchSource.setField('size', 0); - return searchSource; + return inputSearchSource; }; type PartialVisState = Assign }>; diff --git a/src/plugins/visualize/public/application/types.ts b/src/plugins/visualize/public/application/types.ts index 4debd9a4a7b7d1..e77520c962d886 100644 --- a/src/plugins/visualize/public/application/types.ts +++ b/src/plugins/visualize/public/application/types.ts @@ -8,7 +8,6 @@ import type { EventEmitter } from 'events'; import type { History } from 'history'; - import type { SerializableRecord } from '@kbn/utility-types'; import type { @@ -38,7 +37,7 @@ import type { import type { NavigationPublicPluginStart as NavigationStart } from 'src/plugins/navigation/public'; import type { Query, Filter, DataPublicPluginStart, TimeRange } from 'src/plugins/data/public'; import type { SharePluginStart } from 'src/plugins/share/public'; -import type { SavedObjectsStart, SavedObject } from 'src/plugins/saved_objects/public'; +import type { SavedObjectsStart } from 'src/plugins/saved_objects/public'; import type { EmbeddableStart, EmbeddableStateTransfer } from 'src/plugins/embeddable/public'; import type { UrlForwardingStart } from 'src/plugins/url_forwarding/public'; import type { PresentationUtilPluginStart } from 'src/plugins/presentation_util/public'; @@ -46,6 +45,7 @@ import type { SpacesPluginStart } from '../../../../../x-pack/plugins/spaces/pub import type { DashboardStart } from '../../../dashboard/public'; import type { SavedObjectsTaggingApi } from '../../../saved_objects_tagging_oss/public'; import type { UsageCollectionStart } from '../../../usage_collection/public'; +import type { SavedSearch } from '../../../discover/public'; import { PureVisState } from '../../common/types'; @@ -108,20 +108,15 @@ export interface VisualizeServices extends CoreStart { spaces?: SpacesPluginStart; } -export interface SavedVisInstance { - vis: Vis; - savedVis: VisSavedObject; - savedSearch?: SavedObject; - embeddableHandler: VisualizeEmbeddableContract; -} - -export interface ByValueVisInstance { +export interface VisInstance { vis: Vis; savedVis: VisSavedObject; - savedSearch?: SavedObject; + savedSearch?: SavedSearch; embeddableHandler: VisualizeEmbeddableContract; } +export type SavedVisInstance = VisInstance; +export type ByValueVisInstance = VisInstance; export type VisualizeEditorVisInstance = SavedVisInstance | ByValueVisInstance; export type VisEditorConstructor = new ( @@ -142,7 +137,7 @@ export interface EditorRenderProps { filters: Filter[]; timeRange: TimeRange; query?: Query; - savedSearch?: SavedObject; + savedSearch?: SavedSearch; uiState: PersistedState; /** * Flag to determine if visualiztion is linked to the saved search diff --git a/src/plugins/visualize/public/application/utils/get_visualization_instance.test.ts b/src/plugins/visualize/public/application/utils/get_visualization_instance.test.ts index 209516793d69d0..777ba244c06a11 100644 --- a/src/plugins/visualize/public/application/utils/get_visualization_instance.test.ts +++ b/src/plugins/visualize/public/application/utils/get_visualization_instance.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { createSavedSearchesLoader } from '../../../../discover/public'; +import { getSavedSearch } from '../../../../discover/public'; import type { VisualizeInput, VisSavedObject, @@ -21,13 +21,13 @@ import { createVisualizeServicesMock } from './mocks'; import { VisualizeServices } from '../types'; import { BehaviorSubject } from 'rxjs'; -const mockSavedSearchObj = {}; -const mockGetSavedSearch = jest.fn(() => mockSavedSearchObj); - jest.mock('../../../../discover/public', () => ({ - createSavedSearchesLoader: jest.fn(() => ({ - get: mockGetSavedSearch, - })), + getSavedSearch: jest.fn().mockResolvedValue({ + id: 'savedSearch', + title: 'savedSearchTitle', + searchSource: {}, + }), + throwErrorOnSavedSearchUrlConflict: jest.fn(), })); let savedVisMock: VisSavedObject; @@ -116,9 +116,14 @@ describe('getVisualizationInstance', () => { visMock.data.savedSearchId = 'saved_search_id'; const { savedSearch } = await getVisualizationInstance(mockServices, 'saved_vis_id'); - expect(createSavedSearchesLoader).toHaveBeenCalled(); - expect(mockGetSavedSearch).toHaveBeenCalledWith(visMock.data.savedSearchId); - expect(savedSearch).toBe(mockSavedSearchObj); + expect(getSavedSearch).toHaveBeenCalled(); + expect(savedSearch).toMatchInlineSnapshot(` + Object { + "id": "savedSearch", + "searchSource": Object {}, + "title": "savedSearchTitle", + } + `); }); test('should subscribe on embeddable handler updates and send toasts on errors', async () => { diff --git a/src/plugins/visualize/public/application/utils/get_visualization_instance.ts b/src/plugins/visualize/public/application/utils/get_visualization_instance.ts index faf25ff28cec00..876501d5f099b9 100644 --- a/src/plugins/visualize/public/application/utils/get_visualization_instance.ts +++ b/src/plugins/visualize/public/application/utils/get_visualization_instance.ts @@ -14,10 +14,13 @@ import { VisualizeInput, } from 'src/plugins/visualizations/public'; import { SearchSourceFields } from 'src/plugins/data/public'; -import { SavedObject } from 'src/plugins/saved_objects/public'; import { cloneDeep } from 'lodash'; import { ExpressionValueError } from 'src/plugins/expressions/public'; -import { createSavedSearchesLoader } from '../../../../discover/public'; +import { + getSavedSearch, + SavedSearch, + throwErrorOnSavedSearchUrlConflict, +} from '../../../../discover/public'; import { SavedFieldNotFound, SavedFieldTypeInvalidForAgg } from '../../../../kibana_utils/common'; import { VisualizeServices } from '../types'; @@ -33,8 +36,7 @@ const createVisualizeEmbeddableAndLinkSavedSearch = async ( vis: Vis, visualizeServices: VisualizeServices ) => { - const { data, createVisEmbeddableFromObject, savedObjects, savedObjectsPublic } = - visualizeServices; + const { data, createVisEmbeddableFromObject, savedObjects, spaces } = visualizeServices; const embeddableHandler = (await createVisEmbeddableFromObject(vis, { id: '', timeRange: data.query.timefilter.timefilter.getTime(), @@ -50,13 +52,16 @@ const createVisualizeEmbeddableAndLinkSavedSearch = async ( } }); - let savedSearch: SavedObject | undefined; + let savedSearch: SavedSearch | undefined; if (vis.data.savedSearchId) { - savedSearch = await createSavedSearchesLoader({ + savedSearch = await getSavedSearch(vis.data.savedSearchId, { + search: data.search, savedObjectsClient: savedObjects.client, - savedObjects: savedObjectsPublic, - }).get(vis.data.savedSearchId); + spaces, + }); + + await throwErrorOnSavedSearchUrlConflict(savedSearch); } return { savedSearch, embeddableHandler }; diff --git a/src/plugins/visualize/public/plugin.ts b/src/plugins/visualize/public/plugin.ts index c9df6a6ec57d89..7ff3434286b6bc 100644 --- a/src/plugins/visualize/public/plugin.ts +++ b/src/plugins/visualize/public/plugin.ts @@ -28,7 +28,6 @@ import { createKbnUrlStateStorage, withNotifyOnErrors, } from '../../kibana_utils/public'; -import type { SpacesPluginStart } from '../../../../x-pack/plugins/spaces/public'; import { VisualizeConstants } from './application/visualize_constants'; import { DataPublicPluginStart, DataPublicPluginSetup, esFilters } from '../../data/public'; @@ -45,6 +44,7 @@ import type { EmbeddableStart } from '../../embeddable/public'; import type { DashboardStart } from '../../dashboard/public'; import type { SavedObjectTaggingOssPluginStart } from '../../saved_objects_tagging_oss/public'; import type { UsageCollectionStart } from '../../usage_collection/public'; +import type { SpacesApi } from '../../../../x-pack/plugins/spaces/public'; import { setVisEditorsRegistry, setUISettings, setUsageCollector } from './services'; import { createVisEditorsRegistry, VisEditorsRegistry } from './vis_editors_registry'; @@ -62,7 +62,7 @@ export interface VisualizePluginStartDependencies { savedObjectsTaggingOss?: SavedObjectTaggingOssPluginStart; presentationUtil: PresentationUtilPluginStart; usageCollection?: UsageCollectionStart; - spaces: SpacesPluginStart; + spaces?: SpacesApi; } export interface VisualizePluginSetupDependencies { diff --git a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.test.ts b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.test.ts index bedf310725ae25..a4fe1835ce5f70 100644 --- a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.test.ts +++ b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.test.ts @@ -107,7 +107,7 @@ describe('GetCsvReportPanelAction', () => { columns: [], objectType: 'downloadCsv', searchSource: {}, - title: undefined, + title: '', version: '7.15.0', }); }); @@ -144,7 +144,7 @@ describe('GetCsvReportPanelAction', () => { columns: ['column_a', 'column_b'], objectType: 'downloadCsv', searchSource: { testData: 'testDataValue' }, - title: undefined, + title: '', version: '7.15.0', }); }); diff --git a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx index 115d7599c6bc84..fbfaeab9bc4f22 100644 --- a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx +++ b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx @@ -122,7 +122,7 @@ export class ReportingCsvPanelAction implements ActionDefinition const immediateJobParams = this.apiClient.getDecoratedJobParams({ searchSource: getSearchSource(true), columns, - title: savedSearch.title, + title: savedSearch.title || '', objectType: 'downloadCsv', // FIXME: added for typescript, but immediate download job does not need objectType }); diff --git a/x-pack/plugins/transform/kibana.json b/x-pack/plugins/transform/kibana.json index 5e1c1fb938a86f..f47b018dd1d26f 100644 --- a/x-pack/plugins/transform/kibana.json +++ b/x-pack/plugins/transform/kibana.json @@ -17,6 +17,7 @@ "optionalPlugins": [ "security", "usageCollection", + "spaces", "alerting" ], "configPath": ["xpack", "transform"], diff --git a/x-pack/plugins/transform/public/__mocks__/shared_imports.ts b/x-pack/plugins/transform/public/__mocks__/shared_imports.ts index ae072e6666e4aa..e0788d7747e655 100644 --- a/x-pack/plugins/transform/public/__mocks__/shared_imports.ts +++ b/x-pack/plugins/transform/public/__mocks__/shared_imports.ts @@ -13,7 +13,7 @@ export const useRequest = jest.fn(() => ({ error: null, data: undefined, })); -export const createSavedSearchesLoader = jest.fn(); +export const getSavedSearch = jest.fn(); // just passing through the reimports export { getMlSharedImports, ES_CLIENT_TOTAL_HITS_RELATION } from '../../../ml/public'; diff --git a/x-pack/plugins/transform/public/app/app_dependencies.tsx b/x-pack/plugins/transform/public/app/app_dependencies.tsx index c39aa5a49e5e96..d3f356f3e83b33 100644 --- a/x-pack/plugins/transform/public/app/app_dependencies.tsx +++ b/x-pack/plugins/transform/public/app/app_dependencies.tsx @@ -10,6 +10,7 @@ import type { DataPublicPluginStart } from 'src/plugins/data/public'; import type { SavedObjectsStart } from 'src/plugins/saved_objects/public'; import type { ScopedHistory } from 'kibana/public'; import type { SharePluginStart } from 'src/plugins/share/public'; +import type { SpacesPluginStart } from '../../../spaces/public'; import { useKibana } from '../../../../../src/plugins/kibana_react/public'; import type { Storage } from '../../../../../src/plugins/kibana_utils/public'; @@ -32,6 +33,7 @@ export interface AppDependencies { savedObjectsPlugin: SavedObjectsStart; share: SharePluginStart; ml: GetMlSharedImportsReturnType; + spaces?: SpacesPluginStart; } export const useAppDependencies = () => { diff --git a/x-pack/plugins/transform/public/app/hooks/use_search_items/common.ts b/x-pack/plugins/transform/public/app/hooks/use_search_items/common.ts index 3e85f5d4d49a4f..db5fd82808a911 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_search_items/common.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_search_items/common.ts @@ -20,12 +20,10 @@ import { isIndexPattern } from '../../../../common/types/index_pattern'; export type SavedSearchQuery = object; type IndexPatternId = string; -type SavedSearchId = string; let indexPatternCache: Array>> = []; let fullIndexPatterns; let currentIndexPattern = null; -let currentSavedSearch = null; export let refreshIndexPatterns: () => Promise; @@ -76,11 +74,6 @@ export function loadCurrentIndexPattern( return currentIndexPattern; } -export function loadCurrentSavedSearch(savedSearches: any, savedSearchId: SavedSearchId) { - currentSavedSearch = savedSearches.get(savedSearchId); - return currentSavedSearch; -} - export interface SearchItems { indexPattern: IndexPattern; savedSearch: any; diff --git a/x-pack/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts b/x-pack/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts index 44e3cc347824d8..bd225ae9d0a320 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts @@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n'; import { isIndexPattern } from '../../../../common/types/index_pattern'; -import { createSavedSearchesLoader } from '../../../shared_imports'; +import { getSavedSearch, getSavedSearchUrlConflictMessage } from '../../../shared_imports'; import { useAppDependencies } from '../../app_dependencies'; @@ -20,7 +20,6 @@ import { getIndexPatternIdByTitle, loadCurrentIndexPattern, loadIndexPatterns, - loadCurrentSavedSearch, SearchItems, } from './common'; @@ -32,10 +31,6 @@ export const useSearchItems = (defaultSavedObjectId: string | undefined) => { const indexPatterns = appDeps.data.indexPatterns; const uiSettings = appDeps.uiSettings; const savedObjectsClient = appDeps.savedObjects.client; - const savedSearches = createSavedSearchesLoader({ - savedObjectsClient, - savedObjects: appDeps.savedObjectsPlugin, - }); const [searchItems, setSearchItems] = useState(undefined); @@ -52,7 +47,16 @@ export const useSearchItems = (defaultSavedObjectId: string | undefined) => { } try { - fetchedSavedSearch = await loadCurrentSavedSearch(savedSearches, id); + fetchedSavedSearch = await getSavedSearch(id, { + search: appDeps.data.search, + savedObjectsClient: appDeps.savedObjects.client, + spaces: appDeps.spaces, + }); + + if (fetchedSavedSearch?.sharingSavedObjectProps?.errorJSON) { + setError(await getSavedSearchUrlConflictMessage(fetchedSavedSearch)); + return; + } } catch (e) { // Just let fetchedSavedSearch stay undefined in case it doesn't exist. } diff --git a/x-pack/plugins/transform/public/app/mount_management_section.ts b/x-pack/plugins/transform/public/app/mount_management_section.ts index 1d39d233f82846..17473308185478 100644 --- a/x-pack/plugins/transform/public/app/mount_management_section.ts +++ b/x-pack/plugins/transform/public/app/mount_management_section.ts @@ -29,7 +29,7 @@ export async function mountManagementSection( const startServices = await getStartServices(); const [core, plugins] = startServices; const { application, chrome, docLinks, i18n, overlays, savedObjects, uiSettings } = core; - const { data, share } = plugins; + const { data, share, spaces } = plugins; const { docTitle } = chrome; // Initialize services @@ -53,6 +53,7 @@ export async function mountManagementSection( history, savedObjectsPlugin: plugins.savedObjects, share, + spaces, ml: await getMlSharedImports(), }; diff --git a/x-pack/plugins/transform/public/plugin.ts b/x-pack/plugins/transform/public/plugin.ts index 4ed4e640703440..da280452c1f0fb 100644 --- a/x-pack/plugins/transform/public/plugin.ts +++ b/x-pack/plugins/transform/public/plugin.ts @@ -13,6 +13,7 @@ import type { HomePublicPluginSetup } from 'src/plugins/home/public'; import type { SavedObjectsStart } from 'src/plugins/saved_objects/public'; import type { ManagementSetup } from 'src/plugins/management/public'; import type { SharePluginStart } from 'src/plugins/share/public'; +import type { SpacesApi } from '../../spaces/public'; import { registerFeature } from './register_feature'; import type { PluginSetupContract as AlertingSetup } from '../../alerting/public'; import type { TriggersAndActionsUIPublicPluginSetup } from '../../triggers_actions_ui/public'; @@ -24,6 +25,7 @@ export interface PluginsDependencies { home: HomePublicPluginSetup; savedObjects: SavedObjectsStart; share: SharePluginStart; + spaces?: SpacesApi; alerting?: AlertingSetup; triggersActionsUi?: TriggersAndActionsUIPublicPluginSetup; } diff --git a/x-pack/plugins/transform/public/shared_imports.ts b/x-pack/plugins/transform/public/shared_imports.ts index edd27fd43c2af9..b8f5d882058585 100644 --- a/x-pack/plugins/transform/public/shared_imports.ts +++ b/x-pack/plugins/transform/public/shared_imports.ts @@ -5,9 +5,12 @@ * 2.0. */ -export { createSavedSearchesLoader } from '../../../../src/plugins/discover/public'; export { XJsonMode } from '@kbn/ace'; export { UseRequestConfig, useRequest } from '../../../../src/plugins/es_ui_shared/public'; +export { + getSavedSearch, + getSavedSearchUrlConflictMessage, +} from '../../../../src/plugins/discover/public'; export { getMlSharedImports,