[Discover] Keep fetched results when switching tabs (#216741)

## Summary

This PR implements the initial work to keep fetched results when
switching tabs:
- Avoid cancelling the current document request when switching tabs
(this still needs more work, especially migrating the `use_esql_mode`
hook to the central data fetching).
- Move `DiscoverStateContainer` and `DiscoverCustomizationService` to
`RuntimeStateManager` so they can be reused by tabs without
reinitializing.
- Re-add the current tab ID to `InternalStateStore` for high-level tab
management only (called `unsafeCurrentId` now to discourage misuse).
- Move `initializeAndSync` and initial `fetchData` call to the
`initializeSession` thunk to avoid calling it when switching back to
existing tabs.
- Move URL tracking directly into `DiscoverSavedSearchContainer` since
it previously used a hook which could now become out of sync because
`initializeAndSync` was moved (URL could update before the hook was
called).
- Support fully disconnecting tabs with a new `disconnectTab` thunk
(called on tab close and for all remaining tabs when leaving Discover).
- Sync global services to current tab state when switching tabs (this
should probably be cleaned up more, but it should work as a start).
- Basic implementation of `getPreviewData` (needs to be cleaned up).
- A couple of small misc changes (e.g. fixing scrollbar when tabs are
enabled).

Part of #216475.

### Checklist

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [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
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This was checked for breaking HTTP API changes, and any breaking
changes have been approved by the breaking-change committee. The
`release_note:breaking` label should be applied in these situations.
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [ ] 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)
This commit is contained in:
Davis McPhee 2025-04-08 16:28:46 -03:00 committed by GitHub
parent 990432ccc0
commit c3bcdab741
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 637 additions and 579 deletions

View file

@ -18,7 +18,7 @@ import {
createInternalStateStore,
createRuntimeStateManager,
} from '../application/main/state_management/redux';
import type { HistoryLocationState } from '../build_services';
import type { DiscoverServices, HistoryLocationState } from '../build_services';
import type { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import { createKbnUrlStateStorage, withNotifyOnErrors } from '@kbn/kibana-utils-plugin/public';
import type { History } from 'history';
@ -31,6 +31,7 @@ export function getDiscoverStateMock({
runtimeStateManager,
history,
customizationContext = mockCustomizationContext,
services: originalServices = discoverServiceMock,
}: {
isTimeBased?: boolean;
savedSearch?: SavedSearch | false;
@ -38,12 +39,13 @@ export function getDiscoverStateMock({
stateStorageContainer?: IKbnUrlStateStorage;
history?: History<HistoryLocationState>;
customizationContext?: DiscoverCustomizationContext;
services?: DiscoverServices;
}) {
if (!history) {
history = createBrowserHistory<HistoryLocationState>();
history.push('/');
}
const services = { ...discoverServiceMock, history };
const services = { ...originalServices, history };
const storeInSessionStorage = services.uiSettings.get('state:storeInSessionStorage');
const toasts = services.core.notifications.toasts;
stateStorageContainer =
@ -62,7 +64,7 @@ export function getDiscoverStateMock({
urlStateStorage: stateStorageContainer,
});
const container = getDiscoverStateContainer({
tabId: internalState.getState().tabs.allIds[0],
tabId: internalState.getState().tabs.unsafeCurrentId,
services,
customizationContext,
stateStorageContainer,

View file

@ -42,6 +42,7 @@ import {
RuntimeStateProvider,
internalStateActions,
} from '../../state_management/redux';
import { TABS_ENABLED } from '../../discover_main_route';
function getStateContainer(savedSearch?: SavedSearch) {
const stateContainer = getDiscoverStateMock({ isTimeBased: true, savedSearch });
@ -177,10 +178,12 @@ const mountComponent = async ({
describe('Discover histogram layout component', () => {
describe('render', () => {
it('should render null if there is no search session', async () => {
const { component } = await mountComponent({ searchSessionId: null });
expect(component.isEmptyRender()).toBe(true);
});
if (!TABS_ENABLED) {
it('should render null if there is no search session', async () => {
const { component } = await mountComponent({ searchSessionId: null });
expect(component.isEmptyRender()).toBe(true);
});
}
it('should not render null if there is a search session', async () => {
const { component } = await mountComponent();

View file

@ -15,6 +15,7 @@ import { useDiscoverHistogram } from './use_discover_histogram';
import { type DiscoverMainContentProps, DiscoverMainContent } from './discover_main_content';
import { useAppStateSelector } from '../../state_management/discover_app_state_container';
import { useIsEsqlMode } from '../../hooks/use_is_esql_mode';
import { TABS_ENABLED } from '../../discover_main_route';
export interface DiscoverHistogramLayoutProps extends DiscoverMainContentProps {
container: HTMLElement | null;
@ -51,8 +52,11 @@ export const DiscoverHistogramLayout = ({
// Initialized when the first search has been requested or
// when in ES|QL mode since search sessions are not supported
if (!searchSessionId && !isEsqlMode) {
return null;
// TODO: Handle this for tabs
if (!TABS_ENABLED) {
if (!searchSessionId && !isEsqlMode) {
return null;
}
}
return (

View file

@ -58,6 +58,7 @@ import { PanelsToggle } from '../../../../components/panels_toggle';
import { sendErrorMsg } from '../../hooks/use_saved_search_messages';
import { useIsEsqlMode } from '../../hooks/use_is_esql_mode';
import { useCurrentDataView, useCurrentTabSelector } from '../../state_management/redux';
import { TABS_ENABLED } from '../../discover_main_route';
const SidebarMemoized = React.memo(DiscoverSidebarResponsive);
const TopNavMemoized = React.memo(DiscoverTopNav);
@ -368,7 +369,7 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) {
background-color: ${pageBackgroundColor};
${useEuiBreakpoint(['m', 'l', 'xl'])} {
${kibanaFullBodyHeightCss()}
${kibanaFullBodyHeightCss(TABS_ENABLED ? 32 : undefined)}
}
`}
>

View file

@ -9,8 +9,4 @@
export { BrandedLoadingIndicator } from './branded_loading_indicator';
export { NoDataPage } from './no_data_page';
export {
DiscoverSessionView,
type DiscoverSessionViewProps,
type DiscoverSessionViewRef,
} from './session_view';
export { DiscoverSessionView, type DiscoverSessionViewProps } from './session_view';

View file

@ -9,7 +9,6 @@
import React, { useEffect } from 'react';
import { RootDragDropProvider } from '@kbn/dom-drag-drop';
import { useUrlTracking } from '../../hooks/use_url_tracking';
import type { DiscoverStateContainer } from '../../state_management/discover_state';
import { DiscoverLayout } from '../layout';
import { setBreadcrumbs } from '../../../../utils/breadcrumbs';
@ -19,7 +18,6 @@ import { useSavedSearchAliasMatchRedirect } from '../../../../hooks/saved_search
import { useSavedSearchInitial } from '../../state_management/discover_state_provider';
import { useAdHocDataViews } from '../../hooks/use_adhoc_data_views';
import { useEsqlMode } from '../../hooks/use_esql_mode';
import { addLog } from '../../../../utils/add_log';
const DiscoverLayoutMemoized = React.memo(DiscoverLayout);
@ -30,18 +28,15 @@ export interface DiscoverMainProps {
stateContainer: DiscoverStateContainer;
}
export function DiscoverMainApp(props: DiscoverMainProps) {
const { stateContainer } = props;
export function DiscoverMainApp({ stateContainer }: DiscoverMainProps) {
const savedSearch = useSavedSearchInitial();
const services = useDiscoverServices();
const { chrome, docLinks, data, spaces, history } = services;
useUrlTracking(stateContainer);
/**
* Adhoc data views functionality
*/
useAdHocDataViews({ stateContainer, services });
useAdHocDataViews();
/**
* State changes (data view, columns), when a text base query result is returned
@ -51,16 +46,6 @@ export function DiscoverMainApp(props: DiscoverMainProps) {
stateContainer,
});
/**
* Start state syncing and fetch data if necessary
*/
useEffect(() => {
stateContainer.actions.initializeAndSync();
addLog('[DiscoverMainApp] state container initialization triggers data fetching');
stateContainer.actions.fetchData(true);
return () => stateContainer.actions.stopSyncing();
}, [stateContainer]);
/**
* SavedSearch dependent initializing
*/

View file

@ -7,13 +7,15 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { forwardRef, useEffect, useImperativeHandle } from 'react';
import React from 'react';
import type { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/public';
import { useParams } from 'react-router-dom';
import useLatest from 'react-use/lib/useLatest';
import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/common';
import { useExecutionContext } from '@kbn/kibana-react-plugin/public';
import useMount from 'react-use/lib/useMount';
import useUpdateEffect from 'react-use/lib/useUpdateEffect';
import { useUrl } from '../../hooks/use_url';
import { useAlertResultsToast } from '../../hooks/use_alert_results_toast';
import { createDataViewDataSource } from '../../../../../common/data_sources';
@ -21,7 +23,6 @@ import type { MainHistoryLocationState } from '../../../../../common';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
import type { DiscoverAppState } from '../../state_management/discover_app_state_container';
import { getDiscoverStateContainer } from '../../state_management/discover_state';
import type { DiscoverStateContainer } from '../../state_management/discover_state';
import {
RuntimeStateProvider,
internalStateActions,
@ -39,7 +40,7 @@ import type {
import type { InternalStateStore, RuntimeStateManager } from '../../state_management/redux';
import {
DiscoverCustomizationProvider,
useDiscoverCustomizationService,
getConnectedCustomizationService,
} from '../../../../customizations';
import { DiscoverError } from '../../../../components/common/error_alert';
import { NoDataPage } from './no_data_page';
@ -57,171 +58,163 @@ export interface DiscoverSessionViewProps {
runtimeStateManager: RuntimeStateManager;
}
export interface DiscoverSessionViewRef {
stopSyncing: () => void;
interface SessionInitializationState {
showNoDataPage: boolean;
}
type SessionInitializationState =
| {
showNoDataPage: true;
stateContainer: undefined;
}
| {
showNoDataPage: false;
stateContainer: DiscoverStateContainer;
};
type InitializeSession = (options?: {
dataViewSpec?: DataViewSpec | undefined;
defaultUrlState?: DiscoverAppState;
}) => Promise<SessionInitializationState>;
export const DiscoverSessionView = forwardRef<DiscoverSessionViewRef, DiscoverSessionViewProps>(
(
{
customizationContext,
customizationCallbacks,
urlStateStorage,
internalState,
runtimeStateManager,
},
ref
) => {
const dispatch = useInternalStateDispatch();
const services = useDiscoverServices();
const { core, history, getScopedHistory } = services;
const { id: discoverSessionId } = useParams<{ id?: string }>();
const currentTabId = useCurrentTabSelector((tab) => tab.id);
const initializeSessionAction = useCurrentTabAction(internalStateActions.initializeSession);
const [initializeSessionState, initializeSession] = useAsyncFunction<InitializeSession>(
async ({ dataViewSpec, defaultUrlState } = {}) => {
initializeSessionState.value?.stateContainer?.actions.stopSyncing();
const stateContainer = getDiscoverStateContainer({
tabId: currentTabId,
services,
customizationContext,
stateStorageContainer: urlStateStorage,
internalState,
runtimeStateManager,
});
const { showNoDataPage } = await dispatch(
initializeSessionAction({
initializeSessionParams: {
stateContainer,
discoverSessionId,
dataViewSpec,
defaultUrlState,
},
})
);
return showNoDataPage ? { showNoDataPage } : { showNoDataPage, stateContainer };
}
);
const initializeSessionWithDefaultLocationState = useLatest(() => {
const historyLocationState = getScopedHistory<
MainHistoryLocationState & { defaultState?: DiscoverAppState }
>()?.location.state;
initializeSession({
dataViewSpec: historyLocationState?.dataViewSpec,
defaultUrlState: historyLocationState?.defaultState,
export const DiscoverSessionView = ({
customizationContext,
customizationCallbacks,
urlStateStorage,
internalState,
runtimeStateManager,
}: DiscoverSessionViewProps) => {
const dispatch = useInternalStateDispatch();
const services = useDiscoverServices();
const { core, history, getScopedHistory } = services;
const { id: discoverSessionId } = useParams<{ id?: string }>();
const currentTabId = useCurrentTabSelector((tab) => tab.id);
const currentStateContainer = useCurrentTabRuntimeState(
runtimeStateManager,
(tab) => tab.stateContainer$
);
const currentCustomizationService = useCurrentTabRuntimeState(
runtimeStateManager,
(tab) => tab.customizationService$
);
const initializeSessionAction = useCurrentTabAction(internalStateActions.initializeSession);
const [initializeSessionState, initializeSession] = useAsyncFunction<InitializeSession>(
async ({ dataViewSpec, defaultUrlState } = {}) => {
const stateContainer = getDiscoverStateContainer({
tabId: currentTabId,
services,
customizationContext,
stateStorageContainer: urlStateStorage,
internalState,
runtimeStateManager,
});
const customizationService = await getConnectedCustomizationService({
stateContainer,
customizationCallbacks,
});
});
const customizationService = useDiscoverCustomizationService({
customizationCallbacks,
stateContainer: initializeSessionState.value?.stateContainer,
});
const initializationState = useInternalStateSelector((state) => state.initializationState);
const currentDataView = useCurrentTabRuntimeState(
runtimeStateManager,
(tab) => tab.currentDataView$
);
const adHocDataViews = useRuntimeState(runtimeStateManager.adHocDataViews$);
useImperativeHandle(
ref,
() => ({
stopSyncing: () => initializeSessionState.value?.stateContainer?.actions.stopSyncing(),
}),
[initializeSessionState.value?.stateContainer]
);
return dispatch(
initializeSessionAction({
initializeSessionParams: {
stateContainer,
customizationService,
discoverSessionId,
dataViewSpec,
defaultUrlState,
},
})
);
},
currentStateContainer && currentCustomizationService
? { loading: false, value: { showNoDataPage: false } }
: { loading: true }
);
const initializeSessionWithDefaultLocationState = useLatest(() => {
const historyLocationState = getScopedHistory<
MainHistoryLocationState & { defaultState?: DiscoverAppState }
>()?.location.state;
initializeSession({
dataViewSpec: historyLocationState?.dataViewSpec,
defaultUrlState: historyLocationState?.defaultState,
});
});
const initializationState = useInternalStateSelector((state) => state.initializationState);
const currentDataView = useCurrentTabRuntimeState(
runtimeStateManager,
(tab) => tab.currentDataView$
);
const adHocDataViews = useRuntimeState(runtimeStateManager.adHocDataViews$);
useEffect(() => {
useMount(() => {
if (!currentStateContainer || !currentCustomizationService) {
initializeSessionWithDefaultLocationState.current();
}, [discoverSessionId, initializeSessionWithDefaultLocationState]);
useUrl({
history,
savedSearchId: discoverSessionId,
onNewUrl: () => {
initializeSessionWithDefaultLocationState.current();
},
});
useAlertResultsToast();
useExecutionContext(core.executionContext, {
type: 'application',
page: 'app',
id: discoverSessionId || 'new',
});
if (initializeSessionState.loading) {
return <BrandedLoadingIndicator />;
}
});
if (initializeSessionState.error) {
if (initializeSessionState.error instanceof SavedObjectNotFound) {
return (
<RedirectWhenSavedObjectNotFound
error={initializeSessionState.error}
discoverSessionId={discoverSessionId}
/>
);
}
useUpdateEffect(() => {
initializeSessionWithDefaultLocationState.current();
}, [discoverSessionId, initializeSessionWithDefaultLocationState]);
return <DiscoverError error={initializeSessionState.error} />;
}
useUrl({
history,
savedSearchId: discoverSessionId,
onNewUrl: () => {
initializeSessionWithDefaultLocationState.current();
},
});
if (initializeSessionState.value.showNoDataPage) {
useAlertResultsToast();
useExecutionContext(core.executionContext, {
type: 'application',
page: 'app',
id: discoverSessionId || 'new',
});
if (initializeSessionState.loading) {
return <BrandedLoadingIndicator />;
}
if (initializeSessionState.error) {
if (initializeSessionState.error instanceof SavedObjectNotFound) {
return (
<NoDataPage
{...initializationState}
onDataViewCreated={async (dataViewUnknown) => {
await dispatch(internalStateActions.loadDataViewList());
dispatch(
internalStateActions.setInitializationState({
hasESData: true,
hasUserDataView: true,
})
);
const dataView = dataViewUnknown as DataView;
initializeSession({
defaultUrlState: dataView.id
? { dataSource: createDataViewDataSource({ dataViewId: dataView.id }) }
: undefined,
});
}}
onESQLNavigationComplete={() => {
initializeSession();
}}
<RedirectWhenSavedObjectNotFound
error={initializeSessionState.error}
discoverSessionId={discoverSessionId}
/>
);
}
if (!customizationService || !currentDataView) {
return <BrandedLoadingIndicator />;
}
return <DiscoverError error={initializeSessionState.error} />;
}
if (initializeSessionState.value.showNoDataPage) {
return (
<DiscoverCustomizationProvider value={customizationService}>
<DiscoverMainProvider value={initializeSessionState.value.stateContainer}>
<RuntimeStateProvider currentDataView={currentDataView} adHocDataViews={adHocDataViews}>
<DiscoverMainApp stateContainer={initializeSessionState.value.stateContainer} />
</RuntimeStateProvider>
</DiscoverMainProvider>
</DiscoverCustomizationProvider>
<NoDataPage
{...initializationState}
onDataViewCreated={async (dataViewUnknown) => {
await dispatch(internalStateActions.loadDataViewList());
dispatch(
internalStateActions.setInitializationState({
hasESData: true,
hasUserDataView: true,
})
);
const dataView = dataViewUnknown as DataView;
initializeSession({
defaultUrlState: dataView.id
? { dataSource: createDataViewDataSource({ dataViewId: dataView.id }) }
: undefined,
});
}}
onESQLNavigationComplete={() => {
initializeSession();
}}
/>
);
}
);
if (!currentStateContainer || !currentCustomizationService || !currentDataView) {
return <BrandedLoadingIndicator />;
}
return (
<DiscoverCustomizationProvider value={currentCustomizationService}>
<DiscoverMainProvider value={currentStateContainer}>
<RuntimeStateProvider currentDataView={currentDataView} adHocDataViews={adHocDataViews}>
<DiscoverMainApp stateContainer={currentStateContainer} />
</RuntimeStateProvider>
</DiscoverMainProvider>
</DiscoverCustomizationProvider>
);
};

View file

@ -8,56 +8,68 @@
*/
import { type TabItem, UnifiedTabs, TabStatus } from '@kbn/unified-tabs';
import React, { useRef, useState } from 'react';
import React, { useState } from 'react';
import { pick } from 'lodash';
import type { DiscoverSessionViewRef } from '../session_view';
import { isOfAggregateQueryType } from '@kbn/es-query';
import { DiscoverSessionView, type DiscoverSessionViewProps } from '../session_view';
import {
CurrentTabProvider,
createTabItem,
internalStateActions,
selectAllTabs,
selectTabRuntimeState,
useInternalStateDispatch,
useInternalStateSelector,
} from '../../state_management/redux';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
import { FetchStatus } from '../../../types';
export const TabsView = ({
initialTabId,
sessionViewProps,
}: {
initialTabId: string;
sessionViewProps: DiscoverSessionViewProps;
}) => {
export const TabsView = (props: DiscoverSessionViewProps) => {
const services = useDiscoverServices();
const dispatch = useInternalStateDispatch();
const allTabs = useInternalStateSelector(selectAllTabs);
const [currentTabId, setCurrentTabId] = useState(initialTabId);
const currentTabId = useInternalStateSelector((state) => state.tabs.unsafeCurrentId);
const [initialItems] = useState<TabItem[]>(() => allTabs.map((tab) => pick(tab, 'id', 'label')));
const sessionViewRef = useRef<DiscoverSessionViewRef>(null);
return (
<UnifiedTabs
services={services}
initialItems={initialItems}
onChanged={async (updateState) => {
await dispatch(
internalStateActions.updateTabs({
currentTabId,
updateState,
stopSyncing: sessionViewRef.current?.stopSyncing,
})
);
setCurrentTabId(updateState.selectedItem?.id ?? currentTabId);
}}
onChanged={(updateState) => dispatch(internalStateActions.updateTabs(updateState))}
createItem={() => createTabItem(allTabs)}
getPreviewData={() => ({
query: { language: 'kuery', query: 'sample query' },
status: TabStatus.SUCCESS,
})}
getPreviewData={(item) => {
const defaultQuery = { language: 'kuery', query: '(Empty query)' };
const stateContainer = selectTabRuntimeState(
props.runtimeStateManager,
item.id
).stateContainer$.getValue();
if (!stateContainer) {
return {
query: defaultQuery,
status: TabStatus.RUNNING,
};
}
const fetchStatus = stateContainer.dataState.data$.main$.getValue().fetchStatus;
const query = stateContainer.appState.getState().query;
return {
query: isOfAggregateQueryType(query)
? { esql: query.esql.trim() || defaultQuery.query }
: query
? { ...query, query: query.query.trim() || defaultQuery.query }
: defaultQuery,
status: [FetchStatus.UNINITIALIZED, FetchStatus.COMPLETE].includes(fetchStatus)
? TabStatus.SUCCESS
: fetchStatus === FetchStatus.ERROR
? TabStatus.ERROR
: TabStatus.RUNNING,
};
}}
renderContent={() => (
<CurrentTabProvider currentTabId={currentTabId}>
<DiscoverSessionView key={currentTabId} ref={sessionViewRef} {...sessionViewProps} />
<DiscoverSessionView key={currentTabId} {...props} />
</CurrentTabProvider>
)}
/>

View file

@ -25,13 +25,13 @@ import type { MainHistoryLocationState } from '../../../common';
import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
import type { RootProfileState } from '../../context_awareness';
let mockCustomizationService: DiscoverCustomizationService | undefined;
let mockCustomizationService: Promise<DiscoverCustomizationService> | undefined;
jest.mock('../../customizations', () => {
const originalModule = jest.requireActual('../../customizations');
return {
...originalModule,
useDiscoverCustomizationService: () => mockCustomizationService,
useDiscoverCustomizationService: () => () => mockCustomizationService,
};
});
@ -58,7 +58,7 @@ jest.mock('../../context_awareness', () => {
describe('DiscoverMainRoute', () => {
beforeEach(() => {
mockCustomizationService = createCustomizationService();
mockCustomizationService = Promise.resolve(createCustomizationService());
mockRootProfileState = defaultRootProfileState;
});
@ -124,13 +124,16 @@ describe('DiscoverMainRoute', () => {
});
test('renders LoadingIndicator while customizations are loading', async () => {
mockCustomizationService = undefined;
let resolveService = (_: DiscoverCustomizationService) => {};
mockCustomizationService = new Promise((resolve) => {
resolveService = resolve;
});
const component = mountComponent(true, true);
await waitFor(() => {
component.update();
expect(component.find(DiscoverMainApp).exists()).toBe(false);
});
mockCustomizationService = createCustomizationService();
resolveService(createCustomizationService());
await waitFor(() => {
component.setProps({}).update();
expect(component.find(DiscoverMainApp).exists()).toBe(true);

View file

@ -12,6 +12,7 @@ import type { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import { createKbnUrlStateStorage, withNotifyOnErrors } from '@kbn/kibana-utils-plugin/public';
import { useEffect, useState } from 'react';
import React from 'react';
import useUnmount from 'react-use/lib/useUnmount';
import { useDiscoverServices } from '../../hooks/use_discover_services';
import type { CustomizationCallback, DiscoverCustomizationContext } from '../../customizations';
import {
@ -35,7 +36,7 @@ import { useAsyncFunction } from './hooks/use_async_function';
import { TabsView } from './components/tabs_view';
// TEMPORARY: This is a temporary flag to enable/disable tabs in Discover until the feature is fully implemented.
const TABS_ENABLED = false;
export const TABS_ENABLED = false;
export interface MainRouteProps {
customizationContext: DiscoverCustomizationContext;
@ -76,7 +77,6 @@ export const DiscoverMainRoute = ({
urlStateStorage,
})
);
const [initialTabId] = useState(() => internalState.getState().tabs.allIds[0]);
const { initializeProfileDataViews } = useDefaultAdHocDataViews({ internalState });
const [mainRouteInitializationState, initializeMainRoute] = useAsyncFunction<InitializeMainRoute>(
async (loadedRootProfileState) => {
@ -105,6 +105,12 @@ export const DiscoverMainRoute = ({
}
}, [initializeMainRoute, rootProfileState]);
useUnmount(() => {
for (const tabId of Object.keys(runtimeStateManager.tabs.byId)) {
internalState.dispatch(internalStateActions.disconnectTab({ tabId }));
}
});
if (rootProfileState.rootProfileLoading || mainRouteInitializationState.loading) {
return <BrandedLoadingIndicator />;
}
@ -139,9 +145,9 @@ export const DiscoverMainRoute = ({
<InternalStateProvider store={internalState}>
<rootProfileState.AppWrapper>
{TABS_ENABLED ? (
<TabsView initialTabId={initialTabId} sessionViewProps={sessionViewProps} />
<TabsView {...sessionViewProps} />
) : (
<CurrentTabProvider currentTabId={initialTabId}>
<CurrentTabProvider currentTabId={internalState.getState().tabs.unsafeCurrentId}>
<DiscoverSessionView {...sessionViewProps} />
</CurrentTabProvider>
)}

View file

@ -9,30 +9,24 @@
import { useEffect } from 'react';
import { METRIC_TYPE } from '@kbn/analytics';
import type { DiscoverServices } from '../../../build_services';
import { useSavedSearch } from '../state_management/discover_state_provider';
import { ADHOC_DATA_VIEW_RENDER_EVENT } from '../../../constants';
import type { DiscoverStateContainer } from '../state_management/discover_state';
import { useFiltersValidation } from './use_filters_validation';
import { useIsEsqlMode } from './use_is_esql_mode';
import { useCurrentDataView } from '../state_management/redux';
import { useDiscoverServices } from '../../../hooks/use_discover_services';
export const useAdHocDataViews = ({
services,
}: {
stateContainer: DiscoverStateContainer;
services: DiscoverServices;
}) => {
export const useAdHocDataViews = () => {
const dataView = useCurrentDataView();
const savedSearch = useSavedSearch();
const isEsqlMode = useIsEsqlMode();
const { filterManager, toastNotifications } = services;
const { filterManager, toastNotifications, trackUiMetric } = useDiscoverServices();
useEffect(() => {
if (dataView && !dataView.isPersisted()) {
services.trackUiMetric?.(METRIC_TYPE.COUNT, ADHOC_DATA_VIEW_RENDER_EVENT);
trackUiMetric?.(METRIC_TYPE.COUNT, ADHOC_DATA_VIEW_RENDER_EVENT);
}
}, [dataView, isEsqlMode, services]);
}, [dataView, isEsqlMode, trackUiMetric]);
/**
* Takes care of checking data view id references in filters

View file

@ -1,87 +0,0 @@
/*
* 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 { renderHook } from '@testing-library/react';
import { useUrlTracking } from './use_url_tracking';
import React from 'react';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { createDiscoverServicesMock } from '../../../__mocks__/services';
import { getDiscoverStateMock } from '../../../__mocks__/discover_state.mock';
import type { DiscoverServices } from '../../../build_services';
import type { DiscoverStateContainer } from '../state_management/discover_state';
import { omit } from 'lodash';
import { createSavedSearchAdHocMock, createSavedSearchMock } from '../../../__mocks__/saved_search';
import { internalStateActions } from '../state_management/redux';
const renderUrlTracking = ({
services,
stateContainer,
}: {
services: DiscoverServices;
stateContainer: DiscoverStateContainer;
}) =>
renderHook(useUrlTracking, {
initialProps: stateContainer,
wrapper: ({ children }) => (
<KibanaContextProvider services={services}>{children}</KibanaContextProvider>
),
});
describe('useUrlTracking', () => {
it('should enable URL tracking for a persisted data view', () => {
const services = createDiscoverServicesMock();
const savedSearch = omit(createSavedSearchMock(), 'id');
const stateContainer = getDiscoverStateMock({ savedSearch });
expect(services.urlTracker.setTrackingEnabled).not.toHaveBeenCalled();
renderUrlTracking({ services, stateContainer });
expect(services.urlTracker.setTrackingEnabled).toHaveBeenCalledWith(true);
});
it('should disable URL tracking for an ad hoc data view', () => {
const services = createDiscoverServicesMock();
const savedSearch = omit(createSavedSearchAdHocMock(), 'id');
const stateContainer = getDiscoverStateMock({ savedSearch });
expect(services.urlTracker.setTrackingEnabled).not.toHaveBeenCalled();
renderUrlTracking({ services, stateContainer });
expect(services.urlTracker.setTrackingEnabled).toHaveBeenCalledWith(false);
});
it('should enable URL tracking if the ad hoc data view is a default profile data view', () => {
const services = createDiscoverServicesMock();
const savedSearch = omit(createSavedSearchAdHocMock(), 'id');
const stateContainer = getDiscoverStateMock({ savedSearch });
stateContainer.internalState.dispatch(
internalStateActions.setDefaultProfileAdHocDataViews([
savedSearch.searchSource.getField('index')!,
])
);
expect(services.urlTracker.setTrackingEnabled).not.toHaveBeenCalled();
renderUrlTracking({ services, stateContainer });
expect(services.urlTracker.setTrackingEnabled).toHaveBeenCalledWith(true);
});
it('should enable URL tracking with an ad hoc data view if in ES|QL mode', () => {
const services = createDiscoverServicesMock();
const savedSearch = omit(createSavedSearchAdHocMock(), 'id');
savedSearch.searchSource.setField('query', { esql: 'FROM test' });
const stateContainer = getDiscoverStateMock({ savedSearch });
expect(services.urlTracker.setTrackingEnabled).not.toHaveBeenCalled();
renderUrlTracking({ services, stateContainer });
expect(services.urlTracker.setTrackingEnabled).toHaveBeenCalledWith(true);
});
it('should enable URL tracking with an ad hoc data view if the saved search has an ID (persisted)', () => {
const services = createDiscoverServicesMock();
const savedSearch = createSavedSearchAdHocMock();
const stateContainer = getDiscoverStateMock({ savedSearch });
expect(services.urlTracker.setTrackingEnabled).not.toHaveBeenCalled();
renderUrlTracking({ services, stateContainer });
expect(services.urlTracker.setTrackingEnabled).toHaveBeenCalledWith(true);
});
});

View file

@ -1,47 +0,0 @@
/*
* 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 } from 'react';
import { isOfAggregateQueryType } from '@kbn/es-query';
import { useDiscoverServices } from '../../../hooks/use_discover_services';
import type { DiscoverStateContainer } from '../state_management/discover_state';
/**
* Enable/disable kbn url tracking (That's the URL used when selecting Discover in the side menu)
*/
export function useUrlTracking(stateContainer: DiscoverStateContainer) {
const { savedSearchState, internalState } = stateContainer;
const { urlTracker } = useDiscoverServices();
useEffect(() => {
const subscription = savedSearchState.getCurrent$().subscribe((savedSearch) => {
const dataView = savedSearch.searchSource.getField('index');
if (!dataView?.id) {
return;
}
const dataViewSupportsTracking =
// Disable for ad hoc data views, since they can't be restored after a page refresh
dataView.isPersisted() ||
// Unless it's a default profile data view, which can be restored on refresh
internalState.getState().defaultProfileAdHocDataViewIds.includes(dataView.id) ||
// Or we're in ES|QL mode, in which case we don't care about the data view
isOfAggregateQueryType(savedSearch.searchSource.getField('query'));
const trackingEnabled = dataViewSupportsTracking || Boolean(savedSearch.id);
urlTracker.setTrackingEnabled(trackingEnabled);
});
return () => {
subscription.unsubscribe();
};
}, [internalState, savedSearchState, urlTracker]);
}

View file

@ -350,8 +350,6 @@ export function getDataStateContainer({
.subscribe();
return () => {
abortController?.abort();
abortControllerFetchMore?.abort();
subscription.unsubscribe();
};
}

View file

@ -10,15 +10,21 @@
import { getSavedSearchContainer, isEqualSavedSearch } from './discover_saved_search_container';
import type { SavedSearch } from '@kbn/saved-search-plugin/public';
import { discoverServiceMock } from '../../../__mocks__/services';
import { savedSearchMock, savedSearchMockWithTimeField } from '../../../__mocks__/saved_search';
import {
createSavedSearchAdHocMock,
createSavedSearchMock,
savedSearchMock,
savedSearchMockWithTimeField,
} from '../../../__mocks__/saved_search';
import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
import { dataViewComplexMock } from '../../../__mocks__/data_view_complex';
import { getDiscoverGlobalStateContainer } from './discover_global_state_container';
import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import { VIEW_MODE } from '../../../../common/constants';
import { createSearchSourceMock } from '@kbn/data-plugin/common/search/search_source/mocks';
import { createInternalStateStore, createRuntimeStateManager } from './redux';
import { createInternalStateStore, createRuntimeStateManager, internalStateActions } from './redux';
import { mockCustomizationContext } from '../../../customizations/__mocks__/customization_context';
import { omit } from 'lodash';
describe('DiscoverSavedSearchContainer', () => {
const savedSearch = savedSearchMock;
@ -37,7 +43,6 @@ describe('DiscoverSavedSearchContainer', () => {
const container = getSavedSearchContainer({
services,
globalStateContainer,
internalState,
});
expect(container.getTitle()).toBe(undefined);
@ -47,7 +52,6 @@ describe('DiscoverSavedSearchContainer', () => {
const container = getSavedSearchContainer({
services,
globalStateContainer,
internalState,
});
container.set(savedSearch);
@ -60,7 +64,6 @@ describe('DiscoverSavedSearchContainer', () => {
const container = getSavedSearchContainer({
services,
globalStateContainer,
internalState,
});
const newSavedSearch: SavedSearch = { ...savedSearch, title: 'New title' };
@ -78,7 +81,6 @@ describe('DiscoverSavedSearchContainer', () => {
const container = getSavedSearchContainer({
services,
globalStateContainer,
internalState,
});
const newSavedSearch: SavedSearch = { ...savedSearch, title: 'New title' };
@ -95,7 +97,6 @@ describe('DiscoverSavedSearchContainer', () => {
const savedSearchContainer = getSavedSearchContainer({
services: discoverServiceMock,
globalStateContainer,
internalState,
});
const savedSearchToPersist = {
@ -122,7 +123,6 @@ describe('DiscoverSavedSearchContainer', () => {
const savedSearchContainer = getSavedSearchContainer({
services: discoverServiceMock,
globalStateContainer,
internalState,
});
@ -136,7 +136,6 @@ describe('DiscoverSavedSearchContainer', () => {
const savedSearchContainer = getSavedSearchContainer({
services: discoverServiceMock,
globalStateContainer,
internalState,
});
const savedSearchToPersist = {
@ -157,7 +156,6 @@ describe('DiscoverSavedSearchContainer', () => {
const savedSearchContainer = getSavedSearchContainer({
services: discoverServiceMock,
globalStateContainer,
internalState,
});
const savedSearchToPersist = {
@ -183,7 +181,6 @@ describe('DiscoverSavedSearchContainer', () => {
const savedSearchContainer = getSavedSearchContainer({
services: discoverServiceMock,
globalStateContainer,
internalState,
});
savedSearchContainer.set(savedSearch);
@ -206,7 +203,6 @@ describe('DiscoverSavedSearchContainer', () => {
const savedSearchContainer = getSavedSearchContainer({
services: discoverServiceMock,
globalStateContainer,
internalState,
});
savedSearchContainer.set(savedSearch);
@ -223,7 +219,6 @@ describe('DiscoverSavedSearchContainer', () => {
const savedSearchContainer = getSavedSearchContainer({
services: discoverServiceMock,
globalStateContainer,
internalState,
});
const updated = savedSearchContainer.update({ nextDataView: dataViewMock });
@ -289,4 +284,87 @@ describe('DiscoverSavedSearchContainer', () => {
expect(isEqualSavedSearch(savedSearch1, savedSearch2)).toBe(false);
});
});
describe('URL tracking', () => {
it('should enable URL tracking for a persisted data view', () => {
const savedSearchContainer = getSavedSearchContainer({
services: discoverServiceMock,
globalStateContainer,
internalState,
});
const unsubscribe = savedSearchContainer.initUrlTracking();
jest.spyOn(services.urlTracker, 'setTrackingEnabled').mockClear();
expect(services.urlTracker.setTrackingEnabled).not.toHaveBeenCalled();
const currentSavedSearch = omit(createSavedSearchMock(), 'id');
savedSearchContainer.set(currentSavedSearch);
expect(services.urlTracker.setTrackingEnabled).toHaveBeenCalledWith(true);
unsubscribe();
});
it('should disable URL tracking for an ad hoc data view', () => {
const savedSearchContainer = getSavedSearchContainer({
services: discoverServiceMock,
globalStateContainer,
internalState,
});
const unsubscribe = savedSearchContainer.initUrlTracking();
jest.spyOn(services.urlTracker, 'setTrackingEnabled').mockClear();
expect(services.urlTracker.setTrackingEnabled).not.toHaveBeenCalled();
const currentSavedSearch = omit(createSavedSearchAdHocMock(), 'id');
savedSearchContainer.set(currentSavedSearch);
expect(services.urlTracker.setTrackingEnabled).toHaveBeenCalledWith(false);
unsubscribe();
});
it('should enable URL tracking if the ad hoc data view is a default profile data view', () => {
const savedSearchContainer = getSavedSearchContainer({
services: discoverServiceMock,
globalStateContainer,
internalState,
});
const unsubscribe = savedSearchContainer.initUrlTracking();
jest.spyOn(services.urlTracker, 'setTrackingEnabled').mockClear();
expect(services.urlTracker.setTrackingEnabled).not.toHaveBeenCalled();
const currentSavedSearch = omit(createSavedSearchAdHocMock(), 'id');
internalState.dispatch(
internalStateActions.setDefaultProfileAdHocDataViews([
currentSavedSearch.searchSource.getField('index')!,
])
);
savedSearchContainer.set(currentSavedSearch);
expect(services.urlTracker.setTrackingEnabled).toHaveBeenCalledWith(true);
unsubscribe();
});
it('should enable URL tracking with an ad hoc data view if in ES|QL mode', () => {
const savedSearchContainer = getSavedSearchContainer({
services: discoverServiceMock,
globalStateContainer,
internalState,
});
const unsubscribe = savedSearchContainer.initUrlTracking();
jest.spyOn(services.urlTracker, 'setTrackingEnabled').mockClear();
expect(services.urlTracker.setTrackingEnabled).not.toHaveBeenCalled();
const currentSavedSearch = omit(createSavedSearchAdHocMock(), 'id');
currentSavedSearch.searchSource.setField('query', { esql: 'FROM test' });
savedSearchContainer.set(currentSavedSearch);
expect(services.urlTracker.setTrackingEnabled).toHaveBeenCalledWith(true);
unsubscribe();
});
it('should enable URL tracking with an ad hoc data view if the saved search has an ID (persisted)', () => {
const savedSearchContainer = getSavedSearchContainer({
services: discoverServiceMock,
globalStateContainer,
internalState,
});
const unsubscribe = savedSearchContainer.initUrlTracking();
jest.spyOn(services.urlTracker, 'setTrackingEnabled').mockClear();
expect(services.urlTracker.setTrackingEnabled).not.toHaveBeenCalled();
const currentSavedSearch = createSavedSearchAdHocMock();
savedSearchContainer.set(currentSavedSearch);
expect(services.urlTracker.setTrackingEnabled).toHaveBeenCalledWith(true);
unsubscribe();
});
});
});

View file

@ -12,7 +12,7 @@ import type { SavedSearch } from '@kbn/saved-search-plugin/public';
import { BehaviorSubject } from 'rxjs';
import { cloneDeep } from 'lodash';
import type { FilterCompareOptions } from '@kbn/es-query';
import { COMPARE_ALL_OPTIONS, updateFilterReferences } from '@kbn/es-query';
import { COMPARE_ALL_OPTIONS, isOfAggregateQueryType, updateFilterReferences } from '@kbn/es-query';
import type { SearchSourceFields } from '@kbn/data-plugin/common';
import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/common';
import type { UnifiedHistogramVisContext } from '@kbn/unified-histogram-plugin/public';
@ -58,6 +58,10 @@ export interface UpdateParams {
* - useSavedSearchInitial for the persisted or initial state, just updated when the saved search is peristed or loaded
*/
export interface DiscoverSavedSearchContainer {
/**
* Enable/disable kbn url tracking (That's the URL used when selecting Discover in the side menu)
*/
initUrlTracking: () => () => void;
/**
* Get an BehaviorSubject which contains the current state of the current saved search
* All modifications are applied to this state
@ -148,6 +152,32 @@ export function getSavedSearchContainer({
const getTitle = () => savedSearchCurrent$.getValue().title;
const getId = () => savedSearchCurrent$.getValue().id;
const initUrlTracking = () => {
const subscription = savedSearchCurrent$.subscribe((savedSearch) => {
const dataView = savedSearch.searchSource.getField('index');
if (!dataView?.id) {
return;
}
const dataViewSupportsTracking =
// Disable for ad hoc data views, since they can't be restored after a page refresh
dataView.isPersisted() ||
// Unless it's a default profile data view, which can be restored on refresh
internalState.getState().defaultProfileAdHocDataViewIds.includes(dataView.id) ||
// Or we're in ES|QL mode, in which case we don't care about the data view
isOfAggregateQueryType(savedSearch.searchSource.getField('query'));
const trackingEnabled = dataViewSupportsTracking || Boolean(savedSearch.id);
services.urlTracker.setTrackingEnabled(trackingEnabled);
});
return () => {
subscription.unsubscribe();
};
};
const persist = async (nextSavedSearch: SavedSearch, saveOptions?: SavedObjectSaveOpts) => {
addLog('[savedSearch] persist', { nextSavedSearch, saveOptions });
@ -264,6 +294,7 @@ export function getSavedSearchContainer({
};
return {
initUrlTracking,
getCurrent$,
getHasChanged$,
getId,

View file

@ -21,7 +21,7 @@ import {
savedSearchMockWithTimeFieldNew,
savedSearchMockWithESQL,
} from '../../../__mocks__/saved_search';
import { discoverServiceMock } from '../../../__mocks__/services';
import { createDiscoverServicesMock } from '../../../__mocks__/services';
import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
import { getInitialState, type DiscoverAppStateContainer } from './discover_app_state_container';
import { waitFor } from '@testing-library/react';
@ -36,6 +36,7 @@ import { createRuntimeStateManager } from './redux';
import type { HistoryLocationState } from '../../../build_services';
import { getDiscoverStateMock } from '../../../__mocks__/discover_state.mock';
import { updateSavedSearch } from './utils/update_saved_search';
import { getConnectedCustomizationService } from '../../../customizations';
const startSync = (appState: DiscoverAppStateContainer) => {
const { start, stop } = appState.syncState();
@ -43,14 +44,15 @@ const startSync = (appState: DiscoverAppStateContainer) => {
return stop;
};
let mockServices = createDiscoverServicesMock();
async function getState(
url: string = '/',
{ savedSearch, isEmptyUrl }: { savedSearch?: SavedSearch; isEmptyUrl?: boolean } = {}
) {
const nextHistory = createBrowserHistory<HistoryLocationState>();
nextHistory.push(url);
discoverServiceMock.dataViews.create = jest.fn().mockImplementation((spec) => {
mockServices.dataViews.create = jest.fn().mockImplementation((spec) => {
spec.id = spec.id ?? 'ad-hoc-id';
spec.title = spec.title ?? 'test';
return Promise.resolve({
@ -65,6 +67,7 @@ async function getState(
savedSearch: false,
runtimeStateManager,
history: nextHistory,
services: mockServices,
});
jest.spyOn(nextState.dataState, 'fetch');
await nextState.internalState.dispatch(internalStateActions.loadDataViewList());
@ -72,12 +75,12 @@ async function getState(
internalStateActions.setInitializationState({ hasESData: true, hasUserDataView: true })
);
if (savedSearch) {
jest.spyOn(discoverServiceMock.savedSearch, 'get').mockImplementation(() => {
jest.spyOn(mockServices.savedSearch, 'get').mockImplementation(() => {
nextState.savedSearchState.set(copySavedSearch(savedSearch));
return Promise.resolve(savedSearch);
});
} else {
jest.spyOn(discoverServiceMock.savedSearch, 'get').mockImplementation(() => {
jest.spyOn(mockServices.savedSearch, 'get').mockImplementation(() => {
nextState.savedSearchState.set(copySavedSearch(savedSearchMockWithTimeFieldNew));
return Promise.resolve(savedSearchMockWithTimeFieldNew);
});
@ -87,6 +90,10 @@ async function getState(
return {
history: nextHistory,
state: nextState,
customizationService: await getConnectedCustomizationService({
customizationCallbacks: [],
stateContainer: nextState,
}),
runtimeStateManager,
getCurrentUrl,
};
@ -94,7 +101,7 @@ async function getState(
describe('Discover state', () => {
beforeEach(() => {
jest.spyOn(discoverServiceMock.savedSearch, 'get').mockReset();
mockServices = createDiscoverServicesMock();
});
describe('Test discover state', () => {
@ -240,8 +247,6 @@ describe('Discover state', () => {
});
describe('Test discover initial state sort handling', () => {
beforeEach(() => {});
test('Non-empty sort in URL should not be overwritten by saved search sort', async () => {
const savedSearch = {
...savedSearchMockWithTimeField,
@ -259,26 +264,24 @@ describe('Discover state', () => {
...savedSearchMock,
...{ sort: [['bytes', 'desc']] as SortOrder[] },
};
const { state } = await getState('/', { savedSearch: nextSavedSearch });
const { state, customizationService } = await getState('/', { savedSearch: nextSavedSearch });
await state.internalState.dispatch(
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
},
})
);
state.actions.initializeAndSync();
expect(state.appState.getState().sort).toEqual([['bytes', 'desc']]);
state.actions.stopSyncing();
});
});
describe('Test discover state with legacy migration', () => {
beforeEach(() => {});
test('migration of legacy query ', async () => {
const { state } = await getState(
"/#?_a=(query:(query_string:(analyze_wildcard:!t,query:'type:nice%20name:%22yeah%22')))",
@ -314,8 +317,6 @@ describe('Discover state', () => {
getSavedSearch: () => mockSavedSearch,
});
beforeEach(() => {});
describe('session name', () => {
test('No persisted saved search returns default name', async () => {
expect(await searchSessionInfoProvider.getName()).toBe('Discover');
@ -387,12 +388,10 @@ describe('Discover state', () => {
});
describe('Test discover searchSessionManager', () => {
beforeEach(() => {});
test('getting the next session id', async () => {
const { state } = await getState();
const nextId = 'id';
discoverServiceMock.data.search.session.start = jest.fn(() => nextId);
mockServices.data.search.session.start = jest.fn(() => nextId);
state.actions.initializeAndSync();
expect(state.searchSessionManager.getNextSearchSessionId()).toBe(nextId);
});
@ -400,16 +399,16 @@ describe('Discover state', () => {
describe('Test discover state actions', () => {
beforeEach(async () => {
discoverServiceMock.data.query.timefilter.timefilter.getTime = jest.fn(() => {
mockServices.data.query.timefilter.timefilter.getTime = jest.fn(() => {
return { from: 'now-15d', to: 'now' };
});
discoverServiceMock.data.query.timefilter.timefilter.getRefreshInterval = jest.fn(() => {
mockServices.data.query.timefilter.timefilter.getRefreshInterval = jest.fn(() => {
return { pause: true, value: 1000 };
});
discoverServiceMock.data.search.searchSource.create = jest
mockServices.data.search.searchSource.create = jest
.fn()
.mockReturnValue(savedSearchMock.searchSource);
discoverServiceMock.core.savedObjects.client.resolve = jest.fn().mockReturnValue({
mockServices.core.savedObjects.client.resolve = jest.fn().mockReturnValue({
saved_object: {
attributes: {
kibanaSavedObjectMeta: {
@ -460,7 +459,7 @@ describe('Discover state', () => {
});
test('fetchData', async () => {
const { state } = await getState('/');
const { state, customizationService } = await getState('/');
const dataState = state.dataState;
await state.internalState.dispatch(internalStateActions.loadDataViewList());
expect(dataState.data$.main$.value.fetchStatus).toBe(FetchStatus.LOADING);
@ -468,14 +467,13 @@ describe('Discover state', () => {
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: undefined,
dataViewSpec: undefined,
defaultUrlState: undefined,
},
})
);
state.actions.initializeAndSync();
state.actions.fetchData();
await waitFor(() => {
expect(dataState.data$.documents$.value.fetchStatus).toBe(FetchStatus.COMPLETE);
});
@ -491,12 +489,13 @@ describe('Discover state', () => {
});
test('loadSavedSearch with no id given an empty URL', async () => {
const { state, getCurrentUrl } = await getState('');
const { state, customizationService, getCurrentUrl } = await getState('');
await state.internalState.dispatch(internalStateActions.loadDataViewList());
await state.internalState.dispatch(
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: undefined,
dataViewSpec: undefined,
defaultUrlState: undefined,
@ -505,7 +504,6 @@ describe('Discover state', () => {
);
const newSavedSearch = state.savedSearchState.getState();
expect(newSavedSearch?.id).toBeUndefined();
state.actions.initializeAndSync();
await new Promise(process.nextTick);
expect(getCurrentUrl()).toMatchInlineSnapshot(
`"/#?_g=(refreshInterval:(pause:!t,value:1000),time:(from:now-15d,to:now))&_a=(columns:!(default_column),dataSource:(dataViewId:the-data-view-id,type:dataView),interval:auto,sort:!())"`
@ -534,11 +532,12 @@ describe('Discover state', () => {
});
test('loadNewSavedSearch given an empty URL using loadSavedSearch', async () => {
const { state, getCurrentUrl } = await getState('/');
const { state, customizationService, getCurrentUrl } = await getState('/');
await state.internalState.dispatch(
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: undefined,
dataViewSpec: undefined,
defaultUrlState: undefined,
@ -547,7 +546,6 @@ describe('Discover state', () => {
);
const newSavedSearch = state.savedSearchState.getState();
expect(newSavedSearch?.id).toBeUndefined();
state.actions.initializeAndSync();
await new Promise(process.nextTick);
expect(getCurrentUrl()).toMatchInlineSnapshot(
`"/#?_g=(refreshInterval:(pause:!t,value:1000),time:(from:now-15d,to:now))&_a=(columns:!(default_column),dataSource:(dataViewId:the-data-view-id,type:dataView),interval:auto,sort:!())"`
@ -557,7 +555,7 @@ describe('Discover state', () => {
});
test('loadNewSavedSearch with URL changing interval state', async () => {
const { state, getCurrentUrl } = await getState(
const { state, customizationService, getCurrentUrl } = await getState(
'/#?_a=(interval:month,columns:!(bytes))&_g=()',
{ isEmptyUrl: false }
);
@ -565,6 +563,7 @@ describe('Discover state', () => {
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: undefined,
dataViewSpec: undefined,
defaultUrlState: undefined,
@ -573,7 +572,6 @@ describe('Discover state', () => {
);
const newSavedSearch = state.savedSearchState.getState();
expect(newSavedSearch?.id).toBeUndefined();
state.actions.initializeAndSync();
await new Promise(process.nextTick);
expect(getCurrentUrl()).toMatchInlineSnapshot(
`"/#?_a=(columns:!(bytes),dataSource:(dataViewId:the-data-view-id,type:dataView),interval:month,sort:!())&_g=()"`
@ -583,7 +581,7 @@ describe('Discover state', () => {
});
test('loadSavedSearch with no id, given URL changes state', async () => {
const { state, getCurrentUrl } = await getState(
const { state, customizationService, getCurrentUrl } = await getState(
'/#?_a=(interval:month,columns:!(bytes))&_g=()',
{ isEmptyUrl: false }
);
@ -591,6 +589,7 @@ describe('Discover state', () => {
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: undefined,
dataViewSpec: undefined,
defaultUrlState: undefined,
@ -599,7 +598,6 @@ describe('Discover state', () => {
);
const newSavedSearch = state.savedSearchState.getState();
expect(newSavedSearch?.id).toBeUndefined();
state.actions.initializeAndSync();
await new Promise(process.nextTick);
expect(getCurrentUrl()).toMatchInlineSnapshot(
`"/#?_a=(columns:!(bytes),dataSource:(dataViewId:the-data-view-id,type:dataView),interval:month,sort:!())&_g=()"`
@ -609,18 +607,20 @@ describe('Discover state', () => {
});
test('loadSavedSearch given an empty URL, no state changes', async () => {
const { state, getCurrentUrl } = await getState('/', { savedSearch: savedSearchMock });
jest.spyOn(discoverServiceMock.savedSearch, 'get').mockImplementationOnce(() => {
const { state, customizationService, getCurrentUrl } = await getState('/', {
savedSearch: savedSearchMock,
});
jest.spyOn(mockServices.savedSearch, 'get').mockImplementationOnce(() => {
const savedSearch = copySavedSearch(savedSearchMock);
const savedSearchWithDefaults = updateSavedSearch({
savedSearch,
state: getInitialState({
initialUrlState: undefined,
savedSearch,
services: discoverServiceMock,
services: mockServices,
}),
globalStateContainer: state.globalState,
services: discoverServiceMock,
services: mockServices,
});
return Promise.resolve(savedSearchWithDefaults);
});
@ -628,6 +628,7 @@ describe('Discover state', () => {
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: 'the-saved-search-id',
dataViewSpec: undefined,
defaultUrlState: undefined,
@ -635,7 +636,6 @@ describe('Discover state', () => {
})
);
const newSavedSearch = state.savedSearchState.getState();
state.actions.initializeAndSync();
await new Promise(process.nextTick);
expect(newSavedSearch?.id).toBe('the-saved-search-id');
expect(getCurrentUrl()).toMatchInlineSnapshot(
@ -647,7 +647,7 @@ describe('Discover state', () => {
test('loadSavedSearch given a URL with different interval and columns modifying the state', async () => {
const url = '/#?_a=(interval:month,columns:!(message))&_g=()';
const { state, getCurrentUrl } = await getState(url, {
const { state, customizationService, getCurrentUrl } = await getState(url, {
savedSearch: savedSearchMock,
isEmptyUrl: false,
});
@ -655,13 +655,13 @@ describe('Discover state', () => {
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
},
})
);
state.actions.initializeAndSync();
await new Promise(process.nextTick);
expect(getCurrentUrl()).toMatchInlineSnapshot(
`"/#?_a=(columns:!(message),dataSource:(dataViewId:the-data-view-id,type:dataView),interval:month,sort:!())&_g=()"`
@ -678,7 +678,7 @@ describe('Discover state', () => {
timeRestore: true,
timeRange: { from: 'now-15d', to: 'now' },
};
const { state } = await getState(url, {
const { state, customizationService } = await getState(url, {
savedSearch,
isEmptyUrl: false,
});
@ -686,13 +686,13 @@ describe('Discover state', () => {
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
},
})
);
state.actions.initializeAndSync();
await new Promise(process.nextTick);
expect(state.savedSearchState.getHasChanged$().getValue()).toBe(true);
state.actions.stopSyncing();
@ -700,7 +700,7 @@ describe('Discover state', () => {
test('loadSavedSearch given a URL with different refresh interval than the stored one showing as changed', async () => {
const url = '/#_g=(time:(from:now-15d,to:now),refreshInterval:(pause:!f,value:1234))';
discoverServiceMock.data.query.timefilter.timefilter.getRefreshInterval = jest.fn(() => {
mockServices.data.query.timefilter.timefilter.getRefreshInterval = jest.fn(() => {
return { pause: false, value: 1234 };
});
const savedSearch = {
@ -710,7 +710,7 @@ describe('Discover state', () => {
timeRange: { from: 'now-15d', to: 'now' },
refreshInterval: { pause: false, value: 60000 },
};
const { state } = await getState(url, {
const { state, customizationService } = await getState(url, {
savedSearch,
isEmptyUrl: false,
});
@ -718,13 +718,13 @@ describe('Discover state', () => {
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
},
})
);
state.actions.initializeAndSync();
await new Promise(process.nextTick);
expect(state.savedSearchState.getHasChanged$().getValue()).toBe(true);
state.actions.stopSyncing();
@ -732,7 +732,7 @@ describe('Discover state', () => {
test('loadSavedSearch given a URL with matching time range and refresh interval not showing as changed', async () => {
const url = '/#?_g=(time:(from:now-15d,to:now),refreshInterval:(pause:!f,value:60000))';
discoverServiceMock.data.query.timefilter.timefilter.getRefreshInterval = jest.fn(() => {
mockServices.data.query.timefilter.timefilter.getRefreshInterval = jest.fn(() => {
return { pause: false, value: 60000 };
});
const savedSearch = {
@ -742,7 +742,7 @@ describe('Discover state', () => {
timeRange: { from: 'now-15d', to: 'now' },
refreshInterval: { pause: false, value: 60000 },
};
const { state } = await getState(url, {
const { state, customizationService } = await getState(url, {
savedSearch,
isEmptyUrl: false,
});
@ -750,13 +750,13 @@ describe('Discover state', () => {
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
},
})
);
state.actions.initializeAndSync();
await new Promise(process.nextTick);
expect(state.savedSearchState.getHasChanged$().getValue()).toBe(false);
state.actions.stopSyncing();
@ -764,11 +764,12 @@ describe('Discover state', () => {
test('loadSavedSearch ignoring hideChart in URL', async () => {
const url = '/#?_a=(hideChart:true,columns:!(message))&_g=()';
const { state } = await getState(url, { savedSearch: savedSearchMock });
const { state, customizationService } = await getState(url, { savedSearch: savedSearchMock });
await state.internalState.dispatch(
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: undefined,
dataViewSpec: undefined,
defaultUrlState: undefined,
@ -781,11 +782,15 @@ describe('Discover state', () => {
test('loadSavedSearch without id ignoring invalid index in URL, adding a warning toast', async () => {
const url = '/#?_a=(dataSource:(dataViewId:abc,type:dataView))&_g=()';
const { state } = await getState(url, { savedSearch: savedSearchMock, isEmptyUrl: false });
const { state, customizationService } = await getState(url, {
savedSearch: savedSearchMock,
isEmptyUrl: false,
});
await state.internalState.dispatch(
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: undefined,
dataViewSpec: undefined,
defaultUrlState: undefined,
@ -795,7 +800,7 @@ describe('Discover state', () => {
expect(state.savedSearchState.getState().searchSource.getField('index')?.id).toBe(
'the-data-view-id'
);
expect(discoverServiceMock.toastNotifications.addWarning).toHaveBeenCalledWith(
expect(mockServices.toastNotifications.addWarning).toHaveBeenCalledWith(
expect.objectContaining({
'data-test-subj': 'dscDataViewNotFoundShowDefaultWarning',
})
@ -805,11 +810,15 @@ describe('Discover state', () => {
test('loadSavedSearch without id containing ES|QL, adding no warning toast with an invalid index', async () => {
const url =
"/#?_a=(dataSource:(dataViewId:abcde,type:dataView),query:(esql:'FROM test'))&_g=()";
const { state } = await getState(url, { savedSearch: savedSearchMock, isEmptyUrl: false });
const { state, customizationService } = await getState(url, {
savedSearch: savedSearchMock,
isEmptyUrl: false,
});
await state.internalState.dispatch(
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: undefined,
dataViewSpec: undefined,
defaultUrlState: undefined,
@ -817,16 +826,20 @@ describe('Discover state', () => {
})
);
expect(state.appState.getState().dataSource).toEqual(createEsqlDataSource());
expect(discoverServiceMock.toastNotifications.addWarning).not.toHaveBeenCalled();
expect(mockServices.toastNotifications.addWarning).not.toHaveBeenCalled();
});
test('loadSavedSearch with id ignoring invalid index in URL, adding a warning toast', async () => {
const url = '/#?_a=(dataSource:(dataViewId:abc,type:dataView))&_g=()';
const { state } = await getState(url, { savedSearch: savedSearchMock, isEmptyUrl: false });
const { state, customizationService } = await getState(url, {
savedSearch: savedSearchMock,
isEmptyUrl: false,
});
await state.internalState.dispatch(
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
@ -836,7 +849,7 @@ describe('Discover state', () => {
expect(state.savedSearchState.getState().searchSource.getField('index')?.id).toBe(
'the-data-view-id'
);
expect(discoverServiceMock.toastNotifications.addWarning).toHaveBeenCalledWith(
expect(mockServices.toastNotifications.addWarning).toHaveBeenCalledWith(
expect.objectContaining({
'data-test-subj': 'dscDataViewNotFoundShowSavedWarning',
})
@ -844,18 +857,20 @@ describe('Discover state', () => {
});
test('loadSavedSearch data view handling', async () => {
const { state } = await getState('/', { savedSearch: savedSearchMock });
jest.spyOn(discoverServiceMock.savedSearch, 'get').mockImplementationOnce(() => {
const { state, customizationService, history } = await getState('/', {
savedSearch: savedSearchMock,
});
jest.spyOn(mockServices.savedSearch, 'get').mockImplementationOnce(() => {
const savedSearch = copySavedSearch(savedSearchMock);
const savedSearchWithDefaults = updateSavedSearch({
savedSearch,
state: getInitialState({
initialUrlState: undefined,
savedSearch,
services: discoverServiceMock,
services: mockServices,
}),
globalStateContainer: state.globalState,
services: discoverServiceMock,
services: mockServices,
});
return Promise.resolve(savedSearchWithDefaults);
});
@ -863,6 +878,7 @@ describe('Discover state', () => {
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
@ -873,27 +889,29 @@ describe('Discover state', () => {
'the-data-view-id'
);
expect(state.savedSearchState.getHasChanged$().getValue()).toBe(false);
jest.spyOn(discoverServiceMock.savedSearch, 'get').mockImplementationOnce(() => {
jest.spyOn(mockServices.savedSearch, 'get').mockImplementationOnce(() => {
const savedSearch = copySavedSearch(savedSearchMockWithTimeField);
const savedSearchWithDefaults = updateSavedSearch({
savedSearch,
state: getInitialState({
initialUrlState: undefined,
savedSearch,
services: discoverServiceMock,
services: mockServices,
}),
globalStateContainer: state.globalState,
services: discoverServiceMock,
services: mockServices,
});
return Promise.resolve(savedSearchWithDefaults);
});
history.push('/');
await state.internalState.dispatch(
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: 'the-saved-search-id-with-timefield',
dataViewSpec: undefined,
defaultUrlState: undefined,
defaultUrlState: {},
},
})
);
@ -901,17 +919,17 @@ describe('Discover state', () => {
'index-pattern-with-timefield-id'
);
expect(state.savedSearchState.getHasChanged$().getValue()).toBe(false);
jest.spyOn(discoverServiceMock.savedSearch, 'get').mockImplementationOnce(() => {
jest.spyOn(mockServices.savedSearch, 'get').mockImplementationOnce(() => {
const savedSearch = copySavedSearch(savedSearchMock);
const savedSearchWithDefaults = updateSavedSearch({
savedSearch,
state: getInitialState({
initialUrlState: undefined,
savedSearch,
services: discoverServiceMock,
services: mockServices,
}),
globalStateContainer: state.globalState,
services: discoverServiceMock,
services: mockServices,
});
return Promise.resolve(savedSearchWithDefaults);
});
@ -919,6 +937,7 @@ describe('Discover state', () => {
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: {
@ -936,13 +955,13 @@ describe('Discover state', () => {
});
test('loadSavedSearch generating a new saved search, updated by ad-hoc data view', async () => {
const { state } = await getState('/');
const { state, customizationService } = await getState('/');
const dataViewSpecMock = {
id: 'mock-id',
title: 'mock-title',
timeFieldName: 'mock-time-field-name',
};
const dataViewsCreateMock = discoverServiceMock.dataViews.create as jest.Mock;
const dataViewsCreateMock = mockServices.dataViews.create as jest.Mock;
dataViewsCreateMock.mockImplementationOnce(() => ({
...dataViewMock,
...dataViewSpecMock,
@ -952,6 +971,7 @@ describe('Discover state', () => {
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: undefined,
dataViewSpec: dataViewSpecMock,
defaultUrlState: undefined,
@ -971,19 +991,20 @@ describe('Discover state', () => {
});
test('loadSavedSearch resetting query & filters of data service', async () => {
const { state } = await getState('/', { savedSearch: savedSearchMock });
const { state, customizationService } = await getState('/', { savedSearch: savedSearchMock });
await state.internalState.dispatch(
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
},
})
);
expect(discoverServiceMock.data.query.queryString.clearQuery).toHaveBeenCalled();
expect(discoverServiceMock.data.query.filterManager.setAppFilters).toHaveBeenCalledWith([]);
expect(mockServices.data.query.queryString.clearQuery).toHaveBeenCalled();
expect(mockServices.data.query.filterManager.setAppFilters).toHaveBeenCalledWith([]);
});
test('loadSavedSearch setting query & filters of data service if query and filters are persisted', async () => {
@ -992,31 +1013,35 @@ describe('Discover state', () => {
const filters = [{ meta: { index: 'the-data-view-id' }, query: { match_all: {} } }];
savedSearchWithQueryAndFilters.searchSource.setField('query', query);
savedSearchWithQueryAndFilters.searchSource.setField('filter', filters);
const { state } = await getState('/', { savedSearch: savedSearchWithQueryAndFilters });
const { state, customizationService } = await getState('/', {
savedSearch: savedSearchWithQueryAndFilters,
});
await state.internalState.dispatch(
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
},
})
);
expect(discoverServiceMock.data.query.queryString.setQuery).toHaveBeenCalledWith(query);
expect(discoverServiceMock.data.query.filterManager.setAppFilters).toHaveBeenCalledWith(
filters
);
expect(mockServices.data.query.queryString.setQuery).toHaveBeenCalledWith(query);
expect(mockServices.data.query.filterManager.setAppFilters).toHaveBeenCalledWith(filters);
});
test('loadSavedSearch with ad-hoc data view being added to internal state adHocDataViews', async () => {
const savedSearchAdHocCopy = copySavedSearch(savedSearchAdHoc);
const adHocDataViewId = savedSearchAdHoc.searchSource.getField('index')!.id;
const { state } = await getState('/', { savedSearch: savedSearchAdHocCopy });
const { state, customizationService } = await getState('/', {
savedSearch: savedSearchAdHocCopy,
});
await state.internalState.dispatch(
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: savedSearchAdHoc.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
@ -1033,7 +1058,7 @@ describe('Discover state', () => {
const savedSearchMockWithESQLCopy = copySavedSearch(savedSearchMockWithESQL);
const persistedDataViewId = savedSearchMockWithESQLCopy?.searchSource.getField('index')!.id;
const url = "/#?_a=(dataSource:(dataViewId:'the-data-view-id',type:dataView))&_g=()";
const { state } = await getState(url, {
const { state, customizationService } = await getState(url, {
savedSearch: savedSearchMockWithESQLCopy,
isEmptyUrl: false,
});
@ -1041,6 +1066,7 @@ describe('Discover state', () => {
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: savedSearchMockWithESQL.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
@ -1080,23 +1106,25 @@ describe('Discover state', () => {
});
test('onChangeDataView', async () => {
const { state, getCurrentUrl } = await getState('/', { savedSearch: savedSearchMock });
const { state, customizationService, getCurrentUrl } = await getState('/', {
savedSearch: savedSearchMock,
});
const { actions, savedSearchState, dataState } = state;
await state.internalState.dispatch(
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
},
})
);
actions.initializeAndSync();
await new Promise(process.nextTick);
// test initial state
expect(dataState.fetch).toHaveBeenCalledTimes(0);
expect(dataState.fetch).toHaveBeenCalledTimes(1);
expect(savedSearchState.getState().searchSource.getField('index')!.id).toBe(dataViewMock.id);
expect(getCurrentUrl()).toContain(dataViewMock.id);
@ -1105,7 +1133,7 @@ describe('Discover state', () => {
await new Promise(process.nextTick);
// test changed state, fetch should be called once and URL should be updated
expect(dataState.fetch).toHaveBeenCalledTimes(1);
expect(dataState.fetch).toHaveBeenCalledTimes(2);
expect(state.appState.getState().dataSource).toEqual(
createDataViewDataSource({ dataViewId: dataViewComplexMock.id! })
);
@ -1118,18 +1146,18 @@ describe('Discover state', () => {
});
test('onDataViewCreated - persisted data view', async () => {
const { state } = await getState('/', { savedSearch: savedSearchMock });
const { state, customizationService } = await getState('/', { savedSearch: savedSearchMock });
await state.internalState.dispatch(
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
},
})
);
state.actions.initializeAndSync();
await state.actions.onDataViewCreated(dataViewComplexMock);
await waitFor(() => {
expect(state.getCurrentTab().dataViewId).toBe(dataViewComplexMock.id);
@ -1144,20 +1172,20 @@ describe('Discover state', () => {
});
test('onDataViewCreated - ad-hoc data view', async () => {
const { state } = await getState('/', { savedSearch: savedSearchMock });
const { state, customizationService } = await getState('/', { savedSearch: savedSearchMock });
await state.internalState.dispatch(
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
},
})
);
state.actions.initializeAndSync();
jest
.spyOn(discoverServiceMock.dataViews, 'get')
.spyOn(mockServices.dataViews, 'get')
.mockImplementationOnce((id) =>
id === dataViewAdHoc.id ? Promise.resolve(dataViewAdHoc) : Promise.reject()
);
@ -1175,11 +1203,12 @@ describe('Discover state', () => {
});
test('onDataViewEdited - persisted data view', async () => {
const { state } = await getState('/', { savedSearch: savedSearchMock });
const { state, customizationService } = await getState('/', { savedSearch: savedSearchMock });
await state.internalState.dispatch(
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
@ -1188,7 +1217,6 @@ describe('Discover state', () => {
);
const selectedDataViewId = state.getCurrentTab().dataViewId;
expect(selectedDataViewId).toBe(dataViewMock.id);
state.actions.initializeAndSync();
await state.actions.onDataViewEdited(dataViewMock);
await waitFor(() => {
expect(state.getCurrentTab().dataViewId).toBe(selectedDataViewId);
@ -1209,12 +1237,12 @@ describe('Discover state', () => {
});
test('onOpenSavedSearch - same target id', async () => {
const { state } = await getState('/', { savedSearch: savedSearchMock });
state.actions.initializeAndSync();
const { state, customizationService } = await getState('/', { savedSearch: savedSearchMock });
await state.internalState.dispatch(
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
@ -1229,14 +1257,18 @@ describe('Discover state', () => {
});
test('onOpenSavedSearch - cleanup of previous filter', async () => {
const { state, history } = await getState(
const { state, customizationService, history } = await getState(
"/#?_g=(filters:!(),refreshInterval:(pause:!t,value:60000),time:(from:now-15m,to:now))&_a=(columns:!(customer_first_name),filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:ff959d40-b880-11e8-a6d9-e546fe2bba5f,key:customer_first_name,negate:!f,params:(query:Mary),type:phrase),query:(match_phrase:(customer_first_name:Mary)))),hideChart:!f,index:ff959d40-b880-11e8-a6d9-e546fe2bba5f,interval:auto,query:(language:kuery,query:''),sort:!())",
{ savedSearch: savedSearchMock, isEmptyUrl: false }
);
jest.spyOn(mockServices.filterManager, 'getAppFilters').mockImplementation(() => {
return state.appState.getState().filters!;
});
await state.internalState.dispatch(
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
@ -1249,6 +1281,7 @@ describe('Discover state', () => {
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: undefined,
dataViewSpec: undefined,
defaultUrlState: undefined,
@ -1259,18 +1292,18 @@ describe('Discover state', () => {
});
test('onCreateDefaultAdHocDataView', async () => {
const { state } = await getState('/', { savedSearch: savedSearchMock });
const { state, customizationService } = await getState('/', { savedSearch: savedSearchMock });
await state.internalState.dispatch(
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
},
})
);
state.actions.initializeAndSync();
await state.actions.createAndAppendAdHocDataView({ title: 'ad-hoc-test' });
expect(state.appState.getState().dataSource).toEqual(
createDataViewDataSource({ dataViewId: 'ad-hoc-id' })
@ -1280,19 +1313,21 @@ describe('Discover state', () => {
});
test('undoSavedSearchChanges - when changing data views', async () => {
const { state, getCurrentUrl } = await getState('/', { savedSearch: savedSearchMock });
const { state, customizationService, getCurrentUrl } = await getState('/', {
savedSearch: savedSearchMock,
});
// Load a given persisted saved search
await state.internalState.dispatch(
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,
},
})
);
state.actions.initializeAndSync();
await new Promise(process.nextTick);
const initialUrlState =
'/#?_g=(refreshInterval:(pause:!t,value:1000),time:(from:now-15d,to:now))&_a=(columns:!(default_column),dataSource:(dataViewId:the-data-view-id,type:dataView),interval:auto,sort:!())';
@ -1306,7 +1341,7 @@ describe('Discover state', () => {
`"/#?_g=(refreshInterval:(pause:!t,value:1000),time:(from:now-15d,to:now))&_a=(columns:!(),dataSource:(dataViewId:data-view-with-various-field-types-id,type:dataView),interval:auto,sort:!(!(data,desc)))"`
);
await waitFor(() => {
expect(state.dataState.fetch).toHaveBeenCalledTimes(1);
expect(state.dataState.fetch).toHaveBeenCalledTimes(2);
});
expect(state.getCurrentTab().dataViewId).toBe(dataViewComplexMock.id!);
@ -1315,7 +1350,7 @@ describe('Discover state', () => {
await new Promise(process.nextTick);
expect(getCurrentUrl()).toBe(initialUrlState);
await waitFor(() => {
expect(state.dataState.fetch).toHaveBeenCalledTimes(2);
expect(state.dataState.fetch).toHaveBeenCalledTimes(3);
});
expect(state.getCurrentTab().dataViewId).toBe(dataViewMock.id!);
@ -1323,7 +1358,7 @@ describe('Discover state', () => {
});
test('undoSavedSearchChanges with timeRestore', async () => {
const { state } = await getState('/', {
const { state, customizationService } = await getState('/', {
savedSearch: {
...savedSearchMockWithTimeField,
timeRestore: true,
@ -1333,12 +1368,13 @@ describe('Discover state', () => {
});
const setTime = jest.fn();
const setRefreshInterval = jest.fn();
discoverServiceMock.data.query.timefilter.timefilter.setTime = setTime;
discoverServiceMock.data.query.timefilter.timefilter.setRefreshInterval = setRefreshInterval;
mockServices.data.query.timefilter.timefilter.setTime = setTime;
mockServices.data.query.timefilter.timefilter.setRefreshInterval = setRefreshInterval;
await state.internalState.dispatch(
state.injectCurrentTab(internalStateActions.initializeSession)({
initializeSessionParams: {
stateContainer: state,
customizationService,
discoverSessionId: savedSearchMock.id,
dataViewSpec: undefined,
defaultUrlState: undefined,

View file

@ -9,7 +9,6 @@
import { i18n } from '@kbn/i18n';
import type { IKbnUrlStateStorage, StateContainer } from '@kbn/kibana-utils-plugin/public';
import { createKbnUrlStateStorage, withNotifyOnErrors } from '@kbn/kibana-utils-plugin/public';
import type { DataPublicPluginStart, SearchSessionInfoProvider } from '@kbn/data-plugin/public';
import { noSearchSessionStorageCapabilityMessage } from '@kbn/data-plugin/public';
import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/public';
@ -71,9 +70,9 @@ export interface DiscoverStateContainerParams {
*/
customizationContext: DiscoverCustomizationContext;
/**
* a custom url state storage
* URL state storage
*/
stateStorageContainer?: IKbnUrlStateStorage;
stateStorageContainer: IKbnUrlStateStorage;
/**
* Internal shared state that's used at several places in the UI
*/
@ -240,27 +239,13 @@ export function getDiscoverStateContainer({
tabId,
services,
customizationContext,
stateStorageContainer,
stateStorageContainer: stateStorage,
internalState,
runtimeStateManager,
}: DiscoverStateContainerParams): DiscoverStateContainer {
const storeInSessionStorage = services.uiSettings.get('state:storeInSessionStorage');
const toasts = services.core.notifications.toasts;
const injectCurrentTab = createTabActionInjector(tabId);
const getCurrentTab = () => selectTab(internalState.getState(), tabId);
/**
* state storage for state in the URL
*/
const stateStorage =
stateStorageContainer ??
createKbnUrlStateStorage({
useHash: storeInSessionStorage,
history: services.history,
useHashQuery: customizationContext.displayMode !== 'embedded',
...(toasts && withNotifyOnErrors(toasts)),
});
/**
* Search session logic
*/
@ -456,6 +441,9 @@ export function getDiscoverStateContainer({
savedSearchContainer.updateTimeRange();
});
// Enable/disable kbn url tracking (That's the URL used when selecting Discover in the side menu)
const unsubscribeSavedSearchUrlTracking = savedSearchContainer.initUrlTracking();
// initialize app state container, syncing with _g and _a part of the URL
const appStateInitAndSyncUnsubscribe = appStateContainer.initAndSync();
@ -508,6 +496,7 @@ export function getDiscoverStateContainer({
unsubscribeData();
appStateUnsubscribe();
appStateInitAndSyncUnsubscribe();
unsubscribeSavedSearchUrlTracking();
filterUnsubscribe.unsubscribe();
timefilerUnsubscribe.unsubscribe();
};

View file

@ -35,9 +35,12 @@ import { getValidFilters } from '../../../../../utils/get_valid_filters';
import { updateSavedSearch } from '../../utils/update_saved_search';
import { APP_STATE_URL_KEY } from '../../../../../../common';
import { selectTabRuntimeState } from '../runtime_state';
import type { ConnectedCustomizationService } from '../../../../../customizations';
import { disconnectTab } from './tabs';
export interface InitializeSessionParams {
stateContainer: DiscoverStateContainer;
customizationService: ConnectedCustomizationService;
discoverSessionId: string | undefined;
dataViewSpec: DataViewSpec | undefined;
defaultUrlState: DiscoverAppState | undefined;
@ -49,13 +52,20 @@ export const initializeSession: InternalStateThunkActionCreator<
> =
({
tabId,
initializeSessionParams: { stateContainer, discoverSessionId, dataViewSpec, defaultUrlState },
initializeSessionParams: {
stateContainer,
customizationService,
discoverSessionId,
dataViewSpec,
defaultUrlState,
},
}) =>
async (
dispatch,
getState,
{ services, customizationContext, runtimeStateManager, urlStateStorage }
) => {
dispatch(disconnectTab({ tabId }));
dispatch(internalStateSlice.actions.resetOnSavedSearchChange({ tabId }));
/**
@ -113,11 +123,13 @@ export const initializeSession: InternalStateThunkActionCreator<
setBreadcrumbs({ services, titleBreadcrumbText: persistedDiscoverSession.title });
}
const { currentDataView$, stateContainer$, customizationService$ } = selectTabRuntimeState(
runtimeStateManager,
tabId
);
let dataView: DataView;
if (isOfAggregateQueryType(initialQuery)) {
const { currentDataView$ } = selectTabRuntimeState(runtimeStateManager, tabId);
// Regardless of what was requested, we always use ad hoc data views for ES|QL
dataView = await getEsqlDataView(
initialQuery,
@ -239,6 +251,12 @@ export const initializeSession: InternalStateThunkActionCreator<
// Make sure app state container is completely reset
stateContainer.appState.resetToState(initialState);
stateContainer.appState.resetInitialState();
stateContainer$.next(stateContainer);
customizationService$.next(customizationService);
// Begin syncing the state and trigger the initial fetch
stateContainer.actions.initializeAndSync();
stateContainer.actions.fetchData(true);
discoverSessionLoadTracker.reportEvent();
return { showNoDataPage: false };

View file

@ -8,15 +8,16 @@
*/
import type { TabbedContentState } from '@kbn/unified-tabs/src/components/tabbed_content/tabbed_content';
import { differenceBy } from 'lodash';
import { cloneDeep, differenceBy } from 'lodash';
import type { TabState } from '../types';
import { selectAllTabs, selectTab } from '../selectors';
import {
defaultTabState,
internalStateSlice,
type TabActionPayload,
type InternalStateThunkActionCreator,
} from '../internal_state';
import { createTabRuntimeState } from '../runtime_state';
import { createTabRuntimeState, selectTabRuntimeState } from '../runtime_state';
export const setTabs: InternalStateThunkActionCreator<
[Parameters<typeof internalStateSlice.actions.setTabs>[0]]
@ -28,6 +29,7 @@ export const setTabs: InternalStateThunkActionCreator<
const addedTabs = differenceBy(params.allTabs, previousTabs, (tab) => tab.id);
for (const tab of removedTabs) {
dispatch(disconnectTab({ tabId: tab.id }));
delete runtimeStateManager.tabs.byId[tab.id];
}
@ -38,24 +40,20 @@ export const setTabs: InternalStateThunkActionCreator<
dispatch(internalStateSlice.actions.setTabs(params));
};
export interface UpdateTabsParams {
currentTabId: string;
updateState: TabbedContentState;
stopSyncing?: () => void;
}
export const updateTabs: InternalStateThunkActionCreator<[UpdateTabsParams], Promise<void>> =
({ currentTabId, updateState: { items, selectedItem }, stopSyncing }) =>
async (dispatch, getState, { urlStateStorage }) => {
export const updateTabs: InternalStateThunkActionCreator<[TabbedContentState], Promise<void>> =
({ items, selectedItem }) =>
async (dispatch, getState, { services, runtimeStateManager, urlStateStorage }) => {
const currentState = getState();
const currentTab = selectTab(currentState, currentTabId);
const currentTab = selectTab(currentState, currentState.tabs.unsafeCurrentId);
let updatedTabs = items.map<TabState>((item) => {
const existingTab = currentState.tabs.byId[item.id];
const existingTab = selectTab(currentState, item.id);
return existingTab ? { ...existingTab, ...item } : { ...defaultTabState, ...item };
});
if (selectedItem?.id !== currentTab.id) {
stopSyncing?.();
const previousTabRuntimeState = selectTabRuntimeState(runtimeStateManager, currentTab.id);
previousTabRuntimeState.stateContainer$.getValue()?.actions.stopSyncing();
updatedTabs = updatedTabs.map((tab) =>
tab.id === currentTab.id
@ -67,16 +65,57 @@ export const updateTabs: InternalStateThunkActionCreator<[UpdateTabsParams], Pro
: tab
);
const existingTab = selectedItem ? currentState.tabs.byId[selectedItem.id] : undefined;
const nextTab = selectedItem ? selectTab(currentState, selectedItem.id) : undefined;
if (existingTab) {
await urlStateStorage.set('_g', existingTab.globalState);
await urlStateStorage.set('_a', existingTab.appState);
if (nextTab) {
await urlStateStorage.set('_g', nextTab.globalState);
await urlStateStorage.set('_a', nextTab.appState);
} else {
await urlStateStorage.set('_g', {});
await urlStateStorage.set('_a', {});
await urlStateStorage.set('_g', null);
await urlStateStorage.set('_a', null);
}
const nextTabRuntimeState = selectedItem
? selectTabRuntimeState(runtimeStateManager, selectedItem.id)
: undefined;
const nextTabStateContainer = nextTabRuntimeState?.stateContainer$.getValue();
if (nextTabStateContainer) {
const {
time,
refreshInterval,
filters: globalFilters,
} = nextTabStateContainer.globalState.get() ?? {};
const { filters: appFilters, query } = nextTabStateContainer.appState.getState();
services.timefilter.setTime(time ?? services.timefilter.getTimeDefaults());
services.timefilter.setRefreshInterval(
refreshInterval ?? services.timefilter.getRefreshIntervalDefaults()
);
services.filterManager.setGlobalFilters(globalFilters ?? []);
services.filterManager.setAppFilters(cloneDeep(appFilters ?? []));
services.data.query.queryString.setQuery(
query ?? services.data.query.queryString.getDefaultQuery()
);
nextTabStateContainer.actions.initializeAndSync();
}
}
dispatch(setTabs({ allTabs: updatedTabs }));
dispatch(
setTabs({
allTabs: updatedTabs,
selectedTabId: selectedItem?.id ?? currentTab.id,
})
);
};
export const disconnectTab: InternalStateThunkActionCreator<[TabActionPayload]> =
({ tabId }) =>
(_, __, { runtimeStateManager }) => {
const tabRuntimeState = selectTabRuntimeState(runtimeStateManager, tabId);
const stateContainer = tabRuntimeState.stateContainer$.getValue();
stateContainer?.dataState.cancel();
stateContainer?.actions.stopSyncing();
tabRuntimeState.customizationService$.getValue()?.cleanup();
};

View file

@ -19,6 +19,7 @@ import {
setDefaultProfileAdHocDataViews,
setTabs,
updateTabs,
disconnectTab,
} from './actions';
export type { DiscoverInternalState, TabState, InternalStateDataRequestParams } from './types';
@ -35,6 +36,7 @@ export const internalStateActions = {
loadDataViewList,
setTabs,
updateTabs,
disconnectTab,
setDataView,
setAdHocDataViews,
setDefaultProfileAdHocDataViews,

View file

@ -28,7 +28,7 @@ import {
type TabState,
} from './types';
import { loadDataViewList, setTabs } from './actions';
import { selectAllTabs } from './selectors';
import { selectAllTabs, selectTab } from './selectors';
import { createTabItem } from './utils';
export const defaultTabState: Omit<TabState, keyof TabItem> = {
@ -62,7 +62,7 @@ const initialState: DiscoverInternalState = {
savedDataViews: [],
expandedDoc: undefined,
isESQLToDataViewTransitionModalVisible: false,
tabs: { byId: {}, allIds: [] },
tabs: { byId: {}, allIds: [], unsafeCurrentId: '' },
};
export type TabActionPayload<T extends { [key: string]: unknown } = {}> = { tabId: string } & T;
@ -74,7 +74,7 @@ const withTab = <TAction extends TabAction>(
action: TAction,
fn: (tab: TabState) => void
) => {
const tab = state.tabs.byId[action.payload.tabId];
const tab = selectTab(state, action.payload.tabId);
if (tab) {
fn(tab);
@ -92,7 +92,7 @@ export const internalStateSlice = createSlice({
state.initializationState = action.payload;
},
setTabs: (state, action: PayloadAction<{ allTabs: TabState[] }>) => {
setTabs: (state, action: PayloadAction<{ allTabs: TabState[]; selectedTabId: string }>) => {
state.tabs.byId = action.payload.allTabs.reduce<Record<string, TabState>>(
(acc, tab) => ({
...acc,
@ -101,6 +101,7 @@ export const internalStateSlice = createSlice({
{}
);
state.tabs.allIds = action.payload.allTabs.map((tab) => tab.id);
state.tabs.unsafeCurrentId = action.payload.selectedTabId;
},
setDataViewId: (state, action: TabAction<{ dataViewId: string | undefined }>) =>
@ -203,7 +204,7 @@ export const createInternalStateStore = (options: InternalStateThunkDependencies
...defaultTabState,
...createTabItem(selectAllTabs(store.getState())),
};
store.dispatch(setTabs({ allTabs: [defaultTab] }));
store.dispatch(setTabs({ allTabs: [defaultTab], selectedTabId: defaultTab.id }));
return store;
};

View file

@ -12,12 +12,16 @@ import React, { type PropsWithChildren, createContext, useContext, useMemo } fro
import useObservable from 'react-use/lib/useObservable';
import { BehaviorSubject } from 'rxjs';
import { useCurrentTabContext } from './hooks';
import type { DiscoverStateContainer } from '../discover_state';
import type { ConnectedCustomizationService } from '../../../../customizations';
interface DiscoverRuntimeState {
adHocDataViews: DataView[];
}
interface TabRuntimeState {
stateContainer?: DiscoverStateContainer;
customizationService?: ConnectedCustomizationService;
currentDataView: DataView;
}
@ -39,6 +43,8 @@ export const createRuntimeStateManager = (): RuntimeStateManager => ({
});
export const createTabRuntimeState = (): ReactiveTabRuntimeState => ({
stateContainer$: new BehaviorSubject<DiscoverStateContainer | undefined>(undefined),
customizationService$: new BehaviorSubject<ConnectedCustomizationService | undefined>(undefined),
currentDataView$: new BehaviorSubject<DataView | undefined>(undefined),
});

View file

@ -9,7 +9,7 @@
import type { DiscoverInternalState } from './types';
export const selectAllTabs = (state: DiscoverInternalState) =>
state.tabs.allIds.map((id) => state.tabs.byId[id]);
export const selectTab = (state: DiscoverInternalState, tabId: string) => state.tabs.byId[tabId];
export const selectAllTabs = (state: DiscoverInternalState) =>
state.tabs.allIds.map((id) => selectTab(state, id));

View file

@ -70,5 +70,13 @@ export interface DiscoverInternalState {
tabs: {
byId: Record<string, TabState>;
allIds: string[];
/**
* WARNING: You probably don't want to use this property.
* This is used high in the component tree for managing tabs,
* but is unsafe to use in actions and selectors since it can
* change between renders and leak state between tabs.
* Actions and selectors should use a tab ID parameter instead.
*/
unsafeCurrentId: string;
};
}

View file

@ -11,46 +11,42 @@ import { renderHook, act } from '@testing-library/react';
import React from 'react';
import { getDiscoverStateMock } from '../__mocks__/discover_state.mock';
import {
type ConnectedCustomizationService,
DiscoverCustomizationProvider,
getConnectedCustomizationService,
useDiscoverCustomization,
useDiscoverCustomization$,
useDiscoverCustomizationService,
} from './customization_provider';
import type {
DiscoverCustomization,
DiscoverCustomizationId,
DiscoverCustomizationService,
} from './customization_service';
import type { DiscoverCustomization, DiscoverCustomizationId } from './customization_service';
import { createCustomizationService } from './customization_service';
import type { CustomizationCallback } from './types';
describe('useDiscoverCustomizationService', () => {
describe('getConnectedCustomizationService', () => {
it('should provide customization service', async () => {
let resolveCallback = (_: () => void) => {};
const promise = new Promise<() => void>((resolve) => {
resolveCallback = resolve;
});
let service: DiscoverCustomizationService | undefined;
const callback = jest.fn(({ customizations }) => {
service = customizations;
return promise;
});
const customizationCallbacks: CustomizationCallback[] = [callback];
const stateContainer = getDiscoverStateMock({ isTimeBased: true });
const wrapper = renderHook(() =>
useDiscoverCustomizationService({ stateContainer, customizationCallbacks })
);
expect(wrapper.result.current).toBeUndefined();
const servicePromise = getConnectedCustomizationService({
stateContainer,
customizationCallbacks,
});
let service: ConnectedCustomizationService | undefined;
expect(callback).toHaveBeenCalledTimes(1);
const cleanup = jest.fn();
await act(async () => {
resolveCallback(cleanup);
await promise;
service = await servicePromise;
});
expect(wrapper.result.current).toBe(service);
expect(callback).toHaveBeenCalledTimes(1);
expect(cleanup).not.toHaveBeenCalled();
wrapper.unmount();
await service?.cleanup();
await act(async () => {
await promise;
});

View file

@ -7,10 +7,9 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { createContext, useContext, useEffect, useState } from 'react';
import { createContext, useContext } from 'react';
import useObservable from 'react-use/lib/useObservable';
import { isFunction } from 'lodash';
import useLatest from 'react-use/lib/useLatest';
import type { DiscoverStateContainer } from '../application/main/state_management/discover_state';
import type { CustomizationCallback } from './types';
import type {
@ -23,43 +22,6 @@ const customizationContext = createContext(createCustomizationService());
export const DiscoverCustomizationProvider = customizationContext.Provider;
export const useDiscoverCustomizationService = ({
customizationCallbacks: originalCustomizationCallbacks,
stateContainer,
}: {
customizationCallbacks: CustomizationCallback[];
stateContainer?: DiscoverStateContainer;
}) => {
const customizationCallbacks = useLatest(originalCustomizationCallbacks);
const [customizationService, setCustomizationService] = useState<DiscoverCustomizationService>();
useEffect(() => {
setCustomizationService(undefined);
if (!stateContainer) {
return;
}
const customizations = createCustomizationService();
const callbacks = customizationCallbacks.current.map((callback) =>
Promise.resolve(callback({ customizations, stateContainer }))
);
const initialize = () => Promise.all(callbacks).then((result) => result.filter(isFunction));
initialize().then(() => {
setCustomizationService(customizations);
});
return () => {
initialize().then((cleanups) => {
cleanups.forEach((cleanup) => cleanup());
});
};
}, [customizationCallbacks, stateContainer]);
return customizationService;
};
export const useDiscoverCustomization$ = <TCustomizationId extends DiscoverCustomizationId>(
id: TCustomizationId
) => useContext(customizationContext).get$(id);
@ -70,3 +32,32 @@ export const useDiscoverCustomization = <TCustomizationId extends DiscoverCustom
const customizationService = useContext(customizationContext);
return useObservable(customizationService.get$(id), customizationService.get(id));
};
export interface ConnectedCustomizationService extends DiscoverCustomizationService {
cleanup: () => Promise<void>;
}
export const getConnectedCustomizationService = async ({
customizationCallbacks,
stateContainer,
}: {
customizationCallbacks: CustomizationCallback[];
stateContainer: DiscoverStateContainer;
}): Promise<ConnectedCustomizationService> => {
const customizations = createCustomizationService();
const callbacks = customizationCallbacks.map((callback) =>
Promise.resolve(callback({ customizations, stateContainer }))
);
const initialize = () => Promise.all(callbacks).then((result) => result.filter(isFunction));
// TODO: Race condition?
await initialize();
return {
...customizations,
cleanup: async () => {
const cleanups = await initialize();
cleanups.forEach((cleanup) => cleanup());
},
};
};