mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51: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 { DataView } from '@kbn/data-views-plugin/public';
|
||||||
import type { DataViewField } 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 { 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 { type TabPreviewData, TabStatus } from '@kbn/unified-tabs';
|
||||||
import { PLUGIN_ID, PLUGIN_NAME } from '../common';
|
import { PLUGIN_ID, PLUGIN_NAME } from '../common';
|
||||||
import { FieldListSidebar, FieldListSidebarProps } from './field_list_sidebar';
|
import { FieldListSidebar, FieldListSidebarProps } from './field_list_sidebar';
|
||||||
|
@ -67,9 +67,13 @@ export const UnifiedTabsExampleApp: React.FC<UnifiedTabsExampleAppProps> = ({
|
||||||
const [dataView, setDataView] = useState<DataView | null>();
|
const [dataView, setDataView] = useState<DataView | null>();
|
||||||
const [selectedFieldNames, setSelectedFieldNames] = useState<string[]>([]);
|
const [selectedFieldNames, setSelectedFieldNames] = useState<string[]>([]);
|
||||||
const { getNewTabDefaultProps } = useNewTabProps({ numberOfInitialItems: 0 });
|
const { getNewTabDefaultProps } = useNewTabProps({ numberOfInitialItems: 0 });
|
||||||
const [initialItems] = useState<TabItem[]>(() =>
|
const [{ managedItems, managedSelectedItemId }, setState] = useState<{
|
||||||
Array.from({ length: 7 }, () => getNewTabDefaultProps())
|
managedItems: UnifiedTabsProps['items'];
|
||||||
);
|
managedSelectedItemId: UnifiedTabsProps['selectedItemId'];
|
||||||
|
}>(() => ({
|
||||||
|
managedItems: Array.from({ length: 7 }, () => getNewTabDefaultProps()),
|
||||||
|
managedSelectedItemId: undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
const onAddFieldToWorkspace = useCallback(
|
const onAddFieldToWorkspace = useCallback(
|
||||||
(field: DataViewField) => {
|
(field: DataViewField) => {
|
||||||
|
@ -121,13 +125,20 @@ export const UnifiedTabsExampleApp: React.FC<UnifiedTabsExampleAppProps> = ({
|
||||||
{dataView ? (
|
{dataView ? (
|
||||||
<div className="eui-fullHeight">
|
<div className="eui-fullHeight">
|
||||||
<UnifiedTabs
|
<UnifiedTabs
|
||||||
initialItems={initialItems}
|
items={managedItems}
|
||||||
|
selectedItemId={managedSelectedItemId}
|
||||||
|
recentlyClosedItems={[]}
|
||||||
maxItemsCount={25}
|
maxItemsCount={25}
|
||||||
services={services}
|
services={services}
|
||||||
onChanged={() => {}}
|
onChanged={(updatedState) =>
|
||||||
|
setState({
|
||||||
|
managedItems: updatedState.items,
|
||||||
|
managedSelectedItemId: updatedState.selectedItem?.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
createItem={getNewTabDefaultProps}
|
createItem={getNewTabDefaultProps}
|
||||||
getPreviewData={
|
getPreviewData={() =>
|
||||||
() => TAB_CONTENT_MOCK[Math.floor(Math.random() * TAB_CONTENT_MOCK.length)] // TODO change mock to real data when ready
|
TAB_CONTENT_MOCK[Math.floor(Math.random() * TAB_CONTENT_MOCK.length)]
|
||||||
}
|
}
|
||||||
renderContent={({ label }) => {
|
renderContent={({ label }) => {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
* 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 type { Meta, StoryFn, StoryObj } from '@storybook/react';
|
||||||
import { action } from '@storybook/addon-actions';
|
import { action } from '@storybook/addon-actions';
|
||||||
import { TabbedContent, type TabbedContentProps } from '../tabbed_content';
|
import { TabbedContent, type TabbedContentProps } from '../tabbed_content';
|
||||||
|
@ -27,16 +27,33 @@ export default {
|
||||||
|
|
||||||
const TabbedContentTemplate: StoryFn<TabbedContentProps> = (args) => {
|
const TabbedContentTemplate: StoryFn<TabbedContentProps> = (args) => {
|
||||||
const { getNewTabDefaultProps } = useNewTabProps({
|
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 (
|
return (
|
||||||
<TabbedContent
|
<TabbedContent
|
||||||
{...args}
|
{...args}
|
||||||
|
items={managedItems}
|
||||||
|
selectedItemId={managedSelectedItemId}
|
||||||
|
recentlyClosedItems={[]}
|
||||||
createItem={getNewTabDefaultProps}
|
createItem={getNewTabDefaultProps}
|
||||||
getPreviewData={getPreviewDataMock}
|
getPreviewData={getPreviewDataMock}
|
||||||
services={servicesMock}
|
services={servicesMock}
|
||||||
onChanged={action('onClosed')}
|
onChanged={(updatedState) => {
|
||||||
|
action('onChanged')(updatedState);
|
||||||
|
setState({
|
||||||
|
managedItems: updatedState.items,
|
||||||
|
managedSelectedItemId: updatedState.selectedItem?.id,
|
||||||
|
});
|
||||||
|
}}
|
||||||
renderContent={(item) => (
|
renderContent={(item) => (
|
||||||
<div style={{ paddingTop: '16px' }}>Content for tab: {item.label}</div>
|
<div style={{ paddingTop: '16px' }}>Content for tab: {item.label}</div>
|
||||||
)}
|
)}
|
||||||
|
@ -48,7 +65,7 @@ export const Default: StoryObj<TabbedContentProps> = {
|
||||||
render: TabbedContentTemplate,
|
render: TabbedContentTemplate,
|
||||||
|
|
||||||
args: {
|
args: {
|
||||||
initialItems: [
|
items: [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
label: 'Tab 1',
|
label: 'Tab 1',
|
||||||
|
@ -61,7 +78,7 @@ export const WithMultipleTabs: StoryObj<TabbedContentProps> = {
|
||||||
render: TabbedContentTemplate,
|
render: TabbedContentTemplate,
|
||||||
|
|
||||||
args: {
|
args: {
|
||||||
initialItems: [
|
items: [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
label: 'Tab 1',
|
label: 'Tab 1',
|
||||||
|
@ -75,6 +92,6 @@ export const WithMultipleTabs: StoryObj<TabbedContentProps> = {
|
||||||
label: 'Tab 3',
|
label: 'Tab 3',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
initialSelectedItemId: '3',
|
selectedItemId: '3',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
* 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 { render, screen, waitFor } from '@testing-library/react';
|
||||||
import { userEvent } from '@testing-library/user-event';
|
import { userEvent } from '@testing-library/user-event';
|
||||||
import { TabbedContent, type TabbedContentProps } from './tabbed_content';
|
import { TabbedContent, type TabbedContentProps } from './tabbed_content';
|
||||||
|
@ -26,15 +26,33 @@ describe('TabbedContent', () => {
|
||||||
initialItems,
|
initialItems,
|
||||||
initialSelectedItemId,
|
initialSelectedItemId,
|
||||||
onChanged,
|
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 (
|
return (
|
||||||
<TabbedContent
|
<TabbedContent
|
||||||
initialItems={initialItems}
|
items={managedItems}
|
||||||
initialSelectedItemId={initialSelectedItemId}
|
selectedItemId={managedSelectedItemId}
|
||||||
|
recentlyClosedItems={[]}
|
||||||
createItem={() => NEW_TAB}
|
createItem={() => NEW_TAB}
|
||||||
getPreviewData={getPreviewDataMock}
|
getPreviewData={getPreviewDataMock}
|
||||||
services={servicesMock}
|
services={servicesMock}
|
||||||
onChanged={onChanged}
|
onChanged={(updatedState) => {
|
||||||
|
onChanged(updatedState);
|
||||||
|
setState({
|
||||||
|
managedItems: updatedState.items,
|
||||||
|
managedSelectedItemId: updatedState.selectedItem?.id,
|
||||||
|
});
|
||||||
|
}}
|
||||||
renderContent={(item) => (
|
renderContent={(item) => (
|
||||||
<div style={{ paddingTop: '16px' }}>Content for tab: {item.label}</div>
|
<div style={{ paddingTop: '16px' }}>Content for tab: {item.label}</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -18,6 +18,7 @@ import {
|
||||||
addTab,
|
addTab,
|
||||||
closeTab,
|
closeTab,
|
||||||
selectTab,
|
selectTab,
|
||||||
|
selectRecentlyClosedTab,
|
||||||
insertTabAfter,
|
insertTabAfter,
|
||||||
replaceTabWith,
|
replaceTabWith,
|
||||||
closeOtherTabs,
|
closeOtherTabs,
|
||||||
|
@ -25,25 +26,10 @@ import {
|
||||||
} from '../../utils/manage_tabs';
|
} from '../../utils/manage_tabs';
|
||||||
import type { TabItem, TabsServices, TabPreviewData } from '../../types';
|
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'> {
|
export interface TabbedContentProps extends Pick<TabsBarProps, 'maxItemsCount'> {
|
||||||
initialItems: TabItem[];
|
items: TabItem[];
|
||||||
initialSelectedItemId?: string;
|
selectedItemId?: string;
|
||||||
|
recentlyClosedItems: TabItem[];
|
||||||
'data-test-subj'?: string;
|
'data-test-subj'?: string;
|
||||||
services: TabsServices;
|
services: TabsServices;
|
||||||
renderContent: (selectedItem: TabItem) => React.ReactNode;
|
renderContent: (selectedItem: TabItem) => React.ReactNode;
|
||||||
|
@ -58,8 +44,9 @@ export interface TabbedContentState {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TabbedContent: React.FC<TabbedContentProps> = ({
|
export const TabbedContent: React.FC<TabbedContentProps> = ({
|
||||||
initialItems,
|
items: managedItems,
|
||||||
initialSelectedItemId,
|
selectedItemId: managedSelectedItemId,
|
||||||
|
recentlyClosedItems,
|
||||||
maxItemsCount,
|
maxItemsCount,
|
||||||
services,
|
services,
|
||||||
renderContent,
|
renderContent,
|
||||||
|
@ -69,14 +56,10 @@ export const TabbedContent: React.FC<TabbedContentProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const tabsBarApi = useRef<TabsBarApi | null>(null);
|
const tabsBarApi = useRef<TabsBarApi | null>(null);
|
||||||
const [tabContentId] = useState(() => htmlIdGenerator()());
|
const [tabContentId] = useState(() => htmlIdGenerator()());
|
||||||
const [state, _setState] = useState<TabbedContentState>(() => {
|
const state = useMemo(
|
||||||
return {
|
() => prepareStateFromProps(managedItems, managedSelectedItemId),
|
||||||
items: initialItems,
|
[managedItems, managedSelectedItemId]
|
||||||
selectedItem:
|
);
|
||||||
(initialSelectedItemId && initialItems.find((item) => item.id === initialSelectedItemId)) ||
|
|
||||||
initialItems[0],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const { items, selectedItem } = state;
|
const { items, selectedItem } = state;
|
||||||
const stateRef = React.useRef<TabbedContentState>();
|
const stateRef = React.useRef<TabbedContentState>();
|
||||||
stateRef.current = state;
|
stateRef.current = state;
|
||||||
|
@ -88,10 +71,9 @@ export const TabbedContent: React.FC<TabbedContentProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextState = getNextState(stateRef.current);
|
const nextState = getNextState(stateRef.current);
|
||||||
_setState(nextState);
|
|
||||||
onChanged(nextState);
|
onChanged(nextState);
|
||||||
},
|
},
|
||||||
[_setState, onChanged]
|
[onChanged]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onLabelEdited = useCallback(
|
const onLabelEdited = useCallback(
|
||||||
|
@ -110,6 +92,13 @@ export const TabbedContent: React.FC<TabbedContentProps> = ({
|
||||||
[changeState]
|
[changeState]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onSelectRecentlyClosed = useCallback(
|
||||||
|
async (item: TabItem) => {
|
||||||
|
changeState((prevState) => selectRecentlyClosedTab(prevState, item));
|
||||||
|
},
|
||||||
|
[changeState]
|
||||||
|
);
|
||||||
|
|
||||||
const onClose = useCallback(
|
const onClose = useCallback(
|
||||||
async (item: TabItem) => {
|
async (item: TabItem) => {
|
||||||
changeState((prevState) => {
|
changeState((prevState) => {
|
||||||
|
@ -194,7 +183,7 @@ export const TabbedContent: React.FC<TabbedContentProps> = ({
|
||||||
ref={tabsBarApi}
|
ref={tabsBarApi}
|
||||||
items={items}
|
items={items}
|
||||||
selectedItem={selectedItem}
|
selectedItem={selectedItem}
|
||||||
recentlyClosedItems={RECENTLY_CLOSED_TABS_MOCK}
|
recentlyClosedItems={recentlyClosedItems}
|
||||||
maxItemsCount={maxItemsCount}
|
maxItemsCount={maxItemsCount}
|
||||||
tabContentId={tabContentId}
|
tabContentId={tabContentId}
|
||||||
getTabMenuItems={getTabMenuItems}
|
getTabMenuItems={getTabMenuItems}
|
||||||
|
@ -202,6 +191,7 @@ export const TabbedContent: React.FC<TabbedContentProps> = ({
|
||||||
onAdd={onAdd}
|
onAdd={onAdd}
|
||||||
onLabelEdited={onLabelEdited}
|
onLabelEdited={onLabelEdited}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
|
onSelectRecentlyClosed={onSelectRecentlyClosed}
|
||||||
onReorder={onReorder}
|
onReorder={onReorder}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
getPreviewData={getPreviewData}
|
getPreviewData={getPreviewData}
|
||||||
|
@ -220,3 +210,11 @@ export const TabbedContent: React.FC<TabbedContentProps> = ({
|
||||||
</EuiFlexGroup>
|
</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 () => {
|
it('renders tabs bar', async () => {
|
||||||
const onAdd = jest.fn();
|
const onAdd = jest.fn();
|
||||||
const onSelect = jest.fn();
|
const onSelect = jest.fn();
|
||||||
|
const onSelectRecentlyClosed = jest.fn();
|
||||||
const onLabelEdited = jest.fn();
|
const onLabelEdited = jest.fn();
|
||||||
const onClose = jest.fn();
|
const onClose = jest.fn();
|
||||||
const onReorder = jest.fn();
|
const onReorder = jest.fn();
|
||||||
|
@ -53,6 +54,7 @@ describe('TabsBar', () => {
|
||||||
onAdd={onAdd}
|
onAdd={onAdd}
|
||||||
onLabelEdited={onLabelEdited}
|
onLabelEdited={onLabelEdited}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
|
onSelectRecentlyClosed={onSelectRecentlyClosed}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onReorder={onReorder}
|
onReorder={onReorder}
|
||||||
getPreviewData={getPreviewData}
|
getPreviewData={getPreviewData}
|
||||||
|
|
|
@ -36,7 +36,7 @@ import type { TabItem, TabsServices } from '../../types';
|
||||||
import { getTabIdAttribute } from '../../utils/get_tab_attributes';
|
import { getTabIdAttribute } from '../../utils/get_tab_attributes';
|
||||||
import { useResponsiveTabs } from '../../hooks/use_responsive_tabs';
|
import { useResponsiveTabs } from '../../hooks/use_responsive_tabs';
|
||||||
import { TabsBarWithBackground } from '../tabs_visual_glue_to_header/tabs_bar_with_background';
|
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';
|
const DROPPABLE_ID = 'unifiedTabsOrder';
|
||||||
|
|
||||||
|
@ -60,6 +60,7 @@ export type TabsBarProps = Pick<
|
||||||
maxItemsCount?: number;
|
maxItemsCount?: number;
|
||||||
services: TabsServices;
|
services: TabsServices;
|
||||||
onAdd: () => Promise<void>;
|
onAdd: () => Promise<void>;
|
||||||
|
onSelectRecentlyClosed: TabsBarMenuProps['onSelectRecentlyClosed'];
|
||||||
onReorder: (items: TabItem[]) => void;
|
onReorder: (items: TabItem[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -80,6 +81,7 @@ export const TabsBar = forwardRef<TabsBarApi, TabsBarProps>(
|
||||||
onAdd,
|
onAdd,
|
||||||
onLabelEdited,
|
onLabelEdited,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onSelectRecentlyClosed,
|
||||||
onReorder,
|
onReorder,
|
||||||
onClose,
|
onClose,
|
||||||
getPreviewData,
|
getPreviewData,
|
||||||
|
@ -278,10 +280,11 @@ export const TabsBar = forwardRef<TabsBarApi, TabsBarProps>(
|
||||||
</EuiFlexItem>
|
</EuiFlexItem>
|
||||||
<EuiFlexItem grow={false}>
|
<EuiFlexItem grow={false}>
|
||||||
<TabsBarMenu
|
<TabsBarMenu
|
||||||
openedItems={items}
|
items={items}
|
||||||
selectedItem={selectedItem}
|
selectedItem={selectedItem}
|
||||||
onSelectOpenedTab={onSelect}
|
|
||||||
recentlyClosedItems={recentlyClosedItems}
|
recentlyClosedItems={recentlyClosedItems}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onSelectRecentlyClosed={onSelectRecentlyClosed}
|
||||||
/>
|
/>
|
||||||
</EuiFlexItem>
|
</EuiFlexItem>
|
||||||
</EuiFlexGroup>
|
</EuiFlexGroup>
|
||||||
|
|
|
@ -7,4 +7,4 @@
|
||||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
* 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 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';
|
import { TabsBarMenu } from './tabs_bar_menu';
|
||||||
|
|
||||||
const mockTabs = [
|
const mockTabs = [
|
||||||
|
@ -26,77 +27,94 @@ const tabsBarMenuButtonTestId = 'unifiedTabs_tabsBarMenuButton';
|
||||||
|
|
||||||
describe('TabsBarMenu', () => {
|
describe('TabsBarMenu', () => {
|
||||||
const mockOnSelectOpenedTab = jest.fn();
|
const mockOnSelectOpenedTab = jest.fn();
|
||||||
|
const mockOnSelectClosedTab = jest.fn();
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
openedItems: mockTabs,
|
items: mockTabs,
|
||||||
selectedItem: mockTabs[0],
|
selectedItem: mockTabs[0],
|
||||||
onSelectOpenedTab: mockOnSelectOpenedTab,
|
|
||||||
recentlyClosedItems: mockRecentlyClosedTabs,
|
recentlyClosedItems: mockRecentlyClosedTabs,
|
||||||
|
onSelect: mockOnSelectOpenedTab,
|
||||||
|
onSelectRecentlyClosed: mockOnSelectClosedTab,
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the menu button', () => {
|
it('renders the menu button', async () => {
|
||||||
render(<TabsBarMenu {...defaultProps} />);
|
render(<TabsBarMenu {...defaultProps} />);
|
||||||
|
|
||||||
const menuButton = screen.getByTestId(tabsBarMenuButtonTestId);
|
const menuButton = await screen.findByTestId(tabsBarMenuButtonTestId);
|
||||||
expect(menuButton).toBeInTheDocument();
|
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} />);
|
render(<TabsBarMenu {...defaultProps} />);
|
||||||
|
|
||||||
const menuButton = screen.getByTestId(tabsBarMenuButtonTestId);
|
const menuButton = await screen.findByTestId(tabsBarMenuButtonTestId);
|
||||||
|
await user.click(menuButton);
|
||||||
|
|
||||||
fireEvent.click(menuButton);
|
const tabsBarMenu = await screen.findByTestId('unifiedTabs_tabsBarMenu');
|
||||||
|
|
||||||
const tabsBarMenu = screen.getByTestId('unifiedTabs_tabsBarMenu');
|
|
||||||
expect(tabsBarMenu).toBeInTheDocument();
|
expect(tabsBarMenu).toBeInTheDocument();
|
||||||
expect(screen.getByText('Opened tabs')).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} />);
|
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('Opened tabs')).toBeInTheDocument();
|
||||||
|
for (const tab of mockTabs) {
|
||||||
mockTabs.forEach((tab) => {
|
expect(await screen.findByText(tab.label)).toBeInTheDocument();
|
||||||
expect(screen.getByText(tab.label)).toBeInTheDocument();
|
}
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('selects a tab when clicked', () => {
|
it('selects a tab when clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
render(<TabsBarMenu {...defaultProps} />);
|
render(<TabsBarMenu {...defaultProps} />);
|
||||||
|
|
||||||
const menuButton = screen.getByTestId(tabsBarMenuButtonTestId);
|
const menuButton = await screen.findByTestId(tabsBarMenuButtonTestId);
|
||||||
|
await user.click(menuButton);
|
||||||
|
|
||||||
fireEvent.click(menuButton);
|
const secondTabOption = (await screen.findAllByRole('option'))[1];
|
||||||
|
await user.click(secondTabOption);
|
||||||
const secondTabOption = screen.getByText(mockTabs[1].label);
|
|
||||||
fireEvent.click(secondTabOption);
|
|
||||||
|
|
||||||
expect(mockOnSelectOpenedTab).toHaveBeenCalledWith(mockTabs[1]);
|
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} />);
|
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();
|
for (const tab of mockRecentlyClosedTabs) {
|
||||||
|
expect(await screen.findByText(tab.label)).toBeInTheDocument();
|
||||||
mockRecentlyClosedTabs.forEach((tab) => {
|
}
|
||||||
expect(screen.getByText(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 = {
|
const propsWithNoClosedTabs = {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
recentlyClosedItems: [],
|
recentlyClosedItems: [],
|
||||||
|
@ -104,22 +122,24 @@ describe('TabsBarMenu', () => {
|
||||||
|
|
||||||
render(<TabsBarMenu {...propsWithNoClosedTabs} />);
|
render(<TabsBarMenu {...propsWithNoClosedTabs} />);
|
||||||
|
|
||||||
const menuButton = screen.getByTestId(tabsBarMenuButtonTestId);
|
const menuButton = await screen.findByTestId(tabsBarMenuButtonTestId);
|
||||||
|
await user.click(menuButton);
|
||||||
fireEvent.click(menuButton);
|
|
||||||
|
|
||||||
expect(screen.queryByText('Recently closed')).not.toBeInTheDocument();
|
expect(screen.queryByText('Recently closed')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks the selected tab as checked', () => {
|
it('marks the selected tab as checked', async () => {
|
||||||
render(<TabsBarMenu {...defaultProps} />);
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<div style={{ width: '1000px' }}>
|
||||||
|
<TabsBarMenu {...defaultProps} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const menuButton = screen.getByTestId(tabsBarMenuButtonTestId);
|
const menuButton = await screen.findByTestId(tabsBarMenuButtonTestId);
|
||||||
|
await user.click(menuButton);
|
||||||
fireEvent.click(menuButton);
|
|
||||||
|
|
||||||
const selectedTabOption = screen.getByText(mockTabs[0].label);
|
|
||||||
|
|
||||||
|
const selectedTabOption = (await screen.findAllByTitle(mockTabs[0].label))[0];
|
||||||
expect(selectedTabOption.closest('[aria-selected="true"]')).toBeInTheDocument();
|
expect(selectedTabOption.closest('[aria-selected="true"]')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -22,7 +22,6 @@ import {
|
||||||
EuiSelectableOptionsListProps,
|
EuiSelectableOptionsListProps,
|
||||||
} from '@elastic/eui';
|
} from '@elastic/eui';
|
||||||
import type { TabItem } from '../../types';
|
import type { TabItem } from '../../types';
|
||||||
import type { TabsBarProps } from '../tabs_bar';
|
|
||||||
|
|
||||||
const getOpenedTabsList = (
|
const getOpenedTabsList = (
|
||||||
tabItems: TabItem[],
|
tabItems: TabItem[],
|
||||||
|
@ -42,18 +41,19 @@ const getRecentlyClosedTabsList = (tabItems: TabItem[]): EuiSelectableOption[] =
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
interface TabsBarMenuProps {
|
export interface TabsBarMenuProps {
|
||||||
onSelectOpenedTab: TabsBarProps['onSelect'];
|
items: TabItem[];
|
||||||
selectedItem: TabsBarProps['selectedItem'];
|
selectedItem: TabItem | null;
|
||||||
openedItems: TabsBarProps['items'];
|
recentlyClosedItems: TabItem[];
|
||||||
recentlyClosedItems: TabsBarProps['recentlyClosedItems'];
|
onSelect: (item: TabItem) => Promise<void>;
|
||||||
|
onSelectRecentlyClosed: (item: TabItem) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TabsBarMenu: React.FC<TabsBarMenuProps> = React.memo(
|
export const TabsBarMenu: React.FC<TabsBarMenuProps> = React.memo(
|
||||||
({ openedItems, selectedItem, onSelectOpenedTab, recentlyClosedItems }) => {
|
({ items, selectedItem, recentlyClosedItems, onSelect, onSelectRecentlyClosed }) => {
|
||||||
const openedTabsList = useMemo(
|
const openedTabsList = useMemo(
|
||||||
() => getOpenedTabsList(openedItems, selectedItem),
|
() => getOpenedTabsList(items, selectedItem),
|
||||||
[openedItems, selectedItem]
|
[items, selectedItem]
|
||||||
);
|
);
|
||||||
const recentlyClosedTabsList = useMemo(
|
const recentlyClosedTabsList = useMemo(
|
||||||
() => getRecentlyClosedTabsList(recentlyClosedItems),
|
() => getRecentlyClosedTabsList(recentlyClosedItems),
|
||||||
|
@ -109,9 +109,9 @@ export const TabsBarMenu: React.FC<TabsBarMenuProps> = React.memo(
|
||||||
options={openedTabsList}
|
options={openedTabsList}
|
||||||
onChange={(newOptions) => {
|
onChange={(newOptions) => {
|
||||||
const clickedTabId = newOptions.find((option) => option.checked)?.key;
|
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) {
|
if (tabToNavigate) {
|
||||||
onSelectOpenedTab(tabToNavigate);
|
onSelect(tabToNavigate);
|
||||||
closePopover();
|
closePopover();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
@ -137,12 +137,16 @@ export const TabsBarMenu: React.FC<TabsBarMenuProps> = React.memo(
|
||||||
defaultMessage: 'Recently closed tabs list',
|
defaultMessage: 'Recently closed tabs list',
|
||||||
})}
|
})}
|
||||||
options={recentlyClosedTabsList}
|
options={recentlyClosedTabsList}
|
||||||
onChange={() => {
|
|
||||||
alert('restore tab'); // TODO restore closed tab
|
|
||||||
closePopover();
|
|
||||||
}}
|
|
||||||
singleSelection={true}
|
singleSelection={true}
|
||||||
listProps={selectableListProps}
|
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) => (
|
{(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
|
// TODO status value for now matches EuiHealth colors for mocking simplicity, adjust when real data is available
|
||||||
export enum TabStatus {
|
export enum TabStatus {
|
||||||
SUCCESS = 'success',
|
DEFAULT = 'default',
|
||||||
RUNNING = 'running',
|
RUNNING = 'running',
|
||||||
|
SUCCESS = 'success',
|
||||||
ERROR = 'danger',
|
ERROR = 'danger',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { omit } from 'lodash';
|
||||||
import type { TabItem } from '../types';
|
import type { TabItem } from '../types';
|
||||||
|
|
||||||
export interface TabsState {
|
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 => {
|
export const closeTab = ({ items, selectedItem }: TabsState, item: TabItem): TabsState => {
|
||||||
const itemIndex = items.findIndex((i) => i.id === item.id);
|
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
|
* The query param key used to store the Discover app state in the URL
|
||||||
*/
|
*/
|
||||||
export const APP_STATE_URL_KEY = '_a';
|
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 { History } from 'history';
|
||||||
import type { DiscoverCustomizationContext } from '../customizations';
|
import type { DiscoverCustomizationContext } from '../customizations';
|
||||||
import { createCustomizationService } from '../customizations/customization_service';
|
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({
|
export function getDiscoverStateMock({
|
||||||
isTimeBased = true,
|
isTimeBased = true,
|
||||||
|
@ -59,12 +61,20 @@ export function getDiscoverStateMock({
|
||||||
...(toasts && withNotifyOnErrors(toasts)),
|
...(toasts && withNotifyOnErrors(toasts)),
|
||||||
});
|
});
|
||||||
runtimeStateManager = runtimeStateManager ?? createRuntimeStateManager();
|
runtimeStateManager = runtimeStateManager ?? createRuntimeStateManager();
|
||||||
|
const tabsStorageManager = createTabsStorageManager({
|
||||||
|
urlStateStorage: stateStorageContainer,
|
||||||
|
storage: services.storage,
|
||||||
|
});
|
||||||
const internalState = createInternalStateStore({
|
const internalState = createInternalStateStore({
|
||||||
services,
|
services,
|
||||||
customizationContext,
|
customizationContext,
|
||||||
runtimeStateManager,
|
runtimeStateManager,
|
||||||
urlStateStorage: stateStorageContainer,
|
urlStateStorage: stateStorageContainer,
|
||||||
|
tabsStorageManager,
|
||||||
});
|
});
|
||||||
|
internalState.dispatch(
|
||||||
|
internalStateActions.initializeTabs({ userId: 'mockUserId', spaceId: 'mockSpaceId' })
|
||||||
|
);
|
||||||
const container = getDiscoverStateContainer({
|
const container = getDiscoverStateContainer({
|
||||||
tabId: internalState.getState().tabs.unsafeCurrentId,
|
tabId: internalState.getState().tabs.unsafeCurrentId,
|
||||||
services,
|
services,
|
||||||
|
|
|
@ -381,6 +381,7 @@ describe('useDiscoverHistogram', () => {
|
||||||
dataRequestParams: {
|
dataRequestParams: {
|
||||||
timeRangeAbsolute: timeRangeAbs,
|
timeRangeAbsolute: timeRangeAbs,
|
||||||
timeRangeRelative: timeRangeRel,
|
timeRangeRelative: timeRangeRel,
|
||||||
|
searchSessionId: '123',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
|
@ -64,6 +64,7 @@ async function mountComponent(
|
||||||
from: '2020-05-14T11:05:13.590',
|
from: '2020-05-14T11:05:13.590',
|
||||||
to: '2020-05-14T11:20:13.590',
|
to: '2020-05-14T11:20:13.590',
|
||||||
},
|
},
|
||||||
|
searchSessionId: 'test',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
|
@ -43,6 +43,8 @@ import {
|
||||||
import { ChartPortalsRenderer } from '../chart';
|
import { ChartPortalsRenderer } from '../chart';
|
||||||
import { UnifiedHistogramChart } from '@kbn/unified-histogram';
|
import { UnifiedHistogramChart } from '@kbn/unified-histogram';
|
||||||
|
|
||||||
|
const mockSearchSessionId = '123';
|
||||||
|
|
||||||
jest.mock('@elastic/eui', () => ({
|
jest.mock('@elastic/eui', () => ({
|
||||||
...jest.requireActual('@elastic/eui'),
|
...jest.requireActual('@elastic/eui'),
|
||||||
useResizeObserver: jest.fn(() => ({ width: 1000, height: 1000 })),
|
useResizeObserver: jest.fn(() => ({ width: 1000, height: 1000 })),
|
||||||
|
@ -53,7 +55,7 @@ function getStateContainer({
|
||||||
searchSessionId,
|
searchSessionId,
|
||||||
}: {
|
}: {
|
||||||
savedSearch?: SavedSearch;
|
savedSearch?: SavedSearch;
|
||||||
searchSessionId?: string | null;
|
searchSessionId?: string;
|
||||||
}) {
|
}) {
|
||||||
const stateContainer = getDiscoverStateMock({ isTimeBased: true, savedSearch });
|
const stateContainer = getDiscoverStateMock({ isTimeBased: true, savedSearch });
|
||||||
const dataView = savedSearch?.searchSource?.getField('index') as DataView;
|
const dataView = savedSearch?.searchSource?.getField('index') as DataView;
|
||||||
|
@ -79,7 +81,7 @@ function getStateContainer({
|
||||||
from: '2020-05-14T11:05:13.590',
|
from: '2020-05-14T11:05:13.590',
|
||||||
to: '2020-05-14T11:20:13.590',
|
to: '2020-05-14T11:20:13.590',
|
||||||
},
|
},
|
||||||
...(searchSessionId && { searchSessionId }),
|
searchSessionId,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -88,16 +90,14 @@ function getStateContainer({
|
||||||
}
|
}
|
||||||
|
|
||||||
const mountComponent = async ({
|
const mountComponent = async ({
|
||||||
isEsqlMode = false,
|
|
||||||
storage,
|
storage,
|
||||||
savedSearch = savedSearchMockWithTimeField,
|
savedSearch = savedSearchMockWithTimeField,
|
||||||
searchSessionId = '123',
|
noSearchSessionId,
|
||||||
}: {
|
}: {
|
||||||
isEsqlMode?: boolean;
|
|
||||||
isTimeBased?: boolean;
|
isTimeBased?: boolean;
|
||||||
storage?: Storage;
|
storage?: Storage;
|
||||||
savedSearch?: SavedSearch;
|
savedSearch?: SavedSearch;
|
||||||
searchSessionId?: string | null;
|
noSearchSessionId?: boolean;
|
||||||
} = {}) => {
|
} = {}) => {
|
||||||
const dataView = savedSearch?.searchSource?.getField('index') as DataView;
|
const dataView = savedSearch?.searchSource?.getField('index') as DataView;
|
||||||
|
|
||||||
|
@ -132,7 +132,10 @@ const mountComponent = async ({
|
||||||
totalHits$,
|
totalHits$,
|
||||||
};
|
};
|
||||||
|
|
||||||
const stateContainer = getStateContainer({ savedSearch, searchSessionId });
|
const stateContainer = getStateContainer({
|
||||||
|
savedSearch,
|
||||||
|
searchSessionId: noSearchSessionId ? undefined : mockSearchSessionId,
|
||||||
|
});
|
||||||
stateContainer.dataState.data$ = savedSearchData$;
|
stateContainer.dataState.data$ = savedSearchData$;
|
||||||
stateContainer.actions.undoSavedSearchChanges = jest.fn();
|
stateContainer.actions.undoSavedSearchChanges = jest.fn();
|
||||||
|
|
||||||
|
@ -187,7 +190,7 @@ const mountComponent = async ({
|
||||||
describe('Discover histogram layout component', () => {
|
describe('Discover histogram layout component', () => {
|
||||||
describe('render', () => {
|
describe('render', () => {
|
||||||
it('should not render chart if there is no search session', async () => {
|
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);
|
expect(component.exists(UnifiedHistogramChart)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -196,11 +199,6 @@ describe('Discover histogram layout component', () => {
|
||||||
expect(component.exists(UnifiedHistogramChart)).toBe(true);
|
expect(component.exists(UnifiedHistogramChart)).toBe(true);
|
||||||
}, 10000);
|
}, 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 () => {
|
it('should render PanelsToggle', async () => {
|
||||||
const { component } = await mountComponent();
|
const { component } = await mountComponent();
|
||||||
expect(component.find(PanelsToggle).first().prop('isChartAvailable')).toBe(undefined);
|
expect(component.find(PanelsToggle).first().prop('isChartAvailable')).toBe(undefined);
|
||||||
|
|
|
@ -65,6 +65,7 @@ interface SessionInitializationState {
|
||||||
type InitializeSession = (options?: {
|
type InitializeSession = (options?: {
|
||||||
dataViewSpec?: DataViewSpec | undefined;
|
dataViewSpec?: DataViewSpec | undefined;
|
||||||
defaultUrlState?: DiscoverAppState;
|
defaultUrlState?: DiscoverAppState;
|
||||||
|
shouldClearAllTabs?: boolean;
|
||||||
}) => Promise<SessionInitializationState>;
|
}) => Promise<SessionInitializationState>;
|
||||||
|
|
||||||
export const DiscoverSessionView = ({
|
export const DiscoverSessionView = ({
|
||||||
|
@ -89,7 +90,7 @@ export const DiscoverSessionView = ({
|
||||||
);
|
);
|
||||||
const initializeSessionAction = useCurrentTabAction(internalStateActions.initializeSession);
|
const initializeSessionAction = useCurrentTabAction(internalStateActions.initializeSession);
|
||||||
const [initializeSessionState, initializeSession] = useAsyncFunction<InitializeSession>(
|
const [initializeSessionState, initializeSession] = useAsyncFunction<InitializeSession>(
|
||||||
async ({ dataViewSpec, defaultUrlState } = {}) => {
|
async ({ dataViewSpec, defaultUrlState, shouldClearAllTabs = false } = {}) => {
|
||||||
const stateContainer = getDiscoverStateContainer({
|
const stateContainer = getDiscoverStateContainer({
|
||||||
tabId: currentTabId,
|
tabId: currentTabId,
|
||||||
services,
|
services,
|
||||||
|
@ -111,6 +112,7 @@ export const DiscoverSessionView = ({
|
||||||
discoverSessionId,
|
discoverSessionId,
|
||||||
dataViewSpec,
|
dataViewSpec,
|
||||||
defaultUrlState,
|
defaultUrlState,
|
||||||
|
shouldClearAllTabs,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -119,15 +121,18 @@ export const DiscoverSessionView = ({
|
||||||
? { loading: false, value: { showNoDataPage: false } }
|
? { loading: false, value: { showNoDataPage: false } }
|
||||||
: { loading: true }
|
: { loading: true }
|
||||||
);
|
);
|
||||||
const initializeSessionWithDefaultLocationState = useLatest(() => {
|
const initializeSessionWithDefaultLocationState = useLatest(
|
||||||
const historyLocationState = getScopedHistory<
|
(options?: { shouldClearAllTabs?: boolean }) => {
|
||||||
MainHistoryLocationState & { defaultState?: DiscoverAppState }
|
const historyLocationState = getScopedHistory<
|
||||||
>()?.location.state;
|
MainHistoryLocationState & { defaultState?: DiscoverAppState }
|
||||||
initializeSession({
|
>()?.location.state;
|
||||||
dataViewSpec: historyLocationState?.dataViewSpec,
|
initializeSession({
|
||||||
defaultUrlState: historyLocationState?.defaultState,
|
dataViewSpec: historyLocationState?.dataViewSpec,
|
||||||
});
|
defaultUrlState: historyLocationState?.defaultState,
|
||||||
});
|
shouldClearAllTabs: options?.shouldClearAllTabs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
const initializationState = useInternalStateSelector((state) => state.initializationState);
|
const initializationState = useInternalStateSelector((state) => state.initializationState);
|
||||||
const currentDataView = useCurrentTabRuntimeState(
|
const currentDataView = useCurrentTabRuntimeState(
|
||||||
runtimeStateManager,
|
runtimeStateManager,
|
||||||
|
@ -149,7 +154,7 @@ export const DiscoverSessionView = ({
|
||||||
history,
|
history,
|
||||||
savedSearchId: discoverSessionId,
|
savedSearchId: discoverSessionId,
|
||||||
onNewUrl: () => {
|
onNewUrl: () => {
|
||||||
initializeSessionWithDefaultLocationState.current();
|
initializeSessionWithDefaultLocationState.current({ shouldClearAllTabs: true });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -7,14 +7,14 @@
|
||||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type TabItem, UnifiedTabs } from '@kbn/unified-tabs';
|
import { UnifiedTabs, type UnifiedTabsProps } from '@kbn/unified-tabs';
|
||||||
import React, { useState } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { pick } from 'lodash';
|
|
||||||
import { DiscoverSessionView, type DiscoverSessionViewProps } from '../session_view';
|
import { DiscoverSessionView, type DiscoverSessionViewProps } from '../session_view';
|
||||||
import {
|
import {
|
||||||
createTabItem,
|
createTabItem,
|
||||||
internalStateActions,
|
internalStateActions,
|
||||||
selectAllTabs,
|
selectAllTabs,
|
||||||
|
selectRecentlyClosedTabs,
|
||||||
useInternalStateDispatch,
|
useInternalStateDispatch,
|
||||||
useInternalStateSelector,
|
useInternalStateSelector,
|
||||||
} from '../../state_management/redux';
|
} from '../../state_management/redux';
|
||||||
|
@ -24,19 +24,36 @@ import { usePreviewData } from './use_preview_data';
|
||||||
export const TabsView = (props: DiscoverSessionViewProps) => {
|
export const TabsView = (props: DiscoverSessionViewProps) => {
|
||||||
const services = useDiscoverServices();
|
const services = useDiscoverServices();
|
||||||
const dispatch = useInternalStateDispatch();
|
const dispatch = useInternalStateDispatch();
|
||||||
const allTabs = useInternalStateSelector(selectAllTabs);
|
const items = useInternalStateSelector(selectAllTabs);
|
||||||
|
const recentlyClosedItems = useInternalStateSelector(selectRecentlyClosedTabs);
|
||||||
const currentTabId = useInternalStateSelector((state) => state.tabs.unsafeCurrentId);
|
const currentTabId = useInternalStateSelector((state) => state.tabs.unsafeCurrentId);
|
||||||
const [initialItems] = useState<TabItem[]>(() => allTabs.map((tab) => pick(tab, 'id', 'label')));
|
|
||||||
const { getPreviewData } = usePreviewData(props.runtimeStateManager);
|
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 (
|
return (
|
||||||
<UnifiedTabs
|
<UnifiedTabs
|
||||||
services={services}
|
services={services}
|
||||||
initialItems={initialItems}
|
items={items}
|
||||||
onChanged={(updateState) => dispatch(internalStateActions.updateTabs(updateState))}
|
selectedItemId={currentTabId}
|
||||||
createItem={() => createTabItem(allTabs)}
|
recentlyClosedItems={recentlyClosedItems}
|
||||||
|
createItem={createItem}
|
||||||
getPreviewData={getPreviewData}
|
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(
|
selectTabRuntimeState(runtimeStateManager, tabId).stateContainer$.pipe(
|
||||||
switchMap((tabStateContainer) => {
|
switchMap((tabStateContainer) => {
|
||||||
if (!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;
|
const { appState } = tabStateContainer;
|
||||||
|
|
|
@ -18,8 +18,6 @@ import type { CustomizationCallback, DiscoverCustomizationContext } from '../../
|
||||||
import {
|
import {
|
||||||
type DiscoverInternalState,
|
type DiscoverInternalState,
|
||||||
InternalStateProvider,
|
InternalStateProvider,
|
||||||
createInternalStateStore,
|
|
||||||
createRuntimeStateManager,
|
|
||||||
internalStateActions,
|
internalStateActions,
|
||||||
} from './state_management/redux';
|
} from './state_management/redux';
|
||||||
import type { RootProfileState } from '../../context_awareness';
|
import type { RootProfileState } from '../../context_awareness';
|
||||||
|
@ -35,6 +33,8 @@ import { useAsyncFunction } from './hooks/use_async_function';
|
||||||
import { TabsView } from './components/tabs_view';
|
import { TabsView } from './components/tabs_view';
|
||||||
import { TABS_ENABLED } from '../../constants';
|
import { TABS_ENABLED } from '../../constants';
|
||||||
import { ChartPortalsRenderer } from './components/chart';
|
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 {
|
export interface MainRouteProps {
|
||||||
customizationContext: DiscoverCustomizationContext;
|
customizationContext: DiscoverCustomizationContext;
|
||||||
|
@ -66,26 +66,28 @@ export const DiscoverMainRoute = ({
|
||||||
...withNotifyOnErrors(services.core.notifications.toasts),
|
...withNotifyOnErrors(services.core.notifications.toasts),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const [runtimeStateManager] = useState(() => createRuntimeStateManager());
|
|
||||||
const [internalState] = useState(() =>
|
const { internalState, runtimeStateManager } = useStateManagers({
|
||||||
createInternalStateStore({
|
services,
|
||||||
services,
|
urlStateStorage,
|
||||||
customizationContext,
|
customizationContext,
|
||||||
runtimeStateManager,
|
});
|
||||||
urlStateStorage,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const { initializeProfileDataViews } = useDefaultAdHocDataViews({ internalState });
|
const { initializeProfileDataViews } = useDefaultAdHocDataViews({ internalState });
|
||||||
const [mainRouteInitializationState, initializeMainRoute] = useAsyncFunction<InitializeMainRoute>(
|
const [mainRouteInitializationState, initializeMainRoute] = useAsyncFunction<InitializeMainRoute>(
|
||||||
async (loadedRootProfileState) => {
|
async (loadedRootProfileState) => {
|
||||||
const { dataViews } = services;
|
const { dataViews } = services;
|
||||||
const [hasESData, hasUserDataView, defaultDataViewExists] = await Promise.all([
|
const [hasESData, hasUserDataView, defaultDataViewExists, userAndSpaceIds] =
|
||||||
dataViews.hasData.hasESData().catch(() => false),
|
await Promise.all([
|
||||||
dataViews.hasData.hasUserDataView().catch(() => false),
|
dataViews.hasData.hasESData().catch(() => false),
|
||||||
dataViews.defaultDataViewExists().catch(() => false),
|
dataViews.hasData.hasUserDataView().catch(() => false),
|
||||||
internalState.dispatch(internalStateActions.loadDataViewList()).catch(() => {}),
|
dataViews.defaultDataViewExists().catch(() => false),
|
||||||
initializeProfileDataViews(loadedRootProfileState).catch(() => {}),
|
getUserAndSpaceIds(services),
|
||||||
]);
|
internalState.dispatch(internalStateActions.loadDataViewList()).catch(() => {}),
|
||||||
|
initializeProfileDataViews(loadedRootProfileState).catch(() => {}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
internalState.dispatch(internalStateActions.initializeTabs(userAndSpaceIds));
|
||||||
|
|
||||||
const initializationState: DiscoverInternalState['initializationState'] = {
|
const initializationState: DiscoverInternalState['initializationState'] = {
|
||||||
hasESData,
|
hasESData,
|
||||||
hasUserDataView: hasUserDataView && defaultDataViewExists,
|
hasUserDataView: hasUserDataView && defaultDataViewExists,
|
||||||
|
|
|
@ -28,13 +28,16 @@ import {
|
||||||
createRuntimeStateManager,
|
createRuntimeStateManager,
|
||||||
createTabActionInjector,
|
createTabActionInjector,
|
||||||
selectTab,
|
selectTab,
|
||||||
|
internalStateActions,
|
||||||
} from './redux';
|
} from './redux';
|
||||||
import { mockCustomizationContext } from '../../../customizations/__mocks__/customization_context';
|
import { mockCustomizationContext } from '../../../customizations/__mocks__/customization_context';
|
||||||
|
import { createTabsStorageManager, type TabsStorageManager } from './tabs_storage_manager';
|
||||||
|
|
||||||
let history: History;
|
let history: History;
|
||||||
let stateStorage: IKbnUrlStateStorage;
|
let stateStorage: IKbnUrlStateStorage;
|
||||||
let internalState: InternalStateStore;
|
let internalState: InternalStateStore;
|
||||||
let savedSearchState: DiscoverSavedSearchContainer;
|
let savedSearchState: DiscoverSavedSearchContainer;
|
||||||
|
let tabsStorageManager: TabsStorageManager;
|
||||||
let getCurrentTab: () => TabState;
|
let getCurrentTab: () => TabState;
|
||||||
|
|
||||||
describe('Test discover app state container', () => {
|
describe('Test discover app state container', () => {
|
||||||
|
@ -46,12 +49,20 @@ describe('Test discover app state container', () => {
|
||||||
history,
|
history,
|
||||||
...(toasts && withNotifyOnErrors(toasts)),
|
...(toasts && withNotifyOnErrors(toasts)),
|
||||||
});
|
});
|
||||||
|
tabsStorageManager = createTabsStorageManager({
|
||||||
|
urlStateStorage: stateStorage,
|
||||||
|
storage: discoverServiceMock.storage,
|
||||||
|
});
|
||||||
internalState = createInternalStateStore({
|
internalState = createInternalStateStore({
|
||||||
services: discoverServiceMock,
|
services: discoverServiceMock,
|
||||||
customizationContext: mockCustomizationContext,
|
customizationContext: mockCustomizationContext,
|
||||||
runtimeStateManager: createRuntimeStateManager(),
|
runtimeStateManager: createRuntimeStateManager(),
|
||||||
urlStateStorage: stateStorage,
|
urlStateStorage: stateStorage,
|
||||||
|
tabsStorageManager,
|
||||||
});
|
});
|
||||||
|
internalState.dispatch(
|
||||||
|
internalStateActions.initializeTabs({ userId: 'mockUserId', spaceId: 'mockSpaceId' })
|
||||||
|
);
|
||||||
savedSearchState = getSavedSearchContainer({
|
savedSearchState = getSavedSearchContainer({
|
||||||
services: discoverServiceMock,
|
services: discoverServiceMock,
|
||||||
globalStateContainer: getDiscoverGlobalStateContainer(stateStorage),
|
globalStateContainer: getDiscoverGlobalStateContainer(stateStorage),
|
||||||
|
|
|
@ -9,14 +9,13 @@
|
||||||
|
|
||||||
import type { QueryState } from '@kbn/data-plugin/common';
|
import type { QueryState } from '@kbn/data-plugin/common';
|
||||||
import type { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
|
import type { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
|
||||||
|
import { GLOBAL_STATE_URL_KEY } from '../../../../common/constants';
|
||||||
|
|
||||||
export interface DiscoverGlobalStateContainer {
|
export interface DiscoverGlobalStateContainer {
|
||||||
get: () => QueryState | null;
|
get: () => QueryState | null;
|
||||||
set: (state: QueryState) => Promise<void>;
|
set: (state: QueryState) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GLOBAL_STATE_URL_KEY = '_g';
|
|
||||||
|
|
||||||
export const getDiscoverGlobalStateContainer = (
|
export const getDiscoverGlobalStateContainer = (
|
||||||
stateStorage: IKbnUrlStateStorage
|
stateStorage: IKbnUrlStateStorage
|
||||||
): DiscoverGlobalStateContainer => ({
|
): DiscoverGlobalStateContainer => ({
|
||||||
|
|
|
@ -25,18 +25,27 @@ import { createSearchSourceMock } from '@kbn/data-plugin/common/search/search_so
|
||||||
import { createInternalStateStore, createRuntimeStateManager, internalStateActions } from './redux';
|
import { createInternalStateStore, createRuntimeStateManager, internalStateActions } from './redux';
|
||||||
import { mockCustomizationContext } from '../../../customizations/__mocks__/customization_context';
|
import { mockCustomizationContext } from '../../../customizations/__mocks__/customization_context';
|
||||||
import { omit } from 'lodash';
|
import { omit } from 'lodash';
|
||||||
|
import { createTabsStorageManager } from './tabs_storage_manager';
|
||||||
|
|
||||||
describe('DiscoverSavedSearchContainer', () => {
|
describe('DiscoverSavedSearchContainer', () => {
|
||||||
const savedSearch = savedSearchMock;
|
const savedSearch = savedSearchMock;
|
||||||
const services = discoverServiceMock;
|
const services = discoverServiceMock;
|
||||||
const urlStateStorage = createKbnUrlStateStorage();
|
const urlStateStorage = createKbnUrlStateStorage();
|
||||||
const globalStateContainer = getDiscoverGlobalStateContainer(urlStateStorage);
|
const globalStateContainer = getDiscoverGlobalStateContainer(urlStateStorage);
|
||||||
|
const tabsStorageManager = createTabsStorageManager({
|
||||||
|
urlStateStorage,
|
||||||
|
storage: services.storage,
|
||||||
|
});
|
||||||
const internalState = createInternalStateStore({
|
const internalState = createInternalStateStore({
|
||||||
services,
|
services,
|
||||||
customizationContext: mockCustomizationContext,
|
customizationContext: mockCustomizationContext,
|
||||||
runtimeStateManager: createRuntimeStateManager(),
|
runtimeStateManager: createRuntimeStateManager(),
|
||||||
urlStateStorage,
|
urlStateStorage,
|
||||||
|
tabsStorageManager,
|
||||||
});
|
});
|
||||||
|
internalState.dispatch(
|
||||||
|
internalStateActions.initializeTabs({ userId: 'mockUserId', spaceId: 'mockSpaceId' })
|
||||||
|
);
|
||||||
|
|
||||||
describe('getTitle', () => {
|
describe('getTitle', () => {
|
||||||
it('returns undefined for new saved searches', () => {
|
it('returns undefined for new saved searches', () => {
|
||||||
|
|
|
@ -273,6 +273,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: savedSearchMock.id,
|
discoverSessionId: savedSearchMock.id,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -471,6 +472,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: undefined,
|
discoverSessionId: undefined,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -499,6 +501,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: undefined,
|
discoverSessionId: undefined,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -541,6 +544,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: undefined,
|
discoverSessionId: undefined,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -567,6 +571,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: undefined,
|
discoverSessionId: undefined,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -593,6 +598,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: undefined,
|
discoverSessionId: undefined,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -632,6 +638,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: 'the-saved-search-id',
|
discoverSessionId: 'the-saved-search-id',
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -659,6 +666,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: savedSearchMock.id,
|
discoverSessionId: savedSearchMock.id,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -690,6 +698,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: savedSearchMock.id,
|
discoverSessionId: savedSearchMock.id,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -722,6 +731,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: savedSearchMock.id,
|
discoverSessionId: savedSearchMock.id,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -754,6 +764,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: savedSearchMock.id,
|
discoverSessionId: savedSearchMock.id,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -773,6 +784,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: undefined,
|
discoverSessionId: undefined,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -794,6 +806,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: undefined,
|
discoverSessionId: undefined,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -822,6 +835,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: undefined,
|
discoverSessionId: undefined,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -843,6 +857,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: savedSearchMock.id,
|
discoverSessionId: savedSearchMock.id,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -882,6 +897,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: savedSearchMock.id,
|
discoverSessionId: savedSearchMock.id,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -912,6 +928,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: 'the-saved-search-id-with-timefield',
|
discoverSessionId: 'the-saved-search-id-with-timefield',
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: {},
|
defaultUrlState: {},
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -945,6 +962,7 @@ describe('Discover state', () => {
|
||||||
dataViewId: 'index-pattern-with-timefield-id',
|
dataViewId: 'index-pattern-with-timefield-id',
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -975,6 +993,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: undefined,
|
discoverSessionId: undefined,
|
||||||
dataViewSpec: dataViewSpecMock,
|
dataViewSpec: dataViewSpecMock,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -1000,6 +1019,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: savedSearchMock.id,
|
discoverSessionId: savedSearchMock.id,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -1024,6 +1044,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: savedSearchMock.id,
|
discoverSessionId: savedSearchMock.id,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -1045,6 +1066,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: savedSearchAdHoc.id,
|
discoverSessionId: savedSearchAdHoc.id,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -1070,6 +1092,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: savedSearchMockWithESQL.id,
|
discoverSessionId: savedSearchMockWithESQL.id,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -1119,6 +1142,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: savedSearchMock.id,
|
discoverSessionId: savedSearchMock.id,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -1155,6 +1179,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: savedSearchMock.id,
|
discoverSessionId: savedSearchMock.id,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -1181,6 +1206,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: savedSearchMock.id,
|
discoverSessionId: savedSearchMock.id,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -1212,6 +1238,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: savedSearchMock.id,
|
discoverSessionId: savedSearchMock.id,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -1246,6 +1273,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: savedSearchMock.id,
|
discoverSessionId: savedSearchMock.id,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -1272,6 +1300,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: savedSearchMock.id,
|
discoverSessionId: savedSearchMock.id,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -1285,6 +1314,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: undefined,
|
discoverSessionId: undefined,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -1301,6 +1331,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: savedSearchMock.id,
|
discoverSessionId: savedSearchMock.id,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -1325,6 +1356,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: savedSearchMock.id,
|
discoverSessionId: savedSearchMock.id,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -1378,6 +1410,7 @@ describe('Discover state', () => {
|
||||||
discoverSessionId: savedSearchMock.id,
|
discoverSessionId: savedSearchMock.id,
|
||||||
dataViewSpec: undefined,
|
dataViewSpec: undefined,
|
||||||
defaultUrlState: undefined,
|
defaultUrlState: undefined,
|
||||||
|
shouldClearAllTabs: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
|
@ -433,6 +433,10 @@ export function getDiscoverStateContainer({
|
||||||
* state containers initializing and subscribing to changes triggering e.g. data fetching
|
* state containers initializing and subscribing to changes triggering e.g. data fetching
|
||||||
*/
|
*/
|
||||||
const initializeAndSync = () => {
|
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
|
// 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.
|
// might change the time filter and thus needs to re-check whether the saved search has changed.
|
||||||
const timefilerUnsubscribe = merge(
|
const timefilerUnsubscribe = merge(
|
||||||
|
@ -440,6 +444,7 @@ export function getDiscoverStateContainer({
|
||||||
services.timefilter.getRefreshIntervalUpdate$()
|
services.timefilter.getRefreshIntervalUpdate$()
|
||||||
).subscribe(() => {
|
).subscribe(() => {
|
||||||
savedSearchContainer.updateTimeRange();
|
savedSearchContainer.updateTimeRange();
|
||||||
|
updateTabAppStateAndGlobalState();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Enable/disable kbn url tracking (That's the URL used when selecting Discover in the side menu)
|
// 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
|
// start subscribing to dataStateContainer, triggering data fetching
|
||||||
const unsubscribeData = dataStateContainer.subscribe();
|
const unsubscribeData = dataStateContainer.subscribe();
|
||||||
|
|
||||||
|
@ -494,6 +503,7 @@ export function getDiscoverStateContainer({
|
||||||
);
|
);
|
||||||
|
|
||||||
internalStopSyncing = () => {
|
internalStopSyncing = () => {
|
||||||
|
savedSearchChangesSubscription.unsubscribe();
|
||||||
unsubscribeData();
|
unsubscribeData();
|
||||||
appStateUnsubscribe();
|
appStateUnsubscribe();
|
||||||
appStateInitAndSyncUnsubscribe();
|
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 { getValidFilters } from '../../../../../utils/get_valid_filters';
|
||||||
import { updateSavedSearch } from '../../utils/update_saved_search';
|
import { updateSavedSearch } from '../../utils/update_saved_search';
|
||||||
import { APP_STATE_URL_KEY } from '../../../../../../common';
|
import { APP_STATE_URL_KEY } from '../../../../../../common';
|
||||||
|
import { TABS_ENABLED } from '../../../../../constants';
|
||||||
import { selectTabRuntimeState } from '../runtime_state';
|
import { selectTabRuntimeState } from '../runtime_state';
|
||||||
import type { ConnectedCustomizationService } from '../../../../../customizations';
|
import type { ConnectedCustomizationService } from '../../../../../customizations';
|
||||||
import { disconnectTab } from './tabs';
|
import { disconnectTab, clearAllTabs } from './tabs';
|
||||||
|
|
||||||
export interface InitializeSessionParams {
|
export interface InitializeSessionParams {
|
||||||
stateContainer: DiscoverStateContainer;
|
stateContainer: DiscoverStateContainer;
|
||||||
|
@ -44,6 +45,7 @@ export interface InitializeSessionParams {
|
||||||
discoverSessionId: string | undefined;
|
discoverSessionId: string | undefined;
|
||||||
dataViewSpec: DataViewSpec | undefined;
|
dataViewSpec: DataViewSpec | undefined;
|
||||||
defaultUrlState: DiscoverAppState | undefined;
|
defaultUrlState: DiscoverAppState | undefined;
|
||||||
|
shouldClearAllTabs: boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const initializeSession: InternalStateThunkActionCreator<
|
export const initializeSession: InternalStateThunkActionCreator<
|
||||||
|
@ -58,25 +60,32 @@ export const initializeSession: InternalStateThunkActionCreator<
|
||||||
discoverSessionId,
|
discoverSessionId,
|
||||||
dataViewSpec,
|
dataViewSpec,
|
||||||
defaultUrlState,
|
defaultUrlState,
|
||||||
|
shouldClearAllTabs,
|
||||||
},
|
},
|
||||||
}) =>
|
}) =>
|
||||||
async (
|
async (
|
||||||
dispatch,
|
dispatch,
|
||||||
getState,
|
getState,
|
||||||
{ services, customizationContext, runtimeStateManager, urlStateStorage }
|
{ services, customizationContext, runtimeStateManager, urlStateStorage, tabsStorageManager }
|
||||||
) => {
|
) => {
|
||||||
dispatch(disconnectTab({ tabId }));
|
dispatch(disconnectTab({ tabId }));
|
||||||
dispatch(internalStateSlice.actions.resetOnSavedSearchChange({ tabId }));
|
dispatch(internalStateSlice.actions.resetOnSavedSearchChange({ tabId }));
|
||||||
|
|
||||||
|
if (TABS_ENABLED && shouldClearAllTabs) {
|
||||||
|
dispatch(clearAllTabs());
|
||||||
|
}
|
||||||
|
|
||||||
const discoverSessionLoadTracker =
|
const discoverSessionLoadTracker =
|
||||||
services.ebtManager.trackPerformanceEvent('discoverLoadSavedSearch');
|
services.ebtManager.trackPerformanceEvent('discoverLoadSavedSearch');
|
||||||
|
|
||||||
const { currentDataView$, stateContainer$, customizationService$ } = selectTabRuntimeState(
|
const { currentDataView$, stateContainer$, customizationService$ } = selectTabRuntimeState(
|
||||||
runtimeStateManager,
|
runtimeStateManager,
|
||||||
tabId
|
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());
|
const wasTabInitialized = Boolean(stateContainer$.getValue());
|
||||||
|
@ -89,14 +98,33 @@ export const initializeSession: InternalStateThunkActionCreator<
|
||||||
customizationService$.next(undefined);
|
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
|
* "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
|
const persistedDiscoverSession = discoverSessionId
|
||||||
? await services.savedSearch.get(discoverSessionId)
|
? await services.savedSearch.get(discoverSessionId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
|
@ -8,29 +8,35 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { TabbedContentState } from '@kbn/unified-tabs/src/components/tabbed_content/tabbed_content';
|
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 { QueryState } from '@kbn/data-plugin/common';
|
||||||
import type { TabState } from '../types';
|
import type { TabState } from '../types';
|
||||||
import { selectAllTabs, selectTab } from '../selectors';
|
import { selectAllTabs, selectRecentlyClosedTabs, selectTab } from '../selectors';
|
||||||
import {
|
import {
|
||||||
defaultTabState,
|
defaultTabState,
|
||||||
internalStateSlice,
|
internalStateSlice,
|
||||||
type TabActionPayload,
|
type TabActionPayload,
|
||||||
type InternalStateThunkActionCreator,
|
type InternalStateThunkActionCreator,
|
||||||
} from '../internal_state';
|
} from '../internal_state';
|
||||||
import { createTabRuntimeState, selectTabRuntimeState } from '../runtime_state';
|
import {
|
||||||
import { APP_STATE_URL_KEY } from '../../../../../../common';
|
createTabRuntimeState,
|
||||||
import { GLOBAL_STATE_URL_KEY } from '../../discover_global_state_container';
|
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 type { DiscoverAppState } from '../../discover_app_state_container';
|
||||||
|
import { createTabItem } from '../utils';
|
||||||
|
|
||||||
export const setTabs: InternalStateThunkActionCreator<
|
export const setTabs: InternalStateThunkActionCreator<
|
||||||
[Parameters<typeof internalStateSlice.actions.setTabs>[0]]
|
[Parameters<typeof internalStateSlice.actions.setTabs>[0]]
|
||||||
> =
|
> =
|
||||||
(params) =>
|
(params) =>
|
||||||
(dispatch, getState, { runtimeStateManager }) => {
|
(dispatch, getState, { runtimeStateManager, tabsStorageManager }) => {
|
||||||
const previousTabs = selectAllTabs(getState());
|
const previousState = getState();
|
||||||
const removedTabs = differenceBy(previousTabs, params.allTabs, (tab) => tab.id);
|
const previousTabs = selectAllTabs(previousState);
|
||||||
const addedTabs = differenceBy(params.allTabs, previousTabs, (tab) => tab.id);
|
const removedTabs = differenceBy(previousTabs, params.allTabs, differenceIterateeByTabId);
|
||||||
|
const addedTabs = differenceBy(params.allTabs, previousTabs, differenceIterateeByTabId);
|
||||||
|
|
||||||
for (const tab of removedTabs) {
|
for (const tab of removedTabs) {
|
||||||
dispatch(disconnectTab({ tabId: tab.id }));
|
dispatch(disconnectTab({ tabId: tab.id }));
|
||||||
|
@ -41,7 +47,16 @@ export const setTabs: InternalStateThunkActionCreator<
|
||||||
runtimeStateManager.tabs.byId[tab.id] = createTabRuntimeState();
|
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>> =
|
export const updateTabs: InternalStateThunkActionCreator<[TabbedContentState], Promise<void>> =
|
||||||
|
@ -49,9 +64,13 @@ export const updateTabs: InternalStateThunkActionCreator<[TabbedContentState], P
|
||||||
async (dispatch, getState, { services, runtimeStateManager, urlStateStorage }) => {
|
async (dispatch, getState, { services, runtimeStateManager, urlStateStorage }) => {
|
||||||
const currentState = getState();
|
const currentState = getState();
|
||||||
const currentTab = selectTab(currentState, currentState.tabs.unsafeCurrentId);
|
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);
|
const existingTab = selectTab(currentState, item.id);
|
||||||
return existingTab ? { ...existingTab, ...item } : { ...defaultTabState, ...item };
|
return {
|
||||||
|
...defaultTabState,
|
||||||
|
...existingTab,
|
||||||
|
...pick(item, 'id', 'label'),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (selectedItem?.id !== currentTab.id) {
|
if (selectedItem?.id !== currentTab.id) {
|
||||||
|
@ -60,20 +79,6 @@ export const updateTabs: InternalStateThunkActionCreator<[TabbedContentState], P
|
||||||
|
|
||||||
previousTabStateContainer?.actions.stopSyncing();
|
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 nextTab = selectedItem ? selectTab(currentState, selectedItem.id) : undefined;
|
||||||
const nextTabRuntimeState = selectedItem
|
const nextTabRuntimeState = selectedItem
|
||||||
? selectTabRuntimeState(runtimeStateManager, selectedItem.id)
|
? selectTabRuntimeState(runtimeStateManager, selectedItem.id)
|
||||||
|
@ -117,6 +122,77 @@ export const updateTabs: InternalStateThunkActionCreator<[TabbedContentState], P
|
||||||
setTabs({
|
setTabs({
|
||||||
allTabs: updatedTabs,
|
allTabs: updatedTabs,
|
||||||
selectedTabId: selectedItem?.id ?? currentTab.id,
|
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();
|
stateContainer?.actions.stopSyncing();
|
||||||
tabRuntimeState.customizationService$.getValue()?.cleanup();
|
tabRuntimeState.customizationService$.getValue()?.cleanup();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function differenceIterateeByTabId(tab: TabState) {
|
||||||
|
return tab.id;
|
||||||
|
}
|
||||||
|
|
|
@ -20,6 +20,10 @@ import {
|
||||||
setTabs,
|
setTabs,
|
||||||
updateTabs,
|
updateTabs,
|
||||||
disconnectTab,
|
disconnectTab,
|
||||||
|
updateTabAppStateAndGlobalState,
|
||||||
|
restoreTab,
|
||||||
|
clearAllTabs,
|
||||||
|
initializeTabs,
|
||||||
} from './actions';
|
} from './actions';
|
||||||
|
|
||||||
export type { DiscoverInternalState, TabState, InternalStateDataRequestParams } from './types';
|
export type { DiscoverInternalState, TabState, InternalStateDataRequestParams } from './types';
|
||||||
|
@ -43,6 +47,10 @@ export const internalStateActions = {
|
||||||
appendAdHocDataViews,
|
appendAdHocDataViews,
|
||||||
replaceAdHocDataViewWithId,
|
replaceAdHocDataViewWithId,
|
||||||
initializeSession,
|
initializeSession,
|
||||||
|
updateTabAppStateAndGlobalState,
|
||||||
|
restoreTab,
|
||||||
|
clearAllTabs,
|
||||||
|
initializeTabs,
|
||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
@ -56,7 +64,7 @@ export {
|
||||||
useDataViewsForPicker,
|
useDataViewsForPicker,
|
||||||
} from './hooks';
|
} from './hooks';
|
||||||
|
|
||||||
export { selectAllTabs, selectTab } from './selectors';
|
export { selectAllTabs, selectRecentlyClosedTabs, selectTab } from './selectors';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
type RuntimeStateManager,
|
type RuntimeStateManager,
|
||||||
|
|
|
@ -18,16 +18,27 @@ import {
|
||||||
import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
|
import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
|
||||||
import { mockCustomizationContext } from '../../../../customizations/__mocks__/customization_context';
|
import { mockCustomizationContext } from '../../../../customizations/__mocks__/customization_context';
|
||||||
import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
|
import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
|
||||||
|
import { createTabsStorageManager } from '../tabs_storage_manager';
|
||||||
|
|
||||||
describe('InternalStateStore', () => {
|
describe('InternalStateStore', () => {
|
||||||
it('should set data view', () => {
|
it('should set data view', () => {
|
||||||
|
const services = createDiscoverServicesMock();
|
||||||
|
const urlStateStorage = createKbnUrlStateStorage();
|
||||||
const runtimeStateManager = createRuntimeStateManager();
|
const runtimeStateManager = createRuntimeStateManager();
|
||||||
|
const tabsStorageManager = createTabsStorageManager({
|
||||||
|
urlStateStorage,
|
||||||
|
storage: services.storage,
|
||||||
|
});
|
||||||
const store = createInternalStateStore({
|
const store = createInternalStateStore({
|
||||||
services: createDiscoverServicesMock(),
|
services: createDiscoverServicesMock(),
|
||||||
customizationContext: mockCustomizationContext,
|
customizationContext: mockCustomizationContext,
|
||||||
runtimeStateManager,
|
runtimeStateManager,
|
||||||
urlStateStorage: createKbnUrlStateStorage(),
|
urlStateStorage,
|
||||||
|
tabsStorageManager,
|
||||||
});
|
});
|
||||||
|
store.dispatch(
|
||||||
|
internalStateActions.initializeTabs({ userId: 'mockUserId', spaceId: 'mockSpaceId' })
|
||||||
|
);
|
||||||
const tabId = store.getState().tabs.unsafeCurrentId;
|
const tabId = store.getState().tabs.unsafeCurrentId;
|
||||||
expect(selectTab(store.getState(), tabId).dataViewId).toBeUndefined();
|
expect(selectTab(store.getState(), tabId).dataViewId).toBeUndefined();
|
||||||
expect(
|
expect(
|
||||||
|
|
|
@ -9,33 +9,46 @@
|
||||||
|
|
||||||
import type { DataTableRecord } from '@kbn/discover-utils';
|
import type { DataTableRecord } from '@kbn/discover-utils';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { throttle } from 'lodash';
|
||||||
import {
|
import {
|
||||||
type PayloadAction,
|
type PayloadAction,
|
||||||
configureStore,
|
configureStore,
|
||||||
createSlice,
|
createSlice,
|
||||||
type ThunkAction,
|
type ThunkAction,
|
||||||
type ThunkDispatch,
|
type ThunkDispatch,
|
||||||
|
type AnyAction,
|
||||||
|
type Dispatch,
|
||||||
|
createListenerMiddleware,
|
||||||
} from '@reduxjs/toolkit';
|
} from '@reduxjs/toolkit';
|
||||||
import type { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
|
import type { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
|
||||||
import type { TabItem } from '@kbn/unified-tabs';
|
import type { TabItem } from '@kbn/unified-tabs';
|
||||||
import type { DiscoverCustomizationContext } from '../../../../customizations';
|
import type { DiscoverCustomizationContext } from '../../../../customizations';
|
||||||
import type { DiscoverServices } from '../../../../build_services';
|
import type { DiscoverServices } from '../../../../build_services';
|
||||||
import { type RuntimeStateManager } from './runtime_state';
|
import { type RuntimeStateManager, selectTabRuntimeAppState } from './runtime_state';
|
||||||
import {
|
import {
|
||||||
LoadingStatus,
|
LoadingStatus,
|
||||||
type DiscoverInternalState,
|
type DiscoverInternalState,
|
||||||
type InternalStateDataRequestParams,
|
type InternalStateDataRequestParams,
|
||||||
type TabState,
|
type TabState,
|
||||||
|
type RecentlyClosedTabState,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { loadDataViewList, setTabs } from './actions';
|
import { loadDataViewList } from './actions/data_views';
|
||||||
import { selectAllTabs, selectTab } from './selectors';
|
import { selectTab } from './selectors';
|
||||||
import { createTabItem } from './utils';
|
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> = {
|
export const defaultTabState: Omit<TabState, keyof TabItem> = {
|
||||||
lastPersistedGlobalState: {},
|
lastPersistedGlobalState: {},
|
||||||
dataViewId: undefined,
|
dataViewId: undefined,
|
||||||
isDataViewLoading: false,
|
isDataViewLoading: false,
|
||||||
dataRequestParams: {},
|
dataRequestParams: {
|
||||||
|
timeRangeAbsolute: undefined,
|
||||||
|
timeRangeRelative: undefined,
|
||||||
|
searchSessionId: undefined,
|
||||||
|
},
|
||||||
overriddenVisContextAfterInvalidation: undefined,
|
overriddenVisContextAfterInvalidation: undefined,
|
||||||
resetDefaultProfileState: {
|
resetDefaultProfileState: {
|
||||||
resetId: '',
|
resetId: '',
|
||||||
|
@ -63,7 +76,7 @@ const initialState: DiscoverInternalState = {
|
||||||
savedDataViews: [],
|
savedDataViews: [],
|
||||||
expandedDoc: undefined,
|
expandedDoc: undefined,
|
||||||
isESQLToDataViewTransitionModalVisible: false,
|
isESQLToDataViewTransitionModalVisible: false,
|
||||||
tabs: { byId: {}, allIds: [], unsafeCurrentId: '' },
|
tabs: { byId: {}, allIds: [], unsafeCurrentId: '', recentlyClosedTabIds: [] },
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TabActionPayload<T extends { [key: string]: unknown } = {}> = { tabId: string } & T;
|
export type TabActionPayload<T extends { [key: string]: unknown } = {}> = { tabId: string } & T;
|
||||||
|
@ -93,8 +106,17 @@ export const internalStateSlice = createSlice({
|
||||||
state.initializationState = action.payload;
|
state.initializationState = action.payload;
|
||||||
},
|
},
|
||||||
|
|
||||||
setTabs: (state, action: PayloadAction<{ allTabs: TabState[]; selectedTabId: string }>) => {
|
setTabs: (
|
||||||
state.tabs.byId = action.payload.allTabs.reduce<Record<string, TabState>>(
|
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) => ({
|
||||||
...acc,
|
...acc,
|
||||||
[tab.id]: tab,
|
[tab.id]: tab,
|
||||||
|
@ -103,6 +125,7 @@ export const internalStateSlice = createSlice({
|
||||||
);
|
);
|
||||||
state.tabs.allIds = action.payload.allTabs.map((tab) => tab.id);
|
state.tabs.allIds = action.payload.allTabs.map((tab) => tab.id);
|
||||||
state.tabs.unsafeCurrentId = action.payload.selectedTabId;
|
state.tabs.unsafeCurrentId = action.payload.selectedTabId;
|
||||||
|
state.tabs.recentlyClosedTabIds = action.payload.recentlyClosedTabs.map((tab) => tab.id);
|
||||||
},
|
},
|
||||||
|
|
||||||
setDataViewId: (state, action: TabAction<{ dataViewId: string | undefined }>) =>
|
setDataViewId: (state, action: TabAction<{ dataViewId: string | undefined }>) =>
|
||||||
|
@ -142,6 +165,17 @@ export const internalStateSlice = createSlice({
|
||||||
tab.dataRequestParams = action.payload.dataRequestParams;
|
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: (
|
setOverriddenVisContextAfterInvalidation: (
|
||||||
state,
|
state,
|
||||||
action: TabAction<{
|
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;
|
services: DiscoverServices;
|
||||||
customizationContext: DiscoverCustomizationContext;
|
customizationContext: DiscoverCustomizationContext;
|
||||||
runtimeStateManager: RuntimeStateManager;
|
runtimeStateManager: RuntimeStateManager;
|
||||||
urlStateStorage: IKbnUrlStateStorage;
|
urlStateStorage: IKbnUrlStateStorage;
|
||||||
|
tabsStorageManager: TabsStorageManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IS_JEST_ENVIRONMENT = typeof jest !== 'undefined';
|
const IS_JEST_ENVIRONMENT = typeof jest !== 'undefined';
|
||||||
|
|
||||||
export const createInternalStateStore = (options: InternalStateThunkDependencies) => {
|
export const createInternalStateStore = (options: InternalStateDependencies) => {
|
||||||
const store = configureStore({
|
return configureStore({
|
||||||
reducer: internalStateSlice.reducer,
|
reducer: internalStateSlice.reducer,
|
||||||
middleware: (getDefaultMiddleware) =>
|
middleware: (getDefaultMiddleware) =>
|
||||||
getDefaultMiddleware({
|
getDefaultMiddleware({
|
||||||
thunk: { extraArgument: options },
|
thunk: { extraArgument: options },
|
||||||
serializableCheck: !IS_JEST_ENVIRONMENT,
|
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 InternalStateStore = ReturnType<typeof createInternalStateStore>;
|
||||||
|
|
||||||
export type InternalStateDispatch = InternalStateStore['dispatch'];
|
export type InternalStateDispatch = ThunkDispatch<
|
||||||
|
DiscoverInternalState,
|
||||||
|
InternalStateDependencies,
|
||||||
|
AnyAction
|
||||||
|
> &
|
||||||
|
Dispatch<AnyAction>;
|
||||||
|
|
||||||
type InternalStateThunkAction<TReturn = void> = ThunkAction<
|
type InternalStateThunkAction<TReturn = void> = ThunkAction<
|
||||||
TReturn,
|
TReturn,
|
||||||
|
|
|
@ -15,6 +15,7 @@ import type { UnifiedHistogramPartialLayoutProps } from '@kbn/unified-histogram'
|
||||||
import { useCurrentTabContext } from './hooks';
|
import { useCurrentTabContext } from './hooks';
|
||||||
import type { DiscoverStateContainer } from '../discover_state';
|
import type { DiscoverStateContainer } from '../discover_state';
|
||||||
import type { ConnectedCustomizationService } from '../../../../customizations';
|
import type { ConnectedCustomizationService } from '../../../../customizations';
|
||||||
|
import type { TabState } from './types';
|
||||||
|
|
||||||
interface DiscoverRuntimeState {
|
interface DiscoverRuntimeState {
|
||||||
adHocDataViews: DataView[];
|
adHocDataViews: DataView[];
|
||||||
|
@ -59,6 +60,33 @@ export const useRuntimeState = <T,>(stateSubject$: BehaviorSubject<T>) =>
|
||||||
export const selectTabRuntimeState = (runtimeStateManager: RuntimeStateManager, tabId: string) =>
|
export const selectTabRuntimeState = (runtimeStateManager: RuntimeStateManager, tabId: string) =>
|
||||||
runtimeStateManager.tabs.byId[tabId];
|
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,>(
|
export const useCurrentTabRuntimeState = <T,>(
|
||||||
runtimeStateManager: RuntimeStateManager,
|
runtimeStateManager: RuntimeStateManager,
|
||||||
selector: (tab: ReactiveTabRuntimeState) => BehaviorSubject<T>
|
selector: (tab: ReactiveTabRuntimeState) => BehaviorSubject<T>
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSelector } from '@reduxjs/toolkit';
|
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];
|
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])
|
(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 type ChartRequest = RequestState<{}>;
|
||||||
|
|
||||||
export interface InternalStateDataRequestParams {
|
export interface InternalStateDataRequestParams {
|
||||||
timeRangeAbsolute?: TimeRange;
|
timeRangeAbsolute: TimeRange | undefined;
|
||||||
timeRangeRelative?: TimeRange;
|
timeRangeRelative: TimeRange | undefined;
|
||||||
searchSessionId?: string;
|
searchSessionId: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TabState extends TabItem {
|
export interface TabState extends TabItem {
|
||||||
|
@ -66,6 +66,10 @@ export interface TabState extends TabItem {
|
||||||
chartRequest: ChartRequest;
|
chartRequest: ChartRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RecentlyClosedTabState extends TabState {
|
||||||
|
closedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DiscoverInternalState {
|
export interface DiscoverInternalState {
|
||||||
initializationState: { hasESData: boolean; hasUserDataView: boolean };
|
initializationState: { hasESData: boolean; hasUserDataView: boolean };
|
||||||
savedDataViews: DataViewListItem[];
|
savedDataViews: DataViewListItem[];
|
||||||
|
@ -74,8 +78,9 @@ export interface DiscoverInternalState {
|
||||||
initialDocViewerTabId?: string;
|
initialDocViewerTabId?: string;
|
||||||
isESQLToDataViewTransitionModalVisible: boolean;
|
isESQLToDataViewTransitionModalVisible: boolean;
|
||||||
tabs: {
|
tabs: {
|
||||||
byId: Record<string, TabState>;
|
byId: Record<string, TabState | RecentlyClosedTabState>;
|
||||||
allIds: string[];
|
allIds: string[];
|
||||||
|
recentlyClosedTabIds: string[];
|
||||||
/**
|
/**
|
||||||
* WARNING: You probably don't want to use this property.
|
* WARNING: You probably don't want to use this property.
|
||||||
* This is used high in the component tree for managing tabs,
|
* 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 { DiscoverInternalState, TabState } from './types';
|
||||||
import type {
|
import type {
|
||||||
InternalStateDispatch,
|
InternalStateDispatch,
|
||||||
InternalStateThunkDependencies,
|
InternalStateDependencies,
|
||||||
TabActionPayload,
|
TabActionPayload,
|
||||||
} from './internal_state';
|
} from './internal_state';
|
||||||
|
|
||||||
|
@ -24,7 +24,7 @@ type CreateInternalStateAsyncThunk = ReturnType<
|
||||||
typeof createAsyncThunk.withTypes<{
|
typeof createAsyncThunk.withTypes<{
|
||||||
state: DiscoverInternalState;
|
state: DiscoverInternalState;
|
||||||
dispatch: InternalStateDispatch;
|
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