mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 10:40:07 -04:00
[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 statebcba741abc/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 inbcba741abc/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:
parent
9d94d8facc
commit
c348586e58
38 changed files with 1656 additions and 230 deletions
|
@ -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 (
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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],
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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) => (
|
||||
<>
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -381,6 +381,7 @@ describe('useDiscoverHistogram', () => {
|
|||
dataRequestParams: {
|
||||
timeRangeAbsolute: timeRangeAbs,
|
||||
timeRangeRelative: timeRangeRel,
|
||||
searchSessionId: '123',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
|
|
@ -64,6 +64,7 @@ async function mountComponent(
|
|||
from: '2020-05-14T11:05:13.590',
|
||||
to: '2020-05-14T11:20:13.590',
|
||||
},
|
||||
searchSessionId: 'test',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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 => ({
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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[]
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}>
|
||||
>;
|
||||
|
||||
|
|
|
@ -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 })),
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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 };
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue