diff --git a/src/platform/plugins/shared/data/public/search/session/sessions_mgmt/components/table/table.tsx b/src/platform/plugins/shared/data/public/search/session/sessions_mgmt/components/table/table.tsx index b77f1c5f63183..5d7c1f29ed791 100644 --- a/src/platform/plugins/shared/data/public/search/session/sessions_mgmt/components/table/table.tsx +++ b/src/platform/plugins/shared/data/public/search/session/sessions_mgmt/components/table/table.tsx @@ -75,6 +75,10 @@ export function SearchSessionsMgmtTable({ () => moment.duration(config.management.refreshInterval).asMilliseconds(), [config.management.refreshInterval] ); + const enableOpeningInNewTab = useMemo( + () => core.featureFlags.getBooleanValue('discover.tabsEnabled', false), + [core.featureFlags] + ); const { pageSize, sorting, onTableChange } = useEuiTablePersist({ tableId: 'searchSessionsMgmt', @@ -111,7 +115,12 @@ export function SearchSessionsMgmtTable({ try { const { savedObjects, statuses } = await api.fetchTableData({ appId }); results = savedObjects.map((savedObject) => - mapToUISession({ savedObject, locators, sessionStatuses: statuses }) + mapToUISession({ + savedObject, + locators, + sessionStatuses: statuses, + enableOpeningInNewTab, + }) ); } catch (e) {} // eslint-disable-line no-empty @@ -125,7 +134,7 @@ export function SearchSessionsMgmtTable({ if (refreshTimeoutRef.current) clearTimeout(refreshTimeoutRef.current); refreshTimeoutRef.current = window.setTimeout(doRefresh, refreshInterval); } - }, [api, refreshInterval, locators, appId]); + }, [api, refreshInterval, locators, appId, enableOpeningInNewTab]); // initial data load useEffect(() => { diff --git a/src/platform/plugins/shared/data/public/search/session/sessions_mgmt/components/table/utils/map_to_ui_session.ts b/src/platform/plugins/shared/data/public/search/session/sessions_mgmt/components/table/utils/map_to_ui_session.ts index 72656e06e1703..4fdbec5b06758 100644 --- a/src/platform/plugins/shared/data/public/search/session/sessions_mgmt/components/table/utils/map_to_ui_session.ts +++ b/src/platform/plugins/shared/data/public/search/session/sessions_mgmt/components/table/utils/map_to_ui_session.ts @@ -30,11 +30,13 @@ export const mapToUISession = ({ locators, sessionStatuses, actions: filteredActions, + enableOpeningInNewTab, }: { savedObject: SearchSessionSavedObject; locators: LocatorsStart; sessionStatuses: SearchSessionsFindResponse['statuses']; actions?: ACTION[]; + enableOpeningInNewTab?: boolean; }): UISession => { const { name, @@ -59,7 +61,16 @@ export const mapToUISession = ({ if (initialState) delete initialState.searchSessionId; // derive the URL and add it in const reloadUrl = getUrlFromState(locators, locatorId, initialState); - const restoreUrl = getUrlFromState(locators, locatorId, restoreState); + const restoreUrl = getUrlFromState( + locators, + locatorId, + enableOpeningInNewTab + ? { + ...restoreState, + tab: { id: 'new', label: name }, + } + : restoreState + ); return { id: savedObject.id, diff --git a/src/platform/plugins/shared/discover/common/app_locator.test.ts b/src/platform/plugins/shared/discover/common/app_locator.test.ts index 22309b7e3a270..8f45aa25ba6fc 100644 --- a/src/platform/plugins/shared/discover/common/app_locator.test.ts +++ b/src/platform/plugins/shared/discover/common/app_locator.test.ts @@ -236,6 +236,30 @@ describe('Discover url generator', () => { expect(path).toContain('__test__'); }); + test('can specify a tab id', async () => { + const { locator } = await setup(); + const { path } = await locator.getLocation({ + tab: { id: '__test__' }, + }); + + expect(path).toMatchInlineSnapshot(`"#/?_tab=(tabId:__test__)"`); + }); + + test('can specify to open in a new tab', async () => { + const { locator } = await setup(); + const { path } = await locator.getLocation({ + tab: { id: 'new', label: 'My new tab' }, + query: { + esql: 'SELECT * FROM test', + }, + searchSessionId: '__test__', + }); + + expect(path).toMatchInlineSnapshot( + `"#/?searchSessionId=__test__&_a=(dataSource:(type:esql),query:(esql:'SELECT%20*%20FROM%20test'))&_tab=(tabId:new,tabLabel:'My%20new%20tab')"` + ); + }); + test('can specify columns, grid, interval, sort and savedQuery', async () => { const { locator } = await setup(); const { path } = await locator.getLocation({ diff --git a/src/platform/plugins/shared/discover/common/app_locator.ts b/src/platform/plugins/shared/discover/common/app_locator.ts index d2a73ee6b5ab7..5fcebaaace68e 100644 --- a/src/platform/plugins/shared/discover/common/app_locator.ts +++ b/src/platform/plugins/shared/discover/common/app_locator.ts @@ -13,7 +13,7 @@ import type { RefreshInterval } from '@kbn/data-plugin/public'; import type { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public'; import type { DiscoverGridSettings } from '@kbn/saved-search-plugin/common'; import type { DataViewSpec } from '@kbn/data-views-plugin/common'; -import type { VIEW_MODE } from './constants'; +import type { VIEW_MODE, NEW_TAB_ID } from './constants'; export const DISCOVER_APP_LOCATOR = 'DISCOVER_APP_LOCATOR'; @@ -65,6 +65,13 @@ export interface DiscoverAppLocatorParams extends SerializableRecord { */ searchSessionId?: string; + /** + * Optionally set Discover tab state. + * Use `new` as value for `id` to indicate that a new tab should be created. + * Once created, the new tab will have a unique id which can be referenced too if necessary. + */ + tab?: { id: typeof NEW_TAB_ID; label?: string } | { id: string }; + /** * Columns displayed in the table */ diff --git a/src/platform/plugins/shared/discover/common/app_locator_get_location.ts b/src/platform/plugins/shared/discover/common/app_locator_get_location.ts index 1233f2b97f8e4..56ac905e364fe 100644 --- a/src/platform/plugins/shared/discover/common/app_locator_get_location.ts +++ b/src/platform/plugins/shared/discover/common/app_locator_get_location.ts @@ -13,7 +13,12 @@ import type { setStateToKbnUrl as setStateToKbnUrlCommon } from '@kbn/kibana-uti import type { DiscoverAppLocatorGetLocation, MainHistoryLocationState } from './app_locator'; import type { DiscoverAppState } from '../public'; import { createDataViewDataSource, createEsqlDataSource } from './data_sources'; -import { APP_STATE_URL_KEY } from './constants'; +import { + APP_STATE_URL_KEY, + GLOBAL_STATE_URL_KEY, + NEW_TAB_ID, + TAB_STATE_URL_KEY, +} from './constants'; export const appLocatorGetLocationCommon = async ( { @@ -45,6 +50,7 @@ export const appLocatorGetLocationCommon = async ( hideAggregatedPreview, breakdownField, isAlertResults, + tab, } = params; const savedSearchPath = savedSearchId ? `view/${encodeURIComponent(savedSearchId)}` : ''; const appState: Partial = {}; @@ -79,13 +85,27 @@ export const appLocatorGetLocationCommon = async ( } if (Object.keys(queryState).length) { - path = setStateToKbnUrl('_g', queryState, { useHash }, path); + path = setStateToKbnUrl( + GLOBAL_STATE_URL_KEY, + queryState, + { useHash }, + path + ); } if (Object.keys(appState).length) { path = setStateToKbnUrl(APP_STATE_URL_KEY, appState, { useHash }, path); } + if (tab?.id) { + path = setStateToKbnUrl( + TAB_STATE_URL_KEY, + { tabId: tab.id, tabLabel: tab.id === NEW_TAB_ID && 'label' in tab ? tab.label : undefined }, + { useHash }, + path + ); + } + return { app: 'discover', path, diff --git a/src/platform/plugins/shared/discover/common/constants.ts b/src/platform/plugins/shared/discover/common/constants.ts index 345f64bdc8e2d..881d257c19cff 100644 --- a/src/platform/plugins/shared/discover/common/constants.ts +++ b/src/platform/plugins/shared/discover/common/constants.ts @@ -26,12 +26,19 @@ export const getDefaultRowsPerPage = (uiSettings: IUiSettingsClient): number => // local storage key for the ES|QL to Dataviews transition modal export const ESQL_TRANSITION_MODAL_KEY = 'data.textLangTransitionModal'; +/** + * The id value used to indicate that a link should open in a new Discover tab. + * It will be used in the `_tab` URL param to indicate that a new tab should be created. + * Once created, the new tab will have a unique id. + */ +export const NEW_TAB_ID = 'new' as const; + /** * The query param key used to store the Discover app state in the URL */ export const APP_STATE_URL_KEY = '_a'; export const GLOBAL_STATE_URL_KEY = '_g'; -export const TABS_STATE_URL_KEY = '_t'; +export const TAB_STATE_URL_KEY = '_tab'; // `_t` is already used by Kibana for time, so we use `_tab` here /** * Product feature IDs diff --git a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx index ae029a0f5b1c4..e704731a6e88f 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/top_nav/app_menu_actions/get_share.tsx @@ -70,6 +70,8 @@ export const getShareAppMenuItem = ({ timeRange, refreshInterval, }; + + // TODO: for a persisted saved search, add the current tab ID to the params const relativeUrl = locator.getRedirectUrl(params); // This logic is duplicated from `relativeToAbsolute` (for bundle size reasons). Ultimately, this should be diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/hooks/use_state_managers.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/hooks/use_state_managers.ts index 186ea50dfd6af..e716763a6b850 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/hooks/use_state_managers.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/hooks/use_state_managers.ts @@ -41,7 +41,7 @@ export const useStateManagers = ({ TABS_ENABLED_FEATURE_FLAG_KEY, false ); - // syncing with the _t part URL + // syncing with the _tab part URL const [tabsStorageManager] = useState(() => createTabsStorageManager({ urlStateStorage, @@ -63,13 +63,13 @@ export const useStateManagers = ({ useEffect(() => { const stopUrlSync = tabsStorageManager.startUrlSync({ - // if `_t` in URL changes (for example via browser history), try to restore the previous state + // if `_tab` in URL changes (for example via browser history), try to restore the previous state onChanged: (urlState) => { const { tabId: restoreTabId } = urlState; if (restoreTabId) { internalState.dispatch(internalStateActions.restoreTab({ restoreTabId })); } else { - // if tabId is not present in `_t`, clear all tabs + // if tabId is not present in `_tab`, clear all tabs internalState.dispatch(internalStateActions.clearAllTabs()); } }, diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/tabs.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/tabs.ts index 42aa16f0082e4..3f5a294871cbd 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/tabs.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/actions/tabs.ts @@ -29,7 +29,11 @@ import { selectInitialUnifiedHistogramLayoutPropsMap, selectTabRuntimeInternalState, } from '../runtime_state'; -import { APP_STATE_URL_KEY, GLOBAL_STATE_URL_KEY } from '../../../../../../common/constants'; +import { + APP_STATE_URL_KEY, + GLOBAL_STATE_URL_KEY, + NEW_TAB_ID, +} from '../../../../../../common/constants'; import type { DiscoverAppState } from '../../discover_app_state_container'; import { createInternalStateAsyncThunk, createTabItem } from '../utils'; import { setBreadcrumbs } from '../../../../../utils/breadcrumbs'; @@ -113,6 +117,20 @@ export const updateTabs: InternalStateThunkActionCreator<[TabbedContentState], P }; if (!existingTab) { + // the following assignments for initialAppState, globalState, and dataRequestParams are for supporting `openInNewTab` action + tab.initialAppState = + 'initialAppState' in item + ? cloneDeep(item.initialAppState as TabState['initialAppState']) + : tab.initialAppState; + tab.globalState = + 'globalState' in item + ? cloneDeep(item.globalState as TabState['globalState']) + : tab.globalState; + tab.dataRequestParams = + 'dataRequestParams' in item + ? (item.dataRequestParams as TabState['dataRequestParams']) + : tab.dataRequestParams; + if (item.duplicatedFromId) { // the new tab was created by duplicating an existing tab const existingTabToDuplicateFrom = selectTab(currentState, item.duplicatedFromId); @@ -288,7 +306,9 @@ export const restoreTab: InternalStateThunkActionCreator<[{ restoreTabId: string (dispatch, getState) => { const currentState = getState(); - if (restoreTabId === currentState.tabs.unsafeCurrentId) { + // Restoring the 'new' tab ID is a no-op because it represents a placeholder for creating new tabs, + // not an actual tab that can be restored. + if (restoreTabId === currentState.tabs.unsafeCurrentId || restoreTabId === NEW_TAB_ID) { return; } @@ -318,6 +338,46 @@ export const restoreTab: InternalStateThunkActionCreator<[{ restoreTabId: string ); }; +export const openInNewTab: InternalStateThunkActionCreator< + [ + { + tabLabel?: string; + appState?: TabState['initialAppState']; + globalState?: TabState['globalState']; + searchSessionId?: string; + } + ] +> = + ({ tabLabel, appState, globalState, searchSessionId }) => + (dispatch, getState) => { + const initialAppState = appState ? cloneDeep(appState) : undefined; + const initialGlobalState = globalState ? cloneDeep(globalState) : {}; + const currentState = getState(); + const currentTabs = selectAllTabs(currentState); + + const newDefaultTab: TabState = { + ...DEFAULT_TAB_STATE, + ...createTabItem(currentTabs), + initialAppState, + globalState: initialGlobalState, + }; + + if (tabLabel) { + newDefaultTab.label = tabLabel; + } + + if (searchSessionId) { + newDefaultTab.dataRequestParams = { + ...newDefaultTab.dataRequestParams, + searchSessionId, + }; + } + + return dispatch( + updateTabs({ items: [...currentTabs, newDefaultTab], selectedItem: newDefaultTab }) + ); + }; + export const disconnectTab: InternalStateThunkActionCreator<[TabActionPayload]> = ({ tabId }) => (_, __, { runtimeStateManager }) => { diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/index.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/index.ts index 48ea85c750bd2..e740f2dbc9562 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/index.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/index.ts @@ -21,6 +21,7 @@ import { updateTabs, disconnectTab, restoreTab, + openInNewTab, clearAllTabs, initializeTabs, saveDiscoverSession, @@ -56,6 +57,7 @@ export const internalStateActions = { initializeSingleTab, syncLocallyPersistedTabState, restoreTab, + openInNewTab, clearAllTabs, initializeTabs, saveDiscoverSession, diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/internal_state.test.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/internal_state.test.ts index 9711f01ba75e5..27405a98ee844 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/internal_state.test.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/internal_state.test.ts @@ -20,7 +20,7 @@ import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; import { createTabsStorageManager } from '../tabs_storage_manager'; describe('InternalStateStore', () => { - it('should set data view', async () => { + const createTestStore = async () => { const services = createDiscoverServicesMock(); const urlStateStorage = createKbnUrlStateStorage(); const runtimeStateManager = createRuntimeStateManager(); @@ -36,6 +36,12 @@ describe('InternalStateStore', () => { tabsStorageManager, }); await store.dispatch(internalStateActions.initializeTabs({ discoverSessionId: undefined })); + + return { store, runtimeStateManager }; + }; + + it('should set data view', async () => { + const { store, runtimeStateManager } = await createTestStore(); const tabId = store.getState().tabs.unsafeCurrentId; expect( selectTabRuntimeState(runtimeStateManager, tabId).currentDataView$.value @@ -45,4 +51,37 @@ describe('InternalStateStore', () => { dataViewMock ); }); + + it('should append a new tab to the tabs list', async () => { + const { store } = await createTestStore(); + const initialTabId = store.getState().tabs.unsafeCurrentId; + expect(store.getState().tabs.allIds).toHaveLength(1); + expect(store.getState().tabs.unsafeCurrentId).toBe(initialTabId); + const params = { + tabLabel: 'New tab', + searchSessionId: 'session_123', + appState: { + query: { query: 'test this', language: 'kuery' }, + }, + globalState: { + timeRange: { + from: '2024-01-01T00:00:00.000Z', + to: '2024-01-02T00:00:00.000Z', + }, + }, + }; + await store.dispatch(internalStateActions.openInNewTab(params)); + const tabsState = store.getState().tabs; + expect(tabsState.allIds).toHaveLength(2); + expect(tabsState.unsafeCurrentId).not.toBe(initialTabId); + expect(tabsState.unsafeCurrentId).toBe(tabsState.allIds[1]); + expect(tabsState.byId[tabsState.unsafeCurrentId].label).toBe(params.tabLabel); + expect(tabsState.byId[tabsState.unsafeCurrentId].initialAppState).toEqual(params.appState); + expect(tabsState.byId[tabsState.unsafeCurrentId].globalState).toEqual(params.globalState); + expect(tabsState.byId[tabsState.unsafeCurrentId].dataRequestParams).toEqual({ + searchSessionId: params.searchSessionId, + timeRangeAbsolute: undefined, + timeRangeRelative: undefined, + }); + }); }); diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/tabs_storage_manager.test.tsx b/src/platform/plugins/shared/discover/public/application/main/state_management/tabs_storage_manager.test.tsx index 8a0caac4cfa1d..1d309b15f5896 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/tabs_storage_manager.test.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/tabs_storage_manager.test.tsx @@ -16,7 +16,7 @@ import { type TabsInternalStatePayload, } from './tabs_storage_manager'; import type { RecentlyClosedTabState, TabState } from './redux/types'; -import { TABS_STATE_URL_KEY } from '../../../../common/constants'; +import { TAB_STATE_URL_KEY } from '../../../../common/constants'; import { DEFAULT_TAB_STATE, fromSavedSearchToSavedObjectTab } from './redux'; import { getRecentlyClosedTabStateMock, @@ -152,7 +152,11 @@ describe('TabsStorageManager', () => { await tabsStorageManager.persistLocally(props, mockGetAppState, mockGetInternalState); - expect(urlStateStorage.set).toHaveBeenCalledWith(TABS_STATE_URL_KEY, { tabId: 'tab1' }); + expect(urlStateStorage.set).toHaveBeenCalledWith( + TAB_STATE_URL_KEY, + { tabId: 'tab1' }, + { replace: false } + ); expect(storage.set).toHaveBeenCalledWith(TABS_LOCAL_STORAGE_KEY, { userId: mockUserId, spaceId: mockSpaceId, @@ -177,7 +181,7 @@ describe('TabsStorageManager', () => { closedTabs: [toStoredTab(mockRecentlyClosedTab)], }); - urlStateStorage.set(TABS_STATE_URL_KEY, { + urlStateStorage.set(TAB_STATE_URL_KEY, { tabId: 'tab2', }); @@ -195,7 +199,7 @@ describe('TabsStorageManager', () => { selectedTabId: 'tab2', recentlyClosedTabs: [toRestoredTab(mockRecentlyClosedTab)], }); - expect(urlStateStorage.get).toHaveBeenCalledWith(TABS_STATE_URL_KEY); + expect(urlStateStorage.get).toHaveBeenCalledWith(TAB_STATE_URL_KEY); expect(storage.get).toHaveBeenCalledWith(TABS_LOCAL_STORAGE_KEY); expect(urlStateStorage.set).not.toHaveBeenCalled(); expect(storage.set).not.toHaveBeenCalled(); @@ -224,7 +228,7 @@ describe('TabsStorageManager', () => { ], }); - urlStateStorage.set(TABS_STATE_URL_KEY, { + urlStateStorage.set(TAB_STATE_URL_KEY, { tabId: mockRecentlyClosedTab2.id, }); @@ -251,7 +255,7 @@ describe('TabsStorageManager', () => { toRestoredTab(mockRecentlyClosedTab2), ], }); - expect(urlStateStorage.get).toHaveBeenCalledWith(TABS_STATE_URL_KEY); + expect(urlStateStorage.get).toHaveBeenCalledWith(TAB_STATE_URL_KEY); expect(storage.get).toHaveBeenCalledWith(TABS_LOCAL_STORAGE_KEY); expect(urlStateStorage.set).not.toHaveBeenCalled(); expect(storage.set).not.toHaveBeenCalled(); @@ -279,7 +283,7 @@ describe('TabsStorageManager', () => { closedTabs: [toStoredTab(mockRecentlyClosedTab)], }); - urlStateStorage.set(TABS_STATE_URL_KEY, { + urlStateStorage.set(TAB_STATE_URL_KEY, { tabId: props.selectedTabId, }); @@ -300,7 +304,7 @@ describe('TabsStorageManager', () => { }) ); expect(loadedProps.selectedTabId).toBe(loadedProps.allTabs[0].id); - expect(urlStateStorage.get).toHaveBeenCalledWith(TABS_STATE_URL_KEY); + expect(urlStateStorage.get).toHaveBeenCalledWith(TAB_STATE_URL_KEY); expect(storage.get).toHaveBeenCalledWith(TABS_LOCAL_STORAGE_KEY); expect(urlStateStorage.set).not.toHaveBeenCalled(); expect(storage.set).not.toHaveBeenCalled(); @@ -325,7 +329,7 @@ describe('TabsStorageManager', () => { closedTabs: [toStoredTab(mockRecentlyClosedTab)], }); - urlStateStorage.set(TABS_STATE_URL_KEY, null); + urlStateStorage.set(TAB_STATE_URL_KEY, null); jest.spyOn(urlStateStorage, 'set'); jest.spyOn(storage, 'set'); @@ -352,7 +356,7 @@ describe('TabsStorageManager', () => { }) ); expect(loadedProps.selectedTabId).toBe(loadedProps.allTabs[0].id); - expect(urlStateStorage.get).toHaveBeenCalledWith(TABS_STATE_URL_KEY); + expect(urlStateStorage.get).toHaveBeenCalledWith(TAB_STATE_URL_KEY); expect(storage.get).toHaveBeenCalledWith(TABS_LOCAL_STORAGE_KEY); expect(urlStateStorage.set).not.toHaveBeenCalled(); expect(storage.set).not.toHaveBeenCalled(); @@ -526,7 +530,7 @@ describe('TabsStorageManager', () => { closedTabs: [toStoredTab(mockRecentlyClosedTab)], }); - urlStateStorage.set(TABS_STATE_URL_KEY, { + urlStateStorage.set(TAB_STATE_URL_KEY, { tabId: mockTab2.id, }); @@ -562,7 +566,7 @@ describe('TabsStorageManager', () => { closedTabs: [toStoredTab(mockRecentlyClosedTab)], }); - urlStateStorage.set(TABS_STATE_URL_KEY, { + urlStateStorage.set(TAB_STATE_URL_KEY, { tabId: mockTab1.id, }); @@ -606,7 +610,7 @@ describe('TabsStorageManager', () => { closedTabs: [toStoredTab(mockRecentlyClosedTab)], }); - urlStateStorage.set(TABS_STATE_URL_KEY, { + urlStateStorage.set(TAB_STATE_URL_KEY, { tabId: 'bad-tab', }); @@ -650,7 +654,7 @@ describe('TabsStorageManager', () => { closedTabs: [toStoredTab(mockRecentlyClosedTab)], }); - urlStateStorage.set(TABS_STATE_URL_KEY, { + urlStateStorage.set(TAB_STATE_URL_KEY, { tabId: mockRecentlyClosedTab.id, }); @@ -679,4 +683,50 @@ describe('TabsStorageManager', () => { expect(loadedProps.selectedTabId).toBe(persistedTabId); expect(loadedProps.allTabs.find((t) => t.id === mockTab1.id)).toBeUndefined(); }); + + it('should load tabs state from local storage and append a new tab', () => { + const { + tabsStorageManager, + urlStateStorage, + services: { storage }, + } = create(); + jest.spyOn(urlStateStorage, 'get'); + jest.spyOn(storage, 'get'); + + storage.set(TABS_LOCAL_STORAGE_KEY, { + userId: mockUserId, + spaceId: mockSpaceId, + openTabs: [toStoredTab(mockTab1), toStoredTab(mockTab2)], + closedTabs: [toStoredTab(mockRecentlyClosedTab)], + }); + + urlStateStorage.set(TAB_STATE_URL_KEY, { + tabId: 'new', + tabLabel: 'New tab test', + }); + + jest.spyOn(urlStateStorage, 'set'); + jest.spyOn(storage, 'set'); + + const loadedProps = tabsStorageManager.loadLocally({ + userId: mockUserId, + spaceId: mockSpaceId, + defaultTabState: DEFAULT_TAB_STATE, + }); + + expect(loadedProps.recentlyClosedTabs).toEqual([toRestoredTab(mockRecentlyClosedTab)]); + expect(loadedProps.allTabs).toHaveLength(3); + expect(loadedProps.allTabs[0]).toEqual(toRestoredTab(mockTab1)); + expect(loadedProps.allTabs[1]).toEqual(toRestoredTab(mockTab2)); + expect(loadedProps.allTabs[2]).toEqual( + expect.objectContaining({ + label: 'New tab test', + }) + ); + expect(loadedProps.selectedTabId).toBe(loadedProps.allTabs[2].id); + expect(urlStateStorage.get).toHaveBeenCalledWith(TAB_STATE_URL_KEY); + expect(storage.get).toHaveBeenCalledWith(TABS_LOCAL_STORAGE_KEY); + expect(urlStateStorage.set).not.toHaveBeenCalled(); + expect(storage.set).not.toHaveBeenCalled(); + }); }); diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/tabs_storage_manager.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/tabs_storage_manager.ts index b69a8fa0b1af3..25d75c9fa42ca 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/tabs_storage_manager.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/tabs_storage_manager.ts @@ -16,7 +16,7 @@ import { import type { TabItem } from '@kbn/unified-tabs'; import type { Storage } from '@kbn/kibana-utils-plugin/public'; import type { DiscoverSession } from '@kbn/saved-search-plugin/common'; -import { TABS_STATE_URL_KEY } from '../../../../common/constants'; +import { TAB_STATE_URL_KEY, NEW_TAB_ID } from '../../../../common/constants'; import type { TabState, RecentlyClosedTabState } from './redux/types'; import { createTabItem } from './redux/utils'; import type { DiscoverAppState } from './discover_app_state_container'; @@ -57,7 +57,14 @@ export interface TabsInternalStatePayload { } export interface TabsUrlState { - tabId?: string; // syncing the selected tab id with the URL + /** + * Syncing the selected tab id with the URL + */ + tabId?: string; + /** + * (Optional) Label for the tab, used when creating a new tab via locator URL. + */ + tabLabel?: string; } export interface TabsStorageManager { @@ -123,7 +130,7 @@ export const createTabsStorageManager = ({ } }, }, - storageKey: TABS_STATE_URL_KEY, + storageKey: TAB_STATE_URL_KEY, }); const listener = onChanged @@ -140,15 +147,19 @@ export const createTabsStorageManager = ({ }; }; - const getSelectedTabIdFromURL = () => { - return (urlStateStorage.get(TABS_STATE_URL_KEY) as TabsUrlState)?.tabId; + const getTabsStateFromURL = () => { + return urlStateStorage.get(TAB_STATE_URL_KEY) as TabsUrlState; }; const pushSelectedTabIdToUrl = async (selectedTabId: string) => { const nextState: TabsUrlState = { tabId: selectedTabId, }; - await urlStateStorage.set(TABS_STATE_URL_KEY, nextState); + const previousState = getTabsStateFromURL(); + // If the previous tab was a "new" (unsaved) tab, we replace the URL state instead of pushing a new history entry. + // This prevents cluttering the browser history with intermediate "new tab" states that are not meaningful to the user. + const shouldReplace = previousState?.tabId === NEW_TAB_ID; + await urlStateStorage.set(TAB_STATE_URL_KEY, nextState, { replace: shouldReplace }); }; const toTabStateInStorage = ( @@ -359,7 +370,8 @@ export const createTabsStorageManager = ({ persistedDiscoverSession, defaultTabState, }) => { - const selectedTabId = enabled ? getSelectedTabIdFromURL() : undefined; + const tabsStateFromURL = getTabsStateFromURL(); + const selectedTabId = enabled ? tabsStateFromURL?.tabId : undefined; let storedTabsState: TabsStateInLocalStorage = enabled ? readFromLocalStorage() : defaultTabsStateInLocalStorage; @@ -389,6 +401,25 @@ export const createTabsStorageManager = ({ ); if (enabled && selectedTabId) { + if (selectedTabId === NEW_TAB_ID) { + // append a new tab if requested via URL + + const newTab = { + ...defaultTabState, + ...createTabItem(openTabs), + }; + + if (tabsStateFromURL?.tabLabel) { + newTab.label = tabsStateFromURL.tabLabel; + } + + return { + allTabs: [...openTabs, newTab], + selectedTabId: newTab.id, + recentlyClosedTabs: closedTabs, + }; + } + // restore previously opened tabs if (openTabs.find((tab) => tab.id === selectedTabId)) { return { @@ -414,17 +445,21 @@ export const createTabsStorageManager = ({ } } - const defaultTab = persistedTabs - ? persistedTabs[0] - : { - ...defaultTabState, - ...createTabItem([]), - }; - const allTabs = persistedTabs ?? [defaultTab]; + const newDefaultTab = { + ...defaultTabState, + ...createTabItem([]), + }; + let allTabs = [newDefaultTab]; + let selectedTab = newDefaultTab; + + if (persistedTabs?.length) { + allTabs = persistedTabs; + selectedTab = persistedTabs[0]; + } return { allTabs, - selectedTabId: defaultTab.id, + selectedTabId: selectedTab.id, recentlyClosedTabs: getNRecentlyClosedTabs(closedTabs, openTabs), }; };