[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
>();
const stateStorage = stateContainer.stateStorage;
const currentTabId = stateContainer.internalState.getState().tabs.currentId;
const dataView = useObservable(
stateContainer.runtimeStateManager.currentDataView$,
stateContainer.runtimeStateManager.currentDataView$.getValue()
stateContainer.runtimeStateManager.tabs.byId[currentTabId].currentDataView$,
stateContainer.runtimeStateManager.tabs.byId[currentTabId].currentDataView$.getValue()
);
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 {
analyticsServiceMock,
chromeServiceMock,
coreMock,
docLinksServiceMock,
scopedHistoryMock,
@ -150,18 +149,19 @@ export function createDiscoverServicesMock(): DiscoverServices {
corePluginMock.theme = theme;
corePluginMock.chrome.getActiveSolutionNavId$.mockReturnValue(new BehaviorSubject(null));
corePluginMock.chrome.getChromeStyle$.mockReturnValue(new BehaviorSubject('classic'));
return {
analytics: analyticsServiceMock.createAnalyticsServiceStart(),
application: corePluginMock.application,
core: corePluginMock,
charts: chartPluginMock.createSetupContract(),
chrome: chromeServiceMock.createStartContract(),
chrome: corePluginMock.chrome,
history: {
location: {
search: '',
},
listen: jest.fn(),
listen: jest.fn(() => () => {}),
},
getScopedHistory: () => scopedHistoryMock.create(),
data: dataPlugin,

View file

@ -79,6 +79,7 @@ import {
useInternalStateDispatch,
useInternalStateSelector,
} from '../../state_management/redux';
import { useCurrentTabSelector } from '../../state_management/redux/hooks';
const DiscoverGridMemoized = React.memo(DiscoverGrid);
@ -110,7 +111,7 @@ function DiscoverDocumentsComponent({
const documents$ = stateContainer.dataState.data$.documents$;
const savedSearch = useSavedSearchInitial();
const { dataViews, capabilities, uiSettings, uiActions, ebtManager, fieldsMetadata } = services;
const requestParams = useInternalStateSelector((state) => state.dataRequestParams);
const requestParams = useCurrentTabSelector((state) => state.dataRequestParams);
const [
dataSource,
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
// This solution switches to the loading state in this component when the URL index doesn't match the dataView.id
const isDataViewLoading =
useInternalStateSelector((state) => state.isDataViewLoading) && !isEsqlMode;
useCurrentTabSelector((state) => state.isDataViewLoading) && !isEsqlMode;
const isEmptyDataResult =
isEsqlMode || !documentState.result || documentState.result.length === 0;
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 { sendErrorMsg } from '../../hooks/use_saved_search_messages';
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 TopNavMemoized = React.memo(DiscoverTopNav);
@ -102,7 +103,7 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) {
return state.viewMode ?? VIEW_MODE.DOCUMENT_LEVEL;
});
const dataView = useCurrentDataView();
const dataViewLoading = useInternalStateSelector((state) => state.isDataViewLoading);
const dataViewLoading = useCurrentTabSelector((state) => state.isDataViewLoading);
const dataState: DataMainMsg = useDataState(main$);
const savedSearch = useSavedSearchInitial();
const fetchCounter = useRef<number>(0);

View file

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

View file

@ -9,4 +9,8 @@
export { BrandedLoadingIndicator } from './branded_loading_indicator';
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
*/
useEffect(() => {
const unsubscribe = stateContainer.actions.initializeAndSync();
stateContainer.actions.initializeAndSync();
addLog('[DiscoverMainApp] state container initialization triggers data fetching');
stateContainer.actions.fetchData(true);
return () => unsubscribe();
return () => stateContainer.actions.stopSyncing();
}, [stateContainer]);
/**

View file

@ -7,7 +7,7 @@
* 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 { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/public';
import { useParams } from 'react-router-dom';
@ -28,6 +28,7 @@ import {
useInternalStateDispatch,
useInternalStateSelector,
useRuntimeState,
useCurrentTabRuntimeState,
} from '../../state_management/redux';
import type {
CustomizationCallback,
@ -46,7 +47,7 @@ import { RedirectWhenSavedObjectNotFound } from './redirect_not_found';
import { DiscoverMainApp } from './main_app';
import { useAsyncFunction } from '../../hooks/use_async_function';
interface DiscoverSessionViewProps {
export interface DiscoverSessionViewProps {
customizationContext: DiscoverCustomizationContext;
customizationCallbacks: CustomizationCallback[];
urlStateStorage: IKbnUrlStateStorage;
@ -54,6 +55,10 @@ interface DiscoverSessionViewProps {
runtimeStateManager: RuntimeStateManager;
}
export interface DiscoverSessionViewRef {
stopSyncing: () => void;
}
type SessionInitializationState =
| {
showNoDataPage: true;
@ -69,126 +74,147 @@ type InitializeSession = (options?: {
defaultUrlState?: DiscoverAppState;
}) => Promise<SessionInitializationState>;
export const DiscoverSessionView = ({
customizationContext,
customizationCallbacks,
urlStateStorage,
internalState,
runtimeStateManager,
}: DiscoverSessionViewProps) => {
const dispatch = useInternalStateDispatch();
const services = useDiscoverServices();
const { core, history, getScopedHistory } = services;
const { id: discoverSessionId } = useParams<{ id?: string }>();
const [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();
export const DiscoverSessionView = forwardRef<DiscoverSessionViewRef, DiscoverSessionViewProps>(
(
{
customizationContext,
customizationCallbacks,
urlStateStorage,
internalState,
runtimeStateManager,
},
});
ref
) => {
const dispatch = useInternalStateDispatch();
const services = useDiscoverServices();
const { core, history, getScopedHistory } = services;
const { id: discoverSessionId } = useParams<{ id?: string }>();
const [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, {
type: 'application',
page: 'app',
id: discoverSessionId || 'new',
});
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 = useCurrentTabRuntimeState(
runtimeStateManager,
(tab) => tab.currentDataView$
);
const adHocDataViews = useRuntimeState(runtimeStateManager.adHocDataViews$);
if (initializeSessionState.loading) {
return <BrandedLoadingIndicator />;
}
useImperativeHandle(
ref,
() => ({
stopSyncing: () => initializeSessionState.value?.stateContainer?.actions.stopSyncing(),
}),
[initializeSessionState.value?.stateContainer]
);
if (initializeSessionState.error) {
if (initializeSessionState.error instanceof SavedObjectNotFound) {
useEffect(() => {
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 (
<RedirectWhenSavedObjectNotFound
error={initializeSessionState.error}
discoverSessionId={discoverSessionId}
<NoDataPage
{...initializationState}
onDataViewCreated={async (dataViewUnknown) => {
await dispatch(internalStateActions.loadDataViewList());
dispatch(
internalStateActions.setInitializationState({
hasESData: true,
hasUserDataView: true,
})
);
const dataView = dataViewUnknown as DataView;
initializeSession({
defaultUrlState: dataView.id
? { dataSource: createDataViewDataSource({ dataViewId: dataView.id }) }
: undefined,
});
}}
onESQLNavigationComplete={() => {
initializeSession();
}}
/>
);
}
return <DiscoverError error={initializeSessionState.error} />;
}
if (!customizationService || !currentDataView) {
return <BrandedLoadingIndicator />;
}
if (initializeSessionState.value.showNoDataPage) {
return (
<NoDataPage
{...initializationState}
onDataViewCreated={async (dataViewUnknown) => {
await dispatch(internalStateActions.loadDataViewList());
dispatch(
internalStateActions.setInitializationState({ hasESData: true, hasUserDataView: true })
);
const dataView = dataViewUnknown as DataView;
initializeSession({
defaultUrlState: dataView.id
? { dataSource: createDataViewDataSource({ dataViewId: dataView.id }) }
: undefined,
});
}}
onESQLNavigationComplete={() => {
initializeSession();
}}
/>
<DiscoverCustomizationProvider value={customizationService}>
<DiscoverMainProvider value={initializeSessionState.value.stateContainer}>
<RuntimeStateProvider currentDataView={currentDataView} adHocDataViews={adHocDataViews}>
<DiscoverMainApp stateContainer={initializeSessionState.value.stateContainer} />
</RuntimeStateProvider>
</DiscoverMainProvider>
</DiscoverCustomizationProvider>
);
}
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 { DiscoverStateContainer } from '../../state_management/discover_state';
import { getAllowedSampleSize } from '../../../../utils/get_allowed_sample_size';
import { internalStateActions } from '../../state_management/redux';
import { internalStateActions, selectCurrentTab } from '../../state_management/redux';
async function saveDataSource({
savedSearch,
@ -95,8 +95,8 @@ export async function onSaveSearch({
}) {
const { uiSettings, savedObjectsTagging } = services;
const dataView = savedSearch.searchSource.getField('index');
const overriddenVisContextAfterInvalidation =
state.internalState.getState().overriddenVisContextAfterInvalidation;
const currentTab = selectCurrentTab(state.internalState.getState());
const overriddenVisContextAfterInvalidation = currentTab.overriddenVisContextAfterInvalidation;
const onSave = async ({
newTitle,

View file

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

View file

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

View file

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

View file

@ -23,7 +23,7 @@ import { getSavedSearchContainer } from './discover_saved_search_container';
import { getDiscoverGlobalStateContainer } from './discover_global_state_container';
import { omit } from 'lodash';
import type { InternalStateStore } from './redux';
import { createInternalStateStore, createRuntimeStateManager } from './redux';
import { createInternalStateStore, createRuntimeStateManager, selectCurrentTab } from './redux';
import { mockCustomizationContext } from '../../../customizations/__mocks__/customization_context';
let history: History;
@ -274,13 +274,17 @@ describe('Test discover app state container', () => {
describe('initAndSync', () => {
it('should call setResetDefaultProfileState correctly with no initial state', () => {
const state = getStateContainer();
expect(omit(internalState.getState().resetDefaultProfileState, 'resetId')).toEqual({
expect(
omit(selectCurrentTab(internalState.getState()).resetDefaultProfileState, 'resetId')
).toEqual({
columns: false,
rowHeight: false,
breakdownField: false,
});
state.initAndSync();
expect(omit(internalState.getState().resetDefaultProfileState, 'resetId')).toEqual({
expect(
omit(selectCurrentTab(internalState.getState()).resetDefaultProfileState, 'resetId')
).toEqual({
columns: true,
rowHeight: true,
breakdownField: true,
@ -291,13 +295,17 @@ describe('Test discover app state container', () => {
const stateStorageGetSpy = jest.spyOn(stateStorage, 'get');
stateStorageGetSpy.mockReturnValue({ columns: ['test'] });
const state = getStateContainer();
expect(omit(internalState.getState().resetDefaultProfileState, 'resetId')).toEqual({
expect(
omit(selectCurrentTab(internalState.getState()).resetDefaultProfileState, 'resetId')
).toEqual({
columns: false,
rowHeight: false,
breakdownField: false,
});
state.initAndSync();
expect(omit(internalState.getState().resetDefaultProfileState, 'resetId')).toEqual({
expect(
omit(selectCurrentTab(internalState.getState()).resetDefaultProfileState, 'resetId')
).toEqual({
columns: false,
rowHeight: true,
breakdownField: true,
@ -308,13 +316,17 @@ describe('Test discover app state container', () => {
const stateStorageGetSpy = jest.spyOn(stateStorage, 'get');
stateStorageGetSpy.mockReturnValue({ rowHeight: 5 });
const state = getStateContainer();
expect(omit(internalState.getState().resetDefaultProfileState, 'resetId')).toEqual({
expect(
omit(selectCurrentTab(internalState.getState()).resetDefaultProfileState, 'resetId')
).toEqual({
columns: false,
rowHeight: false,
breakdownField: false,
});
state.initAndSync();
expect(omit(internalState.getState().resetDefaultProfileState, 'resetId')).toEqual({
expect(
omit(selectCurrentTab(internalState.getState()).resetDefaultProfileState, 'resetId')
).toEqual({
columns: true,
rowHeight: false,
breakdownField: true,
@ -331,13 +343,17 @@ describe('Test discover app state container', () => {
managed: false,
});
const state = getStateContainer();
expect(omit(internalState.getState().resetDefaultProfileState, 'resetId')).toEqual({
expect(
omit(selectCurrentTab(internalState.getState()).resetDefaultProfileState, 'resetId')
).toEqual({
columns: false,
rowHeight: false,
breakdownField: false,
});
state.initAndSync();
expect(omit(internalState.getState().resetDefaultProfileState, 'resetId')).toEqual({
expect(
omit(selectCurrentTab(internalState.getState()).resetDefaultProfileState, 'resetId')
).toEqual({
columns: false,
rowHeight: 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 { fetchDocuments } from '../data_fetching/fetch_documents';
import { omit } from 'lodash';
import { internalStateActions } from './redux';
import { internalStateActions, selectCurrentTab } from './redux';
jest.mock('../data_fetching/fetch_documents', () => ({
fetchDocuments: jest.fn().mockResolvedValue({ records: [] }),
@ -195,7 +195,10 @@ describe('test getDataStateContainer', () => {
expect(dataState.data$.main$.value.fetchStatus).toBe(FetchStatus.COMPLETE);
});
expect(
omit(stateContainer.internalState.getState().resetDefaultProfileState, 'resetId')
omit(
selectCurrentTab(stateContainer.internalState.getState()).resetDefaultProfileState,
'resetId'
)
).toEqual({
columns: false,
rowHeight: false,
@ -230,7 +233,10 @@ describe('test getDataStateContainer', () => {
expect(dataState.data$.main$.value.fetchStatus).toBe(FetchStatus.COMPLETE);
});
expect(
omit(stateContainer.internalState.getState().resetDefaultProfileState, 'resetId')
omit(
selectCurrentTab(stateContainer.internalState.getState()).resetDefaultProfileState,
'resetId'
)
).toEqual({
columns: 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 { getDefaultProfileState } from './utils/get_default_profile_state';
import type { InternalStateStore, RuntimeStateManager } from './redux';
import { internalStateActions } from './redux';
import { internalStateActions, selectCurrentTab, selectCurrentTabRuntimeState } from './redux';
export interface SavedSearchData {
main$: DataMain$;
@ -263,8 +263,13 @@ export function getDataStateContainer({
query: appStateContainer.getState().query,
});
const { resetDefaultProfileState } = internalState.getState();
const dataView = runtimeStateManager.currentDataView$.getValue();
const currentInternalState = internalState.getState();
const { resetDefaultProfileState } = selectCurrentTab(currentInternalState);
const { currentDataView$ } = selectCurrentTabRuntimeState(
currentInternalState,
runtimeStateManager
);
const dataView = currentDataView$.getValue();
const defaultProfileState = dataView
? getDefaultProfileState({ profilesManager, resetDefaultProfileState, dataView })
: undefined;
@ -293,7 +298,7 @@ export function getDataStateContainer({
},
async () => {
const { resetDefaultProfileState: currentResetDefaultProfileState } =
internalState.getState();
selectCurrentTab(internalState.getState());
if (currentResetDefaultProfileState.resetId !== resetDefaultProfileState.resetId) {
return;

View file

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

View file

@ -44,7 +44,7 @@ import {
isDataSourceType,
} from '../../../../common/data_sources';
import type { InternalStateStore, RuntimeStateManager } from './redux';
import { internalStateActions } from './redux';
import { internalStateActions, selectCurrentTabRuntimeState } from './redux';
import type { DiscoverSavedSearchContainer } 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
*/
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
* Used by the Data View Picker
@ -302,7 +306,11 @@ export function getDiscoverStateContainer({
* This is to prevent duplicate ids messing with our system
*/
const updateAdHocDataViewId = async () => {
const prevDataView = runtimeStateManager.currentDataView$.getValue();
const { currentDataView$ } = selectCurrentTabRuntimeState(
internalState.getState(),
runtimeStateManager
);
const prevDataView = currentDataView$.getValue();
if (!prevDataView || prevDataView.isPersisted()) return;
const nextDataView = await services.dataViews.create({
@ -408,6 +416,13 @@ export function getDiscoverStateContainer({
fetchData();
};
let internalStopSyncing = () => {};
const stopSyncing = () => {
internalStopSyncing();
internalStopSyncing = () => {};
};
/**
* 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
const filterUnsubscribe = merge(services.filterManager.getFetches$()).subscribe(() => {
const { currentDataView$ } = selectCurrentTabRuntimeState(
internalState.getState(),
runtimeStateManager
);
savedSearchContainer.update({
nextDataView: runtimeStateManager.currentDataView$.getValue(),
nextDataView: currentDataView$.getValue(),
nextState: appStateContainer.getState(),
useFilterAndQueryServices: true,
});
@ -468,7 +487,7 @@ export function getDiscoverStateContainer({
}
);
return () => {
internalStopSyncing = () => {
unsubscribeData();
appStateUnsubscribe();
appStateInitAndSyncUnsubscribe();
@ -581,6 +600,7 @@ export function getDiscoverStateContainer({
customizationContext,
actions: {
initializeAndSync,
stopSyncing,
fetchData,
onChangeDataView,
createAndAppendAdHocDataView,

View file

@ -10,12 +10,20 @@
import type { DataView } from '@kbn/data-views-plugin/common';
import { differenceBy } from 'lodash';
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]> =
(dataView) =>
(dispatch, _, { runtimeStateManager }) => {
(dispatch, getState, { runtimeStateManager }) => {
dispatch(internalStateSlice.actions.setDataViewId(dataView.id));
runtimeStateManager.currentDataView$.next(dataView);
const { currentDataView$ } = selectCurrentTabRuntimeState(getState(), runtimeStateManager);
currentDataView$.next(dataView);
};
export const setAdHocDataViews: InternalStateThunkActionCreator<[DataView[]]> =

View file

@ -9,3 +9,4 @@
export * from './data_views';
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 { updateSavedSearch } from '../../utils/update_saved_search';
import { APP_STATE_URL_KEY } from '../../../../../../common';
import { selectCurrentTabRuntimeState } from '../runtime_state';
export interface InitializeSessionParams {
stateContainer: DiscoverStateContainer;
@ -108,10 +109,12 @@ export const initializeSession: InternalStateThunkActionCreator<
let dataView: DataView;
if (isOfAggregateQueryType(initialQuery)) {
const { currentDataView$ } = selectCurrentTabRuntimeState(getState(), runtimeStateManager);
// Regardless of what was requested, we always use ad hoc data views for ES|QL
dataView = await getEsqlDataView(
initialQuery,
discoverSessionDataView ?? runtimeStateManager.currentDataView$.getValue(),
discoverSessionDataView ?? currentDataView$.getValue(),
services
);
} 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';
import React, { type PropsWithChildren, useMemo, createContext } from 'react';
import { useAdHocDataViews } from './runtime_state';
import type { DiscoverInternalState } from './types';
import type { InternalStateDispatch, InternalStateStore } from './internal_state';
import type { DiscoverInternalState, TabState } from './types';
import { type InternalStateDispatch, type InternalStateStore } from './internal_state';
import { selectCurrentTab } from './selectors';
const internalStateContext = createContext<ReactReduxContextValue>(
// Recommended approach for versions of Redux prior to v9:
@ -41,6 +42,9 @@ export const useInternalStateDispatch: () => InternalStateDispatch =
export const useInternalStateSelector: TypedUseSelectorHook<DiscoverInternalState> =
createSelectorHook(internalStateContext);
export const useCurrentTabSelector: TypedUseSelectorHook<TabState> = (selector) =>
selector(useInternalStateSelector(selectCurrentTab));
export const useDataViewsForPicker = () => {
const originalAdHocDataViews = useAdHocDataViews();
const savedDataViews = useInternalStateSelector((state) => state.savedDataViews);

View file

@ -8,23 +8,33 @@
*/
import { omit } from 'lodash';
import { internalStateSlice, loadDataViewList } from './internal_state';
import { internalStateSlice } from './internal_state';
import {
loadDataViewList,
appendAdHocDataViews,
initializeSession,
replaceAdHocDataViewWithId,
setAdHocDataViews,
setDataView,
setDefaultProfileAdHocDataViews,
setTabs,
updateTabs,
} 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 = {
...omit(internalStateSlice.actions, 'setDataViewId', 'setDefaultProfileAdHocDataViewIds'),
...omit(
internalStateSlice.actions,
'setTabs',
'setDataViewId',
'setDefaultProfileAdHocDataViewIds'
),
loadDataViewList,
setTabs,
updateTabs,
setDataView,
setAdHocDataViews,
setDefaultProfileAdHocDataViews,
@ -40,10 +50,14 @@ export {
useDataViewsForPicker,
} from './hooks';
export { selectAllTabs, selectCurrentTab } from './selectors';
export {
type RuntimeStateManager,
createRuntimeStateManager,
useRuntimeState,
selectCurrentTabRuntimeState,
useCurrentTabRuntimeState,
RuntimeStateProvider,
useCurrentDataView,
useAdHocDataViews,

View file

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

View file

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

View file

@ -8,41 +8,72 @@
*/
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 { BehaviorSubject, skip } from 'rxjs';
import { BehaviorSubject } from 'rxjs';
import { useInternalStateSelector } from './hooks';
import type { DiscoverInternalState } from './types';
interface DiscoverRuntimeState {
currentDataView: DataView;
adHocDataViews: DataView[];
}
type RuntimeStateManagerInternal<TNullable extends keyof DiscoverRuntimeState> = {
[key in keyof DiscoverRuntimeState as `${key}$`]: BehaviorSubject<
key extends TNullable ? DiscoverRuntimeState[key] | undefined : DiscoverRuntimeState[key]
interface TabRuntimeState {
currentDataView: DataView;
}
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 => ({
currentDataView$: new BehaviorSubject<DataView | undefined>(undefined),
adHocDataViews$: new BehaviorSubject<DataView[]>([]),
});
export const useRuntimeState = <T,>(stateSubject$: BehaviorSubject<T>) => {
const [stateObservable$] = useState(() => stateSubject$.pipe(skip(1)));
return useObservable(stateObservable$, stateSubject$.getValue());
export type RuntimeStateManager = ReactiveRuntimeState<DiscoverRuntimeState> & {
tabs: { byId: Record<string, ReactiveTabRuntimeState> };
};
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 = ({
currentDataView,
adHocDataViews,
children,
}: PropsWithChildren<DiscoverRuntimeState>) => {
const runtimeState = useMemo<DiscoverRuntimeState>(
}: PropsWithChildren<CombinedRuntimeState>) => {
const runtimeState = useMemo<CombinedRuntimeState>(
() => ({ currentDataView, adHocDataViews }),
[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 { TimeRange } from '@kbn/es-query';
import type { UnifiedHistogramVisContext } from '@kbn/unified-histogram-plugin/public';
import type { TabItem } from '@kbn/unified-tabs';
export enum LoadingStatus {
Uninitialized = 'uninitialized',
@ -42,16 +43,13 @@ export interface InternalStateDataRequestParams {
timeRangeRelative?: TimeRange;
}
export interface DiscoverInternalState {
initializationState: { hasESData: boolean; hasUserDataView: boolean };
export interface TabState extends TabItem {
globalState?: Record<string, unknown>;
appState?: Record<string, unknown>;
dataViewId: string | undefined;
isDataViewLoading: boolean;
savedDataViews: DataViewListItem[];
defaultProfileAdHocDataViewIds: string[];
expandedDoc: DataTableRecord | undefined;
dataRequestParams: InternalStateDataRequestParams;
overriddenVisContextAfterInvalidation: UnifiedHistogramVisContext | {} | undefined; // it will be used during saved search saving
isESQLToDataViewTransitionModalVisible: boolean;
resetDefaultProfileState: {
resetId: string;
columns: boolean;
@ -62,3 +60,16 @@ export interface DiscoverInternalState {
totalHitsRequest: TotalHitsRequest;
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 { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock';
import { createDataViewDataSource } from '../../../../../common/data_sources';
import { createRuntimeStateManager, internalStateActions } from '../redux';
import { createRuntimeStateManager, internalStateActions, selectCurrentTab } from '../redux';
const setupTestParams = (dataView: DataView | undefined) => {
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 () => {
const params = setupTestParams(dataViewWithDefaultColumnMock);
const promise = changeDataView({ dataViewId: dataViewWithDefaultColumnMock.id!, ...params });
expect(params.internalState.getState().isDataViewLoading).toBe(true);
expect(selectCurrentTab(params.internalState.getState()).isDataViewLoading).toBe(true);
await promise;
expect(params.appState.update).toHaveBeenCalledWith({
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' }),
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 () => {
const params = setupTestParams(dataViewComplexMock);
const promise = changeDataView({ dataViewId: dataViewComplexMock.id!, ...params });
expect(params.internalState.getState().isDataViewLoading).toBe(true);
expect(selectCurrentTab(params.internalState.getState()).isDataViewLoading).toBe(true);
await promise;
expect(params.appState.update).toHaveBeenCalledWith({
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' }),
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 () => {
const params = setupTestParams(undefined);
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;
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 () => {
const params = setupTestParams(dataViewComplexMock);
expect(params.internalState.getState().resetDefaultProfileState).toEqual(
expect(selectCurrentTab(params.internalState.getState()).resetDefaultProfileState).toEqual(
expect.objectContaining({
columns: false,
rowHeight: false,
@ -83,7 +83,7 @@ describe('changeDataView', () => {
})
);
await changeDataView({ dataViewId: dataViewComplexMock.id!, ...params });
expect(params.internalState.getState().resetDefaultProfileState).toEqual(
expect(selectCurrentTab(params.internalState.getState()).resetDefaultProfileState).toEqual(
expect.objectContaining({
columns: true,
rowHeight: true,

View file

@ -18,7 +18,12 @@ import type { DiscoverAppStateContainer } from '../discover_app_state_container'
import { addLog } from '../../../../utils/add_log';
import type { DiscoverServices } from '../../../../build_services';
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
@ -39,7 +44,11 @@ export async function changeDataView({
addLog('[ui] changeDataView', { id: dataViewId });
const { dataViews, uiSettings } = services;
const currentDataView = runtimeStateManager.currentDataView$.getValue();
const { currentDataView$ } = selectCurrentTabRuntimeState(
internalState.getState(),
runtimeStateManager
);
const currentDataView = currentDataView$.getValue();
const state = appState.getState();
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 { getMergedAccessor } from '../../../../context_awareness';
import type { DataDocumentsMsg } from '../discover_data_state_container';
import type { DiscoverInternalState } from '../redux';
import type { TabState } from '../redux';
export const getDefaultProfileState = ({
profilesManager,
@ -22,7 +22,7 @@ export const getDefaultProfileState = ({
dataView,
}: {
profilesManager: ProfilesManager;
resetDefaultProfileState: DiscoverInternalState['resetDefaultProfileState'];
resetDefaultProfileState: TabState['resetDefaultProfileState'];
dataView: DataView;
}) => {
const defaultState = getDefaultState(profilesManager, dataView);

View file

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

View file

@ -36,6 +36,8 @@ jest.mock('uuid', () => ({
.fn()
.mockReturnValueOnce('d594baeb-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('f614baeb-5eca-480c-8885-ba79eaf52483')
.mockReturnValue('1dd5663b-f062-43f8-8688-fc8166c2ca8e'),