[Discover] Initial tabs implementation (disabled in main) (#214861)

## Summary

This PR restructures Discover's state management to support tabs as
outlined in #215398, including the Redux store and
`RuntimeStateManager`. It also adds the initial tabs implementation to
the UI to start building on, but they're disabled by default with a
hardcoded flag. Tabs can be enabled by setting `TABS_ENABLED = true` in
`discover_main_route`, but they don't need to be thoroughly tested in
this PR since most of the functionality is incomplete.

There's also a flaw in the state management approach with `currentId`
since depending on it can cause state to leak across tabs when switching
tabs during async operations (e.g. data fetching). This shouldn't be an
issue while tabs are disabled, and there will be a followup PR #215620
to address it.


https://github.com/user-attachments/assets/ebbb9fa7-a3bc-4e82-9b5c-0d29cd0575f0

Part of #215398.

### 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
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Davis McPhee 2025-03-28 15:38:53 -03:00 committed by GitHub
parent 2cd777d969
commit 0bb73eec2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 775 additions and 314 deletions

View file

@ -240,9 +240,10 @@ export class DiscoverCustomizationExamplesPlugin implements Plugin {
ControlGroupRendererApi | undefined ControlGroupRendererApi | undefined
>(); >();
const stateStorage = stateContainer.stateStorage; const stateStorage = stateContainer.stateStorage;
const currentTabId = stateContainer.internalState.getState().tabs.currentId;
const dataView = useObservable( const dataView = useObservable(
stateContainer.runtimeStateManager.currentDataView$, stateContainer.runtimeStateManager.tabs.byId[currentTabId].currentDataView$,
stateContainer.runtimeStateManager.currentDataView$.getValue() stateContainer.runtimeStateManager.tabs.byId[currentTabId].currentDataView$.getValue()
); );
useEffect(() => { useEffect(() => {

View file

@ -16,7 +16,6 @@ import { expressionsPluginMock } from '@kbn/expressions-plugin/public/mocks';
import { savedSearchPluginMock } from '@kbn/saved-search-plugin/public/mocks'; import { savedSearchPluginMock } from '@kbn/saved-search-plugin/public/mocks';
import { import {
analyticsServiceMock, analyticsServiceMock,
chromeServiceMock,
coreMock, coreMock,
docLinksServiceMock, docLinksServiceMock,
scopedHistoryMock, scopedHistoryMock,
@ -150,18 +149,19 @@ export function createDiscoverServicesMock(): DiscoverServices {
corePluginMock.theme = theme; corePluginMock.theme = theme;
corePluginMock.chrome.getActiveSolutionNavId$.mockReturnValue(new BehaviorSubject(null)); corePluginMock.chrome.getActiveSolutionNavId$.mockReturnValue(new BehaviorSubject(null));
corePluginMock.chrome.getChromeStyle$.mockReturnValue(new BehaviorSubject('classic'));
return { return {
analytics: analyticsServiceMock.createAnalyticsServiceStart(), analytics: analyticsServiceMock.createAnalyticsServiceStart(),
application: corePluginMock.application, application: corePluginMock.application,
core: corePluginMock, core: corePluginMock,
charts: chartPluginMock.createSetupContract(), charts: chartPluginMock.createSetupContract(),
chrome: chromeServiceMock.createStartContract(), chrome: corePluginMock.chrome,
history: { history: {
location: { location: {
search: '', search: '',
}, },
listen: jest.fn(), listen: jest.fn(() => () => {}),
}, },
getScopedHistory: () => scopedHistoryMock.create(), getScopedHistory: () => scopedHistoryMock.create(),
data: dataPlugin, data: dataPlugin,

View file

@ -79,6 +79,7 @@ import {
useInternalStateDispatch, useInternalStateDispatch,
useInternalStateSelector, useInternalStateSelector,
} from '../../state_management/redux'; } from '../../state_management/redux';
import { useCurrentTabSelector } from '../../state_management/redux/hooks';
const DiscoverGridMemoized = React.memo(DiscoverGrid); const DiscoverGridMemoized = React.memo(DiscoverGrid);
@ -110,7 +111,7 @@ function DiscoverDocumentsComponent({
const documents$ = stateContainer.dataState.data$.documents$; const documents$ = stateContainer.dataState.data$.documents$;
const savedSearch = useSavedSearchInitial(); const savedSearch = useSavedSearchInitial();
const { dataViews, capabilities, uiSettings, uiActions, ebtManager, fieldsMetadata } = services; const { dataViews, capabilities, uiSettings, uiActions, ebtManager, fieldsMetadata } = services;
const requestParams = useInternalStateSelector((state) => state.dataRequestParams); const requestParams = useCurrentTabSelector((state) => state.dataRequestParams);
const [ const [
dataSource, dataSource,
query, query,
@ -153,7 +154,7 @@ function DiscoverDocumentsComponent({
// 5. this is propagated to Discover's URL and causes an unwanted change of state to an unsorted state // 5. this is propagated to Discover's URL and causes an unwanted change of state to an unsorted state
// This solution switches to the loading state in this component when the URL index doesn't match the dataView.id // This solution switches to the loading state in this component when the URL index doesn't match the dataView.id
const isDataViewLoading = const isDataViewLoading =
useInternalStateSelector((state) => state.isDataViewLoading) && !isEsqlMode; useCurrentTabSelector((state) => state.isDataViewLoading) && !isEsqlMode;
const isEmptyDataResult = const isEmptyDataResult =
isEsqlMode || !documentState.result || documentState.result.length === 0; isEsqlMode || !documentState.result || documentState.result.length === 0;
const rows = useMemo(() => documentState.result || [], [documentState.result]); const rows = useMemo(() => documentState.result || [], [documentState.result]);

View file

@ -57,7 +57,8 @@ import type { PanelsToggleProps } from '../../../../components/panels_toggle';
import { PanelsToggle } from '../../../../components/panels_toggle'; import { PanelsToggle } from '../../../../components/panels_toggle';
import { sendErrorMsg } from '../../hooks/use_saved_search_messages'; import { sendErrorMsg } from '../../hooks/use_saved_search_messages';
import { useIsEsqlMode } from '../../hooks/use_is_esql_mode'; import { useIsEsqlMode } from '../../hooks/use_is_esql_mode';
import { useCurrentDataView, useInternalStateSelector } from '../../state_management/redux'; import { useCurrentDataView } from '../../state_management/redux';
import { useCurrentTabSelector } from '../../state_management/redux/hooks';
const SidebarMemoized = React.memo(DiscoverSidebarResponsive); const SidebarMemoized = React.memo(DiscoverSidebarResponsive);
const TopNavMemoized = React.memo(DiscoverTopNav); const TopNavMemoized = React.memo(DiscoverTopNav);
@ -102,7 +103,7 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) {
return state.viewMode ?? VIEW_MODE.DOCUMENT_LEVEL; return state.viewMode ?? VIEW_MODE.DOCUMENT_LEVEL;
}); });
const dataView = useCurrentDataView(); const dataView = useCurrentDataView();
const dataViewLoading = useInternalStateSelector((state) => state.isDataViewLoading); const dataViewLoading = useCurrentTabSelector((state) => state.isDataViewLoading);
const dataState: DataMainMsg = useDataState(main$); const dataState: DataMainMsg = useDataState(main$);
const savedSearch = useSavedSearchInitial(); const savedSearch = useSavedSearchInitial();
const fetchCounter = useRef<number>(0); const fetchCounter = useRef<number>(0);

View file

@ -58,8 +58,8 @@ import {
internalStateActions, internalStateActions,
useCurrentDataView, useCurrentDataView,
useInternalStateDispatch, useInternalStateDispatch,
useInternalStateSelector,
} from '../../state_management/redux'; } from '../../state_management/redux';
import { useCurrentTabSelector } from '../../state_management/redux/hooks';
const EMPTY_ESQL_COLUMNS: DatatableColumn[] = []; const EMPTY_ESQL_COLUMNS: DatatableColumn[] = [];
const EMPTY_FILTERS: Filter[] = []; const EMPTY_FILTERS: Filter[] = [];
@ -227,7 +227,7 @@ export const useDiscoverHistogram = ({
* Request params * Request params
*/ */
const { query, filters } = useQuerySubscriber({ data: services.data }); const { query, filters } = useQuerySubscriber({ data: services.data });
const requestParams = useInternalStateSelector((state) => state.dataRequestParams); const requestParams = useCurrentTabSelector((state) => state.dataRequestParams);
const { timeRangeRelative: relativeTimeRange, timeRangeAbsolute: timeRange } = requestParams; const { timeRangeRelative: relativeTimeRange, timeRangeAbsolute: timeRange } = requestParams;
// When in ES|QL mode, update the data view, query, and // When in ES|QL mode, update the data view, query, and
// columns only when documents are done fetching so the Lens suggestions // columns only when documents are done fetching so the Lens suggestions

View file

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

View file

@ -55,10 +55,10 @@ export function DiscoverMainApp(props: DiscoverMainProps) {
* Start state syncing and fetch data if necessary * Start state syncing and fetch data if necessary
*/ */
useEffect(() => { useEffect(() => {
const unsubscribe = stateContainer.actions.initializeAndSync(); stateContainer.actions.initializeAndSync();
addLog('[DiscoverMainApp] state container initialization triggers data fetching'); addLog('[DiscoverMainApp] state container initialization triggers data fetching');
stateContainer.actions.fetchData(true); stateContainer.actions.fetchData(true);
return () => unsubscribe(); return () => stateContainer.actions.stopSyncing();
}, [stateContainer]); }, [stateContainer]);
/** /**

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1". * License v3.0 only", or the "Server Side Public License, v 1".
*/ */
import React, { useEffect } from 'react'; import React, { forwardRef, useEffect, useImperativeHandle } from 'react';
import type { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; import type { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/public'; import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/public';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
@ -28,6 +28,7 @@ import {
useInternalStateDispatch, useInternalStateDispatch,
useInternalStateSelector, useInternalStateSelector,
useRuntimeState, useRuntimeState,
useCurrentTabRuntimeState,
} from '../../state_management/redux'; } from '../../state_management/redux';
import type { import type {
CustomizationCallback, CustomizationCallback,
@ -46,7 +47,7 @@ import { RedirectWhenSavedObjectNotFound } from './redirect_not_found';
import { DiscoverMainApp } from './main_app'; import { DiscoverMainApp } from './main_app';
import { useAsyncFunction } from '../../hooks/use_async_function'; import { useAsyncFunction } from '../../hooks/use_async_function';
interface DiscoverSessionViewProps { export interface DiscoverSessionViewProps {
customizationContext: DiscoverCustomizationContext; customizationContext: DiscoverCustomizationContext;
customizationCallbacks: CustomizationCallback[]; customizationCallbacks: CustomizationCallback[];
urlStateStorage: IKbnUrlStateStorage; urlStateStorage: IKbnUrlStateStorage;
@ -54,6 +55,10 @@ interface DiscoverSessionViewProps {
runtimeStateManager: RuntimeStateManager; runtimeStateManager: RuntimeStateManager;
} }
export interface DiscoverSessionViewRef {
stopSyncing: () => void;
}
type SessionInitializationState = type SessionInitializationState =
| { | {
showNoDataPage: true; showNoDataPage: true;
@ -69,126 +74,147 @@ type InitializeSession = (options?: {
defaultUrlState?: DiscoverAppState; defaultUrlState?: DiscoverAppState;
}) => Promise<SessionInitializationState>; }) => Promise<SessionInitializationState>;
export const DiscoverSessionView = ({ export const DiscoverSessionView = forwardRef<DiscoverSessionViewRef, DiscoverSessionViewProps>(
customizationContext, (
customizationCallbacks, {
urlStateStorage, customizationContext,
internalState, customizationCallbacks,
runtimeStateManager, urlStateStorage,
}: DiscoverSessionViewProps) => { internalState,
const dispatch = useInternalStateDispatch(); runtimeStateManager,
const services = useDiscoverServices();
const { core, history, getScopedHistory } = services;
const { id: discoverSessionId } = useParams<{ id?: string }>();
const [initializeSessionState, initializeSession] = useAsyncFunction<InitializeSession>(
async ({ dataViewSpec, defaultUrlState } = {}) => {
const stateContainer = getDiscoverStateContainer({
services,
customizationContext,
stateStorageContainer: urlStateStorage,
internalState,
runtimeStateManager,
});
const { showNoDataPage } = await dispatch(
internalStateActions.initializeSession({
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,
});
});
const customizationService = useDiscoverCustomizationService({
customizationCallbacks,
stateContainer: initializeSessionState.value?.stateContainer,
});
const initializationState = useInternalStateSelector((state) => state.initializationState);
const currentDataView = useRuntimeState(runtimeStateManager.currentDataView$);
const adHocDataViews = useRuntimeState(runtimeStateManager.adHocDataViews$);
useEffect(() => {
initializeSessionWithDefaultLocationState.current();
}, [discoverSessionId, initializeSessionWithDefaultLocationState]);
useUrl({
history,
savedSearchId: discoverSessionId,
onNewUrl: () => {
initializeSessionWithDefaultLocationState.current();
}, },
}); ref
) => {
const dispatch = useInternalStateDispatch();
const services = useDiscoverServices();
const { core, history, getScopedHistory } = services;
const { id: discoverSessionId } = useParams<{ id?: string }>();
const [initializeSessionState, initializeSession] = useAsyncFunction<InitializeSession>(
async ({ dataViewSpec, defaultUrlState } = {}) => {
initializeSessionState.value?.stateContainer?.actions.stopSyncing();
useAlertResultsToast(); const stateContainer = getDiscoverStateContainer({
services,
customizationContext,
stateStorageContainer: urlStateStorage,
internalState,
runtimeStateManager,
});
const { showNoDataPage } = await dispatch(
internalStateActions.initializeSession({
stateContainer,
discoverSessionId,
dataViewSpec,
defaultUrlState,
})
);
useExecutionContext(core.executionContext, { return showNoDataPage ? { showNoDataPage } : { showNoDataPage, stateContainer };
type: 'application', }
page: 'app', );
id: discoverSessionId || 'new', const initializeSessionWithDefaultLocationState = useLatest(() => {
}); const historyLocationState = getScopedHistory<
MainHistoryLocationState & { defaultState?: DiscoverAppState }
>()?.location.state;
initializeSession({
dataViewSpec: historyLocationState?.dataViewSpec,
defaultUrlState: historyLocationState?.defaultState,
});
});
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$);
if (initializeSessionState.loading) { useImperativeHandle(
return <BrandedLoadingIndicator />; ref,
} () => ({
stopSyncing: () => initializeSessionState.value?.stateContainer?.actions.stopSyncing(),
}),
[initializeSessionState.value?.stateContainer]
);
if (initializeSessionState.error) { useEffect(() => {
if (initializeSessionState.error instanceof SavedObjectNotFound) { 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}
/>
);
}
return <DiscoverError error={initializeSessionState.error} />;
}
if (initializeSessionState.value.showNoDataPage) {
return ( return (
<RedirectWhenSavedObjectNotFound <NoDataPage
error={initializeSessionState.error} {...initializationState}
discoverSessionId={discoverSessionId} 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();
}}
/> />
); );
} }
return <DiscoverError error={initializeSessionState.error} />; if (!customizationService || !currentDataView) {
} return <BrandedLoadingIndicator />;
}
if (initializeSessionState.value.showNoDataPage) {
return ( return (
<NoDataPage <DiscoverCustomizationProvider value={customizationService}>
{...initializationState} <DiscoverMainProvider value={initializeSessionState.value.stateContainer}>
onDataViewCreated={async (dataViewUnknown) => { <RuntimeStateProvider currentDataView={currentDataView} adHocDataViews={adHocDataViews}>
await dispatch(internalStateActions.loadDataViewList()); <DiscoverMainApp stateContainer={initializeSessionState.value.stateContainer} />
dispatch( </RuntimeStateProvider>
internalStateActions.setInitializationState({ hasESData: true, hasUserDataView: true }) </DiscoverMainProvider>
); </DiscoverCustomizationProvider>
const dataView = dataViewUnknown as DataView;
initializeSession({
defaultUrlState: dataView.id
? { dataSource: createDataViewDataSource({ dataViewId: dataView.id }) }
: undefined,
});
}}
onESQLNavigationComplete={() => {
initializeSession();
}}
/>
); );
} }
);
if (!customizationService || !currentDataView) {
return <BrandedLoadingIndicator />;
}
return (
<DiscoverCustomizationProvider value={customizationService}>
<DiscoverMainProvider value={initializeSessionState.value.stateContainer}>
<RuntimeStateProvider currentDataView={currentDataView} adHocDataViews={adHocDataViews}>
<DiscoverMainApp stateContainer={initializeSessionState.value.stateContainer} />
</RuntimeStateProvider>
</DiscoverMainProvider>
</DiscoverCustomizationProvider>
);
};

View file

@ -0,0 +1,10 @@
/*
* 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".
*/
export { TabsView } from './tabs_view';

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { type TabItem, UnifiedTabs, TabStatus } from '@kbn/unified-tabs';
import React, { useRef, useState } from 'react';
import { pick } from 'lodash';
import type { DiscoverSessionViewRef } from '../session_view';
import { DiscoverSessionView, type DiscoverSessionViewProps } from '../session_view';
import {
createTabItem,
internalStateActions,
selectAllTabs,
selectCurrentTab,
useInternalStateDispatch,
useInternalStateSelector,
} from '../../state_management/redux';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
export const TabsView = ({ sessionViewProps }: { sessionViewProps: DiscoverSessionViewProps }) => {
const services = useDiscoverServices();
const dispatch = useInternalStateDispatch();
const currentTab = useInternalStateSelector(selectCurrentTab);
const allTabs = useInternalStateSelector(selectAllTabs);
const [initialItems] = useState<TabItem[]>(() => allTabs.map((tab) => pick(tab, 'id', 'label')));
const sessionViewRef = useRef<DiscoverSessionViewRef>(null);
return (
<UnifiedTabs
services={services}
initialItems={initialItems}
onChanged={(updateState) =>
dispatch(
internalStateActions.updateTabs({
updateState,
stopSyncing: sessionViewRef.current?.stopSyncing,
})
)
}
createItem={() => createTabItem(allTabs)}
getPreviewData={() => ({
query: { language: 'kuery', query: 'sample query' },
status: TabStatus.SUCCESS,
})}
renderContent={() => (
<DiscoverSessionView key={currentTab.id} ref={sessionViewRef} {...sessionViewProps} />
)}
/>
);
};

View file

@ -17,7 +17,7 @@ import type { SavedSearch, SaveSavedSearchOptions } from '@kbn/saved-search-plug
import type { DiscoverServices } from '../../../../build_services'; import type { DiscoverServices } from '../../../../build_services';
import type { DiscoverStateContainer } from '../../state_management/discover_state'; import type { DiscoverStateContainer } from '../../state_management/discover_state';
import { getAllowedSampleSize } from '../../../../utils/get_allowed_sample_size'; import { getAllowedSampleSize } from '../../../../utils/get_allowed_sample_size';
import { internalStateActions } from '../../state_management/redux'; import { internalStateActions, selectCurrentTab } from '../../state_management/redux';
async function saveDataSource({ async function saveDataSource({
savedSearch, savedSearch,
@ -95,8 +95,8 @@ export async function onSaveSearch({
}) { }) {
const { uiSettings, savedObjectsTagging } = services; const { uiSettings, savedObjectsTagging } = services;
const dataView = savedSearch.searchSource.getField('index'); const dataView = savedSearch.searchSource.getField('index');
const overriddenVisContextAfterInvalidation = const currentTab = selectCurrentTab(state.internalState.getState());
state.internalState.getState().overriddenVisContextAfterInvalidation; const overriddenVisContextAfterInvalidation = currentTab.overriddenVisContextAfterInvalidation;
const onSave = async ({ const onSave = async ({
newTitle, newTitle,

View file

@ -35,7 +35,7 @@ import type {
} from '../state_management/discover_data_state_container'; } from '../state_management/discover_data_state_container';
import type { DiscoverServices } from '../../../build_services'; import type { DiscoverServices } from '../../../build_services';
import { fetchEsql } from './fetch_esql'; import { fetchEsql } from './fetch_esql';
import type { InternalStateStore } from '../state_management/redux'; import { selectCurrentTab, type InternalStateStore } from '../state_management/redux';
export interface FetchDeps { export interface FetchDeps {
abortController: AbortController; abortController: AbortController;
@ -78,6 +78,7 @@ export function fetchAll(
const query = getAppState().query; const query = getAppState().query;
const prevQuery = dataSubjects.documents$.getValue().query; const prevQuery = dataSubjects.documents$.getValue().query;
const isEsqlQuery = isOfAggregateQueryType(query); const isEsqlQuery = isOfAggregateQueryType(query);
const currentTab = selectCurrentTab(internalState.getState());
if (reset) { if (reset) {
sendResetMsg(dataSubjects, initialFetchStatus); sendResetMsg(dataSubjects, initialFetchStatus);
@ -89,7 +90,7 @@ export function fetchAll(
dataView, dataView,
services, services,
sort: getAppState().sort as SortOrder[], sort: getAppState().sort as SortOrder[],
inputTimeRange: internalState.getState().dataRequestParams.timeRangeAbsolute, inputTimeRange: currentTab.dataRequestParams.timeRangeAbsolute,
}); });
} }
@ -110,7 +111,7 @@ export function fetchAll(
data, data,
expressions, expressions,
profilesManager, profilesManager,
timeRange: internalState.getState().dataRequestParams.timeRangeAbsolute, timeRange: currentTab.dataRequestParams.timeRangeAbsolute,
}) })
: fetchDocuments(searchSource, fetchDeps); : fetchDocuments(searchSource, fetchDeps);
const fetchType = isEsqlQuery ? 'fetchTextBased' : 'fetchDocuments'; const fetchType = isEsqlQuery ? 'fetchTextBased' : 'fetchDocuments';

View file

@ -24,12 +24,17 @@ import {
import type { RootProfileState } from '../../context_awareness'; import type { RootProfileState } from '../../context_awareness';
import { useRootProfile, useDefaultAdHocDataViews } from '../../context_awareness'; import { useRootProfile, useDefaultAdHocDataViews } from '../../context_awareness';
import { DiscoverError } from '../../components/common/error_alert'; import { DiscoverError } from '../../components/common/error_alert';
import type { DiscoverSessionViewProps } from './components/session_view';
import { import {
BrandedLoadingIndicator, BrandedLoadingIndicator,
DiscoverSessionView, DiscoverSessionView,
NoDataPage, NoDataPage,
} from './components/session_view'; } from './components/session_view';
import { useAsyncFunction } from './hooks/use_async_function'; 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 interface MainRouteProps { export interface MainRouteProps {
customizationContext: DiscoverCustomizationContext; customizationContext: DiscoverCustomizationContext;
@ -120,16 +125,22 @@ export const DiscoverMainRoute = ({
); );
} }
const sessionViewProps: DiscoverSessionViewProps = {
customizationContext,
customizationCallbacks,
urlStateStorage,
internalState,
runtimeStateManager,
};
return ( return (
<InternalStateProvider store={internalState}> <InternalStateProvider store={internalState}>
<rootProfileState.AppWrapper> <rootProfileState.AppWrapper>
<DiscoverSessionView {TABS_ENABLED ? (
customizationContext={customizationContext} <TabsView sessionViewProps={sessionViewProps} />
customizationCallbacks={customizationCallbacks} ) : (
urlStateStorage={urlStateStorage} <DiscoverSessionView {...sessionViewProps} />
internalState={internalState} )}
runtimeStateManager={runtimeStateManager}
/>
</rootProfileState.AppWrapper> </rootProfileState.AppWrapper>
</InternalStateProvider> </InternalStateProvider>
); );

View file

@ -27,7 +27,7 @@ import { dataViewAdHoc } from '../../../__mocks__/data_view_complex';
import type { EsHitRecord } from '@kbn/discover-utils'; import type { EsHitRecord } from '@kbn/discover-utils';
import { buildDataTableRecord } from '@kbn/discover-utils'; import { buildDataTableRecord } from '@kbn/discover-utils';
import { omit } from 'lodash'; import { omit } from 'lodash';
import { internalStateActions } from '../state_management/redux'; import { internalStateActions, selectCurrentTab } from '../state_management/redux';
async function getHookProps( async function getHookProps(
query: AggregateQuery | Query | undefined, query: AggregateQuery | Query | undefined,
@ -507,7 +507,10 @@ describe('useEsqlMode', () => {
); );
const documents$ = stateContainer.dataState.data$.documents$; const documents$ = stateContainer.dataState.data$.documents$;
expect( expect(
omit(stateContainer.internalState.getState().resetDefaultProfileState, 'resetId') omit(
selectCurrentTab(stateContainer.internalState.getState()).resetDefaultProfileState,
'resetId'
)
).toEqual({ ).toEqual({
columns: false, columns: false,
rowHeight: false, rowHeight: false,
@ -524,7 +527,10 @@ describe('useEsqlMode', () => {
}); });
await waitFor(() => await waitFor(() =>
expect( expect(
omit(stateContainer.internalState.getState().resetDefaultProfileState, 'resetId') omit(
selectCurrentTab(stateContainer.internalState.getState()).resetDefaultProfileState,
'resetId'
)
).toEqual({ ).toEqual({
columns: true, columns: true,
rowHeight: true, rowHeight: true,
@ -549,7 +555,10 @@ describe('useEsqlMode', () => {
}); });
await waitFor(() => await waitFor(() =>
expect( expect(
omit(stateContainer.internalState.getState().resetDefaultProfileState, 'resetId') omit(
selectCurrentTab(stateContainer.internalState.getState()).resetDefaultProfileState,
'resetId'
)
).toEqual({ ).toEqual({
columns: false, columns: false,
rowHeight: false, rowHeight: false,
@ -567,7 +576,10 @@ describe('useEsqlMode', () => {
}); });
await waitFor(() => await waitFor(() =>
expect( expect(
omit(stateContainer.internalState.getState().resetDefaultProfileState, 'resetId') omit(
selectCurrentTab(stateContainer.internalState.getState()).resetDefaultProfileState,
'resetId'
)
).toEqual({ ).toEqual({
columns: true, columns: true,
rowHeight: true, rowHeight: true,
@ -586,7 +598,10 @@ describe('useEsqlMode', () => {
const result1 = [buildDataTableRecord({ message: 'foo' } as EsHitRecord)]; const result1 = [buildDataTableRecord({ message: 'foo' } as EsHitRecord)];
const result2 = [buildDataTableRecord({ message: 'foo', extension: 'bar' } as EsHitRecord)]; const result2 = [buildDataTableRecord({ message: 'foo', extension: 'bar' } as EsHitRecord)];
expect( expect(
omit(stateContainer.internalState.getState().resetDefaultProfileState, 'resetId') omit(
selectCurrentTab(stateContainer.internalState.getState()).resetDefaultProfileState,
'resetId'
)
).toEqual({ ).toEqual({
columns: false, columns: false,
rowHeight: false, rowHeight: false,
@ -599,7 +614,10 @@ describe('useEsqlMode', () => {
}); });
await waitFor(() => await waitFor(() =>
expect( expect(
omit(stateContainer.internalState.getState().resetDefaultProfileState, 'resetId') omit(
selectCurrentTab(stateContainer.internalState.getState()).resetDefaultProfileState,
'resetId'
)
).toEqual({ ).toEqual({
columns: false, columns: false,
rowHeight: false, rowHeight: false,
@ -613,7 +631,10 @@ describe('useEsqlMode', () => {
}); });
await waitFor(() => await waitFor(() =>
expect( expect(
omit(stateContainer.internalState.getState().resetDefaultProfileState, 'resetId') omit(
selectCurrentTab(stateContainer.internalState.getState()).resetDefaultProfileState,
'resetId'
)
).toEqual({ ).toEqual({
columns: true, columns: true,
rowHeight: false, rowHeight: false,

View file

@ -23,7 +23,7 @@ import { getSavedSearchContainer } from './discover_saved_search_container';
import { getDiscoverGlobalStateContainer } from './discover_global_state_container'; import { getDiscoverGlobalStateContainer } from './discover_global_state_container';
import { omit } from 'lodash'; import { omit } from 'lodash';
import type { InternalStateStore } from './redux'; import type { InternalStateStore } from './redux';
import { createInternalStateStore, createRuntimeStateManager } from './redux'; import { createInternalStateStore, createRuntimeStateManager, selectCurrentTab } from './redux';
import { mockCustomizationContext } from '../../../customizations/__mocks__/customization_context'; import { mockCustomizationContext } from '../../../customizations/__mocks__/customization_context';
let history: History; let history: History;
@ -274,13 +274,17 @@ describe('Test discover app state container', () => {
describe('initAndSync', () => { describe('initAndSync', () => {
it('should call setResetDefaultProfileState correctly with no initial state', () => { it('should call setResetDefaultProfileState correctly with no initial state', () => {
const state = getStateContainer(); const state = getStateContainer();
expect(omit(internalState.getState().resetDefaultProfileState, 'resetId')).toEqual({ expect(
omit(selectCurrentTab(internalState.getState()).resetDefaultProfileState, 'resetId')
).toEqual({
columns: false, columns: false,
rowHeight: false, rowHeight: false,
breakdownField: false, breakdownField: false,
}); });
state.initAndSync(); state.initAndSync();
expect(omit(internalState.getState().resetDefaultProfileState, 'resetId')).toEqual({ expect(
omit(selectCurrentTab(internalState.getState()).resetDefaultProfileState, 'resetId')
).toEqual({
columns: true, columns: true,
rowHeight: true, rowHeight: true,
breakdownField: true, breakdownField: true,
@ -291,13 +295,17 @@ describe('Test discover app state container', () => {
const stateStorageGetSpy = jest.spyOn(stateStorage, 'get'); const stateStorageGetSpy = jest.spyOn(stateStorage, 'get');
stateStorageGetSpy.mockReturnValue({ columns: ['test'] }); stateStorageGetSpy.mockReturnValue({ columns: ['test'] });
const state = getStateContainer(); const state = getStateContainer();
expect(omit(internalState.getState().resetDefaultProfileState, 'resetId')).toEqual({ expect(
omit(selectCurrentTab(internalState.getState()).resetDefaultProfileState, 'resetId')
).toEqual({
columns: false, columns: false,
rowHeight: false, rowHeight: false,
breakdownField: false, breakdownField: false,
}); });
state.initAndSync(); state.initAndSync();
expect(omit(internalState.getState().resetDefaultProfileState, 'resetId')).toEqual({ expect(
omit(selectCurrentTab(internalState.getState()).resetDefaultProfileState, 'resetId')
).toEqual({
columns: false, columns: false,
rowHeight: true, rowHeight: true,
breakdownField: true, breakdownField: true,
@ -308,13 +316,17 @@ describe('Test discover app state container', () => {
const stateStorageGetSpy = jest.spyOn(stateStorage, 'get'); const stateStorageGetSpy = jest.spyOn(stateStorage, 'get');
stateStorageGetSpy.mockReturnValue({ rowHeight: 5 }); stateStorageGetSpy.mockReturnValue({ rowHeight: 5 });
const state = getStateContainer(); const state = getStateContainer();
expect(omit(internalState.getState().resetDefaultProfileState, 'resetId')).toEqual({ expect(
omit(selectCurrentTab(internalState.getState()).resetDefaultProfileState, 'resetId')
).toEqual({
columns: false, columns: false,
rowHeight: false, rowHeight: false,
breakdownField: false, breakdownField: false,
}); });
state.initAndSync(); state.initAndSync();
expect(omit(internalState.getState().resetDefaultProfileState, 'resetId')).toEqual({ expect(
omit(selectCurrentTab(internalState.getState()).resetDefaultProfileState, 'resetId')
).toEqual({
columns: true, columns: true,
rowHeight: false, rowHeight: false,
breakdownField: true, breakdownField: true,
@ -331,13 +343,17 @@ describe('Test discover app state container', () => {
managed: false, managed: false,
}); });
const state = getStateContainer(); const state = getStateContainer();
expect(omit(internalState.getState().resetDefaultProfileState, 'resetId')).toEqual({ expect(
omit(selectCurrentTab(internalState.getState()).resetDefaultProfileState, 'resetId')
).toEqual({
columns: false, columns: false,
rowHeight: false, rowHeight: false,
breakdownField: false, breakdownField: false,
}); });
state.initAndSync(); state.initAndSync();
expect(omit(internalState.getState().resetDefaultProfileState, 'resetId')).toEqual({ expect(
omit(selectCurrentTab(internalState.getState()).resetDefaultProfileState, 'resetId')
).toEqual({
columns: false, columns: false,
rowHeight: false, rowHeight: false,
breakdownField: false, breakdownField: false,

View file

@ -17,7 +17,7 @@ import type { DataDocuments$ } from './discover_data_state_container';
import { getDiscoverStateMock } from '../../../__mocks__/discover_state.mock'; import { getDiscoverStateMock } from '../../../__mocks__/discover_state.mock';
import { fetchDocuments } from '../data_fetching/fetch_documents'; import { fetchDocuments } from '../data_fetching/fetch_documents';
import { omit } from 'lodash'; import { omit } from 'lodash';
import { internalStateActions } from './redux'; import { internalStateActions, selectCurrentTab } from './redux';
jest.mock('../data_fetching/fetch_documents', () => ({ jest.mock('../data_fetching/fetch_documents', () => ({
fetchDocuments: jest.fn().mockResolvedValue({ records: [] }), fetchDocuments: jest.fn().mockResolvedValue({ records: [] }),
@ -195,7 +195,10 @@ describe('test getDataStateContainer', () => {
expect(dataState.data$.main$.value.fetchStatus).toBe(FetchStatus.COMPLETE); expect(dataState.data$.main$.value.fetchStatus).toBe(FetchStatus.COMPLETE);
}); });
expect( expect(
omit(stateContainer.internalState.getState().resetDefaultProfileState, 'resetId') omit(
selectCurrentTab(stateContainer.internalState.getState()).resetDefaultProfileState,
'resetId'
)
).toEqual({ ).toEqual({
columns: false, columns: false,
rowHeight: false, rowHeight: false,
@ -230,7 +233,10 @@ describe('test getDataStateContainer', () => {
expect(dataState.data$.main$.value.fetchStatus).toBe(FetchStatus.COMPLETE); expect(dataState.data$.main$.value.fetchStatus).toBe(FetchStatus.COMPLETE);
}); });
expect( expect(
omit(stateContainer.internalState.getState().resetDefaultProfileState, 'resetId') omit(
selectCurrentTab(stateContainer.internalState.getState()).resetDefaultProfileState,
'resetId'
)
).toEqual({ ).toEqual({
columns: false, columns: false,
rowHeight: false, rowHeight: false,

View file

@ -31,7 +31,7 @@ import { sendResetMsg } from '../hooks/use_saved_search_messages';
import { getFetch$ } from '../data_fetching/get_fetch_observable'; import { getFetch$ } from '../data_fetching/get_fetch_observable';
import { getDefaultProfileState } from './utils/get_default_profile_state'; import { getDefaultProfileState } from './utils/get_default_profile_state';
import type { InternalStateStore, RuntimeStateManager } from './redux'; import type { InternalStateStore, RuntimeStateManager } from './redux';
import { internalStateActions } from './redux'; import { internalStateActions, selectCurrentTab, selectCurrentTabRuntimeState } from './redux';
export interface SavedSearchData { export interface SavedSearchData {
main$: DataMain$; main$: DataMain$;
@ -263,8 +263,13 @@ export function getDataStateContainer({
query: appStateContainer.getState().query, query: appStateContainer.getState().query,
}); });
const { resetDefaultProfileState } = internalState.getState(); const currentInternalState = internalState.getState();
const dataView = runtimeStateManager.currentDataView$.getValue(); const { resetDefaultProfileState } = selectCurrentTab(currentInternalState);
const { currentDataView$ } = selectCurrentTabRuntimeState(
currentInternalState,
runtimeStateManager
);
const dataView = currentDataView$.getValue();
const defaultProfileState = dataView const defaultProfileState = dataView
? getDefaultProfileState({ profilesManager, resetDefaultProfileState, dataView }) ? getDefaultProfileState({ profilesManager, resetDefaultProfileState, dataView })
: undefined; : undefined;
@ -293,7 +298,7 @@ export function getDataStateContainer({
}, },
async () => { async () => {
const { resetDefaultProfileState: currentResetDefaultProfileState } = const { resetDefaultProfileState: currentResetDefaultProfileState } =
internalState.getState(); selectCurrentTab(internalState.getState());
if (currentResetDefaultProfileState.resetId !== resetDefaultProfileState.resetId) { if (currentResetDefaultProfileState.resetId !== resetDefaultProfileState.resetId) {
return; return;

View file

@ -9,7 +9,7 @@
import type { DiscoverStateContainer } from './discover_state'; import type { DiscoverStateContainer } from './discover_state';
import { createSearchSessionRestorationDataProvider } from './discover_state'; import { createSearchSessionRestorationDataProvider } from './discover_state';
import { internalStateActions } from './redux'; import { internalStateActions, selectCurrentTab, selectCurrentTabRuntimeState } from './redux';
import type { History } from 'history'; import type { History } from 'history';
import { createBrowserHistory, createMemoryHistory } from 'history'; import { createBrowserHistory, createMemoryHistory } from 'history';
import { createSearchSourceMock, dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { createSearchSourceMock, dataPluginMock } from '@kbn/data-plugin/public/mocks';
@ -249,9 +249,9 @@ describe('Discover state', () => {
} as SavedSearch; } as SavedSearch;
const { state } = await getState('/#?_a=(sort:!(!(timestamp,desc)))', { savedSearch }); const { state } = await getState('/#?_a=(sort:!(!(timestamp,desc)))', { savedSearch });
const unsubscribe = state.actions.initializeAndSync(); state.actions.initializeAndSync();
expect(state.appState.getState().sort).toEqual([['timestamp', 'desc']]); expect(state.appState.getState().sort).toEqual([['timestamp', 'desc']]);
unsubscribe(); state.actions.stopSyncing();
}); });
test('Empty URL should use saved search sort for state', async () => { test('Empty URL should use saved search sort for state', async () => {
@ -269,9 +269,9 @@ describe('Discover state', () => {
defaultUrlState: undefined, defaultUrlState: undefined,
}) })
); );
const unsubscribe = state.actions.initializeAndSync(); state.actions.initializeAndSync();
expect(state.appState.getState().sort).toEqual([['bytes', 'desc']]); expect(state.appState.getState().sort).toEqual([['bytes', 'desc']]);
unsubscribe(); state.actions.stopSyncing();
}); });
}); });
@ -442,10 +442,20 @@ describe('Discover state', () => {
test('setDataView', async () => { test('setDataView', async () => {
const { state, runtimeStateManager } = await getState(''); const { state, runtimeStateManager } = await getState('');
expect(runtimeStateManager.currentDataView$.getValue()).toBeUndefined(); expect(
selectCurrentTabRuntimeState(
state.internalState.getState(),
runtimeStateManager
).currentDataView$.getValue()
).toBeUndefined();
state.actions.setDataView(dataViewMock); state.actions.setDataView(dataViewMock);
expect(runtimeStateManager.currentDataView$.getValue()).toBe(dataViewMock); expect(
expect(state.internalState.getState().dataViewId).toBe(dataViewMock.id); selectCurrentTabRuntimeState(
state.internalState.getState(),
runtimeStateManager
).currentDataView$.getValue()
).toBe(dataViewMock);
expect(selectCurrentTab(state.internalState.getState()).dataViewId).toBe(dataViewMock.id);
}); });
test('fetchData', async () => { test('fetchData', async () => {
@ -462,12 +472,12 @@ describe('Discover state', () => {
defaultUrlState: undefined, defaultUrlState: undefined,
}) })
); );
const unsubscribe = state.actions.initializeAndSync(); state.actions.initializeAndSync();
state.actions.fetchData(); state.actions.fetchData();
await waitFor(() => { await waitFor(() => {
expect(dataState.data$.documents$.value.fetchStatus).toBe(FetchStatus.COMPLETE); expect(dataState.data$.documents$.value.fetchStatus).toBe(FetchStatus.COMPLETE);
}); });
unsubscribe(); state.actions.stopSyncing();
expect(dataState.data$.totalHits$.value.result).toBe(0); expect(dataState.data$.totalHits$.value.result).toBe(0);
expect(dataState.data$.documents$.value.result).toEqual([]); expect(dataState.data$.documents$.value.result).toEqual([]);
@ -492,7 +502,7 @@ describe('Discover state', () => {
); );
const newSavedSearch = state.savedSearchState.getState(); const newSavedSearch = state.savedSearchState.getState();
expect(newSavedSearch?.id).toBeUndefined(); expect(newSavedSearch?.id).toBeUndefined();
const unsubscribe = state.actions.initializeAndSync(); state.actions.initializeAndSync();
await new Promise(process.nextTick); await new Promise(process.nextTick);
expect(getCurrentUrl()).toMatchInlineSnapshot( 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:!())"` `"/#?_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:!())"`
@ -517,7 +527,7 @@ describe('Discover state', () => {
} }
`); `);
expect(searchSource.getField('index')?.id).toEqual('the-data-view-id'); expect(searchSource.getField('index')?.id).toEqual('the-data-view-id');
unsubscribe(); state.actions.stopSyncing();
}); });
test('loadNewSavedSearch given an empty URL using loadSavedSearch', async () => { test('loadNewSavedSearch given an empty URL using loadSavedSearch', async () => {
@ -533,13 +543,13 @@ describe('Discover state', () => {
); );
const newSavedSearch = state.savedSearchState.getState(); const newSavedSearch = state.savedSearchState.getState();
expect(newSavedSearch?.id).toBeUndefined(); expect(newSavedSearch?.id).toBeUndefined();
const unsubscribe = state.actions.initializeAndSync(); state.actions.initializeAndSync();
await new Promise(process.nextTick); await new Promise(process.nextTick);
expect(getCurrentUrl()).toMatchInlineSnapshot( 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:!())"` `"/#?_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:!())"`
); );
expect(state.savedSearchState.getHasChanged$().getValue()).toBe(false); expect(state.savedSearchState.getHasChanged$().getValue()).toBe(false);
unsubscribe(); state.actions.stopSyncing();
}); });
test('loadNewSavedSearch with URL changing interval state', async () => { test('loadNewSavedSearch with URL changing interval state', async () => {
@ -558,13 +568,13 @@ describe('Discover state', () => {
); );
const newSavedSearch = state.savedSearchState.getState(); const newSavedSearch = state.savedSearchState.getState();
expect(newSavedSearch?.id).toBeUndefined(); expect(newSavedSearch?.id).toBeUndefined();
const unsubscribe = state.actions.initializeAndSync(); state.actions.initializeAndSync();
await new Promise(process.nextTick); await new Promise(process.nextTick);
expect(getCurrentUrl()).toMatchInlineSnapshot( expect(getCurrentUrl()).toMatchInlineSnapshot(
`"/#?_a=(columns:!(bytes),dataSource:(dataViewId:the-data-view-id,type:dataView),interval:month,sort:!())&_g=()"` `"/#?_a=(columns:!(bytes),dataSource:(dataViewId:the-data-view-id,type:dataView),interval:month,sort:!())&_g=()"`
); );
expect(state.savedSearchState.getHasChanged$().getValue()).toBe(false); expect(state.savedSearchState.getHasChanged$().getValue()).toBe(false);
unsubscribe(); state.actions.stopSyncing();
}); });
test('loadSavedSearch with no id, given URL changes state', async () => { test('loadSavedSearch with no id, given URL changes state', async () => {
@ -583,13 +593,13 @@ describe('Discover state', () => {
); );
const newSavedSearch = state.savedSearchState.getState(); const newSavedSearch = state.savedSearchState.getState();
expect(newSavedSearch?.id).toBeUndefined(); expect(newSavedSearch?.id).toBeUndefined();
const unsubscribe = state.actions.initializeAndSync(); state.actions.initializeAndSync();
await new Promise(process.nextTick); await new Promise(process.nextTick);
expect(getCurrentUrl()).toMatchInlineSnapshot( expect(getCurrentUrl()).toMatchInlineSnapshot(
`"/#?_a=(columns:!(bytes),dataSource:(dataViewId:the-data-view-id,type:dataView),interval:month,sort:!())&_g=()"` `"/#?_a=(columns:!(bytes),dataSource:(dataViewId:the-data-view-id,type:dataView),interval:month,sort:!())&_g=()"`
); );
expect(state.savedSearchState.getHasChanged$().getValue()).toBe(false); expect(state.savedSearchState.getHasChanged$().getValue()).toBe(false);
unsubscribe(); state.actions.stopSyncing();
}); });
test('loadSavedSearch given an empty URL, no state changes', async () => { test('loadSavedSearch given an empty URL, no state changes', async () => {
@ -618,14 +628,14 @@ describe('Discover state', () => {
}) })
); );
const newSavedSearch = state.savedSearchState.getState(); const newSavedSearch = state.savedSearchState.getState();
const unsubscribe = state.actions.initializeAndSync(); state.actions.initializeAndSync();
await new Promise(process.nextTick); await new Promise(process.nextTick);
expect(newSavedSearch?.id).toBe('the-saved-search-id'); expect(newSavedSearch?.id).toBe('the-saved-search-id');
expect(getCurrentUrl()).toMatchInlineSnapshot( 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:!())"` `"/#?_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:!())"`
); );
expect(state.savedSearchState.getHasChanged$().getValue()).toBe(false); expect(state.savedSearchState.getHasChanged$().getValue()).toBe(false);
unsubscribe(); state.actions.stopSyncing();
}); });
test('loadSavedSearch given a URL with different interval and columns modifying the state', async () => { test('loadSavedSearch given a URL with different interval and columns modifying the state', async () => {
@ -643,13 +653,13 @@ describe('Discover state', () => {
defaultUrlState: undefined, defaultUrlState: undefined,
}) })
); );
const unsubscribe = state.actions.initializeAndSync(); state.actions.initializeAndSync();
await new Promise(process.nextTick); await new Promise(process.nextTick);
expect(getCurrentUrl()).toMatchInlineSnapshot( expect(getCurrentUrl()).toMatchInlineSnapshot(
`"/#?_a=(columns:!(message),dataSource:(dataViewId:the-data-view-id,type:dataView),interval:month,sort:!())&_g=()"` `"/#?_a=(columns:!(message),dataSource:(dataViewId:the-data-view-id,type:dataView),interval:month,sort:!())&_g=()"`
); );
expect(state.savedSearchState.getHasChanged$().getValue()).toBe(true); expect(state.savedSearchState.getHasChanged$().getValue()).toBe(true);
unsubscribe(); state.actions.stopSyncing();
}); });
test('loadSavedSearch given a URL with different time range than the stored one showing as changed', async () => { test('loadSavedSearch given a URL with different time range than the stored one showing as changed', async () => {
@ -673,10 +683,10 @@ describe('Discover state', () => {
defaultUrlState: undefined, defaultUrlState: undefined,
}) })
); );
const unsubscribe = state.actions.initializeAndSync(); state.actions.initializeAndSync();
await new Promise(process.nextTick); await new Promise(process.nextTick);
expect(state.savedSearchState.getHasChanged$().getValue()).toBe(true); expect(state.savedSearchState.getHasChanged$().getValue()).toBe(true);
unsubscribe(); state.actions.stopSyncing();
}); });
test('loadSavedSearch given a URL with different refresh interval than the stored one showing as changed', async () => { test('loadSavedSearch given a URL with different refresh interval than the stored one showing as changed', async () => {
@ -704,10 +714,10 @@ describe('Discover state', () => {
defaultUrlState: undefined, defaultUrlState: undefined,
}) })
); );
const unsubscribe = state.actions.initializeAndSync(); state.actions.initializeAndSync();
await new Promise(process.nextTick); await new Promise(process.nextTick);
expect(state.savedSearchState.getHasChanged$().getValue()).toBe(true); expect(state.savedSearchState.getHasChanged$().getValue()).toBe(true);
unsubscribe(); state.actions.stopSyncing();
}); });
test('loadSavedSearch given a URL with matching time range and refresh interval not showing as changed', async () => { test('loadSavedSearch given a URL with matching time range and refresh interval not showing as changed', async () => {
@ -735,10 +745,10 @@ describe('Discover state', () => {
defaultUrlState: undefined, defaultUrlState: undefined,
}) })
); );
const unsubscribe = state.actions.initializeAndSync(); state.actions.initializeAndSync();
await new Promise(process.nextTick); await new Promise(process.nextTick);
expect(state.savedSearchState.getHasChanged$().getValue()).toBe(false); expect(state.savedSearchState.getHasChanged$().getValue()).toBe(false);
unsubscribe(); state.actions.stopSyncing();
}); });
test('loadSavedSearch ignoring hideChart in URL', async () => { test('loadSavedSearch ignoring hideChart in URL', async () => {
@ -1057,7 +1067,7 @@ describe('Discover state', () => {
defaultUrlState: undefined, defaultUrlState: undefined,
}) })
); );
const unsubscribe = actions.initializeAndSync(); actions.initializeAndSync();
await new Promise(process.nextTick); await new Promise(process.nextTick);
// test initial state // test initial state
expect(dataState.fetch).toHaveBeenCalledTimes(0); expect(dataState.fetch).toHaveBeenCalledTimes(0);
@ -1078,7 +1088,7 @@ describe('Discover state', () => {
); );
// check if the changed data view is reflected in the URL // check if the changed data view is reflected in the URL
expect(getCurrentUrl()).toContain(dataViewComplexMock.id); expect(getCurrentUrl()).toContain(dataViewComplexMock.id);
unsubscribe(); state.actions.stopSyncing();
}); });
test('onDataViewCreated - persisted data view', async () => { test('onDataViewCreated - persisted data view', async () => {
@ -1092,10 +1102,12 @@ describe('Discover state', () => {
defaultUrlState: undefined, defaultUrlState: undefined,
}) })
); );
const unsubscribe = state.actions.initializeAndSync(); state.actions.initializeAndSync();
await state.actions.onDataViewCreated(dataViewComplexMock); await state.actions.onDataViewCreated(dataViewComplexMock);
await waitFor(() => { await waitFor(() => {
expect(state.internalState.getState().dataViewId).toBe(dataViewComplexMock.id); expect(selectCurrentTab(state.internalState.getState()).dataViewId).toBe(
dataViewComplexMock.id
);
}); });
expect(state.appState.getState().dataSource).toEqual( expect(state.appState.getState().dataSource).toEqual(
createDataViewDataSource({ dataViewId: dataViewComplexMock.id! }) createDataViewDataSource({ dataViewId: dataViewComplexMock.id! })
@ -1103,7 +1115,7 @@ describe('Discover state', () => {
expect(state.savedSearchState.getState().searchSource.getField('index')!.id).toBe( expect(state.savedSearchState.getState().searchSource.getField('index')!.id).toBe(
dataViewComplexMock.id dataViewComplexMock.id
); );
unsubscribe(); state.actions.stopSyncing();
}); });
test('onDataViewCreated - ad-hoc data view', async () => { test('onDataViewCreated - ad-hoc data view', async () => {
@ -1117,7 +1129,7 @@ describe('Discover state', () => {
defaultUrlState: undefined, defaultUrlState: undefined,
}) })
); );
const unsubscribe = state.actions.initializeAndSync(); state.actions.initializeAndSync();
jest jest
.spyOn(discoverServiceMock.dataViews, 'get') .spyOn(discoverServiceMock.dataViews, 'get')
.mockImplementationOnce((id) => .mockImplementationOnce((id) =>
@ -1125,7 +1137,7 @@ describe('Discover state', () => {
); );
await state.actions.onDataViewCreated(dataViewAdHoc); await state.actions.onDataViewCreated(dataViewAdHoc);
await waitFor(() => { await waitFor(() => {
expect(state.internalState.getState().dataViewId).toBe(dataViewAdHoc.id); expect(selectCurrentTab(state.internalState.getState()).dataViewId).toBe(dataViewAdHoc.id);
}); });
expect(state.appState.getState().dataSource).toEqual( expect(state.appState.getState().dataSource).toEqual(
createDataViewDataSource({ dataViewId: dataViewAdHoc.id! }) createDataViewDataSource({ dataViewId: dataViewAdHoc.id! })
@ -1133,7 +1145,7 @@ describe('Discover state', () => {
expect(state.savedSearchState.getState().searchSource.getField('index')!.id).toBe( expect(state.savedSearchState.getState().searchSource.getField('index')!.id).toBe(
dataViewAdHoc.id dataViewAdHoc.id
); );
unsubscribe(); state.actions.stopSyncing();
}); });
test('onDataViewEdited - persisted data view', async () => { test('onDataViewEdited - persisted data view', async () => {
@ -1147,31 +1159,33 @@ describe('Discover state', () => {
defaultUrlState: undefined, defaultUrlState: undefined,
}) })
); );
const selectedDataViewId = state.internalState.getState().dataViewId; const selectedDataViewId = selectCurrentTab(state.internalState.getState()).dataViewId;
expect(selectedDataViewId).toBe(dataViewMock.id); expect(selectedDataViewId).toBe(dataViewMock.id);
const unsubscribe = state.actions.initializeAndSync(); state.actions.initializeAndSync();
await state.actions.onDataViewEdited(dataViewMock); await state.actions.onDataViewEdited(dataViewMock);
await waitFor(() => { await waitFor(() => {
expect(state.internalState.getState().dataViewId).toBe(selectedDataViewId); expect(selectCurrentTab(state.internalState.getState()).dataViewId).toBe(
selectedDataViewId
);
}); });
unsubscribe(); state.actions.stopSyncing();
}); });
test('onDataViewEdited - ad-hoc data view', async () => { test('onDataViewEdited - ad-hoc data view', async () => {
const { state } = await getState('/', { savedSearch: savedSearchMock }); const { state } = await getState('/', { savedSearch: savedSearchMock });
const unsubscribe = state.actions.initializeAndSync(); state.actions.initializeAndSync();
await state.actions.onDataViewCreated(dataViewAdHoc); await state.actions.onDataViewCreated(dataViewAdHoc);
const previousId = dataViewAdHoc.id; const previousId = dataViewAdHoc.id;
await state.actions.onDataViewEdited(dataViewAdHoc); await state.actions.onDataViewEdited(dataViewAdHoc);
await waitFor(() => { await waitFor(() => {
expect(state.internalState.getState().dataViewId).not.toBe(previousId); expect(selectCurrentTab(state.internalState.getState()).dataViewId).not.toBe(previousId);
}); });
unsubscribe(); state.actions.stopSyncing();
}); });
test('onOpenSavedSearch - same target id', async () => { test('onOpenSavedSearch - same target id', async () => {
const { state } = await getState('/', { savedSearch: savedSearchMock }); const { state } = await getState('/', { savedSearch: savedSearchMock });
const unsubscribe = state.actions.initializeAndSync(); state.actions.initializeAndSync();
await state.internalState.dispatch( await state.internalState.dispatch(
internalStateActions.initializeSession({ internalStateActions.initializeSession({
stateContainer: state, stateContainer: state,
@ -1185,7 +1199,7 @@ describe('Discover state', () => {
expect(state.savedSearchState.getState().hideChart).toBe(true); expect(state.savedSearchState.getState().hideChart).toBe(true);
state.actions.onOpenSavedSearch(savedSearchMock.id!); state.actions.onOpenSavedSearch(savedSearchMock.id!);
expect(state.savedSearchState.getState().hideChart).toBe(undefined); expect(state.savedSearchState.getState().hideChart).toBe(undefined);
unsubscribe(); state.actions.stopSyncing();
}); });
test('onOpenSavedSearch - cleanup of previous filter', async () => { test('onOpenSavedSearch - cleanup of previous filter', async () => {
@ -1227,13 +1241,13 @@ describe('Discover state', () => {
defaultUrlState: undefined, defaultUrlState: undefined,
}) })
); );
const unsubscribe = state.actions.initializeAndSync(); state.actions.initializeAndSync();
await state.actions.createAndAppendAdHocDataView({ title: 'ad-hoc-test' }); await state.actions.createAndAppendAdHocDataView({ title: 'ad-hoc-test' });
expect(state.appState.getState().dataSource).toEqual( expect(state.appState.getState().dataSource).toEqual(
createDataViewDataSource({ dataViewId: 'ad-hoc-id' }) createDataViewDataSource({ dataViewId: 'ad-hoc-id' })
); );
expect(state.runtimeStateManager.adHocDataViews$.getValue()[0].id).toBe('ad-hoc-id'); expect(state.runtimeStateManager.adHocDataViews$.getValue()[0].id).toBe('ad-hoc-id');
unsubscribe(); state.actions.stopSyncing();
}); });
test('undoSavedSearchChanges - when changing data views', async () => { test('undoSavedSearchChanges - when changing data views', async () => {
@ -1248,12 +1262,12 @@ describe('Discover state', () => {
defaultUrlState: undefined, defaultUrlState: undefined,
}) })
); );
const unsubscribe = state.actions.initializeAndSync(); state.actions.initializeAndSync();
await new Promise(process.nextTick); await new Promise(process.nextTick);
const initialUrlState = 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:!())'; '/#?_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:!())';
expect(getCurrentUrl()).toBe(initialUrlState); expect(getCurrentUrl()).toBe(initialUrlState);
expect(state.internalState.getState().dataViewId).toBe(dataViewMock.id!); expect(selectCurrentTab(state.internalState.getState()).dataViewId).toBe(dataViewMock.id!);
// Change the data view, this should change the URL and trigger a fetch // Change the data view, this should change the URL and trigger a fetch
await state.actions.onChangeDataView(dataViewComplexMock.id!); await state.actions.onChangeDataView(dataViewComplexMock.id!);
@ -1264,7 +1278,9 @@ describe('Discover state', () => {
await waitFor(() => { await waitFor(() => {
expect(state.dataState.fetch).toHaveBeenCalledTimes(1); expect(state.dataState.fetch).toHaveBeenCalledTimes(1);
}); });
expect(state.internalState.getState().dataViewId).toBe(dataViewComplexMock.id!); expect(selectCurrentTab(state.internalState.getState()).dataViewId).toBe(
dataViewComplexMock.id!
);
// Undo all changes to the saved search, this should trigger a fetch, again // Undo all changes to the saved search, this should trigger a fetch, again
await state.actions.undoSavedSearchChanges(); await state.actions.undoSavedSearchChanges();
@ -1273,9 +1289,9 @@ describe('Discover state', () => {
await waitFor(() => { await waitFor(() => {
expect(state.dataState.fetch).toHaveBeenCalledTimes(2); expect(state.dataState.fetch).toHaveBeenCalledTimes(2);
}); });
expect(state.internalState.getState().dataViewId).toBe(dataViewMock.id!); expect(selectCurrentTab(state.internalState.getState()).dataViewId).toBe(dataViewMock.id!);
unsubscribe(); state.actions.stopSyncing();
}); });
test('undoSavedSearchChanges with timeRestore', async () => { test('undoSavedSearchChanges with timeRestore', async () => {

View file

@ -44,7 +44,7 @@ import {
isDataSourceType, isDataSourceType,
} from '../../../../common/data_sources'; } from '../../../../common/data_sources';
import type { InternalStateStore, RuntimeStateManager } from './redux'; import type { InternalStateStore, RuntimeStateManager } from './redux';
import { internalStateActions } from './redux'; import { internalStateActions, selectCurrentTabRuntimeState } from './redux';
import type { DiscoverSavedSearchContainer } from './discover_saved_search_container'; import type { DiscoverSavedSearchContainer } from './discover_saved_search_container';
import { getSavedSearchContainer } from './discover_saved_search_container'; import { getSavedSearchContainer } from './discover_saved_search_container';
@ -144,7 +144,11 @@ export interface DiscoverStateContainer {
/** /**
* Initializing state containers and start subscribing to changes triggering e.g. data fetching * Initializing state containers and start subscribing to changes triggering e.g. data fetching
*/ */
initializeAndSync: () => () => void; initializeAndSync: () => void;
/**
* Stop syncing the state containers started by initializeAndSync
*/
stopSyncing: () => void;
/** /**
* Create and select a temporary/adhoc data view by a given index pattern * Create and select a temporary/adhoc data view by a given index pattern
* Used by the Data View Picker * Used by the Data View Picker
@ -302,7 +306,11 @@ export function getDiscoverStateContainer({
* This is to prevent duplicate ids messing with our system * This is to prevent duplicate ids messing with our system
*/ */
const updateAdHocDataViewId = async () => { const updateAdHocDataViewId = async () => {
const prevDataView = runtimeStateManager.currentDataView$.getValue(); const { currentDataView$ } = selectCurrentTabRuntimeState(
internalState.getState(),
runtimeStateManager
);
const prevDataView = currentDataView$.getValue();
if (!prevDataView || prevDataView.isPersisted()) return; if (!prevDataView || prevDataView.isPersisted()) return;
const nextDataView = await services.dataViews.create({ const nextDataView = await services.dataViews.create({
@ -408,6 +416,13 @@ export function getDiscoverStateContainer({
fetchData(); fetchData();
}; };
let internalStopSyncing = () => {};
const stopSyncing = () => {
internalStopSyncing();
internalStopSyncing = () => {};
};
/** /**
* state containers initializing and subscribing to changes triggering e.g. data fetching * state containers initializing and subscribing to changes triggering e.g. data fetching
*/ */
@ -442,8 +457,12 @@ export function getDiscoverStateContainer({
// updates saved search when query or filters change, triggers data fetching // updates saved search when query or filters change, triggers data fetching
const filterUnsubscribe = merge(services.filterManager.getFetches$()).subscribe(() => { const filterUnsubscribe = merge(services.filterManager.getFetches$()).subscribe(() => {
const { currentDataView$ } = selectCurrentTabRuntimeState(
internalState.getState(),
runtimeStateManager
);
savedSearchContainer.update({ savedSearchContainer.update({
nextDataView: runtimeStateManager.currentDataView$.getValue(), nextDataView: currentDataView$.getValue(),
nextState: appStateContainer.getState(), nextState: appStateContainer.getState(),
useFilterAndQueryServices: true, useFilterAndQueryServices: true,
}); });
@ -468,7 +487,7 @@ export function getDiscoverStateContainer({
} }
); );
return () => { internalStopSyncing = () => {
unsubscribeData(); unsubscribeData();
appStateUnsubscribe(); appStateUnsubscribe();
appStateInitAndSyncUnsubscribe(); appStateInitAndSyncUnsubscribe();
@ -581,6 +600,7 @@ export function getDiscoverStateContainer({
customizationContext, customizationContext,
actions: { actions: {
initializeAndSync, initializeAndSync,
stopSyncing,
fetchData, fetchData,
onChangeDataView, onChangeDataView,
createAndAppendAdHocDataView, createAndAppendAdHocDataView,

View file

@ -10,12 +10,20 @@
import type { DataView } from '@kbn/data-views-plugin/common'; import type { DataView } from '@kbn/data-views-plugin/common';
import { differenceBy } from 'lodash'; import { differenceBy } from 'lodash';
import { internalStateSlice, type InternalStateThunkActionCreator } from '../internal_state'; import { internalStateSlice, type InternalStateThunkActionCreator } from '../internal_state';
import { createInternalStateAsyncThunk } from '../utils';
import { selectCurrentTabRuntimeState } from '../runtime_state';
export const loadDataViewList = createInternalStateAsyncThunk(
'internalState/loadDataViewList',
async (_, { extra: { services } }) => services.dataViews.getIdsWithTitle(true)
);
export const setDataView: InternalStateThunkActionCreator<[DataView]> = export const setDataView: InternalStateThunkActionCreator<[DataView]> =
(dataView) => (dataView) =>
(dispatch, _, { runtimeStateManager }) => { (dispatch, getState, { runtimeStateManager }) => {
dispatch(internalStateSlice.actions.setDataViewId(dataView.id)); dispatch(internalStateSlice.actions.setDataViewId(dataView.id));
runtimeStateManager.currentDataView$.next(dataView); const { currentDataView$ } = selectCurrentTabRuntimeState(getState(), runtimeStateManager);
currentDataView$.next(dataView);
}; };
export const setAdHocDataViews: InternalStateThunkActionCreator<[DataView[]]> = export const setAdHocDataViews: InternalStateThunkActionCreator<[DataView[]]> =

View file

@ -9,3 +9,4 @@
export * from './data_views'; export * from './data_views';
export * from './initialize_session'; export * from './initialize_session';
export * from './tabs';

View file

@ -30,6 +30,7 @@ import { isRefreshIntervalValid, isTimeRangeValid } from '../../../../../utils/v
import { getValidFilters } from '../../../../../utils/get_valid_filters'; import { getValidFilters } from '../../../../../utils/get_valid_filters';
import { updateSavedSearch } from '../../utils/update_saved_search'; import { updateSavedSearch } from '../../utils/update_saved_search';
import { APP_STATE_URL_KEY } from '../../../../../../common'; import { APP_STATE_URL_KEY } from '../../../../../../common';
import { selectCurrentTabRuntimeState } from '../runtime_state';
export interface InitializeSessionParams { export interface InitializeSessionParams {
stateContainer: DiscoverStateContainer; stateContainer: DiscoverStateContainer;
@ -108,10 +109,12 @@ export const initializeSession: InternalStateThunkActionCreator<
let dataView: DataView; let dataView: DataView;
if (isOfAggregateQueryType(initialQuery)) { if (isOfAggregateQueryType(initialQuery)) {
const { currentDataView$ } = selectCurrentTabRuntimeState(getState(), runtimeStateManager);
// Regardless of what was requested, we always use ad hoc data views for ES|QL // Regardless of what was requested, we always use ad hoc data views for ES|QL
dataView = await getEsqlDataView( dataView = await getEsqlDataView(
initialQuery, initialQuery,
discoverSessionDataView ?? runtimeStateManager.currentDataView$.getValue(), discoverSessionDataView ?? currentDataView$.getValue(),
services services
); );
} else { } else {

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { TabbedContentState } from '@kbn/unified-tabs/src/components/tabbed_content/tabbed_content';
import { differenceBy } from 'lodash';
import type { TabState } from '../types';
import { selectAllTabs, selectCurrentTab } from '../selectors';
import {
defaultTabState,
internalStateSlice,
type InternalStateThunkActionCreator,
} from '../internal_state';
import { createTabRuntimeState } from '../runtime_state';
export const setTabs: InternalStateThunkActionCreator<
[Parameters<typeof internalStateSlice.actions.setTabs>[0]]
> =
(params) =>
(dispatch, getState, { runtimeStateManager }) => {
const previousTabs = selectAllTabs(getState());
const removedTabs = differenceBy(previousTabs, params.allTabs, (tab) => tab.id);
const addedTabs = differenceBy(params.allTabs, previousTabs, (tab) => tab.id);
for (const tab of removedTabs) {
delete runtimeStateManager.tabs.byId[tab.id];
}
for (const tab of addedTabs) {
runtimeStateManager.tabs.byId[tab.id] = createTabRuntimeState();
}
dispatch(internalStateSlice.actions.setTabs(params));
};
export interface UpdateTabsParams {
updateState: TabbedContentState;
stopSyncing?: () => void;
}
export const updateTabs: InternalStateThunkActionCreator<[UpdateTabsParams], Promise<void>> =
({ updateState: { items, selectedItem }, stopSyncing }) =>
async (dispatch, getState, { urlStateStorage }) => {
const currentState = getState();
const currentTab = selectCurrentTab(currentState);
let updatedTabs = items.map<TabState>((item) => {
const existingTab = currentState.tabs.byId[item.id];
return existingTab ? { ...existingTab, ...item } : { ...defaultTabState, ...item };
});
if (selectedItem?.id !== currentTab.id) {
stopSyncing?.();
updatedTabs = updatedTabs.map((tab) =>
tab.id === currentTab.id
? {
...tab,
globalState: urlStateStorage.get('_g') ?? undefined,
appState: urlStateStorage.get('_a') ?? undefined,
}
: tab
);
const existingTab = selectedItem ? currentState.tabs.byId[selectedItem.id] : undefined;
if (existingTab) {
await urlStateStorage.set('_g', existingTab.globalState);
await urlStateStorage.set('_a', existingTab.appState);
} else {
await urlStateStorage.set('_g', {});
await urlStateStorage.set('_a', {});
}
}
dispatch(
setTabs({
allTabs: updatedTabs,
selectedTabId: selectedItem?.id ?? currentTab.id,
})
);
};

View file

@ -17,8 +17,9 @@ import {
} from 'react-redux'; } from 'react-redux';
import React, { type PropsWithChildren, useMemo, createContext } from 'react'; import React, { type PropsWithChildren, useMemo, createContext } from 'react';
import { useAdHocDataViews } from './runtime_state'; import { useAdHocDataViews } from './runtime_state';
import type { DiscoverInternalState } from './types'; import type { DiscoverInternalState, TabState } from './types';
import type { InternalStateDispatch, InternalStateStore } from './internal_state'; import { type InternalStateDispatch, type InternalStateStore } from './internal_state';
import { selectCurrentTab } from './selectors';
const internalStateContext = createContext<ReactReduxContextValue>( const internalStateContext = createContext<ReactReduxContextValue>(
// Recommended approach for versions of Redux prior to v9: // Recommended approach for versions of Redux prior to v9:
@ -41,6 +42,9 @@ export const useInternalStateDispatch: () => InternalStateDispatch =
export const useInternalStateSelector: TypedUseSelectorHook<DiscoverInternalState> = export const useInternalStateSelector: TypedUseSelectorHook<DiscoverInternalState> =
createSelectorHook(internalStateContext); createSelectorHook(internalStateContext);
export const useCurrentTabSelector: TypedUseSelectorHook<TabState> = (selector) =>
selector(useInternalStateSelector(selectCurrentTab));
export const useDataViewsForPicker = () => { export const useDataViewsForPicker = () => {
const originalAdHocDataViews = useAdHocDataViews(); const originalAdHocDataViews = useAdHocDataViews();
const savedDataViews = useInternalStateSelector((state) => state.savedDataViews); const savedDataViews = useInternalStateSelector((state) => state.savedDataViews);

View file

@ -8,23 +8,33 @@
*/ */
import { omit } from 'lodash'; import { omit } from 'lodash';
import { internalStateSlice, loadDataViewList } from './internal_state'; import { internalStateSlice } from './internal_state';
import { import {
loadDataViewList,
appendAdHocDataViews, appendAdHocDataViews,
initializeSession, initializeSession,
replaceAdHocDataViewWithId, replaceAdHocDataViewWithId,
setAdHocDataViews, setAdHocDataViews,
setDataView, setDataView,
setDefaultProfileAdHocDataViews, setDefaultProfileAdHocDataViews,
setTabs,
updateTabs,
} from './actions'; } from './actions';
export type { DiscoverInternalState, InternalStateDataRequestParams } from './types'; export type { DiscoverInternalState, TabState, InternalStateDataRequestParams } from './types';
export { type InternalStateStore, createInternalStateStore } from './internal_state'; export { type InternalStateStore, createInternalStateStore, createTabItem } from './internal_state';
export const internalStateActions = { export const internalStateActions = {
...omit(internalStateSlice.actions, 'setDataViewId', 'setDefaultProfileAdHocDataViewIds'), ...omit(
internalStateSlice.actions,
'setTabs',
'setDataViewId',
'setDefaultProfileAdHocDataViewIds'
),
loadDataViewList, loadDataViewList,
setTabs,
updateTabs,
setDataView, setDataView,
setAdHocDataViews, setAdHocDataViews,
setDefaultProfileAdHocDataViews, setDefaultProfileAdHocDataViews,
@ -40,10 +50,14 @@ export {
useDataViewsForPicker, useDataViewsForPicker,
} from './hooks'; } from './hooks';
export { selectAllTabs, selectCurrentTab } from './selectors';
export { export {
type RuntimeStateManager, type RuntimeStateManager,
createRuntimeStateManager, createRuntimeStateManager,
useRuntimeState, useRuntimeState,
selectCurrentTabRuntimeState,
useCurrentTabRuntimeState,
RuntimeStateProvider, RuntimeStateProvider,
useCurrentDataView, useCurrentDataView,
useAdHocDataViews, useAdHocDataViews,

View file

@ -8,7 +8,13 @@
*/ */
import { createDiscoverServicesMock } from '../../../../__mocks__/services'; import { createDiscoverServicesMock } from '../../../../__mocks__/services';
import { createInternalStateStore, createRuntimeStateManager, internalStateActions } from '.'; import {
createInternalStateStore,
createRuntimeStateManager,
internalStateActions,
selectCurrentTab,
selectCurrentTabRuntimeState,
} from '.';
import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
import { mockCustomizationContext } from '../../../../customizations/__mocks__/customization_context'; import { mockCustomizationContext } from '../../../../customizations/__mocks__/customization_context';
import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
@ -22,10 +28,14 @@ describe('InternalStateStore', () => {
runtimeStateManager, runtimeStateManager,
urlStateStorage: createKbnUrlStateStorage(), urlStateStorage: createKbnUrlStateStorage(),
}); });
expect(store.getState().dataViewId).toBeUndefined(); expect(selectCurrentTab(store.getState()).dataViewId).toBeUndefined();
expect(runtimeStateManager.currentDataView$.value).toBeUndefined(); expect(
selectCurrentTabRuntimeState(store.getState(), runtimeStateManager).currentDataView$.value
).toBeUndefined();
store.dispatch(internalStateActions.setDataView(dataViewMock)); store.dispatch(internalStateActions.setDataView(dataViewMock));
expect(store.getState().dataViewId).toBe(dataViewMock.id); expect(selectCurrentTab(store.getState()).dataViewId).toBe(dataViewMock.id);
expect(runtimeStateManager.currentDataView$.value).toBe(dataViewMock); expect(
selectCurrentTabRuntimeState(store.getState(), runtimeStateManager).currentDataView$.value
).toBe(dataViewMock);
}); });
}); });

View file

@ -15,28 +15,32 @@ import {
createSlice, createSlice,
type ThunkAction, type ThunkAction,
type ThunkDispatch, type ThunkDispatch,
createAsyncThunk,
} from '@reduxjs/toolkit'; } from '@reduxjs/toolkit';
import type { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; import type { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import { i18n } from '@kbn/i18n';
import type { TabItem } from '@kbn/unified-tabs';
import type { DiscoverCustomizationContext } from '../../../../customizations'; import type { DiscoverCustomizationContext } from '../../../../customizations';
import type { DiscoverServices } from '../../../../build_services'; import type { DiscoverServices } from '../../../../build_services';
import type { RuntimeStateManager } from './runtime_state'; import { type RuntimeStateManager } from './runtime_state';
import { import {
LoadingStatus, LoadingStatus,
type DiscoverInternalState, type DiscoverInternalState,
type InternalStateDataRequestParams, type InternalStateDataRequestParams,
type TabState,
} from './types'; } from './types';
import { loadDataViewList, setTabs } from './actions';
import { selectAllTabs, selectCurrentTab } from './selectors';
const initialState: DiscoverInternalState = { const DEFAULT_TAB_LABEL = i18n.translate('discover.defaultTabLabel', {
initializationState: { hasESData: false, hasUserDataView: false }, defaultMessage: 'Untitled session',
});
const DEFAULT_TAB_REGEX = new RegExp(`^${DEFAULT_TAB_LABEL}( \\d+)?$`);
export const defaultTabState: Omit<TabState, keyof TabItem> = {
dataViewId: undefined, dataViewId: undefined,
isDataViewLoading: false, isDataViewLoading: false,
defaultProfileAdHocDataViewIds: [],
savedDataViews: [],
expandedDoc: undefined,
dataRequestParams: {}, dataRequestParams: {},
overriddenVisContextAfterInvalidation: undefined, overriddenVisContextAfterInvalidation: undefined,
isESQLToDataViewTransitionModalVisible: false,
resetDefaultProfileState: { resetDefaultProfileState: {
resetId: '', resetId: '',
columns: false, columns: false,
@ -57,16 +61,31 @@ const initialState: DiscoverInternalState = {
}, },
}; };
const createInternalStateAsyncThunk = createAsyncThunk.withTypes<{ const initialState: DiscoverInternalState = {
state: DiscoverInternalState; initializationState: { hasESData: false, hasUserDataView: false },
dispatch: InternalStateDispatch; defaultProfileAdHocDataViewIds: [],
extra: InternalStateThunkDependencies; savedDataViews: [],
}>(); expandedDoc: undefined,
isESQLToDataViewTransitionModalVisible: false,
tabs: { byId: {}, allIds: [], currentId: '' },
};
export const loadDataViewList = createInternalStateAsyncThunk( export const createTabItem = (allTabs: TabState[]): TabItem => {
'internalState/loadDataViewList', const id = uuidv4();
async (_, { extra: { services } }) => services.dataViews.getIdsWithTitle(true) const untitledTabCount = allTabs.filter((tab) => DEFAULT_TAB_REGEX.test(tab.label.trim())).length;
); const label =
untitledTabCount > 0 ? `${DEFAULT_TAB_LABEL} ${untitledTabCount}` : DEFAULT_TAB_LABEL;
return { id, label };
};
const withCurrentTab = (state: DiscoverInternalState, fn: (tab: TabState) => void) => {
const currentTab = selectCurrentTab(state);
if (currentTab) {
fn(currentTab);
}
};
export const internalStateSlice = createSlice({ export const internalStateSlice = createSlice({
name: 'internalState', name: 'internalState',
@ -79,17 +98,31 @@ export const internalStateSlice = createSlice({
state.initializationState = action.payload; state.initializationState = action.payload;
}, },
setDataViewId: (state, action: PayloadAction<string | undefined>) => { setTabs: (state, action: PayloadAction<{ allTabs: TabState[]; selectedTabId: string }>) => {
if (action.payload !== state.dataViewId) { state.tabs.byId = action.payload.allTabs.reduce<Record<string, TabState>>(
state.expandedDoc = undefined; (acc, tab) => ({
} ...acc,
[tab.id]: tab,
state.dataViewId = action.payload; }),
{}
);
state.tabs.allIds = action.payload.allTabs.map((tab) => tab.id);
state.tabs.currentId = action.payload.selectedTabId;
}, },
setIsDataViewLoading: (state, action: PayloadAction<boolean>) => { setDataViewId: (state, action: PayloadAction<string | undefined>) =>
state.isDataViewLoading = action.payload; withCurrentTab(state, (tab) => {
}, if (action.payload !== tab.dataViewId) {
state.expandedDoc = undefined;
}
tab.dataViewId = action.payload;
}),
setIsDataViewLoading: (state, action: PayloadAction<boolean>) =>
withCurrentTab(state, (tab) => {
tab.isDataViewLoading = action.payload;
}),
setDefaultProfileAdHocDataViewIds: (state, action: PayloadAction<string[]>) => { setDefaultProfileAdHocDataViewIds: (state, action: PayloadAction<string[]>) => {
state.defaultProfileAdHocDataViewIds = action.payload; state.defaultProfileAdHocDataViewIds = action.payload;
@ -99,16 +132,18 @@ export const internalStateSlice = createSlice({
state.expandedDoc = action.payload; state.expandedDoc = action.payload;
}, },
setDataRequestParams: (state, action: PayloadAction<InternalStateDataRequestParams>) => { setDataRequestParams: (state, action: PayloadAction<InternalStateDataRequestParams>) =>
state.dataRequestParams = action.payload; withCurrentTab(state, (tab) => {
}, tab.dataRequestParams = action.payload;
}),
setOverriddenVisContextAfterInvalidation: ( setOverriddenVisContextAfterInvalidation: (
state, state,
action: PayloadAction<DiscoverInternalState['overriddenVisContextAfterInvalidation']> action: PayloadAction<TabState['overriddenVisContextAfterInvalidation']>
) => { ) =>
state.overriddenVisContextAfterInvalidation = action.payload; withCurrentTab(state, (tab) => {
}, tab.overriddenVisContextAfterInvalidation = action.payload;
}),
setIsESQLToDataViewTransitionModalVisible: (state, action: PayloadAction<boolean>) => { setIsESQLToDataViewTransitionModalVisible: (state, action: PayloadAction<boolean>) => {
state.isESQLToDataViewTransitionModalVisible = action.payload; state.isESQLToDataViewTransitionModalVisible = action.payload;
@ -116,23 +151,24 @@ export const internalStateSlice = createSlice({
setResetDefaultProfileState: { setResetDefaultProfileState: {
prepare: ( prepare: (
resetDefaultProfileState: Omit<DiscoverInternalState['resetDefaultProfileState'], 'resetId'> resetDefaultProfileState: Omit<TabState['resetDefaultProfileState'], 'resetId'>
) => ({ ) => ({
payload: { payload: {
...resetDefaultProfileState, ...resetDefaultProfileState,
resetId: uuidv4(), resetId: uuidv4(),
}, },
}), }),
reducer: ( reducer: (state, action: PayloadAction<TabState['resetDefaultProfileState']>) =>
state, withCurrentTab(state, (tab) => {
action: PayloadAction<DiscoverInternalState['resetDefaultProfileState']> tab.resetDefaultProfileState = action.payload;
) => { }),
state.resetDefaultProfileState = action.payload;
},
}, },
resetOnSavedSearchChange: (state) => { resetOnSavedSearchChange: (state) => {
state.overriddenVisContextAfterInvalidation = undefined; withCurrentTab(state, (tab) => {
tab.overriddenVisContextAfterInvalidation = undefined;
});
state.expandedDoc = undefined; state.expandedDoc = undefined;
}, },
}, },
@ -150,13 +186,23 @@ export interface InternalStateThunkDependencies {
urlStateStorage: IKbnUrlStateStorage; urlStateStorage: IKbnUrlStateStorage;
} }
export const createInternalStateStore = (options: InternalStateThunkDependencies) => export const createInternalStateStore = (options: InternalStateThunkDependencies) => {
configureStore({ const store = configureStore({
reducer: internalStateSlice.reducer, reducer: internalStateSlice.reducer,
middleware: (getDefaultMiddleware) => middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({ thunk: { extraArgument: options } }), getDefaultMiddleware({ thunk: { extraArgument: options } }),
}); });
// TEMPORARY: Create initial default tab
const defaultTab: TabState = {
...defaultTabState,
...createTabItem(selectAllTabs(store.getState())),
};
store.dispatch(setTabs({ allTabs: [defaultTab], selectedTabId: defaultTab.id }));
return store;
};
export type InternalStateStore = ReturnType<typeof createInternalStateStore>; export type InternalStateStore = ReturnType<typeof createInternalStateStore>;
export type InternalStateDispatch = InternalStateStore['dispatch']; export type InternalStateDispatch = InternalStateStore['dispatch'];

View file

@ -8,41 +8,72 @@
*/ */
import type { DataView } from '@kbn/data-views-plugin/common'; import type { DataView } from '@kbn/data-views-plugin/common';
import React, { type PropsWithChildren, createContext, useContext, useMemo, useState } from 'react'; import React, { type PropsWithChildren, createContext, useContext, useMemo } from 'react';
import useObservable from 'react-use/lib/useObservable'; import useObservable from 'react-use/lib/useObservable';
import { BehaviorSubject, skip } from 'rxjs'; import { BehaviorSubject } from 'rxjs';
import { useInternalStateSelector } from './hooks';
import type { DiscoverInternalState } from './types';
interface DiscoverRuntimeState { interface DiscoverRuntimeState {
currentDataView: DataView;
adHocDataViews: DataView[]; adHocDataViews: DataView[];
} }
type RuntimeStateManagerInternal<TNullable extends keyof DiscoverRuntimeState> = { interface TabRuntimeState {
[key in keyof DiscoverRuntimeState as `${key}$`]: BehaviorSubject< currentDataView: DataView;
key extends TNullable ? DiscoverRuntimeState[key] | undefined : DiscoverRuntimeState[key] }
type ReactiveRuntimeState<TState, TNullable extends keyof TState = never> = {
[key in keyof TState & string as `${key}$`]: BehaviorSubject<
key extends TNullable ? TState[key] | undefined : TState[key]
>; >;
}; };
export type RuntimeStateManager = RuntimeStateManagerInternal<'currentDataView'>; type ReactiveTabRuntimeState = ReactiveRuntimeState<TabRuntimeState, 'currentDataView'>;
export const createRuntimeStateManager = (): RuntimeStateManager => ({ export type RuntimeStateManager = ReactiveRuntimeState<DiscoverRuntimeState> & {
currentDataView$: new BehaviorSubject<DataView | undefined>(undefined), tabs: { byId: Record<string, ReactiveTabRuntimeState> };
adHocDataViews$: new BehaviorSubject<DataView[]>([]),
});
export const useRuntimeState = <T,>(stateSubject$: BehaviorSubject<T>) => {
const [stateObservable$] = useState(() => stateSubject$.pipe(skip(1)));
return useObservable(stateObservable$, stateSubject$.getValue());
}; };
const runtimeStateContext = createContext<DiscoverRuntimeState | undefined>(undefined); export const createRuntimeStateManager = (): RuntimeStateManager => ({
adHocDataViews$: new BehaviorSubject<DataView[]>([]),
tabs: { byId: {} },
});
export const createTabRuntimeState = (): ReactiveTabRuntimeState => ({
currentDataView$: new BehaviorSubject<DataView | undefined>(undefined),
});
export const useRuntimeState = <T,>(stateSubject$: BehaviorSubject<T>) =>
useObservable(stateSubject$, stateSubject$.getValue());
export const selectCurrentTabRuntimeState = (
internalState: DiscoverInternalState,
runtimeStateManager: RuntimeStateManager
) => {
const currentTabId = internalState.tabs.currentId;
return runtimeStateManager.tabs.byId[currentTabId];
};
export const useCurrentTabRuntimeState = <T,>(
runtimeStateManager: RuntimeStateManager,
selector: (tab: ReactiveTabRuntimeState) => BehaviorSubject<T>
) => {
const tab = useInternalStateSelector((state) =>
selectCurrentTabRuntimeState(state, runtimeStateManager)
);
return useRuntimeState(selector(tab));
};
type CombinedRuntimeState = DiscoverRuntimeState & TabRuntimeState;
const runtimeStateContext = createContext<CombinedRuntimeState | undefined>(undefined);
export const RuntimeStateProvider = ({ export const RuntimeStateProvider = ({
currentDataView, currentDataView,
adHocDataViews, adHocDataViews,
children, children,
}: PropsWithChildren<DiscoverRuntimeState>) => { }: PropsWithChildren<CombinedRuntimeState>) => {
const runtimeState = useMemo<DiscoverRuntimeState>( const runtimeState = useMemo<CombinedRuntimeState>(
() => ({ currentDataView, adHocDataViews }), () => ({ currentDataView, adHocDataViews }),
[adHocDataViews, currentDataView] [adHocDataViews, currentDataView]
); );

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { DiscoverInternalState } from './types';
export const selectAllTabs = (state: DiscoverInternalState) =>
state.tabs.allIds.map((id) => state.tabs.byId[id]);
export const selectCurrentTab = (state: DiscoverInternalState) =>
state.tabs.byId[state.tabs.currentId];

View file

@ -11,6 +11,7 @@ import type { DataViewListItem } from '@kbn/data-views-plugin/public';
import type { DataTableRecord } from '@kbn/discover-utils'; import type { DataTableRecord } from '@kbn/discover-utils';
import type { TimeRange } from '@kbn/es-query'; import type { TimeRange } from '@kbn/es-query';
import type { UnifiedHistogramVisContext } from '@kbn/unified-histogram-plugin/public'; import type { UnifiedHistogramVisContext } from '@kbn/unified-histogram-plugin/public';
import type { TabItem } from '@kbn/unified-tabs';
export enum LoadingStatus { export enum LoadingStatus {
Uninitialized = 'uninitialized', Uninitialized = 'uninitialized',
@ -42,16 +43,13 @@ export interface InternalStateDataRequestParams {
timeRangeRelative?: TimeRange; timeRangeRelative?: TimeRange;
} }
export interface DiscoverInternalState { export interface TabState extends TabItem {
initializationState: { hasESData: boolean; hasUserDataView: boolean }; globalState?: Record<string, unknown>;
appState?: Record<string, unknown>;
dataViewId: string | undefined; dataViewId: string | undefined;
isDataViewLoading: boolean; isDataViewLoading: boolean;
savedDataViews: DataViewListItem[];
defaultProfileAdHocDataViewIds: string[];
expandedDoc: DataTableRecord | undefined;
dataRequestParams: InternalStateDataRequestParams; dataRequestParams: InternalStateDataRequestParams;
overriddenVisContextAfterInvalidation: UnifiedHistogramVisContext | {} | undefined; // it will be used during saved search saving overriddenVisContextAfterInvalidation: UnifiedHistogramVisContext | {} | undefined; // it will be used during saved search saving
isESQLToDataViewTransitionModalVisible: boolean;
resetDefaultProfileState: { resetDefaultProfileState: {
resetId: string; resetId: string;
columns: boolean; columns: boolean;
@ -62,3 +60,16 @@ export interface DiscoverInternalState {
totalHitsRequest: TotalHitsRequest; totalHitsRequest: TotalHitsRequest;
chartRequest: ChartRequest; chartRequest: ChartRequest;
} }
export interface DiscoverInternalState {
initializationState: { hasESData: boolean; hasUserDataView: boolean };
savedDataViews: DataViewListItem[];
defaultProfileAdHocDataViewIds: string[];
expandedDoc: DataTableRecord | undefined;
isESQLToDataViewTransitionModalVisible: boolean;
tabs: {
byId: Record<string, TabState>;
allIds: string[];
currentId: string;
};
}

View file

@ -0,0 +1,25 @@
/*
* 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 { createAsyncThunk } from '@reduxjs/toolkit';
import type { DiscoverInternalState } from './types';
import type { InternalStateDispatch, InternalStateThunkDependencies } from './internal_state';
// For some reason if this is not explicitly typed, TypeScript fails with the following error:
// TS7056: The inferred type of this node exceeds the maximum length the compiler will serialize. An explicit type annotation is needed.
type CreateInternalStateAsyncThunk = ReturnType<
typeof createAsyncThunk.withTypes<{
state: DiscoverInternalState;
dispatch: InternalStateDispatch;
extra: InternalStateThunkDependencies;
}>
>;
export const createInternalStateAsyncThunk: CreateInternalStateAsyncThunk =
createAsyncThunk.withTypes();

View file

@ -17,7 +17,7 @@ import { discoverServiceMock } from '../../../../__mocks__/services';
import type { DataView } from '@kbn/data-views-plugin/common'; import type { DataView } from '@kbn/data-views-plugin/common';
import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock'; import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock';
import { createDataViewDataSource } from '../../../../../common/data_sources'; import { createDataViewDataSource } from '../../../../../common/data_sources';
import { createRuntimeStateManager, internalStateActions } from '../redux'; import { createRuntimeStateManager, internalStateActions, selectCurrentTab } from '../redux';
const setupTestParams = (dataView: DataView | undefined) => { const setupTestParams = (dataView: DataView | undefined) => {
const savedSearch = savedSearchMock; const savedSearch = savedSearchMock;
@ -41,41 +41,41 @@ describe('changeDataView', () => {
it('should set the right app state when a valid data view (which includes the preconfigured default column) to switch to is given', async () => { it('should set the right app state when a valid data view (which includes the preconfigured default column) to switch to is given', async () => {
const params = setupTestParams(dataViewWithDefaultColumnMock); const params = setupTestParams(dataViewWithDefaultColumnMock);
const promise = changeDataView({ dataViewId: dataViewWithDefaultColumnMock.id!, ...params }); const promise = changeDataView({ dataViewId: dataViewWithDefaultColumnMock.id!, ...params });
expect(params.internalState.getState().isDataViewLoading).toBe(true); expect(selectCurrentTab(params.internalState.getState()).isDataViewLoading).toBe(true);
await promise; await promise;
expect(params.appState.update).toHaveBeenCalledWith({ expect(params.appState.update).toHaveBeenCalledWith({
columns: ['default_column'], // default_column would be added as dataViewWithDefaultColumn has it as a mapped field columns: ['default_column'], // default_column would be added as dataViewWithDefaultColumn has it as a mapped field
dataSource: createDataViewDataSource({ dataViewId: 'data-view-with-user-default-column-id' }), dataSource: createDataViewDataSource({ dataViewId: 'data-view-with-user-default-column-id' }),
sort: [['@timestamp', 'desc']], sort: [['@timestamp', 'desc']],
}); });
expect(params.internalState.getState().isDataViewLoading).toBe(false); expect(selectCurrentTab(params.internalState.getState()).isDataViewLoading).toBe(false);
}); });
it('should set the right app state when a valid data view to switch to is given', async () => { it('should set the right app state when a valid data view to switch to is given', async () => {
const params = setupTestParams(dataViewComplexMock); const params = setupTestParams(dataViewComplexMock);
const promise = changeDataView({ dataViewId: dataViewComplexMock.id!, ...params }); const promise = changeDataView({ dataViewId: dataViewComplexMock.id!, ...params });
expect(params.internalState.getState().isDataViewLoading).toBe(true); expect(selectCurrentTab(params.internalState.getState()).isDataViewLoading).toBe(true);
await promise; await promise;
expect(params.appState.update).toHaveBeenCalledWith({ expect(params.appState.update).toHaveBeenCalledWith({
columns: [], // default_column would not be added as dataViewComplexMock does not have it as a mapped field columns: [], // default_column would not be added as dataViewComplexMock does not have it as a mapped field
dataSource: createDataViewDataSource({ dataViewId: 'data-view-with-various-field-types-id' }), dataSource: createDataViewDataSource({ dataViewId: 'data-view-with-various-field-types-id' }),
sort: [['data', 'desc']], sort: [['data', 'desc']],
}); });
expect(params.internalState.getState().isDataViewLoading).toBe(false); expect(selectCurrentTab(params.internalState.getState()).isDataViewLoading).toBe(false);
}); });
it('should not set the app state when an invalid data view to switch to is given', async () => { it('should not set the app state when an invalid data view to switch to is given', async () => {
const params = setupTestParams(undefined); const params = setupTestParams(undefined);
const promise = changeDataView({ dataViewId: 'data-view-with-various-field-types', ...params }); const promise = changeDataView({ dataViewId: 'data-view-with-various-field-types', ...params });
expect(params.internalState.getState().isDataViewLoading).toBe(true); expect(selectCurrentTab(params.internalState.getState()).isDataViewLoading).toBe(true);
await promise; await promise;
expect(params.appState.update).not.toHaveBeenCalled(); expect(params.appState.update).not.toHaveBeenCalled();
expect(params.internalState.getState().isDataViewLoading).toBe(false); expect(selectCurrentTab(params.internalState.getState()).isDataViewLoading).toBe(false);
}); });
it('should call setResetDefaultProfileState correctly when switching data view', async () => { it('should call setResetDefaultProfileState correctly when switching data view', async () => {
const params = setupTestParams(dataViewComplexMock); const params = setupTestParams(dataViewComplexMock);
expect(params.internalState.getState().resetDefaultProfileState).toEqual( expect(selectCurrentTab(params.internalState.getState()).resetDefaultProfileState).toEqual(
expect.objectContaining({ expect.objectContaining({
columns: false, columns: false,
rowHeight: false, rowHeight: false,
@ -83,7 +83,7 @@ describe('changeDataView', () => {
}) })
); );
await changeDataView({ dataViewId: dataViewComplexMock.id!, ...params }); await changeDataView({ dataViewId: dataViewComplexMock.id!, ...params });
expect(params.internalState.getState().resetDefaultProfileState).toEqual( expect(selectCurrentTab(params.internalState.getState()).resetDefaultProfileState).toEqual(
expect.objectContaining({ expect.objectContaining({
columns: true, columns: true,
rowHeight: true, rowHeight: true,

View file

@ -18,7 +18,12 @@ import type { DiscoverAppStateContainer } from '../discover_app_state_container'
import { addLog } from '../../../../utils/add_log'; import { addLog } from '../../../../utils/add_log';
import type { DiscoverServices } from '../../../../build_services'; import type { DiscoverServices } from '../../../../build_services';
import { getDataViewAppState } from './get_switch_data_view_app_state'; import { getDataViewAppState } from './get_switch_data_view_app_state';
import { internalStateActions, type InternalStateStore, type RuntimeStateManager } from '../redux'; import {
internalStateActions,
selectCurrentTabRuntimeState,
type InternalStateStore,
type RuntimeStateManager,
} from '../redux';
/** /**
* Function executed when switching data view in the UI * Function executed when switching data view in the UI
@ -39,7 +44,11 @@ export async function changeDataView({
addLog('[ui] changeDataView', { id: dataViewId }); addLog('[ui] changeDataView', { id: dataViewId });
const { dataViews, uiSettings } = services; const { dataViews, uiSettings } = services;
const currentDataView = runtimeStateManager.currentDataView$.getValue(); const { currentDataView$ } = selectCurrentTabRuntimeState(
internalState.getState(),
runtimeStateManager
);
const currentDataView = currentDataView$.getValue();
const state = appState.getState(); const state = appState.getState();
let nextDataView: DataView | null = null; let nextDataView: DataView | null = null;

View file

@ -14,7 +14,7 @@ import type { DiscoverAppState } from '../discover_app_state_container';
import type { DefaultAppStateColumn, ProfilesManager } from '../../../../context_awareness'; import type { DefaultAppStateColumn, ProfilesManager } from '../../../../context_awareness';
import { getMergedAccessor } from '../../../../context_awareness'; import { getMergedAccessor } from '../../../../context_awareness';
import type { DataDocumentsMsg } from '../discover_data_state_container'; import type { DataDocumentsMsg } from '../discover_data_state_container';
import type { DiscoverInternalState } from '../redux'; import type { TabState } from '../redux';
export const getDefaultProfileState = ({ export const getDefaultProfileState = ({
profilesManager, profilesManager,
@ -22,7 +22,7 @@ export const getDefaultProfileState = ({
dataView, dataView,
}: { }: {
profilesManager: ProfilesManager; profilesManager: ProfilesManager;
resetDefaultProfileState: DiscoverInternalState['resetDefaultProfileState']; resetDefaultProfileState: TabState['resetDefaultProfileState'];
dataView: DataView; dataView: DataView;
}) => { }) => {
const defaultState = getDefaultState(profilesManager, dataView); const defaultState = getDefaultState(profilesManager, dataView);

View file

@ -103,7 +103,8 @@
"@kbn/response-ops-rule-form", "@kbn/response-ops-rule-form",
"@kbn/embeddable-enhanced-plugin", "@kbn/embeddable-enhanced-plugin",
"@kbn/shared-ux-page-analytics-no-data-types", "@kbn/shared-ux-page-analytics-no-data-types",
"@kbn/core-application-browser-mocks" "@kbn/core-application-browser-mocks",
"@kbn/unified-tabs"
], ],
"exclude": ["target/**/*"] "exclude": ["target/**/*"]
} }

View file

@ -36,6 +36,8 @@ jest.mock('uuid', () => ({
.fn() .fn()
.mockReturnValueOnce('d594baeb-5eca-480c-8885-ba79eaf41372') .mockReturnValueOnce('d594baeb-5eca-480c-8885-ba79eaf41372')
.mockReturnValueOnce('c604baeb-5eca-480c-8885-ba79eaf41372') .mockReturnValueOnce('c604baeb-5eca-480c-8885-ba79eaf41372')
// This ID is used by the Discover state container created in MockDiscoverInTimelineContext
.mockReturnValueOnce('mock-id-for-discover')
.mockReturnValueOnce('e614baeb-5eca-480c-8885-ba79eaf41372') .mockReturnValueOnce('e614baeb-5eca-480c-8885-ba79eaf41372')
.mockReturnValueOnce('f614baeb-5eca-480c-8885-ba79eaf52483') .mockReturnValueOnce('f614baeb-5eca-480c-8885-ba79eaf52483')
.mockReturnValue('1dd5663b-f062-43f8-8688-fc8166c2ca8e'), .mockReturnValue('1dd5663b-f062-43f8-8688-fc8166c2ca8e'),