[Discover] Persist tabs in local storage and sync selected tab ID with URL (#217706)

- Closes https://github.com/elastic/kibana/issues/216549
- Closes https://github.com/elastic/kibana/issues/216071

## Summary

This PR allows to restore the following state for the previously opened
tabs:
- the selected data view
- classic or ES|QL mode
- query and filters
- time range and refresh interval
- and other properties of the app state
bcba741abc/src/platform/plugins/shared/discover/public/application/main/state_management/discover_app_state_container.ts (L92)

## Changes
- [x] Sync selected tab id to URL => after refresh the initial tab would
be the last selected one
- [x] Restore tabs after refresh 
- [x] Restore appState and globalState after reopening closed tabs
- [x] Clear tabs if Discover was opened from another Kibana app  
- [x] Store tabs in LocalStorage
- [x] Fix "New" action and clear all tabs
- [x] Populate "Recently closed tabs" with data from LocalStorage
- [x] If selected tab id changes in URL externally => update the state  
- [x] Reset the stored state when userId or space Id changes
- [x] Fix all tests

### Testing
- Test that the existing functionality is not affected
- Enable tabs feature in
bcba741abc/src/platform/plugins/shared/discover/public/constants.ts (L15)
and test that tabs are being persisted and can be restored manually too.


### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: Davis McPhee <davismcphee@hotmail.com>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Julia Rechkunova 2025-05-27 22:32:56 +02:00 committed by GitHub
parent 9d94d8facc
commit c348586e58
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1656 additions and 230 deletions

View file

@ -24,7 +24,7 @@ import type { AppMountParameters } from '@kbn/core-application-browser';
import type { DataView } from '@kbn/data-views-plugin/public';
import type { DataViewField } from '@kbn/data-views-plugin/public';
import type { DataViewPickerProps } from '@kbn/unified-search-plugin/public';
import { type TabItem, UnifiedTabs, useNewTabProps } from '@kbn/unified-tabs';
import { UnifiedTabs, useNewTabProps, type UnifiedTabsProps } from '@kbn/unified-tabs';
import { type TabPreviewData, TabStatus } from '@kbn/unified-tabs';
import { PLUGIN_ID, PLUGIN_NAME } from '../common';
import { FieldListSidebar, FieldListSidebarProps } from './field_list_sidebar';
@ -67,9 +67,13 @@ export const UnifiedTabsExampleApp: React.FC<UnifiedTabsExampleAppProps> = ({
const [dataView, setDataView] = useState<DataView | null>();
const [selectedFieldNames, setSelectedFieldNames] = useState<string[]>([]);
const { getNewTabDefaultProps } = useNewTabProps({ numberOfInitialItems: 0 });
const [initialItems] = useState<TabItem[]>(() =>
Array.from({ length: 7 }, () => getNewTabDefaultProps())
);
const [{ managedItems, managedSelectedItemId }, setState] = useState<{
managedItems: UnifiedTabsProps['items'];
managedSelectedItemId: UnifiedTabsProps['selectedItemId'];
}>(() => ({
managedItems: Array.from({ length: 7 }, () => getNewTabDefaultProps()),
managedSelectedItemId: undefined,
}));
const onAddFieldToWorkspace = useCallback(
(field: DataViewField) => {
@ -121,13 +125,20 @@ export const UnifiedTabsExampleApp: React.FC<UnifiedTabsExampleAppProps> = ({
{dataView ? (
<div className="eui-fullHeight">
<UnifiedTabs
initialItems={initialItems}
items={managedItems}
selectedItemId={managedSelectedItemId}
recentlyClosedItems={[]}
maxItemsCount={25}
services={services}
onChanged={() => {}}
onChanged={(updatedState) =>
setState({
managedItems: updatedState.items,
managedSelectedItemId: updatedState.selectedItem?.id,
})
}
createItem={getNewTabDefaultProps}
getPreviewData={
() => TAB_CONTENT_MOCK[Math.floor(Math.random() * TAB_CONTENT_MOCK.length)] // TODO change mock to real data when ready
getPreviewData={() =>
TAB_CONTENT_MOCK[Math.floor(Math.random() * TAB_CONTENT_MOCK.length)]
}
renderContent={({ label }) => {
return (

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import React, { useState } from 'react';
import type { Meta, StoryFn, StoryObj } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { TabbedContent, type TabbedContentProps } from '../tabbed_content';
@ -27,16 +27,33 @@ export default {
const TabbedContentTemplate: StoryFn<TabbedContentProps> = (args) => {
const { getNewTabDefaultProps } = useNewTabProps({
numberOfInitialItems: args.initialItems.length,
numberOfInitialItems: args.items.length,
});
const [{ managedItems, managedSelectedItemId }, setState] = useState<{
managedItems: TabbedContentProps['items'];
managedSelectedItemId: TabbedContentProps['selectedItemId'];
}>(() => ({
managedItems: args.items,
managedSelectedItemId: args.selectedItemId,
}));
return (
<TabbedContent
{...args}
items={managedItems}
selectedItemId={managedSelectedItemId}
recentlyClosedItems={[]}
createItem={getNewTabDefaultProps}
getPreviewData={getPreviewDataMock}
services={servicesMock}
onChanged={action('onClosed')}
onChanged={(updatedState) => {
action('onChanged')(updatedState);
setState({
managedItems: updatedState.items,
managedSelectedItemId: updatedState.selectedItem?.id,
});
}}
renderContent={(item) => (
<div style={{ paddingTop: '16px' }}>Content for tab: {item.label}</div>
)}
@ -48,7 +65,7 @@ export const Default: StoryObj<TabbedContentProps> = {
render: TabbedContentTemplate,
args: {
initialItems: [
items: [
{
id: '1',
label: 'Tab 1',
@ -61,7 +78,7 @@ export const WithMultipleTabs: StoryObj<TabbedContentProps> = {
render: TabbedContentTemplate,
args: {
initialItems: [
items: [
{
id: '1',
label: 'Tab 1',
@ -75,6 +92,6 @@ export const WithMultipleTabs: StoryObj<TabbedContentProps> = {
label: 'Tab 3',
},
],
initialSelectedItemId: '3',
selectedItemId: '3',
},
};

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import React, { useState } from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { TabbedContent, type TabbedContentProps } from './tabbed_content';
@ -26,15 +26,33 @@ describe('TabbedContent', () => {
initialItems,
initialSelectedItemId,
onChanged,
}: Pick<TabbedContentProps, 'initialItems' | 'initialSelectedItemId' | 'onChanged'>) => {
}: {
initialItems: TabbedContentProps['items'];
initialSelectedItemId?: TabbedContentProps['selectedItemId'];
onChanged: TabbedContentProps['onChanged'];
}) => {
const [{ managedItems, managedSelectedItemId }, setState] = useState<{
managedItems: TabbedContentProps['items'];
managedSelectedItemId: TabbedContentProps['selectedItemId'];
}>(() => ({
managedItems: initialItems,
managedSelectedItemId: initialSelectedItemId,
}));
return (
<TabbedContent
initialItems={initialItems}
initialSelectedItemId={initialSelectedItemId}
items={managedItems}
selectedItemId={managedSelectedItemId}
recentlyClosedItems={[]}
createItem={() => NEW_TAB}
getPreviewData={getPreviewDataMock}
services={servicesMock}
onChanged={onChanged}
onChanged={(updatedState) => {
onChanged(updatedState);
setState({
managedItems: updatedState.items,
managedSelectedItemId: updatedState.selectedItem?.id,
});
}}
renderContent={(item) => (
<div style={{ paddingTop: '16px' }}>Content for tab: {item.label}</div>
)}

View file

@ -18,6 +18,7 @@ import {
addTab,
closeTab,
selectTab,
selectRecentlyClosedTab,
insertTabAfter,
replaceTabWith,
closeOtherTabs,
@ -25,25 +26,10 @@ import {
} from '../../utils/manage_tabs';
import type { TabItem, TabsServices, TabPreviewData } from '../../types';
// TODO replace with real data when ready
const RECENTLY_CLOSED_TABS_MOCK = [
{
label: 'Session 4',
id: '4',
},
{
label: 'Session 5',
id: '5',
},
{
label: 'Session 6',
id: '6',
},
];
export interface TabbedContentProps extends Pick<TabsBarProps, 'maxItemsCount'> {
initialItems: TabItem[];
initialSelectedItemId?: string;
items: TabItem[];
selectedItemId?: string;
recentlyClosedItems: TabItem[];
'data-test-subj'?: string;
services: TabsServices;
renderContent: (selectedItem: TabItem) => React.ReactNode;
@ -58,8 +44,9 @@ export interface TabbedContentState {
}
export const TabbedContent: React.FC<TabbedContentProps> = ({
initialItems,
initialSelectedItemId,
items: managedItems,
selectedItemId: managedSelectedItemId,
recentlyClosedItems,
maxItemsCount,
services,
renderContent,
@ -69,14 +56,10 @@ export const TabbedContent: React.FC<TabbedContentProps> = ({
}) => {
const tabsBarApi = useRef<TabsBarApi | null>(null);
const [tabContentId] = useState(() => htmlIdGenerator()());
const [state, _setState] = useState<TabbedContentState>(() => {
return {
items: initialItems,
selectedItem:
(initialSelectedItemId && initialItems.find((item) => item.id === initialSelectedItemId)) ||
initialItems[0],
};
});
const state = useMemo(
() => prepareStateFromProps(managedItems, managedSelectedItemId),
[managedItems, managedSelectedItemId]
);
const { items, selectedItem } = state;
const stateRef = React.useRef<TabbedContentState>();
stateRef.current = state;
@ -88,10 +71,9 @@ export const TabbedContent: React.FC<TabbedContentProps> = ({
}
const nextState = getNextState(stateRef.current);
_setState(nextState);
onChanged(nextState);
},
[_setState, onChanged]
[onChanged]
);
const onLabelEdited = useCallback(
@ -110,6 +92,13 @@ export const TabbedContent: React.FC<TabbedContentProps> = ({
[changeState]
);
const onSelectRecentlyClosed = useCallback(
async (item: TabItem) => {
changeState((prevState) => selectRecentlyClosedTab(prevState, item));
},
[changeState]
);
const onClose = useCallback(
async (item: TabItem) => {
changeState((prevState) => {
@ -194,7 +183,7 @@ export const TabbedContent: React.FC<TabbedContentProps> = ({
ref={tabsBarApi}
items={items}
selectedItem={selectedItem}
recentlyClosedItems={RECENTLY_CLOSED_TABS_MOCK}
recentlyClosedItems={recentlyClosedItems}
maxItemsCount={maxItemsCount}
tabContentId={tabContentId}
getTabMenuItems={getTabMenuItems}
@ -202,6 +191,7 @@ export const TabbedContent: React.FC<TabbedContentProps> = ({
onAdd={onAdd}
onLabelEdited={onLabelEdited}
onSelect={onSelect}
onSelectRecentlyClosed={onSelectRecentlyClosed}
onReorder={onReorder}
onClose={onClose}
getPreviewData={getPreviewData}
@ -220,3 +210,11 @@ export const TabbedContent: React.FC<TabbedContentProps> = ({
</EuiFlexGroup>
);
};
function prepareStateFromProps(items: TabItem[], selectedItemId?: string): TabbedContentState {
const selectedItem = selectedItemId && items.find((item) => item.id === selectedItemId);
return {
items,
selectedItem: selectedItem || items[0],
};
}

View file

@ -29,6 +29,7 @@ describe('TabsBar', () => {
it('renders tabs bar', async () => {
const onAdd = jest.fn();
const onSelect = jest.fn();
const onSelectRecentlyClosed = jest.fn();
const onLabelEdited = jest.fn();
const onClose = jest.fn();
const onReorder = jest.fn();
@ -53,6 +54,7 @@ describe('TabsBar', () => {
onAdd={onAdd}
onLabelEdited={onLabelEdited}
onSelect={onSelect}
onSelectRecentlyClosed={onSelectRecentlyClosed}
onClose={onClose}
onReorder={onReorder}
getPreviewData={getPreviewData}

View file

@ -36,7 +36,7 @@ import type { TabItem, TabsServices } from '../../types';
import { getTabIdAttribute } from '../../utils/get_tab_attributes';
import { useResponsiveTabs } from '../../hooks/use_responsive_tabs';
import { TabsBarWithBackground } from '../tabs_visual_glue_to_header/tabs_bar_with_background';
import { TabsBarMenu } from '../tabs_bar_menu';
import { TabsBarMenu, type TabsBarMenuProps } from '../tabs_bar_menu';
const DROPPABLE_ID = 'unifiedTabsOrder';
@ -60,6 +60,7 @@ export type TabsBarProps = Pick<
maxItemsCount?: number;
services: TabsServices;
onAdd: () => Promise<void>;
onSelectRecentlyClosed: TabsBarMenuProps['onSelectRecentlyClosed'];
onReorder: (items: TabItem[]) => void;
};
@ -80,6 +81,7 @@ export const TabsBar = forwardRef<TabsBarApi, TabsBarProps>(
onAdd,
onLabelEdited,
onSelect,
onSelectRecentlyClosed,
onReorder,
onClose,
getPreviewData,
@ -278,10 +280,11 @@ export const TabsBar = forwardRef<TabsBarApi, TabsBarProps>(
</EuiFlexItem>
<EuiFlexItem grow={false}>
<TabsBarMenu
openedItems={items}
items={items}
selectedItem={selectedItem}
onSelectOpenedTab={onSelect}
recentlyClosedItems={recentlyClosedItems}
onSelect={onSelect}
onSelectRecentlyClosed={onSelectRecentlyClosed}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -7,4 +7,4 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { TabsBarMenu } from './tabs_bar_menu';
export { TabsBarMenu, type TabsBarMenuProps } from './tabs_bar_menu';

View file

@ -8,7 +8,8 @@
*/
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TabsBarMenu } from './tabs_bar_menu';
const mockTabs = [
@ -26,77 +27,94 @@ const tabsBarMenuButtonTestId = 'unifiedTabs_tabsBarMenuButton';
describe('TabsBarMenu', () => {
const mockOnSelectOpenedTab = jest.fn();
const mockOnSelectClosedTab = jest.fn();
const defaultProps = {
openedItems: mockTabs,
items: mockTabs,
selectedItem: mockTabs[0],
onSelectOpenedTab: mockOnSelectOpenedTab,
recentlyClosedItems: mockRecentlyClosedTabs,
onSelect: mockOnSelectOpenedTab,
onSelectRecentlyClosed: mockOnSelectClosedTab,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders the menu button', () => {
it('renders the menu button', async () => {
render(<TabsBarMenu {...defaultProps} />);
const menuButton = screen.getByTestId(tabsBarMenuButtonTestId);
const menuButton = await screen.findByTestId(tabsBarMenuButtonTestId);
expect(menuButton).toBeInTheDocument();
});
it('opens popover when menu button is clicked', () => {
it('opens popover when menu button is clicked', async () => {
const user = userEvent.setup();
render(<TabsBarMenu {...defaultProps} />);
const menuButton = screen.getByTestId(tabsBarMenuButtonTestId);
const menuButton = await screen.findByTestId(tabsBarMenuButtonTestId);
await user.click(menuButton);
fireEvent.click(menuButton);
const tabsBarMenu = screen.getByTestId('unifiedTabs_tabsBarMenu');
const tabsBarMenu = await screen.findByTestId('unifiedTabs_tabsBarMenu');
expect(tabsBarMenu).toBeInTheDocument();
expect(screen.getByText('Opened tabs')).toBeInTheDocument();
});
it('displays opened tabs correctly', () => {
it('displays opened tabs correctly', async () => {
const user = userEvent.setup();
render(<TabsBarMenu {...defaultProps} />);
const menuButton = screen.getByTestId(tabsBarMenuButtonTestId);
const menuButton = await screen.findByTestId(tabsBarMenuButtonTestId);
await user.click(menuButton);
fireEvent.click(menuButton);
mockTabs.forEach((tab) => {
expect(screen.getByText(tab.label)).toBeInTheDocument();
});
expect(await screen.findByText('Opened tabs')).toBeInTheDocument();
for (const tab of mockTabs) {
expect(await screen.findByText(tab.label)).toBeInTheDocument();
}
});
it('selects a tab when clicked', () => {
it('selects a tab when clicked', async () => {
const user = userEvent.setup();
render(<TabsBarMenu {...defaultProps} />);
const menuButton = screen.getByTestId(tabsBarMenuButtonTestId);
const menuButton = await screen.findByTestId(tabsBarMenuButtonTestId);
await user.click(menuButton);
fireEvent.click(menuButton);
const secondTabOption = screen.getByText(mockTabs[1].label);
fireEvent.click(secondTabOption);
const secondTabOption = (await screen.findAllByRole('option'))[1];
await user.click(secondTabOption);
expect(mockOnSelectOpenedTab).toHaveBeenCalledWith(mockTabs[1]);
});
it('shows recently closed tabs when present', () => {
it('shows recently closed tabs when present', async () => {
const user = userEvent.setup();
render(<TabsBarMenu {...defaultProps} />);
const menuButton = screen.getByTestId(tabsBarMenuButtonTestId);
const menuButton = await screen.findByTestId(tabsBarMenuButtonTestId);
await user.click(menuButton);
fireEvent.click(menuButton);
expect(await screen.findByText('Recently closed')).toBeInTheDocument();
expect(screen.getByText('Recently closed')).toBeInTheDocument();
mockRecentlyClosedTabs.forEach((tab) => {
expect(screen.getByText(tab.label)).toBeInTheDocument();
});
for (const tab of mockRecentlyClosedTabs) {
expect(await screen.findByText(tab.label)).toBeInTheDocument();
}
});
it('does not show recently closed section when array is empty', () => {
it('selects a closed tab when clicked', async () => {
const user = userEvent.setup();
render(<TabsBarMenu {...defaultProps} />);
const menuButton = await screen.findByTestId(tabsBarMenuButtonTestId);
await user.click(menuButton);
const closedTabOption = (await screen.findAllByTitle(mockRecentlyClosedTabs[0].label))[0];
await user.click(closedTabOption);
expect(mockOnSelectClosedTab).toHaveBeenCalledWith(mockRecentlyClosedTabs[0]);
});
it('does not show recently closed section when array is empty', async () => {
const user = userEvent.setup();
const propsWithNoClosedTabs = {
...defaultProps,
recentlyClosedItems: [],
@ -104,22 +122,24 @@ describe('TabsBarMenu', () => {
render(<TabsBarMenu {...propsWithNoClosedTabs} />);
const menuButton = screen.getByTestId(tabsBarMenuButtonTestId);
fireEvent.click(menuButton);
const menuButton = await screen.findByTestId(tabsBarMenuButtonTestId);
await user.click(menuButton);
expect(screen.queryByText('Recently closed')).not.toBeInTheDocument();
});
it('marks the selected tab as checked', () => {
render(<TabsBarMenu {...defaultProps} />);
it('marks the selected tab as checked', async () => {
const user = userEvent.setup();
render(
<div style={{ width: '1000px' }}>
<TabsBarMenu {...defaultProps} />
</div>
);
const menuButton = screen.getByTestId(tabsBarMenuButtonTestId);
fireEvent.click(menuButton);
const selectedTabOption = screen.getByText(mockTabs[0].label);
const menuButton = await screen.findByTestId(tabsBarMenuButtonTestId);
await user.click(menuButton);
const selectedTabOption = (await screen.findAllByTitle(mockTabs[0].label))[0];
expect(selectedTabOption.closest('[aria-selected="true"]')).toBeInTheDocument();
});
});

View file

@ -22,7 +22,6 @@ import {
EuiSelectableOptionsListProps,
} from '@elastic/eui';
import type { TabItem } from '../../types';
import type { TabsBarProps } from '../tabs_bar';
const getOpenedTabsList = (
tabItems: TabItem[],
@ -42,18 +41,19 @@ const getRecentlyClosedTabsList = (tabItems: TabItem[]): EuiSelectableOption[] =
}));
};
interface TabsBarMenuProps {
onSelectOpenedTab: TabsBarProps['onSelect'];
selectedItem: TabsBarProps['selectedItem'];
openedItems: TabsBarProps['items'];
recentlyClosedItems: TabsBarProps['recentlyClosedItems'];
export interface TabsBarMenuProps {
items: TabItem[];
selectedItem: TabItem | null;
recentlyClosedItems: TabItem[];
onSelect: (item: TabItem) => Promise<void>;
onSelectRecentlyClosed: (item: TabItem) => Promise<void>;
}
export const TabsBarMenu: React.FC<TabsBarMenuProps> = React.memo(
({ openedItems, selectedItem, onSelectOpenedTab, recentlyClosedItems }) => {
({ items, selectedItem, recentlyClosedItems, onSelect, onSelectRecentlyClosed }) => {
const openedTabsList = useMemo(
() => getOpenedTabsList(openedItems, selectedItem),
[openedItems, selectedItem]
() => getOpenedTabsList(items, selectedItem),
[items, selectedItem]
);
const recentlyClosedTabsList = useMemo(
() => getRecentlyClosedTabsList(recentlyClosedItems),
@ -109,9 +109,9 @@ export const TabsBarMenu: React.FC<TabsBarMenuProps> = React.memo(
options={openedTabsList}
onChange={(newOptions) => {
const clickedTabId = newOptions.find((option) => option.checked)?.key;
const tabToNavigate = openedItems.find((tab) => tab.id === clickedTabId);
const tabToNavigate = items.find((tab) => tab.id === clickedTabId);
if (tabToNavigate) {
onSelectOpenedTab(tabToNavigate);
onSelect(tabToNavigate);
closePopover();
}
}}
@ -137,12 +137,16 @@ export const TabsBarMenu: React.FC<TabsBarMenuProps> = React.memo(
defaultMessage: 'Recently closed tabs list',
})}
options={recentlyClosedTabsList}
onChange={() => {
alert('restore tab'); // TODO restore closed tab
closePopover();
}}
singleSelection={true}
listProps={selectableListProps}
onChange={(newOptions) => {
const clickedTabId = newOptions.find((option) => option.checked)?.key;
const tabToNavigate = recentlyClosedItems.find((tab) => tab.id === clickedTabId);
if (tabToNavigate) {
onSelectRecentlyClosed(tabToNavigate);
closePopover();
}
}}
>
{(tabs) => (
<>

View file

@ -24,8 +24,9 @@ export interface TabsSizeConfig {
// TODO status value for now matches EuiHealth colors for mocking simplicity, adjust when real data is available
export enum TabStatus {
SUCCESS = 'success',
DEFAULT = 'default',
RUNNING = 'running',
SUCCESS = 'success',
ERROR = 'danger',
}

View file

@ -7,6 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { omit } from 'lodash';
import type { TabItem } from '../types';
export interface TabsState {
@ -47,6 +48,14 @@ export const selectTab = ({ items, selectedItem }: TabsState, item: TabItem): Ta
};
};
export const selectRecentlyClosedTab = ({ items }: TabsState, item: TabItem): TabsState => {
const nextSelectedItem = omit(item, 'closedAt');
return {
items: [...items.filter((i) => i.id !== item.id), nextSelectedItem],
selectedItem: nextSelectedItem,
};
};
export const closeTab = ({ items, selectedItem }: TabsState, item: TabItem): TabsState => {
const itemIndex = items.findIndex((i) => i.id === item.id);

View file

@ -30,3 +30,5 @@ export const ESQL_TRANSITION_MODAL_KEY = 'data.textLangTransitionModal';
* 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';

View file

@ -25,6 +25,8 @@ import { createKbnUrlStateStorage, withNotifyOnErrors } from '@kbn/kibana-utils-
import type { History } from 'history';
import type { DiscoverCustomizationContext } from '../customizations';
import { createCustomizationService } from '../customizations/customization_service';
import { createTabsStorageManager } from '../application/main/state_management/tabs_storage_manager';
import { internalStateActions } from '../application/main/state_management/redux';
export function getDiscoverStateMock({
isTimeBased = true,
@ -59,12 +61,20 @@ export function getDiscoverStateMock({
...(toasts && withNotifyOnErrors(toasts)),
});
runtimeStateManager = runtimeStateManager ?? createRuntimeStateManager();
const tabsStorageManager = createTabsStorageManager({
urlStateStorage: stateStorageContainer,
storage: services.storage,
});
const internalState = createInternalStateStore({
services,
customizationContext,
runtimeStateManager,
urlStateStorage: stateStorageContainer,
tabsStorageManager,
});
internalState.dispatch(
internalStateActions.initializeTabs({ userId: 'mockUserId', spaceId: 'mockSpaceId' })
);
const container = getDiscoverStateContainer({
tabId: internalState.getState().tabs.unsafeCurrentId,
services,

View file

@ -381,6 +381,7 @@ describe('useDiscoverHistogram', () => {
dataRequestParams: {
timeRangeAbsolute: timeRangeAbs,
timeRangeRelative: timeRangeRel,
searchSessionId: '123',
},
})
);

View file

@ -64,6 +64,7 @@ async function mountComponent(
from: '2020-05-14T11:05:13.590',
to: '2020-05-14T11:20:13.590',
},
searchSessionId: 'test',
},
})
);

View file

@ -43,6 +43,8 @@ import {
import { ChartPortalsRenderer } from '../chart';
import { UnifiedHistogramChart } from '@kbn/unified-histogram';
const mockSearchSessionId = '123';
jest.mock('@elastic/eui', () => ({
...jest.requireActual('@elastic/eui'),
useResizeObserver: jest.fn(() => ({ width: 1000, height: 1000 })),
@ -53,7 +55,7 @@ function getStateContainer({
searchSessionId,
}: {
savedSearch?: SavedSearch;
searchSessionId?: string | null;
searchSessionId?: string;
}) {
const stateContainer = getDiscoverStateMock({ isTimeBased: true, savedSearch });
const dataView = savedSearch?.searchSource?.getField('index') as DataView;
@ -79,7 +81,7 @@ function getStateContainer({
from: '2020-05-14T11:05:13.590',
to: '2020-05-14T11:20:13.590',
},
...(searchSessionId && { searchSessionId }),
searchSessionId,
},
})
);
@ -88,16 +90,14 @@ function getStateContainer({
}
const mountComponent = async ({
isEsqlMode = false,
storage,
savedSearch = savedSearchMockWithTimeField,
searchSessionId = '123',
noSearchSessionId,
}: {
isEsqlMode?: boolean;
isTimeBased?: boolean;
storage?: Storage;
savedSearch?: SavedSearch;
searchSessionId?: string | null;
noSearchSessionId?: boolean;
} = {}) => {
const dataView = savedSearch?.searchSource?.getField('index') as DataView;
@ -132,7 +132,10 @@ const mountComponent = async ({
totalHits$,
};
const stateContainer = getStateContainer({ savedSearch, searchSessionId });
const stateContainer = getStateContainer({
savedSearch,
searchSessionId: noSearchSessionId ? undefined : mockSearchSessionId,
});
stateContainer.dataState.data$ = savedSearchData$;
stateContainer.actions.undoSavedSearchChanges = jest.fn();
@ -187,7 +190,7 @@ const mountComponent = async ({
describe('Discover histogram layout component', () => {
describe('render', () => {
it('should not render chart if there is no search session', async () => {
const { component } = await mountComponent({ searchSessionId: null });
const { component } = await mountComponent({ noSearchSessionId: true });
expect(component.exists(UnifiedHistogramChart)).toBe(false);
});
@ -196,11 +199,6 @@ describe('Discover histogram layout component', () => {
expect(component.exists(UnifiedHistogramChart)).toBe(true);
}, 10000);
it('should render chart if there is no search session, but isEsqlMode is true', async () => {
const { component } = await mountComponent({ isEsqlMode: true });
expect(component.exists(UnifiedHistogramChart)).toBe(true);
});
it('should render PanelsToggle', async () => {
const { component } = await mountComponent();
expect(component.find(PanelsToggle).first().prop('isChartAvailable')).toBe(undefined);

View file

@ -65,6 +65,7 @@ interface SessionInitializationState {
type InitializeSession = (options?: {
dataViewSpec?: DataViewSpec | undefined;
defaultUrlState?: DiscoverAppState;
shouldClearAllTabs?: boolean;
}) => Promise<SessionInitializationState>;
export const DiscoverSessionView = ({
@ -89,7 +90,7 @@ export const DiscoverSessionView = ({
);
const initializeSessionAction = useCurrentTabAction(internalStateActions.initializeSession);
const [initializeSessionState, initializeSession] = useAsyncFunction<InitializeSession>(
async ({ dataViewSpec, defaultUrlState } = {}) => {
async ({ dataViewSpec, defaultUrlState, shouldClearAllTabs = false } = {}) => {
const stateContainer = getDiscoverStateContainer({
tabId: currentTabId,
services,
@ -111,6 +112,7 @@ export const DiscoverSessionView = ({
discoverSessionId,
dataViewSpec,
defaultUrlState,
shouldClearAllTabs,
},
})
);
@ -119,15 +121,18 @@ export const DiscoverSessionView = ({
? { loading: false, value: { showNoDataPage: false } }
: { loading: true }
);
const initializeSessionWithDefaultLocationState = useLatest(() => {
const historyLocationState = getScopedHistory<
MainHistoryLocationState & { defaultState?: DiscoverAppState }
>()?.location.state;
initializeSession({
dataViewSpec: historyLocationState?.dataViewSpec,
defaultUrlState: historyLocationState?.defaultState,
});
});
const initializeSessionWithDefaultLocationState = useLatest(
(options?: { shouldClearAllTabs?: boolean }) => {
const historyLocationState = getScopedHistory<
MainHistoryLocationState & { defaultState?: DiscoverAppState }
>()?.location.state;
initializeSession({
dataViewSpec: historyLocationState?.dataViewSpec,
defaultUrlState: historyLocationState?.defaultState,
shouldClearAllTabs: options?.shouldClearAllTabs,
});
}
);
const initializationState = useInternalStateSelector((state) => state.initializationState);
const currentDataView = useCurrentTabRuntimeState(
runtimeStateManager,
@ -149,7 +154,7 @@ export const DiscoverSessionView = ({
history,
savedSearchId: discoverSessionId,
onNewUrl: () => {
initializeSessionWithDefaultLocationState.current();
initializeSessionWithDefaultLocationState.current({ shouldClearAllTabs: true });
},
});

View file

@ -7,14 +7,14 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { type TabItem, UnifiedTabs } from '@kbn/unified-tabs';
import React, { useState } from 'react';
import { pick } from 'lodash';
import { UnifiedTabs, type UnifiedTabsProps } from '@kbn/unified-tabs';
import React, { useCallback } from 'react';
import { DiscoverSessionView, type DiscoverSessionViewProps } from '../session_view';
import {
createTabItem,
internalStateActions,
selectAllTabs,
selectRecentlyClosedTabs,
useInternalStateDispatch,
useInternalStateSelector,
} from '../../state_management/redux';
@ -24,19 +24,36 @@ import { usePreviewData } from './use_preview_data';
export const TabsView = (props: DiscoverSessionViewProps) => {
const services = useDiscoverServices();
const dispatch = useInternalStateDispatch();
const allTabs = useInternalStateSelector(selectAllTabs);
const items = useInternalStateSelector(selectAllTabs);
const recentlyClosedItems = useInternalStateSelector(selectRecentlyClosedTabs);
const currentTabId = useInternalStateSelector((state) => state.tabs.unsafeCurrentId);
const [initialItems] = useState<TabItem[]>(() => allTabs.map((tab) => pick(tab, 'id', 'label')));
const { getPreviewData } = usePreviewData(props.runtimeStateManager);
const onChanged: UnifiedTabsProps['onChanged'] = useCallback(
(updateState) => dispatch(internalStateActions.updateTabs(updateState)),
[dispatch]
);
const createItem: UnifiedTabsProps['createItem'] = useCallback(
() => createTabItem(items),
[items]
);
const renderContent: UnifiedTabsProps['renderContent'] = useCallback(
() => <DiscoverSessionView key={currentTabId} {...props} />,
[currentTabId, props]
);
return (
<UnifiedTabs
services={services}
initialItems={initialItems}
onChanged={(updateState) => dispatch(internalStateActions.updateTabs(updateState))}
createItem={() => createTabItem(allTabs)}
items={items}
selectedItemId={currentTabId}
recentlyClosedItems={recentlyClosedItems}
createItem={createItem}
getPreviewData={getPreviewData}
renderContent={() => <DiscoverSessionView key={currentTabId} {...props} />}
renderContent={renderContent}
onChanged={onChanged}
/>
);
};

View file

@ -87,7 +87,8 @@ const getPreviewDataObservable = (runtimeStateManager: RuntimeStateManager, tabI
selectTabRuntimeState(runtimeStateManager, tabId).stateContainer$.pipe(
switchMap((tabStateContainer) => {
if (!tabStateContainer) {
return of({ status: TabStatus.RUNNING, query: DEFAULT_PREVIEW_QUERY });
// TODO: show the real query for tabs which are not yet initialized
return of({ status: TabStatus.DEFAULT, query: DEFAULT_PREVIEW_QUERY });
}
const { appState } = tabStateContainer;

View file

@ -18,8 +18,6 @@ import type { CustomizationCallback, DiscoverCustomizationContext } from '../../
import {
type DiscoverInternalState,
InternalStateProvider,
createInternalStateStore,
createRuntimeStateManager,
internalStateActions,
} from './state_management/redux';
import type { RootProfileState } from '../../context_awareness';
@ -35,6 +33,8 @@ import { useAsyncFunction } from './hooks/use_async_function';
import { TabsView } from './components/tabs_view';
import { TABS_ENABLED } from '../../constants';
import { ChartPortalsRenderer } from './components/chart';
import { useStateManagers } from './state_management/hooks/use_state_managers';
import { getUserAndSpaceIds } from './utils/get_user_and_space_ids';
export interface MainRouteProps {
customizationContext: DiscoverCustomizationContext;
@ -66,26 +66,28 @@ export const DiscoverMainRoute = ({
...withNotifyOnErrors(services.core.notifications.toasts),
})
);
const [runtimeStateManager] = useState(() => createRuntimeStateManager());
const [internalState] = useState(() =>
createInternalStateStore({
services,
customizationContext,
runtimeStateManager,
urlStateStorage,
})
);
const { internalState, runtimeStateManager } = useStateManagers({
services,
urlStateStorage,
customizationContext,
});
const { initializeProfileDataViews } = useDefaultAdHocDataViews({ internalState });
const [mainRouteInitializationState, initializeMainRoute] = useAsyncFunction<InitializeMainRoute>(
async (loadedRootProfileState) => {
const { dataViews } = services;
const [hasESData, hasUserDataView, defaultDataViewExists] = await Promise.all([
dataViews.hasData.hasESData().catch(() => false),
dataViews.hasData.hasUserDataView().catch(() => false),
dataViews.defaultDataViewExists().catch(() => false),
internalState.dispatch(internalStateActions.loadDataViewList()).catch(() => {}),
initializeProfileDataViews(loadedRootProfileState).catch(() => {}),
]);
const [hasESData, hasUserDataView, defaultDataViewExists, userAndSpaceIds] =
await Promise.all([
dataViews.hasData.hasESData().catch(() => false),
dataViews.hasData.hasUserDataView().catch(() => false),
dataViews.defaultDataViewExists().catch(() => false),
getUserAndSpaceIds(services),
internalState.dispatch(internalStateActions.loadDataViewList()).catch(() => {}),
initializeProfileDataViews(loadedRootProfileState).catch(() => {}),
]);
internalState.dispatch(internalStateActions.initializeTabs(userAndSpaceIds));
const initializationState: DiscoverInternalState['initializationState'] = {
hasESData,
hasUserDataView: hasUserDataView && defaultDataViewExists,

View file

@ -28,13 +28,16 @@ import {
createRuntimeStateManager,
createTabActionInjector,
selectTab,
internalStateActions,
} from './redux';
import { mockCustomizationContext } from '../../../customizations/__mocks__/customization_context';
import { createTabsStorageManager, type TabsStorageManager } from './tabs_storage_manager';
let history: History;
let stateStorage: IKbnUrlStateStorage;
let internalState: InternalStateStore;
let savedSearchState: DiscoverSavedSearchContainer;
let tabsStorageManager: TabsStorageManager;
let getCurrentTab: () => TabState;
describe('Test discover app state container', () => {
@ -46,12 +49,20 @@ describe('Test discover app state container', () => {
history,
...(toasts && withNotifyOnErrors(toasts)),
});
tabsStorageManager = createTabsStorageManager({
urlStateStorage: stateStorage,
storage: discoverServiceMock.storage,
});
internalState = createInternalStateStore({
services: discoverServiceMock,
customizationContext: mockCustomizationContext,
runtimeStateManager: createRuntimeStateManager(),
urlStateStorage: stateStorage,
tabsStorageManager,
});
internalState.dispatch(
internalStateActions.initializeTabs({ userId: 'mockUserId', spaceId: 'mockSpaceId' })
);
savedSearchState = getSavedSearchContainer({
services: discoverServiceMock,
globalStateContainer: getDiscoverGlobalStateContainer(stateStorage),

View file

@ -9,14 +9,13 @@
import type { QueryState } from '@kbn/data-plugin/common';
import type { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import { GLOBAL_STATE_URL_KEY } from '../../../../common/constants';
export interface DiscoverGlobalStateContainer {
get: () => QueryState | null;
set: (state: QueryState) => Promise<void>;
}
export const GLOBAL_STATE_URL_KEY = '_g';
export const getDiscoverGlobalStateContainer = (
stateStorage: IKbnUrlStateStorage
): DiscoverGlobalStateContainer => ({

View file

@ -25,18 +25,27 @@ import { createSearchSourceMock } from '@kbn/data-plugin/common/search/search_so
import { createInternalStateStore, createRuntimeStateManager, internalStateActions } from './redux';
import { mockCustomizationContext } from '../../../customizations/__mocks__/customization_context';
import { omit } from 'lodash';
import { createTabsStorageManager } from './tabs_storage_manager';
describe('DiscoverSavedSearchContainer', () => {
const savedSearch = savedSearchMock;
const services = discoverServiceMock;
const urlStateStorage = createKbnUrlStateStorage();
const globalStateContainer = getDiscoverGlobalStateContainer(urlStateStorage);
const tabsStorageManager = createTabsStorageManager({
urlStateStorage,
storage: services.storage,
});
const internalState = createInternalStateStore({
services,
customizationContext: mockCustomizationContext,
runtimeStateManager: createRuntimeStateManager(),
urlStateStorage,
tabsStorageManager,
});
internalState.dispatch(
internalStateActions.initializeTabs({ userId: 'mockUserId', spaceId: 'mockSpaceId' })
);
describe('getTitle', () => {
it('returns undefined for new saved searches', () => {

View file

@ -273,6 +273,7 @@ describe('Discover state', () => {
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -471,6 +472,7 @@ describe('Discover state', () => {
discoverSessionId: undefined,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -499,6 +501,7 @@ describe('Discover state', () => {
discoverSessionId: undefined,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -541,6 +544,7 @@ describe('Discover state', () => {
discoverSessionId: undefined,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -567,6 +571,7 @@ describe('Discover state', () => {
discoverSessionId: undefined,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -593,6 +598,7 @@ describe('Discover state', () => {
discoverSessionId: undefined,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -632,6 +638,7 @@ describe('Discover state', () => {
discoverSessionId: 'the-saved-search-id',
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -659,6 +666,7 @@ describe('Discover state', () => {
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -690,6 +698,7 @@ describe('Discover state', () => {
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -722,6 +731,7 @@ describe('Discover state', () => {
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -754,6 +764,7 @@ describe('Discover state', () => {
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -773,6 +784,7 @@ describe('Discover state', () => {
discoverSessionId: undefined,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -794,6 +806,7 @@ describe('Discover state', () => {
discoverSessionId: undefined,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -822,6 +835,7 @@ describe('Discover state', () => {
discoverSessionId: undefined,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -843,6 +857,7 @@ describe('Discover state', () => {
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -882,6 +897,7 @@ describe('Discover state', () => {
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -912,6 +928,7 @@ describe('Discover state', () => {
discoverSessionId: 'the-saved-search-id-with-timefield',
dataViewSpec: undefined,
defaultUrlState: {},
shouldClearAllTabs: false,
},
})
);
@ -945,6 +962,7 @@ describe('Discover state', () => {
dataViewId: 'index-pattern-with-timefield-id',
}),
},
shouldClearAllTabs: false,
},
})
);
@ -975,6 +993,7 @@ describe('Discover state', () => {
discoverSessionId: undefined,
dataViewSpec: dataViewSpecMock,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -1000,6 +1019,7 @@ describe('Discover state', () => {
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -1024,6 +1044,7 @@ describe('Discover state', () => {
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -1045,6 +1066,7 @@ describe('Discover state', () => {
discoverSessionId: savedSearchAdHoc.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -1070,6 +1092,7 @@ describe('Discover state', () => {
discoverSessionId: savedSearchMockWithESQL.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -1119,6 +1142,7 @@ describe('Discover state', () => {
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -1155,6 +1179,7 @@ describe('Discover state', () => {
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -1181,6 +1206,7 @@ describe('Discover state', () => {
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -1212,6 +1238,7 @@ describe('Discover state', () => {
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -1246,6 +1273,7 @@ describe('Discover state', () => {
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -1272,6 +1300,7 @@ describe('Discover state', () => {
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -1285,6 +1314,7 @@ describe('Discover state', () => {
discoverSessionId: undefined,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -1301,6 +1331,7 @@ describe('Discover state', () => {
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -1325,6 +1356,7 @@ describe('Discover state', () => {
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);
@ -1378,6 +1410,7 @@ describe('Discover state', () => {
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
shouldClearAllTabs: false,
},
})
);

View file

@ -433,6 +433,10 @@ export function getDiscoverStateContainer({
* state containers initializing and subscribing to changes triggering e.g. data fetching
*/
const initializeAndSync = () => {
const updateTabAppStateAndGlobalState = () =>
internalState.dispatch(
injectCurrentTab(internalStateActions.updateTabAppStateAndGlobalState)()
);
// This needs to be the first thing that's wired up because initAndSync is pulling the current state from the URL which
// might change the time filter and thus needs to re-check whether the saved search has changed.
const timefilerUnsubscribe = merge(
@ -440,6 +444,7 @@ export function getDiscoverStateContainer({
services.timefilter.getRefreshIntervalUpdate$()
).subscribe(() => {
savedSearchContainer.updateTimeRange();
updateTabAppStateAndGlobalState();
});
// Enable/disable kbn url tracking (That's the URL used when selecting Discover in the side menu)
@ -461,6 +466,10 @@ export function getDiscoverStateContainer({
})
);
const savedSearchChangesSubscription = savedSearchContainer
.getCurrent$()
.subscribe(updateTabAppStateAndGlobalState);
// start subscribing to dataStateContainer, triggering data fetching
const unsubscribeData = dataStateContainer.subscribe();
@ -494,6 +503,7 @@ export function getDiscoverStateContainer({
);
internalStopSyncing = () => {
savedSearchChangesSubscription.unsubscribe();
unsubscribeData();
appStateUnsubscribe();
appStateInitAndSyncUnsubscribe();

View file

@ -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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { useEffect, useMemo, useState } from 'react';
import type { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import {
createInternalStateStore,
createRuntimeStateManager,
internalStateActions,
type InternalStateStore,
type RuntimeStateManager,
} from '../redux';
import { createTabsStorageManager } from '../tabs_storage_manager';
import { TABS_ENABLED } from '../../../../constants';
import type { DiscoverCustomizationContext } from '../../../../customizations';
import type { DiscoverServices } from '../../../../build_services';
interface UseStateManagers {
customizationContext: DiscoverCustomizationContext;
services: DiscoverServices;
urlStateStorage: IKbnUrlStateStorage;
}
interface UseStateManagersReturn {
internalState: InternalStateStore;
runtimeStateManager: RuntimeStateManager;
}
export const useStateManagers = ({
services,
urlStateStorage,
customizationContext,
}: UseStateManagers): UseStateManagersReturn => {
// syncing with the _t part URL
const [tabsStorageManager] = useState(() =>
createTabsStorageManager({
urlStateStorage,
storage: services.storage,
enabled: TABS_ENABLED,
})
);
const [runtimeStateManager] = useState(() => createRuntimeStateManager());
const [internalState] = useState(() =>
createInternalStateStore({
services,
customizationContext,
runtimeStateManager,
urlStateStorage,
tabsStorageManager,
})
);
useEffect(() => {
const stopUrlSync = tabsStorageManager.startUrlSync({
// if `_t` 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
internalState.dispatch(internalStateActions.clearAllTabs());
}
},
});
return () => {
stopUrlSync();
};
}, [tabsStorageManager, internalState]);
return useMemo(
() => ({
internalState,
runtimeStateManager,
}),
[internalState, runtimeStateManager]
);
};

View file

@ -34,9 +34,10 @@ import { isRefreshIntervalValid, isTimeRangeValid } from '../../../../../utils/v
import { getValidFilters } from '../../../../../utils/get_valid_filters';
import { updateSavedSearch } from '../../utils/update_saved_search';
import { APP_STATE_URL_KEY } from '../../../../../../common';
import { TABS_ENABLED } from '../../../../../constants';
import { selectTabRuntimeState } from '../runtime_state';
import type { ConnectedCustomizationService } from '../../../../../customizations';
import { disconnectTab } from './tabs';
import { disconnectTab, clearAllTabs } from './tabs';
export interface InitializeSessionParams {
stateContainer: DiscoverStateContainer;
@ -44,6 +45,7 @@ export interface InitializeSessionParams {
discoverSessionId: string | undefined;
dataViewSpec: DataViewSpec | undefined;
defaultUrlState: DiscoverAppState | undefined;
shouldClearAllTabs: boolean | undefined;
}
export const initializeSession: InternalStateThunkActionCreator<
@ -58,25 +60,32 @@ export const initializeSession: InternalStateThunkActionCreator<
discoverSessionId,
dataViewSpec,
defaultUrlState,
shouldClearAllTabs,
},
}) =>
async (
dispatch,
getState,
{ services, customizationContext, runtimeStateManager, urlStateStorage }
{ services, customizationContext, runtimeStateManager, urlStateStorage, tabsStorageManager }
) => {
dispatch(disconnectTab({ tabId }));
dispatch(internalStateSlice.actions.resetOnSavedSearchChange({ tabId }));
if (TABS_ENABLED && shouldClearAllTabs) {
dispatch(clearAllTabs());
}
const discoverSessionLoadTracker =
services.ebtManager.trackPerformanceEvent('discoverLoadSavedSearch');
const { currentDataView$, stateContainer$, customizationService$ } = selectTabRuntimeState(
runtimeStateManager,
tabId
);
let initialUrlState = defaultUrlState ?? urlStateStorage.get<AppStateUrl>(APP_STATE_URL_KEY);
/**
* New tab initialization or existing tab re-initialization
* New tab initialization with the restored data if available
*/
const wasTabInitialized = Boolean(stateContainer$.getValue());
@ -89,14 +98,33 @@ export const initializeSession: InternalStateThunkActionCreator<
customizationService$.next(undefined);
}
if (TABS_ENABLED && !wasTabInitialized) {
const tabGlobalStateFromLocalStorage =
tabsStorageManager.loadTabGlobalStateFromLocalCache(tabId);
if (tabGlobalStateFromLocalStorage?.filters) {
services.filterManager.setGlobalFilters(cloneDeep(tabGlobalStateFromLocalStorage.filters));
}
if (tabGlobalStateFromLocalStorage?.timeRange) {
services.timefilter.setTime(tabGlobalStateFromLocalStorage.timeRange);
}
if (tabGlobalStateFromLocalStorage?.refreshInterval) {
services.timefilter.setRefreshInterval(tabGlobalStateFromLocalStorage.refreshInterval);
}
const tabAppStateFromLocalStorage = tabsStorageManager.loadTabAppStateFromLocalCache(tabId);
if (tabAppStateFromLocalStorage) {
initialUrlState = tabAppStateFromLocalStorage;
}
}
/**
* "No data" checks
*/
const urlState = cleanupUrlState(initialUrlState, services.uiSettings);
const urlState = cleanupUrlState(
defaultUrlState ?? urlStateStorage.get<AppStateUrl>(APP_STATE_URL_KEY),
services.uiSettings
);
const persistedDiscoverSession = discoverSessionId
? await services.savedSearch.get(discoverSessionId)
: undefined;

View file

@ -8,29 +8,35 @@
*/
import type { TabbedContentState } from '@kbn/unified-tabs/src/components/tabbed_content/tabbed_content';
import { cloneDeep, differenceBy } from 'lodash';
import { cloneDeep, differenceBy, omit, pick } from 'lodash';
import type { QueryState } from '@kbn/data-plugin/common';
import type { TabState } from '../types';
import { selectAllTabs, selectTab } from '../selectors';
import { selectAllTabs, selectRecentlyClosedTabs, selectTab } from '../selectors';
import {
defaultTabState,
internalStateSlice,
type TabActionPayload,
type InternalStateThunkActionCreator,
} from '../internal_state';
import { createTabRuntimeState, selectTabRuntimeState } from '../runtime_state';
import { APP_STATE_URL_KEY } from '../../../../../../common';
import { GLOBAL_STATE_URL_KEY } from '../../discover_global_state_container';
import {
createTabRuntimeState,
selectTabRuntimeState,
selectTabRuntimeAppState,
selectTabRuntimeGlobalState,
} from '../runtime_state';
import { APP_STATE_URL_KEY, GLOBAL_STATE_URL_KEY } from '../../../../../../common/constants';
import type { DiscoverAppState } from '../../discover_app_state_container';
import { createTabItem } from '../utils';
export const setTabs: InternalStateThunkActionCreator<
[Parameters<typeof internalStateSlice.actions.setTabs>[0]]
> =
(params) =>
(dispatch, getState, { runtimeStateManager }) => {
const previousTabs = selectAllTabs(getState());
const removedTabs = differenceBy(previousTabs, params.allTabs, (tab) => tab.id);
const addedTabs = differenceBy(params.allTabs, previousTabs, (tab) => tab.id);
(dispatch, getState, { runtimeStateManager, tabsStorageManager }) => {
const previousState = getState();
const previousTabs = selectAllTabs(previousState);
const removedTabs = differenceBy(previousTabs, params.allTabs, differenceIterateeByTabId);
const addedTabs = differenceBy(params.allTabs, previousTabs, differenceIterateeByTabId);
for (const tab of removedTabs) {
dispatch(disconnectTab({ tabId: tab.id }));
@ -41,7 +47,16 @@ export const setTabs: InternalStateThunkActionCreator<
runtimeStateManager.tabs.byId[tab.id] = createTabRuntimeState();
}
dispatch(internalStateSlice.actions.setTabs(params));
dispatch(
internalStateSlice.actions.setTabs({
...params,
recentlyClosedTabs: tabsStorageManager.getNRecentlyClosedTabs(
// clean up the recently closed tabs if the same ids are present in next open tabs
differenceBy(params.recentlyClosedTabs, params.allTabs, differenceIterateeByTabId),
removedTabs
),
})
);
};
export const updateTabs: InternalStateThunkActionCreator<[TabbedContentState], Promise<void>> =
@ -49,9 +64,13 @@ export const updateTabs: InternalStateThunkActionCreator<[TabbedContentState], P
async (dispatch, getState, { services, runtimeStateManager, urlStateStorage }) => {
const currentState = getState();
const currentTab = selectTab(currentState, currentState.tabs.unsafeCurrentId);
let updatedTabs = items.map<TabState>((item) => {
const updatedTabs = items.map<TabState>((item) => {
const existingTab = selectTab(currentState, item.id);
return existingTab ? { ...existingTab, ...item } : { ...defaultTabState, ...item };
return {
...defaultTabState,
...existingTab,
...pick(item, 'id', 'label'),
};
});
if (selectedItem?.id !== currentTab.id) {
@ -60,20 +79,6 @@ export const updateTabs: InternalStateThunkActionCreator<[TabbedContentState], P
previousTabStateContainer?.actions.stopSyncing();
updatedTabs = updatedTabs.map((tab) => {
if (tab.id !== currentTab.id) {
return tab;
}
const {
time: timeRange,
refreshInterval,
filters,
} = previousTabStateContainer?.globalState.get() ?? {};
return { ...tab, lastPersistedGlobalState: { timeRange, refreshInterval, filters } };
});
const nextTab = selectedItem ? selectTab(currentState, selectedItem.id) : undefined;
const nextTabRuntimeState = selectedItem
? selectTabRuntimeState(runtimeStateManager, selectedItem.id)
@ -117,6 +122,77 @@ export const updateTabs: InternalStateThunkActionCreator<[TabbedContentState], P
setTabs({
allTabs: updatedTabs,
selectedTabId: selectedItem?.id ?? currentTab.id,
recentlyClosedTabs: selectRecentlyClosedTabs(currentState),
})
);
};
export const updateTabAppStateAndGlobalState: InternalStateThunkActionCreator<[TabActionPayload]> =
({ tabId }) =>
(dispatch, _, { runtimeStateManager }) => {
dispatch(
internalStateSlice.actions.setTabAppStateAndGlobalState({
tabId,
appState: selectTabRuntimeAppState(runtimeStateManager, tabId),
globalState: selectTabRuntimeGlobalState(runtimeStateManager, tabId),
})
);
};
export const initializeTabs: InternalStateThunkActionCreator<
[{ userId: string; spaceId: string }]
> =
({ userId, spaceId }) =>
(dispatch, _, { tabsStorageManager }) => {
const initialTabsState = tabsStorageManager.loadLocally({
userId,
spaceId,
defaultTabState,
});
dispatch(setTabs(initialTabsState));
};
export const clearAllTabs: InternalStateThunkActionCreator = () => (dispatch) => {
const defaultTab: TabState = {
...defaultTabState,
...createTabItem([]),
};
return dispatch(updateTabs({ items: [defaultTab], selectedItem: defaultTab }));
};
export const restoreTab: InternalStateThunkActionCreator<[{ restoreTabId: string }]> =
({ restoreTabId }) =>
(dispatch, getState) => {
const currentState = getState();
if (restoreTabId === currentState.tabs.unsafeCurrentId) {
return;
}
const currentTabs = selectAllTabs(currentState);
const currentTab = selectTab(currentState, currentState.tabs.unsafeCurrentId);
let items = currentTabs;
// search among open tabs
let selectedItem = items.find((tab) => tab.id === restoreTabId);
if (!selectedItem) {
// search among recently closed tabs
const recentlyClosedTabs = selectRecentlyClosedTabs(currentState);
const closedTab = recentlyClosedTabs.find((tab) => tab.id === restoreTabId);
if (closedTab) {
// reopening one of the closed tabs
selectedItem = omit(closedTab, 'closedAt');
items = [...items, closedTab];
}
}
return dispatch(
updateTabs({
items,
selectedItem: selectedItem || currentTab,
})
);
};
@ -130,3 +206,7 @@ export const disconnectTab: InternalStateThunkActionCreator<[TabActionPayload]>
stateContainer?.actions.stopSyncing();
tabRuntimeState.customizationService$.getValue()?.cleanup();
};
function differenceIterateeByTabId(tab: TabState) {
return tab.id;
}

View file

@ -20,6 +20,10 @@ import {
setTabs,
updateTabs,
disconnectTab,
updateTabAppStateAndGlobalState,
restoreTab,
clearAllTabs,
initializeTabs,
} from './actions';
export type { DiscoverInternalState, TabState, InternalStateDataRequestParams } from './types';
@ -43,6 +47,10 @@ export const internalStateActions = {
appendAdHocDataViews,
replaceAdHocDataViewWithId,
initializeSession,
updateTabAppStateAndGlobalState,
restoreTab,
clearAllTabs,
initializeTabs,
};
export {
@ -56,7 +64,7 @@ export {
useDataViewsForPicker,
} from './hooks';
export { selectAllTabs, selectTab } from './selectors';
export { selectAllTabs, selectRecentlyClosedTabs, selectTab } from './selectors';
export {
type RuntimeStateManager,

View file

@ -18,16 +18,27 @@ import {
import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
import { mockCustomizationContext } from '../../../../customizations/__mocks__/customization_context';
import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import { createTabsStorageManager } from '../tabs_storage_manager';
describe('InternalStateStore', () => {
it('should set data view', () => {
const services = createDiscoverServicesMock();
const urlStateStorage = createKbnUrlStateStorage();
const runtimeStateManager = createRuntimeStateManager();
const tabsStorageManager = createTabsStorageManager({
urlStateStorage,
storage: services.storage,
});
const store = createInternalStateStore({
services: createDiscoverServicesMock(),
customizationContext: mockCustomizationContext,
runtimeStateManager,
urlStateStorage: createKbnUrlStateStorage(),
urlStateStorage,
tabsStorageManager,
});
store.dispatch(
internalStateActions.initializeTabs({ userId: 'mockUserId', spaceId: 'mockSpaceId' })
);
const tabId = store.getState().tabs.unsafeCurrentId;
expect(selectTab(store.getState(), tabId).dataViewId).toBeUndefined();
expect(

View file

@ -9,33 +9,46 @@
import type { DataTableRecord } from '@kbn/discover-utils';
import { v4 as uuidv4 } from 'uuid';
import { throttle } from 'lodash';
import {
type PayloadAction,
configureStore,
createSlice,
type ThunkAction,
type ThunkDispatch,
type AnyAction,
type Dispatch,
createListenerMiddleware,
} from '@reduxjs/toolkit';
import type { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import type { TabItem } from '@kbn/unified-tabs';
import type { DiscoverCustomizationContext } from '../../../../customizations';
import type { DiscoverServices } from '../../../../build_services';
import { type RuntimeStateManager } from './runtime_state';
import { type RuntimeStateManager, selectTabRuntimeAppState } from './runtime_state';
import {
LoadingStatus,
type DiscoverInternalState,
type InternalStateDataRequestParams,
type TabState,
type RecentlyClosedTabState,
} from './types';
import { loadDataViewList, setTabs } from './actions';
import { selectAllTabs, selectTab } from './selectors';
import { createTabItem } from './utils';
import { loadDataViewList } from './actions/data_views';
import { selectTab } from './selectors';
import type { TabsStorageManager } from '../tabs_storage_manager';
import type { DiscoverAppState } from '../discover_app_state_container';
const MIDDLEWARE_THROTTLE_MS = 300;
const MIDDLEWARE_THROTTLE_OPTIONS = { leading: false, trailing: true };
export const defaultTabState: Omit<TabState, keyof TabItem> = {
lastPersistedGlobalState: {},
dataViewId: undefined,
isDataViewLoading: false,
dataRequestParams: {},
dataRequestParams: {
timeRangeAbsolute: undefined,
timeRangeRelative: undefined,
searchSessionId: undefined,
},
overriddenVisContextAfterInvalidation: undefined,
resetDefaultProfileState: {
resetId: '',
@ -63,7 +76,7 @@ const initialState: DiscoverInternalState = {
savedDataViews: [],
expandedDoc: undefined,
isESQLToDataViewTransitionModalVisible: false,
tabs: { byId: {}, allIds: [], unsafeCurrentId: '' },
tabs: { byId: {}, allIds: [], unsafeCurrentId: '', recentlyClosedTabIds: [] },
};
export type TabActionPayload<T extends { [key: string]: unknown } = {}> = { tabId: string } & T;
@ -93,8 +106,17 @@ export const internalStateSlice = createSlice({
state.initializationState = action.payload;
},
setTabs: (state, action: PayloadAction<{ allTabs: TabState[]; selectedTabId: string }>) => {
state.tabs.byId = action.payload.allTabs.reduce<Record<string, TabState>>(
setTabs: (
state,
action: PayloadAction<{
allTabs: TabState[];
selectedTabId: string;
recentlyClosedTabs: RecentlyClosedTabState[];
}>
) => {
state.tabs.byId = [...action.payload.recentlyClosedTabs, ...action.payload.allTabs].reduce<
Record<string, TabState | RecentlyClosedTabState>
>(
(acc, tab) => ({
...acc,
[tab.id]: tab,
@ -103,6 +125,7 @@ export const internalStateSlice = createSlice({
);
state.tabs.allIds = action.payload.allTabs.map((tab) => tab.id);
state.tabs.unsafeCurrentId = action.payload.selectedTabId;
state.tabs.recentlyClosedTabIds = action.payload.recentlyClosedTabs.map((tab) => tab.id);
},
setDataViewId: (state, action: TabAction<{ dataViewId: string | undefined }>) =>
@ -142,6 +165,17 @@ export const internalStateSlice = createSlice({
tab.dataRequestParams = action.payload.dataRequestParams;
}),
setTabAppStateAndGlobalState: (
state,
action: TabAction<{
appState: DiscoverAppState | undefined;
globalState: TabState['lastPersistedGlobalState'] | undefined;
}>
) =>
withTab(state, action, (tab) => {
tab.lastPersistedGlobalState = action.payload.globalState || {};
}),
setOverriddenVisContextAfterInvalidation: (
state,
action: TabAction<{
@ -193,38 +227,71 @@ export const internalStateSlice = createSlice({
},
});
export interface InternalStateThunkDependencies {
const createMiddleware = ({
tabsStorageManager,
runtimeStateManager,
}: {
tabsStorageManager: TabsStorageManager;
runtimeStateManager: RuntimeStateManager;
}) => {
const listenerMiddleware = createListenerMiddleware();
listenerMiddleware.startListening({
actionCreator: internalStateSlice.actions.setTabs,
effect: throttle(
(action) => {
const getTabAppState = (tabId: string) =>
selectTabRuntimeAppState(runtimeStateManager, tabId);
void tabsStorageManager.persistLocally(action.payload, getTabAppState);
},
MIDDLEWARE_THROTTLE_MS,
MIDDLEWARE_THROTTLE_OPTIONS
),
});
listenerMiddleware.startListening({
actionCreator: internalStateSlice.actions.setTabAppStateAndGlobalState,
effect: throttle(
(action) => {
tabsStorageManager.updateTabStateLocally(action.payload.tabId, action.payload);
},
MIDDLEWARE_THROTTLE_MS,
MIDDLEWARE_THROTTLE_OPTIONS
),
});
return listenerMiddleware;
};
export interface InternalStateDependencies {
services: DiscoverServices;
customizationContext: DiscoverCustomizationContext;
runtimeStateManager: RuntimeStateManager;
urlStateStorage: IKbnUrlStateStorage;
tabsStorageManager: TabsStorageManager;
}
const IS_JEST_ENVIRONMENT = typeof jest !== 'undefined';
export const createInternalStateStore = (options: InternalStateThunkDependencies) => {
const store = configureStore({
export const createInternalStateStore = (options: InternalStateDependencies) => {
return configureStore({
reducer: internalStateSlice.reducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
thunk: { extraArgument: options },
serializableCheck: !IS_JEST_ENVIRONMENT,
}),
}).prepend(createMiddleware(options).middleware),
});
// TEMPORARY: Create initial default tab
const defaultTab: TabState = {
...defaultTabState,
...createTabItem(selectAllTabs(store.getState())),
};
store.dispatch(setTabs({ allTabs: [defaultTab], selectedTabId: defaultTab.id }));
return store;
};
export type InternalStateStore = ReturnType<typeof createInternalStateStore>;
export type InternalStateDispatch = InternalStateStore['dispatch'];
export type InternalStateDispatch = ThunkDispatch<
DiscoverInternalState,
InternalStateDependencies,
AnyAction
> &
Dispatch<AnyAction>;
type InternalStateThunkAction<TReturn = void> = ThunkAction<
TReturn,

View file

@ -15,6 +15,7 @@ import type { UnifiedHistogramPartialLayoutProps } from '@kbn/unified-histogram'
import { useCurrentTabContext } from './hooks';
import type { DiscoverStateContainer } from '../discover_state';
import type { ConnectedCustomizationService } from '../../../../customizations';
import type { TabState } from './types';
interface DiscoverRuntimeState {
adHocDataViews: DataView[];
@ -59,6 +60,33 @@ export const useRuntimeState = <T,>(stateSubject$: BehaviorSubject<T>) =>
export const selectTabRuntimeState = (runtimeStateManager: RuntimeStateManager, tabId: string) =>
runtimeStateManager.tabs.byId[tabId];
export const selectTabRuntimeAppState = (
runtimeStateManager: RuntimeStateManager,
tabId: string
) => {
const tabRuntimeState = selectTabRuntimeState(runtimeStateManager, tabId);
return tabRuntimeState?.stateContainer$.getValue()?.appState?.getState();
};
export const selectTabRuntimeGlobalState = (
runtimeStateManager: RuntimeStateManager,
tabId: string
): TabState['lastPersistedGlobalState'] | undefined => {
const tabRuntimeState = selectTabRuntimeState(runtimeStateManager, tabId);
const globalState = tabRuntimeState?.stateContainer$.getValue()?.globalState?.get();
if (!globalState) {
return undefined;
}
const { time: timeRange, refreshInterval, filters } = globalState;
return {
timeRange,
refreshInterval,
filters,
};
};
export const useCurrentTabRuntimeState = <T,>(
runtimeStateManager: RuntimeStateManager,
selector: (tab: ReactiveTabRuntimeState) => BehaviorSubject<T>

View file

@ -8,7 +8,7 @@
*/
import { createSelector } from '@reduxjs/toolkit';
import type { DiscoverInternalState } from './types';
import type { DiscoverInternalState, RecentlyClosedTabState } from './types';
export const selectTab = (state: DiscoverInternalState, tabId: string) => state.tabs.byId[tabId];
@ -19,3 +19,14 @@ export const selectAllTabs = createSelector(
],
(allIds, byId) => allIds.map((id) => byId[id])
);
export const selectRecentlyClosedTabs = createSelector(
[
(state: DiscoverInternalState) => state.tabs.recentlyClosedTabIds,
(state: DiscoverInternalState) => state.tabs.byId,
],
(recentlyClosedTabIds, byId) =>
recentlyClosedTabIds
.map((id) => byId[id])
.filter((tab) => tab && 'closedAt' in tab) as RecentlyClosedTabState[]
);

View file

@ -40,9 +40,9 @@ export type TotalHitsRequest = RequestState<number>;
export type ChartRequest = RequestState<{}>;
export interface InternalStateDataRequestParams {
timeRangeAbsolute?: TimeRange;
timeRangeRelative?: TimeRange;
searchSessionId?: string;
timeRangeAbsolute: TimeRange | undefined;
timeRangeRelative: TimeRange | undefined;
searchSessionId: string | undefined;
}
export interface TabState extends TabItem {
@ -66,6 +66,10 @@ export interface TabState extends TabItem {
chartRequest: ChartRequest;
}
export interface RecentlyClosedTabState extends TabState {
closedAt: number;
}
export interface DiscoverInternalState {
initializationState: { hasESData: boolean; hasUserDataView: boolean };
savedDataViews: DataViewListItem[];
@ -74,8 +78,9 @@ export interface DiscoverInternalState {
initialDocViewerTabId?: string;
isESQLToDataViewTransitionModalVisible: boolean;
tabs: {
byId: Record<string, TabState>;
byId: Record<string, TabState | RecentlyClosedTabState>;
allIds: string[];
recentlyClosedTabIds: string[];
/**
* WARNING: You probably don't want to use this property.
* This is used high in the component tree for managing tabs,

View file

@ -14,7 +14,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
import type { DiscoverInternalState, TabState } from './types';
import type {
InternalStateDispatch,
InternalStateThunkDependencies,
InternalStateDependencies,
TabActionPayload,
} from './internal_state';
@ -24,7 +24,7 @@ type CreateInternalStateAsyncThunk = ReturnType<
typeof createAsyncThunk.withTypes<{
state: DiscoverInternalState;
dispatch: InternalStateDispatch;
extra: InternalStateThunkDependencies;
extra: InternalStateDependencies;
}>
>;

View file

@ -0,0 +1,463 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { omit } from 'lodash';
import { createKbnUrlStateStorage, Storage } from '@kbn/kibana-utils-plugin/public';
import { createDiscoverServicesMock } from '../../../__mocks__/services';
import {
createTabsStorageManager,
TABS_LOCAL_STORAGE_KEY,
type TabsInternalStatePayload,
} from './tabs_storage_manager';
import { defaultTabState } from './redux/internal_state';
import type { RecentlyClosedTabState, TabState } from './redux/types';
import { TABS_STATE_URL_KEY } from '../../../../common/constants';
const mockUserId = 'testUserId';
const mockSpaceId = 'testSpaceId';
const mockGetAppState = (tabId: string) => {
if (tabId === 'tab1') {
return {
columns: ['a', 'b'],
};
}
if (tabId === 'tab2') {
return {
columns: ['c', 'd'],
};
}
if (tabId.startsWith('closedTab')) {
return {
columns: ['e', 'f'],
};
}
};
const mockTab1: TabState = {
...defaultTabState,
id: 'tab1',
label: 'Tab 1',
lastPersistedGlobalState: {
timeRange: { from: '2025-04-16T14:07:55.127Z', to: '2025-04-16T14:12:55.127Z' },
filters: [],
refreshInterval: { pause: true, value: 1000 },
},
};
const mockTab2: TabState = {
...defaultTabState,
id: 'tab2',
label: 'Tab 2',
lastPersistedGlobalState: {
timeRange: { from: '2025-04-17T03:07:55.127Z', to: '2025-04-17T03:12:55.127Z' },
filters: [],
refreshInterval: { pause: true, value: 1000 },
},
};
const mockRecentlyClosedTab: RecentlyClosedTabState = {
...defaultTabState,
id: 'closedTab1',
label: 'Closed tab 1',
lastPersistedGlobalState: {
timeRange: { from: '2025-04-07T03:07:55.127Z', to: '2025-04-07T03:12:55.127Z' },
filters: [],
refreshInterval: { pause: true, value: 1000 },
},
closedAt: Date.now(),
};
const mockRecentlyClosedTab2: RecentlyClosedTabState = {
...mockRecentlyClosedTab,
id: 'closedTab2',
label: 'Closed tab 2',
};
const mockRecentlyClosedTab3: RecentlyClosedTabState = {
...mockRecentlyClosedTab,
id: 'closedTab3',
label: 'Closed tab 3 with another closedAt',
closedAt: Date.now() + 1000,
};
describe('TabsStorageManager', () => {
const create = () => {
const urlStateStorage = createKbnUrlStateStorage();
const services = createDiscoverServicesMock();
services.storage = new Storage(localStorage);
return {
services,
urlStateStorage,
tabsStorageManager: createTabsStorageManager({
urlStateStorage,
storage: services.storage,
enabled: true,
}),
};
};
const toStoredTab = (tab: TabState | RecentlyClosedTabState) => ({
id: tab.id,
label: tab.label,
appState: mockGetAppState(tab.id),
globalState: tab.lastPersistedGlobalState,
...('closedAt' in tab ? { closedAt: tab.closedAt } : {}),
});
it('should persist tabs state to local storage and push to URL', async () => {
const {
services: { storage },
tabsStorageManager,
urlStateStorage,
} = create();
tabsStorageManager.loadLocally({
userId: mockUserId, // register userId and spaceId in tabsStorageManager
spaceId: mockSpaceId,
defaultTabState,
});
jest.spyOn(urlStateStorage, 'set');
jest.spyOn(storage, 'set');
const props: TabsInternalStatePayload = {
allTabs: [mockTab1, mockTab2],
selectedTabId: 'tab1',
recentlyClosedTabs: [mockRecentlyClosedTab],
};
await tabsStorageManager.persistLocally(props, mockGetAppState);
expect(urlStateStorage.set).toHaveBeenCalledWith(TABS_STATE_URL_KEY, { tabId: 'tab1' });
expect(storage.set).toHaveBeenCalledWith(TABS_LOCAL_STORAGE_KEY, {
userId: mockUserId,
spaceId: mockSpaceId,
openTabs: [toStoredTab(mockTab1), toStoredTab(mockTab2)],
closedTabs: [toStoredTab(mockRecentlyClosedTab)],
});
expect(tabsStorageManager.loadTabAppStateFromLocalCache(mockTab1.id)).toEqual(
mockGetAppState(mockTab1.id)
);
expect(tabsStorageManager.loadTabGlobalStateFromLocalCache(mockTab1.id)).toEqual(
mockTab1.lastPersistedGlobalState
);
expect(tabsStorageManager.loadTabAppStateFromLocalCache(mockRecentlyClosedTab.id)).toEqual(
mockGetAppState(mockRecentlyClosedTab.id)
);
expect(tabsStorageManager.loadTabGlobalStateFromLocalCache(mockRecentlyClosedTab.id)).toEqual(
mockRecentlyClosedTab.lastPersistedGlobalState
);
});
it('should load tabs state from local storage and select one of open tabs', () => {
const {
tabsStorageManager,
urlStateStorage,
services: { storage },
} = create();
jest.spyOn(urlStateStorage, 'get');
jest.spyOn(storage, 'get');
const props: TabsInternalStatePayload = {
allTabs: [mockTab1, mockTab2],
selectedTabId: 'tab2',
recentlyClosedTabs: [mockRecentlyClosedTab],
};
storage.set(TABS_LOCAL_STORAGE_KEY, {
userId: mockUserId,
spaceId: mockSpaceId,
openTabs: [toStoredTab(mockTab1), toStoredTab(mockTab2)],
closedTabs: [toStoredTab(mockRecentlyClosedTab)],
});
urlStateStorage.set(TABS_STATE_URL_KEY, {
tabId: props.selectedTabId,
});
jest.spyOn(urlStateStorage, 'set');
jest.spyOn(storage, 'set');
const loadedProps = tabsStorageManager.loadLocally({
userId: mockUserId,
spaceId: mockSpaceId,
defaultTabState,
});
expect(loadedProps).toEqual(props);
expect(urlStateStorage.get).toHaveBeenCalledWith(TABS_STATE_URL_KEY);
expect(storage.get).toHaveBeenCalledWith(TABS_LOCAL_STORAGE_KEY);
expect(urlStateStorage.set).not.toHaveBeenCalled();
expect(storage.set).not.toHaveBeenCalled();
});
it('should load tabs state from local storage and select one of closed tabs', () => {
const {
tabsStorageManager,
urlStateStorage,
services: { storage },
} = create();
jest.spyOn(urlStateStorage, 'get');
jest.spyOn(storage, 'get');
const newClosedAt = Date.now() + 1000;
jest.spyOn(Date, 'now').mockReturnValue(newClosedAt);
const props: TabsInternalStatePayload = {
allTabs: [omit(mockRecentlyClosedTab, 'closedAt'), omit(mockRecentlyClosedTab2, 'closedAt')],
selectedTabId: mockRecentlyClosedTab2.id,
recentlyClosedTabs: [
{ ...mockTab1, closedAt: newClosedAt },
{ ...mockTab2, closedAt: newClosedAt },
mockRecentlyClosedTab3,
mockRecentlyClosedTab,
mockRecentlyClosedTab2,
],
};
storage.set(TABS_LOCAL_STORAGE_KEY, {
userId: mockUserId,
spaceId: mockSpaceId,
openTabs: [toStoredTab(mockTab1), toStoredTab(mockTab2)],
closedTabs: [
toStoredTab(mockRecentlyClosedTab),
toStoredTab(mockRecentlyClosedTab2),
toStoredTab(mockRecentlyClosedTab3),
],
});
urlStateStorage.set(TABS_STATE_URL_KEY, {
tabId: mockRecentlyClosedTab2.id,
});
jest.spyOn(urlStateStorage, 'set');
jest.spyOn(storage, 'set');
const loadedProps = tabsStorageManager.loadLocally({
userId: mockUserId,
spaceId: mockSpaceId,
defaultTabState,
});
expect(loadedProps).toEqual(props);
expect(urlStateStorage.get).toHaveBeenCalledWith(TABS_STATE_URL_KEY);
expect(storage.get).toHaveBeenCalledWith(TABS_LOCAL_STORAGE_KEY);
expect(urlStateStorage.set).not.toHaveBeenCalled();
expect(storage.set).not.toHaveBeenCalled();
});
it('should initialize with a default state if user id changes', () => {
const {
tabsStorageManager,
urlStateStorage,
services: { storage },
} = create();
jest.spyOn(urlStateStorage, 'get');
jest.spyOn(storage, 'get');
const props: TabsInternalStatePayload = {
allTabs: [mockTab1, mockTab2],
selectedTabId: 'tab2',
recentlyClosedTabs: [mockRecentlyClosedTab],
};
storage.set(TABS_LOCAL_STORAGE_KEY, {
userId: mockUserId,
spaceId: mockSpaceId,
openTabs: [toStoredTab(mockTab1), toStoredTab(mockTab2)],
closedTabs: [toStoredTab(mockRecentlyClosedTab)],
});
urlStateStorage.set(TABS_STATE_URL_KEY, {
tabId: props.selectedTabId,
});
jest.spyOn(urlStateStorage, 'set');
jest.spyOn(storage, 'set');
const loadedProps = tabsStorageManager.loadLocally({
userId: 'different',
spaceId: mockSpaceId,
defaultTabState,
});
expect(loadedProps.recentlyClosedTabs).toHaveLength(0);
expect(loadedProps.allTabs).toHaveLength(1);
expect(loadedProps.allTabs[0]).toEqual(
expect.objectContaining({
label: 'Untitled session',
})
);
expect(loadedProps.selectedTabId).toBe(loadedProps.allTabs[0].id);
expect(urlStateStorage.get).toHaveBeenCalledWith(TABS_STATE_URL_KEY);
expect(storage.get).toHaveBeenCalledWith(TABS_LOCAL_STORAGE_KEY);
expect(urlStateStorage.set).not.toHaveBeenCalled();
expect(storage.set).not.toHaveBeenCalled();
});
it('should initialize with a default single tab', () => {
const {
tabsStorageManager,
urlStateStorage,
services: { storage },
} = create();
jest.spyOn(urlStateStorage, 'get');
jest.spyOn(storage, 'get');
const newClosedAt = Date.now() + 1000;
jest.spyOn(Date, 'now').mockReturnValue(newClosedAt);
storage.set(TABS_LOCAL_STORAGE_KEY, {
userId: mockUserId,
spaceId: mockSpaceId,
openTabs: [toStoredTab(mockTab1), toStoredTab(mockTab2)],
closedTabs: [toStoredTab(mockRecentlyClosedTab)],
});
urlStateStorage.set(TABS_STATE_URL_KEY, null);
jest.spyOn(urlStateStorage, 'set');
jest.spyOn(storage, 'set');
const loadedProps = tabsStorageManager.loadLocally({
userId: mockUserId,
spaceId: mockSpaceId,
defaultTabState,
});
expect(loadedProps).toEqual(
expect.objectContaining({
recentlyClosedTabs: [
{ ...mockTab1, closedAt: newClosedAt },
{ ...mockTab2, closedAt: newClosedAt },
mockRecentlyClosedTab,
],
})
);
expect(loadedProps.allTabs).toHaveLength(1);
expect(loadedProps.allTabs[0]).toEqual(
expect.objectContaining({
label: 'Untitled session',
})
);
expect(loadedProps.selectedTabId).toBe(loadedProps.allTabs[0].id);
expect(urlStateStorage.get).toHaveBeenCalledWith(TABS_STATE_URL_KEY);
expect(storage.get).toHaveBeenCalledWith(TABS_LOCAL_STORAGE_KEY);
expect(urlStateStorage.set).not.toHaveBeenCalled();
expect(storage.set).not.toHaveBeenCalled();
});
it('should update tab state in local storage', () => {
const { tabsStorageManager, services } = create();
const storage = services.storage;
storage.set(TABS_LOCAL_STORAGE_KEY, {
userId: mockUserId,
spaceId: mockSpaceId,
openTabs: [toStoredTab(mockTab1), toStoredTab(mockTab2)],
closedTabs: [toStoredTab(mockRecentlyClosedTab)],
});
jest.spyOn(storage, 'set');
const updatedTabState = {
appState: {
columns: ['a', 'b', 'c'],
},
globalState: {
refreshInterval: { pause: false, value: 300 },
},
};
tabsStorageManager.updateTabStateLocally(mockTab1.id, updatedTabState);
expect(storage.set).toHaveBeenCalledWith(TABS_LOCAL_STORAGE_KEY, {
userId: mockUserId,
spaceId: mockSpaceId,
openTabs: [
{
...toStoredTab(mockTab1),
...updatedTabState,
},
toStoredTab(mockTab2),
],
closedTabs: [toStoredTab(mockRecentlyClosedTab)],
});
expect(tabsStorageManager.loadTabAppStateFromLocalCache(mockTab1.id)).toEqual(
updatedTabState.appState
);
expect(tabsStorageManager.loadTabGlobalStateFromLocalCache(mockTab1.id)).toEqual(
updatedTabState.globalState
);
});
it('should limit to N recently closed tabs', () => {
const { tabsStorageManager } = create();
const newClosedAt = 15;
jest.spyOn(Date, 'now').mockReturnValue(newClosedAt);
// no previously closed tabs
expect(tabsStorageManager.getNRecentlyClosedTabs([], [mockTab1])).toEqual([
{ ...mockTab1, closedAt: newClosedAt },
]);
// some previously closed tabs
expect(
tabsStorageManager.getNRecentlyClosedTabs(
[
{ ...mockTab2, closedAt: 1 },
{ ...mockRecentlyClosedTab, closedAt: 100 },
],
[mockTab1]
)
).toEqual([
{ ...mockRecentlyClosedTab, closedAt: 100 },
{ ...mockTab1, closedAt: newClosedAt },
{ ...mockTab2, closedAt: 1 },
]);
// over the limit
const closedAtGroup1 = 40;
const closedTabsGroup1 = Array.from({ length: 40 }, (_, i) => ({
...mockRecentlyClosedTab,
id: `closedTab (1) ${i}`,
label: `Closed tab (1) ${i}`,
closedAt: closedAtGroup1,
}));
const closedAtGroup2 = 10;
const closedTabsGroup2 = Array.from({ length: 10 }, (_, i) => ({
...mockRecentlyClosedTab,
id: `closedTab (2) ${i}`,
label: `Closed tab (2) ${i}`,
closedAt: closedAtGroup2,
}));
const newClosedTabs = Array.from({ length: 15 }, (_, i) => ({
...mockTab1,
id: `closedTab (new) ${i}`,
label: `Closed tab (new) ${i}`,
}));
expect(
tabsStorageManager.getNRecentlyClosedTabs(
[...closedTabsGroup1, ...closedTabsGroup2],
newClosedTabs
)
).toEqual([
...closedTabsGroup1,
...newClosedTabs.map((tab) => ({ ...tab, closedAt: newClosedAt })),
]);
});
});

View file

@ -0,0 +1,429 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { orderBy, pick } from 'lodash';
import {
createStateContainer,
type IKbnUrlStateStorage,
syncState,
} from '@kbn/kibana-utils-plugin/public';
import type { TabItem } from '@kbn/unified-tabs';
import type { Storage } from '@kbn/kibana-utils-plugin/public';
import { TABS_STATE_URL_KEY } from '../../../../common/constants';
import type { TabState, RecentlyClosedTabState } from './redux/types';
import { createTabItem } from './redux/utils';
import type { DiscoverAppState } from './discover_app_state_container';
export const TABS_LOCAL_STORAGE_KEY = 'discover.tabs';
export const RECENTLY_CLOSED_TABS_LIMIT = 50;
type TabStateInLocalStorage = Pick<TabState, 'id' | 'label'> & {
appState: DiscoverAppState | undefined;
globalState: TabState['lastPersistedGlobalState'] | undefined;
};
type RecentlyClosedTabStateInLocalStorage = TabStateInLocalStorage &
Pick<RecentlyClosedTabState, 'closedAt'>;
interface TabsStateInLocalStorage {
userId: string;
spaceId: string;
openTabs: TabStateInLocalStorage[];
closedTabs: RecentlyClosedTabStateInLocalStorage[];
}
const defaultTabsStateInLocalStorage: TabsStateInLocalStorage = {
userId: '',
spaceId: '',
openTabs: [],
closedTabs: [],
};
export interface TabsInternalStatePayload {
allTabs: TabState[];
selectedTabId: string;
recentlyClosedTabs: RecentlyClosedTabState[];
}
export interface TabsUrlState {
tabId?: string; // syncing the selected tab id with the URL
}
type TabsInStorageCache = Map<
string,
TabStateInLocalStorage | RecentlyClosedTabStateInLocalStorage
>;
export interface TabsStorageManager {
/**
* Supports two-way sync of the selected tab id with the URL.
*/
startUrlSync: (props: { onChanged?: (nextState: TabsUrlState) => void }) => () => void;
persistLocally: (
props: TabsInternalStatePayload,
getAppState: (tabId: string) => DiscoverAppState | undefined
) => Promise<void>;
updateTabStateLocally: (
tabId: string,
tabState: Pick<TabStateInLocalStorage, 'appState' | 'globalState'>
) => void;
loadLocally: (props: {
userId: string;
spaceId: string;
defaultTabState: Omit<TabState, keyof TabItem>;
}) => TabsInternalStatePayload;
loadTabAppStateFromLocalCache: (tabId: string) => TabStateInLocalStorage['appState'];
loadTabGlobalStateFromLocalCache: (tabId: string) => TabStateInLocalStorage['globalState'];
getNRecentlyClosedTabs: (
previousRecentlyClosedTabs: RecentlyClosedTabState[],
newClosedTabs: TabState[]
) => RecentlyClosedTabState[];
}
export const createTabsStorageManager = ({
urlStateStorage,
storage,
enabled,
}: {
urlStateStorage: IKbnUrlStateStorage;
storage: Storage;
enabled?: boolean;
}): TabsStorageManager => {
const urlStateContainer = createStateContainer<TabsUrlState>({});
const sessionInfo = { userId: '', spaceId: '' };
const tabsInStorageCache: TabsInStorageCache = new Map();
const startUrlSync: TabsStorageManager['startUrlSync'] = ({
onChanged, // can be called when selectedTabId changes in URL to trigger app state change if needed
}) => {
if (!enabled) {
return () => {
// do nothing
};
}
const { start, stop } = syncState({
stateStorage: urlStateStorage,
stateContainer: {
...urlStateContainer,
set: (state) => {
if (state) {
// syncState utils requires to handle incoming "null" value
urlStateContainer.set(state);
}
},
},
storageKey: TABS_STATE_URL_KEY,
});
const listener = onChanged
? urlStateContainer.state$.subscribe((state) => {
onChanged(state);
})
: null;
start();
return () => {
listener?.unsubscribe();
stop();
};
};
const getSelectedTabIdFromURL = () => {
return (urlStateStorage.get(TABS_STATE_URL_KEY) as TabsUrlState)?.tabId;
};
const pushSelectedTabIdToUrl = async (selectedTabId: string) => {
const nextState: TabsUrlState = {
tabId: selectedTabId,
};
await urlStateStorage.set(TABS_STATE_URL_KEY, nextState);
};
const toTabStateInStorage = (
tabState: Pick<TabState, 'id' | 'label' | 'lastPersistedGlobalState'>,
getAppState: (tabId: string) => DiscoverAppState | undefined
): TabStateInLocalStorage => {
const getAppStateForTabWithoutRuntimeState = (tabId: string) =>
getAppState(tabId) || tabsInStorageCache.get(tabId)?.appState;
return {
id: tabState.id,
label: tabState.label,
appState: getAppStateForTabWithoutRuntimeState(tabState.id),
globalState: tabState.lastPersistedGlobalState,
};
};
const toRecentlyClosedTabStateInStorage = (
tabState: RecentlyClosedTabState,
getAppState: (tabId: string) => DiscoverAppState | undefined
): RecentlyClosedTabStateInLocalStorage => {
const state = toTabStateInStorage(tabState, getAppState);
return {
...state,
closedAt: tabState.closedAt,
};
};
const toTabState = (
tabStateInStorage: TabStateInLocalStorage,
defaultTabState: Omit<TabState, keyof TabItem>
): TabState => ({
...defaultTabState,
...pick(tabStateInStorage, 'id', 'label'),
lastPersistedGlobalState:
tabStateInStorage.globalState || defaultTabState.lastPersistedGlobalState,
});
const toRecentlyClosedTabState = (
tabStateInStorage: RecentlyClosedTabStateInLocalStorage,
defaultTabState: Omit<TabState, keyof TabItem>
): RecentlyClosedTabState => ({
...toTabState(tabStateInStorage, defaultTabState),
closedAt: tabStateInStorage.closedAt,
});
const readFromLocalStorage = (): TabsStateInLocalStorage => {
const storedTabsState: TabsStateInLocalStorage | undefined =
storage.get(TABS_LOCAL_STORAGE_KEY);
return {
userId: storedTabsState?.userId || '',
spaceId: storedTabsState?.spaceId || '',
openTabs: storedTabsState?.openTabs || [],
closedTabs: storedTabsState?.closedTabs || [],
};
};
const getNRecentlyClosedTabs: TabsStorageManager['getNRecentlyClosedTabs'] = (
previousRecentlyClosedTabs,
newClosedTabs
) => {
const closedAt = Date.now();
const newRecentlyClosedTabs: RecentlyClosedTabState[] = newClosedTabs.map((tab) => ({
...tab,
closedAt,
}));
const newSortedRecentlyClosedTabs = orderBy(
[...newRecentlyClosedTabs, ...previousRecentlyClosedTabs],
'closedAt',
'desc'
);
const latestNRecentlyClosedTabs = newSortedRecentlyClosedTabs.slice(
0,
RECENTLY_CLOSED_TABS_LIMIT
);
const recentClosedAt =
latestNRecentlyClosedTabs[latestNRecentlyClosedTabs.length - 1]?.closedAt;
if (recentClosedAt) {
// keep other recently closed tabs from the same time point when they were closed
for (let i = RECENTLY_CLOSED_TABS_LIMIT; i < newSortedRecentlyClosedTabs.length; i++) {
if (newSortedRecentlyClosedTabs[i].closedAt === recentClosedAt) {
latestNRecentlyClosedTabs.push(newSortedRecentlyClosedTabs[i]);
} else {
break;
}
}
}
return latestNRecentlyClosedTabs;
};
const persistLocally: TabsStorageManager['persistLocally'] = async (
{ allTabs, selectedTabId, recentlyClosedTabs },
getAppState
) => {
if (!enabled) {
return;
}
await pushSelectedTabIdToUrl(selectedTabId);
const keptTabIds: Record<string, boolean> = {};
const openTabs: TabsStateInLocalStorage['openTabs'] = allTabs.map((tab) => {
const tabStateInStorage = toTabStateInStorage(tab, getAppState);
keptTabIds[tab.id] = true;
tabsInStorageCache.set(tab.id, tabStateInStorage);
return tabStateInStorage;
});
const closedTabs: TabsStateInLocalStorage['closedTabs'] = recentlyClosedTabs.map((tab) => {
const tabStateInStorage = toRecentlyClosedTabStateInStorage(tab, getAppState);
keptTabIds[tab.id] = true;
tabsInStorageCache.set(tab.id, tabStateInStorage);
return tabStateInStorage;
});
for (const tabId of tabsInStorageCache.keys()) {
if (!keptTabIds[tabId]) {
tabsInStorageCache.delete(tabId);
}
}
const nextTabsInStorage: TabsStateInLocalStorage = {
userId: sessionInfo.userId,
spaceId: sessionInfo.spaceId,
openTabs,
closedTabs, // wil be used for "Recently closed tabs" feature
};
storage.set(TABS_LOCAL_STORAGE_KEY, nextTabsInStorage);
};
const updateTabStateLocally: TabsStorageManager['updateTabStateLocally'] = (
tabId,
tabStatePartial
) => {
if (!enabled) {
return;
}
let hasModifications = false;
const storedTabsState = readFromLocalStorage();
const updatedTabsState = {
...storedTabsState,
openTabs: storedTabsState.openTabs.map((tab) => {
if (tab.id === tabId) {
hasModifications = true;
const newTabStateInStorage = {
...tab,
appState: tabStatePartial.appState,
globalState: tabStatePartial.globalState,
};
tabsInStorageCache.set(tabId, newTabStateInStorage);
return newTabStateInStorage;
}
return tab;
}),
};
if (hasModifications) {
storage.set(TABS_LOCAL_STORAGE_KEY, updatedTabsState);
}
};
const loadTabAppStateFromLocalCache: TabsStorageManager['loadTabAppStateFromLocalCache'] = (
tabId
) => {
if (!enabled) {
return undefined;
}
const appState = tabsInStorageCache.get(tabId)?.appState;
if (!appState || !Object.values(appState).filter(Boolean).length) {
return undefined;
}
return appState;
};
const loadTabGlobalStateFromLocalCache: TabsStorageManager['loadTabGlobalStateFromLocalCache'] = (
tabId
) => {
if (!enabled) {
return undefined;
}
const globalState = tabsInStorageCache.get(tabId)?.globalState;
if (!globalState || !Object.values(globalState).filter(Boolean).length) {
return undefined;
}
return globalState;
};
const loadLocally: TabsStorageManager['loadLocally'] = ({ userId, spaceId, defaultTabState }) => {
const selectedTabId = enabled ? getSelectedTabIdFromURL() : undefined;
let storedTabsState: TabsStateInLocalStorage = enabled
? readFromLocalStorage()
: defaultTabsStateInLocalStorage;
if (storedTabsState.userId !== userId || storedTabsState.spaceId !== spaceId) {
// if the userId or spaceId has changed, don't read from the local storage
storedTabsState = {
...defaultTabsStateInLocalStorage,
userId,
spaceId,
};
}
sessionInfo.userId = userId;
sessionInfo.spaceId = spaceId;
storedTabsState.openTabs.forEach((tab) => {
tabsInStorageCache.set(tab.id, tab);
});
storedTabsState.closedTabs.forEach((tab) => {
tabsInStorageCache.set(tab.id, tab);
});
const openTabs = storedTabsState.openTabs.map((tab) => toTabState(tab, defaultTabState));
const closedTabs = storedTabsState.closedTabs.map((tab) =>
toRecentlyClosedTabState(tab, defaultTabState)
);
if (enabled) {
if (selectedTabId) {
// restore previously opened tabs
if (openTabs.find((tab) => tab.id === selectedTabId)) {
return {
allTabs: openTabs,
selectedTabId,
recentlyClosedTabs: closedTabs,
};
}
const storedClosedTab = storedTabsState.closedTabs.find((tab) => tab.id === selectedTabId);
if (storedClosedTab) {
// restore previously closed tabs, for example when only the default tab was shown
return {
allTabs: storedTabsState.closedTabs
.filter((tab) => tab.closedAt === storedClosedTab.closedAt)
.map((tab) => toTabState(tab, defaultTabState)),
selectedTabId,
recentlyClosedTabs: getNRecentlyClosedTabs(closedTabs, openTabs),
};
}
}
}
const defaultTab: TabState = {
...defaultTabState,
...createTabItem([]),
};
tabsInStorageCache.set(
defaultTab.id,
toTabStateInStorage(defaultTab, () => undefined)
);
return {
allTabs: [defaultTab],
selectedTabId: defaultTab.id,
recentlyClosedTabs: getNRecentlyClosedTabs(closedTabs, openTabs),
};
};
return {
startUrlSync,
persistLocally,
updateTabStateLocally,
loadLocally,
loadTabAppStateFromLocalCache,
loadTabGlobalStateFromLocalCache,
getNRecentlyClosedTabs,
};
};

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { DiscoverServices } from '../../../build_services';
export const getUserAndSpaceIds = async (services: DiscoverServices) => {
let userId = '';
let spaceId = '';
try {
userId = (await services.core.security?.authc.getCurrentUser()).profile_uid ?? '';
} catch {
// ignore as user id might be unavailable for some deployments
}
try {
spaceId = (await services.spaces?.getActiveSpace())?.id ?? '';
} catch {
// ignore
}
return { userId, spaceId };
};