[Discover] Handle ES|QL mode when switching tabs (#217600)

## Summary

This PR updates the handling of ES|QL mode fetches so that they continue
to run in the background when switching tabs, and the tab state is
properly updated when a fetch completes (even if not in the selected
tab).

The main changes include the following:
- Removes the `useEsqlMode` hook entirely and migrates the logic to a
`buildEsqlFetchSubscribe` function that's subscribed to directly in
`DiscoverDataStateContainer`, and tied to the lifetime of the state
container.
- Modifies the `updateTabs` logic to remove the dependency on raw URL
state for persisting/restoring tab state, and instead rely directly on
`DiscoverAppState` and a new internal state `lastPersistedGlobalState`
property. This was done because tab state updates can now happen after
changing tabs, and the raw URL state properties can become out of sync
in that case unless we manually sync them, which would be brittle. It
also removes duplicate state which we no longer need since we can just
update the URL directly from the state when switching to a tab.
- Updates `replaceUrlState` in `DiscoverAppStateContainer` to _not_
update the URL if its associated tab is not selected, and instead just
update the app state directly when unselected, to prevent leaking URL
state updates between tabs.
- Moves `TABS_ENABLED` to the main Discover `constants` file as
suggested in a previous PR review to avoid circular dependencies.
- A couple of small cleanups related to `unsafeCurrentId`.

Part of #216475.

### Checklist

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] 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-04-16 19:01:29 -03:00 committed by GitHub
parent f6ad013220
commit bcba741abc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 327 additions and 317 deletions

View file

@ -58,7 +58,7 @@ import { PanelsToggle } from '../../../../components/panels_toggle';
import { sendErrorMsg } from '../../hooks/use_saved_search_messages';
import { useIsEsqlMode } from '../../hooks/use_is_esql_mode';
import { useCurrentDataView, useCurrentTabSelector } from '../../state_management/redux';
import { TABS_ENABLED } from '../../discover_main_route';
import { TABS_ENABLED } from '../../../../constants';
const SidebarMemoized = React.memo(DiscoverSidebarResponsive);
const TopNavMemoized = React.memo(DiscoverTopNav);

View file

@ -17,7 +17,6 @@ import { useDiscoverServices } from '../../../../hooks/use_discover_services';
import { useSavedSearchAliasMatchRedirect } from '../../../../hooks/saved_search_alias_match_redirect';
import { useSavedSearchInitial } from '../../state_management/discover_state_provider';
import { useAdHocDataViews } from '../../hooks/use_adhoc_data_views';
import { useEsqlMode } from '../../hooks/use_esql_mode';
const DiscoverLayoutMemoized = React.memo(DiscoverLayout);
@ -38,14 +37,6 @@ export function DiscoverMainApp({ stateContainer }: DiscoverMainProps) {
*/
useAdHocDataViews();
/**
* State changes (data view, columns), when a text base query result is returned
*/
useEsqlMode({
dataViews: services.dataViews,
stateContainer,
});
/**
* SavedSearch dependent initializing
*/

View file

@ -34,9 +34,7 @@ import {
} 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.
export const TABS_ENABLED = false;
import { TABS_ENABLED } from '../../constants';
export interface MainRouteProps {
customizationContext: DiscoverCustomizationContext;

View file

@ -1,207 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { isEqual } from 'lodash';
import { isOfAggregateQueryType } from '@kbn/es-query';
import { hasTransformationalCommand, getIndexPatternFromESQLQuery } from '@kbn/esql-utils';
import { useCallback, useEffect, useRef } from 'react';
import type { DataViewsContract } from '@kbn/data-views-plugin/public';
import { switchMap } from 'rxjs';
import { useSavedSearchInitial } from '../state_management/discover_state_provider';
import type { DiscoverStateContainer } from '../state_management/discover_state';
import { getValidViewMode } from '../utils/get_valid_view_mode';
import { FetchStatus } from '../../types';
import {
internalStateActions,
useCurrentTabAction,
useInternalStateDispatch,
} from '../state_management/redux';
const MAX_NUM_OF_COLUMNS = 50;
/**
* Hook to take care of ES|QL state transformations when a new result is returned
* If necessary this is setting displayed columns and selected data view
*/
export function useEsqlMode({
dataViews,
stateContainer,
}: {
stateContainer: DiscoverStateContainer;
dataViews: DataViewsContract;
}) {
const setResetDefaultProfileState = useCurrentTabAction(
internalStateActions.setResetDefaultProfileState
);
const dispatch = useInternalStateDispatch();
const savedSearch = useSavedSearchInitial();
const prev = useRef<{
initialFetch: boolean;
query: string;
allColumns: string[];
defaultColumns: string[];
}>({
initialFetch: true,
query: '',
allColumns: [],
defaultColumns: [],
});
const cleanup = useCallback(() => {
if (!prev.current.query) {
return;
}
// cleanup when it's not an ES|QL query
prev.current = {
initialFetch: true,
query: '',
allColumns: [],
defaultColumns: [],
};
}, []);
useEffect(() => {
const subscription = stateContainer.dataState.data$.documents$
.pipe(
switchMap(async (next) => {
const { query: nextQuery } = next;
if (!nextQuery) {
return;
}
if (!isOfAggregateQueryType(nextQuery)) {
// cleanup for a "regular" query
cleanup();
return;
}
// We need to reset the default profile state on index pattern changes
// when loading starts to ensure the correct pre fetch state is available
// before data fetching is triggered
if (next.fetchStatus === FetchStatus.LOADING) {
// We have to grab the current query from appState
// here since nextQuery has not been updated yet
const appStateQuery = stateContainer.appState.getState().query;
if (isOfAggregateQueryType(appStateQuery)) {
if (prev.current.initialFetch) {
prev.current.query = appStateQuery.esql;
}
const indexPatternChanged =
getIndexPatternFromESQLQuery(appStateQuery.esql) !==
getIndexPatternFromESQLQuery(prev.current.query);
// Reset all default profile state when index pattern changes
if (indexPatternChanged) {
dispatch(
setResetDefaultProfileState({
resetDefaultProfileState: {
columns: true,
rowHeight: true,
breakdownField: true,
},
})
);
}
}
return;
}
if (next.fetchStatus === FetchStatus.ERROR) {
// An error occurred, but it's still considered an initial fetch
prev.current.initialFetch = false;
return;
}
if (next.fetchStatus !== FetchStatus.PARTIAL) {
return;
}
let nextAllColumns = prev.current.allColumns;
let nextDefaultColumns = prev.current.defaultColumns;
if (next.result?.length) {
nextAllColumns = Object.keys(next.result[0].raw);
if (hasTransformationalCommand(nextQuery.esql)) {
nextDefaultColumns = nextAllColumns.slice(0, MAX_NUM_OF_COLUMNS);
} else {
nextDefaultColumns = [];
}
}
if (prev.current.initialFetch) {
prev.current.initialFetch = false;
prev.current.query = nextQuery.esql;
prev.current.allColumns = nextAllColumns;
prev.current.defaultColumns = nextDefaultColumns;
}
const indexPatternChanged =
getIndexPatternFromESQLQuery(nextQuery.esql) !==
getIndexPatternFromESQLQuery(prev.current.query);
const allColumnsChanged = !isEqual(nextAllColumns, prev.current.allColumns);
const changeDefaultColumns =
indexPatternChanged || !isEqual(nextDefaultColumns, prev.current.defaultColumns);
const { viewMode } = stateContainer.appState.getState();
const changeViewMode = viewMode !== getValidViewMode({ viewMode, isEsqlMode: true });
// If the index pattern hasn't changed, but the available columns have changed
// due to transformational commands, reset the associated default profile state
if (!indexPatternChanged && allColumnsChanged) {
dispatch(
setResetDefaultProfileState({
resetDefaultProfileState: {
columns: true,
rowHeight: false,
breakdownField: false,
},
})
);
}
prev.current.allColumns = nextAllColumns;
if (indexPatternChanged || changeDefaultColumns || changeViewMode) {
prev.current.query = nextQuery.esql;
prev.current.defaultColumns = nextDefaultColumns;
// just change URL state if necessary
if (changeDefaultColumns || changeViewMode) {
const nextState = {
...(changeDefaultColumns && { columns: nextDefaultColumns }),
...(changeViewMode && { viewMode: undefined }),
};
await stateContainer.appState.replaceUrlState(nextState);
}
}
stateContainer.dataState.data$.documents$.next({
...next,
fetchStatus: FetchStatus.COMPLETE,
});
})
)
.subscribe();
return () => {
// cleanup for e.g. when savedSearch is switched
cleanup();
subscription.unsubscribe();
};
}, [dataViews, stateContainer, savedSearch, cleanup, dispatch, setResetDefaultProfileState]);
}

View file

@ -58,11 +58,12 @@ describe('Test discover app state container', () => {
internalState,
});
getCurrentTab = () =>
selectTab(internalState.getState(), internalState.getState().tabs.allIds[0]);
selectTab(internalState.getState(), internalState.getState().tabs.unsafeCurrentId);
});
const getStateContainer = () =>
getDiscoverAppStateContainer({
tabId: getCurrentTab().id,
stateStorage,
internalState,
savedSearchContainer: savedSearchState,

View file

@ -181,12 +181,14 @@ export const { Provider: DiscoverAppStateProvider, useSelector: useAppStateSelec
* @param services
*/
export const getDiscoverAppStateContainer = ({
tabId,
stateStorage,
internalState,
savedSearchContainer,
services,
injectCurrentTab,
}: {
tabId: string;
stateStorage: IKbnUrlStateStorage;
internalState: InternalStateStore;
savedSearchContainer: DiscoverSavedSearchContainer;
@ -245,7 +247,11 @@ export const getDiscoverAppStateContainer = ({
const replaceUrlState = async (newPartial: DiscoverAppState = {}, merge = true) => {
addLog('[appState] replaceUrlState', { newPartial, merge });
const state = merge ? { ...enhancedAppContainer.getState(), ...newPartial } : newPartial;
await stateStorage.set(APP_STATE_URL_KEY, state, { replace: true });
if (internalState.getState().tabs.unsafeCurrentId === tabId) {
await stateStorage.set(APP_STATE_URL_KEY, state, { replace: true });
} else {
enhancedAppContainer.set(state);
}
};
const startAppStateUrlSync = () => {

View file

@ -8,11 +8,20 @@
*/
import type { Observable } from 'rxjs';
import { BehaviorSubject, filter, map, mergeMap, ReplaySubject, share, Subject, tap } from 'rxjs';
import {
BehaviorSubject,
filter,
map,
mergeMap,
ReplaySubject,
share,
Subject,
switchMap,
tap,
} from 'rxjs';
import type { AutoRefreshDoneFn } from '@kbn/data-plugin/public';
import type { DatatableColumn } from '@kbn/expressions-plugin/common';
import { RequestAdapter } from '@kbn/inspector-plugin/common';
import type { SavedSearch } from '@kbn/saved-search-plugin/public';
import type { AggregateQuery, Query } from '@kbn/es-query';
import { isOfAggregateQueryType } from '@kbn/es-query';
import type { DataView } from '@kbn/data-views-plugin/common';
@ -32,6 +41,8 @@ import { getFetch$ } from '../data_fetching/get_fetch_observable';
import { getDefaultProfileState } from './utils/get_default_profile_state';
import type { InternalStateStore, RuntimeStateManager, TabActionInjector, TabState } from './redux';
import { internalStateActions, selectTabRuntimeState } from './redux';
import { buildEsqlFetchSubscribe } from './utils/build_esql_fetch_subscribe';
import type { DiscoverSavedSearchContainer } from './discover_saved_search_container';
export interface SavedSearchData {
main$: DataMain$;
@ -123,6 +134,7 @@ export interface DiscoverDataStateContainer {
*/
getInitialFetchStatus: () => FetchStatus;
}
/**
* Container responsible for fetching of data in Discover Main
* Either by triggering requests to Elasticsearch directly, or by
@ -134,7 +146,7 @@ export function getDataStateContainer({
appStateContainer,
internalState,
runtimeStateManager,
getSavedSearch,
savedSearchContainer,
setDataView,
injectCurrentTab,
getCurrentTab,
@ -144,7 +156,7 @@ export function getDataStateContainer({
appStateContainer: DiscoverAppStateContainer;
internalState: InternalStateStore;
runtimeStateManager: RuntimeStateManager;
getSavedSearch: () => SavedSearch;
savedSearchContainer: DiscoverSavedSearchContainer;
setDataView: (dataView: DataView) => void;
injectCurrentTab: TabActionInjector;
getCurrentTab: () => TabState;
@ -164,7 +176,7 @@ export function getDataStateContainer({
const getInitialFetchStatus = () => {
const shouldSearchOnPageLoad =
uiSettings.get<boolean>(SEARCH_ON_PAGE_LOAD_SETTING) ||
getSavedSearch().id !== undefined ||
savedSearchContainer.getState().id !== undefined ||
!timefilter.getRefreshInterval().pause ||
searchSessionManager.hasSearchSessionIdInURL();
return shouldSearchOnPageLoad ? FetchStatus.LOADING : FetchStatus.UNINITIALIZED;
@ -180,6 +192,7 @@ export function getDataStateContainer({
documents$: new BehaviorSubject<DataDocumentsMsg>(initialState),
totalHits$: new BehaviorSubject<DataTotalHitsMsg>(initialState),
};
// This is debugging code, helping you to understand which messages are sent to the data observables
// Adding a debugger in the functions can be helpful to understand what triggers a message
// dataSubjects.main$.subscribe((msg) => addLog('dataSubjects.main$', msg));
@ -187,11 +200,26 @@ export function getDataStateContainer({
// dataSubjects.totalHits$.subscribe((msg) => addLog('dataSubjects.totalHits$', msg););
// Add window.ELASTIC_DISCOVER_LOGGER = 'debug' to see messages in console
let autoRefreshDone: AutoRefreshDoneFn | undefined | null = null;
/**
* Subscribes to ES|QL fetches to handle state changes when loading or before a fetch completes
*/
const { esqlFetchSubscribe, cleanupEsql } = buildEsqlFetchSubscribe({
internalState,
appStateContainer,
dataSubjects,
injectCurrentTab,
});
// The main subscription to handle state changes
dataSubjects.documents$.pipe(switchMap(esqlFetchSubscribe)).subscribe();
// Make sure to clean up the ES|QL state when the saved search changes
savedSearchContainer.getInitial$().subscribe(cleanupEsql);
/**
* handler emitted by `timefilter.getAutoRefreshFetch$()`
* to notify when data completed loading and to start a new autorefresh loop
*/
let autoRefreshDone: AutoRefreshDoneFn | undefined | null = null;
const setAutoRefreshDone = (fn: AutoRefreshDoneFn | undefined) => {
autoRefreshDone = fn;
};
@ -200,7 +228,7 @@ export function getDataStateContainer({
data,
main$: dataSubjects.main$,
refetch$,
searchSource: getSavedSearch().searchSource,
searchSource: savedSearchContainer.getState().searchSource,
searchSessionManager,
}).pipe(
filter(() => validateTimeRange(timefilter.getTime(), toastNotifications)),
@ -233,7 +261,7 @@ export function getDataStateContainer({
services,
getAppState: appStateContainer.getState,
internalState,
savedSearch: getSavedSearch(),
savedSearch: savedSearchContainer.getState(),
};
abortController?.abort();
@ -269,7 +297,7 @@ export function getDataStateContainer({
await profilesManager.resolveDataSourceProfile({
dataSource: appStateContainer.getState().dataSource,
dataView: getSavedSearch().searchSource.getField('index'),
dataView: savedSearchContainer.getState().searchSource.getField('index'),
query: appStateContainer.getState().query,
});
@ -359,7 +387,7 @@ export function getDataStateContainer({
const fetchQuery = async () => {
const query = appStateContainer.getState().query;
const currentDataView = getSavedSearch().searchSource.getField('index');
const currentDataView = savedSearchContainer.getState().searchSource.getField('index');
if (isOfAggregateQueryType(query)) {
const nextDataView = await getEsqlDataView(query, currentDataView, services);

View file

@ -15,7 +15,7 @@ export interface DiscoverGlobalStateContainer {
set: (state: QueryState) => Promise<void>;
}
const GLOBAL_STATE_URL_KEY = '_g';
export const GLOBAL_STATE_URL_KEY = '_g';
export const getDiscoverGlobalStateContainer = (
stateStorage: IKbnUrlStateStorage

View file

@ -272,6 +272,7 @@ export function getDiscoverStateContainer({
* App State Container, synced with the _a part URL
*/
const appStateContainer = getDiscoverAppStateContainer({
tabId,
stateStorage,
internalState,
savedSearchContainer,
@ -303,7 +304,7 @@ export function getDiscoverStateContainer({
appStateContainer,
internalState,
runtimeStateManager,
getSavedSearch: savedSearchContainer.getState,
savedSearchContainer,
setDataView,
injectCurrentTab,
getCurrentTab,

View file

@ -108,6 +108,7 @@ export const initializeSession: InternalStateThunkActionCreator<
* Session initialization
*/
// TODO: Needs to happen when switching tabs too?
if (customizationContext.displayMode === 'standalone' && persistedDiscoverSession) {
if (persistedDiscoverSession.id) {
services.chrome.recentlyAccessed.add(

View file

@ -9,6 +9,7 @@
import type { TabbedContentState } from '@kbn/unified-tabs/src/components/tabbed_content/tabbed_content';
import { cloneDeep, differenceBy } from 'lodash';
import type { QueryState } from '@kbn/data-plugin/common';
import type { TabState } from '../types';
import { selectAllTabs, selectTab } from '../selectors';
import {
@ -18,6 +19,9 @@ import {
type InternalStateThunkActionCreator,
} from '../internal_state';
import { createTabRuntimeState, selectTabRuntimeState } from '../runtime_state';
import { APP_STATE_URL_KEY } from '../../../../../../common';
import { GLOBAL_STATE_URL_KEY } from '../../discover_global_state_container';
import type { DiscoverAppState } from '../../discover_app_state_container';
export const setTabs: InternalStateThunkActionCreator<
[Parameters<typeof internalStateSlice.actions.setTabs>[0]]
@ -52,53 +56,60 @@ export const updateTabs: InternalStateThunkActionCreator<[TabbedContentState], P
if (selectedItem?.id !== currentTab.id) {
const previousTabRuntimeState = selectTabRuntimeState(runtimeStateManager, currentTab.id);
const previousTabStateContainer = previousTabRuntimeState.stateContainer$.getValue();
previousTabRuntimeState.stateContainer$.getValue()?.actions.stopSyncing();
previousTabStateContainer?.actions.stopSyncing();
updatedTabs = updatedTabs.map((tab) =>
tab.id === currentTab.id
? {
...tab,
globalState: urlStateStorage.get('_g') ?? undefined,
appState: urlStateStorage.get('_a') ?? undefined,
}
: tab
);
updatedTabs = updatedTabs.map((tab) => {
if (tab.id !== currentTab.id) {
return tab;
}
const {
time: timeRange,
refreshInterval,
filters,
} = previousTabStateContainer?.globalState.get() ?? {};
return { ...tab, lastPersistedGlobalState: { timeRange, refreshInterval, filters } };
});
const nextTab = selectedItem ? selectTab(currentState, selectedItem.id) : undefined;
if (nextTab) {
await urlStateStorage.set('_g', nextTab.globalState);
await urlStateStorage.set('_a', nextTab.appState);
} else {
await urlStateStorage.set('_g', null);
await urlStateStorage.set('_a', null);
}
const nextTabRuntimeState = selectedItem
? selectTabRuntimeState(runtimeStateManager, selectedItem.id)
: undefined;
const nextTabStateContainer = nextTabRuntimeState?.stateContainer$.getValue();
if (nextTabStateContainer) {
if (nextTab && nextTabStateContainer) {
const {
time,
timeRange,
refreshInterval,
filters: globalFilters,
} = nextTabStateContainer.globalState.get() ?? {};
const { filters: appFilters, query } = nextTabStateContainer.appState.getState();
} = nextTab.lastPersistedGlobalState;
const appState = nextTabStateContainer.appState.getState();
const { filters: appFilters, query } = appState;
services.timefilter.setTime(time ?? services.timefilter.getTimeDefaults());
await urlStateStorage.set<QueryState>(GLOBAL_STATE_URL_KEY, {
time: timeRange,
refreshInterval,
filters: globalFilters,
});
await urlStateStorage.set<DiscoverAppState>(APP_STATE_URL_KEY, appState);
services.timefilter.setTime(timeRange ?? services.timefilter.getTimeDefaults());
services.timefilter.setRefreshInterval(
refreshInterval ?? services.timefilter.getRefreshIntervalDefaults()
);
services.filterManager.setGlobalFilters(globalFilters ?? []);
services.filterManager.setGlobalFilters(cloneDeep(globalFilters ?? []));
services.filterManager.setAppFilters(cloneDeep(appFilters ?? []));
services.data.query.queryString.setQuery(
query ?? services.data.query.queryString.getDefaultQuery()
);
nextTabStateContainer.actions.initializeAndSync();
} else {
await urlStateStorage.set(GLOBAL_STATE_URL_KEY, null);
await urlStateStorage.set(APP_STATE_URL_KEY, null);
}
}

View file

@ -28,7 +28,7 @@ describe('InternalStateStore', () => {
runtimeStateManager,
urlStateStorage: createKbnUrlStateStorage(),
});
const tabId = store.getState().tabs.allIds[0];
const tabId = store.getState().tabs.unsafeCurrentId;
expect(selectTab(store.getState(), tabId).dataViewId).toBeUndefined();
expect(
selectTabRuntimeState(runtimeStateManager, tabId).currentDataView$.value

View file

@ -32,6 +32,7 @@ import { selectAllTabs, selectTab } from './selectors';
import { createTabItem } from './utils';
export const defaultTabState: Omit<TabState, keyof TabItem> = {
lastPersistedGlobalState: {},
dataViewId: undefined,
isDataViewLoading: false,
dataRequestParams: {},

View file

@ -7,9 +7,10 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { RefreshInterval } from '@kbn/data-plugin/common';
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 { Filter, TimeRange } from '@kbn/es-query';
import type { UnifiedHistogramVisContext } from '@kbn/unified-histogram-plugin/public';
import type { TabItem } from '@kbn/unified-tabs';
@ -45,8 +46,11 @@ export interface InternalStateDataRequestParams {
}
export interface TabState extends TabItem {
globalState?: Record<string, unknown>;
appState?: Record<string, unknown>;
lastPersistedGlobalState: {
timeRange?: TimeRange;
refreshInterval?: RefreshInterval;
filters?: Filter[];
};
dataViewId: string | undefined;
isDataViewLoading: boolean;
dataRequestParams: InternalStateDataRequestParams;

View file

@ -7,29 +7,25 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { waitFor, renderHook } from '@testing-library/react';
import { waitFor } from '@testing-library/react';
import type { DataViewsContract } from '@kbn/data-plugin/public';
import { discoverServiceMock } from '../../../__mocks__/services';
import { useEsqlMode } from './use_esql_mode';
import { FetchStatus } from '../../types';
import type { DataTableRecord } from '@kbn/discover-utils/types';
import type { AggregateQuery, Query } from '@kbn/es-query';
import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
import type { DataViewListItem } from '@kbn/data-views-plugin/common';
import { savedSearchMock } from '../../../__mocks__/saved_search';
import { getDiscoverStateMock } from '../../../__mocks__/discover_state.mock';
import { DiscoverMainProvider } from '../state_management/discover_state_provider';
import type { DiscoverAppState } from '../state_management/discover_app_state_container';
import type { DiscoverStateContainer } from '../state_management/discover_state';
import { VIEW_MODE } from '@kbn/saved-search-plugin/public';
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 { CurrentTabProvider, internalStateActions } from '../state_management/redux';
import { discoverServiceMock } from '../../../../__mocks__/services';
import { FetchStatus } from '../../../types';
import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock';
import { savedSearchMock } from '../../../../__mocks__/saved_search';
import { internalStateActions } from '../redux';
import type { DiscoverAppState } from '../discover_app_state_container';
import { dataViewAdHoc } from '../../../../__mocks__/data_view_complex';
async function getHookProps(
async function getTestProps(
query: AggregateQuery | Query | undefined,
dataViewsService: DataViewsContract = discoverServiceMock.dataViews,
appState?: Partial<DiscoverAppState>,
@ -56,6 +52,7 @@ async function getHookProps(
replaceUrlState,
};
}
const query = { esql: 'from the-data-view-title' };
const msgComplete = {
fetchStatus: FetchStatus.PARTIAL,
@ -80,45 +77,35 @@ const getDataViewsService = () => {
};
};
const getHookContext = (stateContainer: DiscoverStateContainer) => {
return ({ children }: React.PropsWithChildren) => (
<CurrentTabProvider currentTabId={stateContainer.getCurrentTab().id}>
<DiscoverMainProvider value={stateContainer}>
<>{children}</>
</DiscoverMainProvider>
</CurrentTabProvider>
);
};
const renderHookWithContext = async (
const setupTest = async (
useDataViewsService: boolean = false,
appState?: DiscoverAppState,
defaultFetchStatus?: FetchStatus
) => {
const props = await getHookProps(
const props = await getTestProps(
query,
useDataViewsService ? getDataViewsService() : undefined,
appState,
defaultFetchStatus
);
props.stateContainer.actions.setDataView(dataViewMock);
renderHook(() => useEsqlMode(props), {
wrapper: getHookContext(props.stateContainer),
});
return props;
};
describe('useEsqlMode', () => {
// Testing buildEsqlFetchSubscribe through the state container
// since the logic is pretty intertwined with the state management
describe('buildEsqlFetchSubscribe', () => {
test('an ES|QL query should change state when loading and finished', async () => {
const { replaceUrlState, stateContainer } = await renderHookWithContext(true);
const { replaceUrlState, stateContainer } = await setupTest(true);
replaceUrlState.mockReset();
stateContainer.dataState.data$.documents$.next(msgComplete);
expect(replaceUrlState).toHaveBeenCalledTimes(0);
});
test('should not change viewMode to undefined (default) if it was AGGREGATED_LEVEL', async () => {
const { replaceUrlState } = await renderHookWithContext(false, {
const { replaceUrlState } = await setupTest(false, {
viewMode: VIEW_MODE.AGGREGATED_LEVEL,
});
@ -126,7 +113,7 @@ describe('useEsqlMode', () => {
});
test('should change viewMode to undefined (default) if it was PATTERN_LEVEL', async () => {
const { replaceUrlState } = await renderHookWithContext(false, {
const { replaceUrlState } = await setupTest(false, {
viewMode: VIEW_MODE.PATTERN_LEVEL,
});
@ -137,9 +124,9 @@ describe('useEsqlMode', () => {
});
test('changing an ES|QL query with different result columns should change state when loading and finished', async () => {
const { replaceUrlState, stateContainer } = await renderHookWithContext(false);
const { replaceUrlState, stateContainer } = await setupTest(false);
const documents$ = stateContainer.dataState.data$.documents$;
stateContainer.dataState.data$.documents$.next(msgComplete);
documents$.next(msgComplete);
replaceUrlState.mockReset();
documents$.next({
@ -164,9 +151,9 @@ describe('useEsqlMode', () => {
});
test('changing an ES|QL query with same result columns but a different index pattern should change state when loading and finished', async () => {
const { replaceUrlState, stateContainer } = await renderHookWithContext(false);
const { replaceUrlState, stateContainer } = await setupTest(false);
const documents$ = stateContainer.dataState.data$.documents$;
stateContainer.dataState.data$.documents$.next(msgComplete);
documents$.next(msgComplete);
replaceUrlState.mockReset();
documents$.next({
@ -190,9 +177,9 @@ describe('useEsqlMode', () => {
});
test('changing a ES|QL query with no transformational commands should not change state when loading and finished if index pattern is the same', async () => {
const { replaceUrlState, stateContainer } = await renderHookWithContext(false);
const { replaceUrlState, stateContainer } = await setupTest(false);
const documents$ = stateContainer.dataState.data$.documents$;
stateContainer.dataState.data$.documents$.next(msgComplete);
documents$.next(msgComplete);
await waitFor(() => expect(replaceUrlState).toHaveBeenCalledTimes(0));
replaceUrlState.mockReset();
@ -231,8 +218,7 @@ describe('useEsqlMode', () => {
});
test('only changing an ES|QL query with same result columns should not change columns', async () => {
const { replaceUrlState, stateContainer } = await renderHookWithContext(false);
const { replaceUrlState, stateContainer } = await setupTest(false);
const documents$ = stateContainer.dataState.data$.documents$;
documents$.next(msgComplete);
@ -272,8 +258,9 @@ describe('useEsqlMode', () => {
await waitFor(() => expect(replaceUrlState).toHaveBeenCalledTimes(0));
});
test('if its not an ES|QL query coming along, it should be ignored', async () => {
const { replaceUrlState, stateContainer } = await renderHookWithContext(false);
const { replaceUrlState, stateContainer } = await setupTest(false);
const documents$ = stateContainer.dataState.data$.documents$;
documents$.next(msgComplete);
@ -311,7 +298,7 @@ describe('useEsqlMode', () => {
});
test('it should not overwrite existing state columns on initial fetch', async () => {
const { replaceUrlState, stateContainer } = await renderHookWithContext(false, {
const { replaceUrlState, stateContainer } = await setupTest(false, {
columns: ['field1'],
});
const documents$ = stateContainer.dataState.data$.documents$;
@ -355,7 +342,7 @@ describe('useEsqlMode', () => {
});
test('it should not overwrite existing state columns on initial fetch and non transformational commands', async () => {
const { replaceUrlState, stateContainer } = await renderHookWithContext(false, {
const { replaceUrlState, stateContainer } = await setupTest(false, {
columns: ['field1'],
});
const documents$ = stateContainer.dataState.data$.documents$;
@ -375,8 +362,7 @@ describe('useEsqlMode', () => {
});
test('it should overwrite existing state columns on transitioning from a query with non transformational commands to a query with transformational', async () => {
const { replaceUrlState, stateContainer } = await renderHookWithContext(false, {});
const { replaceUrlState, stateContainer } = await setupTest(false, {});
const documents$ = stateContainer.dataState.data$.documents$;
documents$.next({
@ -409,7 +395,7 @@ describe('useEsqlMode', () => {
});
test('it should not overwrite state column when successfully fetching after an error fetch', async () => {
const { replaceUrlState, stateContainer } = await renderHookWithContext(false, {
const { replaceUrlState, stateContainer } = await setupTest(false, {
columns: [],
});
const documents$ = stateContainer.dataState.data$.documents$;
@ -469,12 +455,8 @@ describe('useEsqlMode', () => {
});
test('changing an ES|QL query with an index pattern that not corresponds to a dataview should return results', async () => {
const props = await getHookProps(query, discoverServiceMock.dataViews);
const { stateContainer, replaceUrlState } = props;
const { stateContainer, replaceUrlState } = await setupTest(false);
const documents$ = stateContainer.dataState.data$.documents$;
props.stateContainer.actions.setDataView(dataViewMock);
renderHook(() => useEsqlMode(props), { wrapper: getHookContext(stateContainer) });
documents$.next(msgComplete);
await waitFor(() => expect(replaceUrlState).toHaveBeenCalledTimes(0));
@ -491,7 +473,7 @@ describe('useEsqlMode', () => {
],
query: { esql: 'from the-data-view-* | keep field1' },
});
props.stateContainer.actions.setDataView(dataViewAdHoc);
stateContainer.actions.setDataView(dataViewAdHoc);
await waitFor(() => expect(replaceUrlState).toHaveBeenCalledTimes(1));
await waitFor(() => {
@ -502,7 +484,7 @@ describe('useEsqlMode', () => {
});
it('should call setResetDefaultProfileState correctly when index pattern changes', async () => {
const { stateContainer } = await renderHookWithContext(
const { stateContainer } = await setupTest(
false,
{ query: { esql: 'from pattern' } },
FetchStatus.LOADING
@ -577,7 +559,7 @@ describe('useEsqlMode', () => {
});
it('should call setResetDefaultProfileState correctly when columns change', async () => {
const { stateContainer } = await renderHookWithContext(false);
const { stateContainer } = await setupTest(false);
const documents$ = stateContainer.dataState.data$.documents$;
const result1 = [buildDataTableRecord({ message: 'foo' } as EsHitRecord)];
const result2 = [buildDataTableRecord({ message: 'foo', extension: 'bar' } as EsHitRecord)];

View file

@ -0,0 +1,190 @@
/*
* 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 { isOfAggregateQueryType } from '@kbn/es-query';
import { getIndexPatternFromESQLQuery, hasTransformationalCommand } from '@kbn/esql-utils';
import { isEqual } from 'lodash';
import type { DataDocumentsMsg, SavedSearchData } from '../discover_data_state_container';
import { FetchStatus } from '../../../types';
import type { DiscoverAppStateContainer } from '../discover_app_state_container';
import type { InternalStateStore, TabActionInjector } from '../redux';
import { internalStateActions } from '../redux';
import { getValidViewMode } from '../../utils/get_valid_view_mode';
const ESQL_MAX_NUM_OF_COLUMNS = 50;
/*
* Takes care of ES|QL state transformations when a new result is returned
* If necessary this is setting displayed columns and selected data view
*/
export const buildEsqlFetchSubscribe = ({
internalState,
appStateContainer,
dataSubjects,
injectCurrentTab,
}: {
internalState: InternalStateStore;
appStateContainer: DiscoverAppStateContainer;
dataSubjects: SavedSearchData;
injectCurrentTab: TabActionInjector;
}) => {
let prevEsqlData: {
initialFetch: boolean;
query: string;
allColumns: string[];
defaultColumns: string[];
} = {
initialFetch: true,
query: '',
allColumns: [],
defaultColumns: [],
};
const cleanupEsql = () => {
if (!prevEsqlData.query) {
return;
}
// cleanup when it's not an ES|QL query
prevEsqlData = {
initialFetch: true,
query: '',
allColumns: [],
defaultColumns: [],
};
};
const esqlFetchSubscribe = async (next: DataDocumentsMsg) => {
const { query: nextQuery } = next;
if (!nextQuery) {
return;
}
if (!isOfAggregateQueryType(nextQuery)) {
// cleanup for a "regular" query
cleanupEsql();
return;
}
// We need to reset the default profile state on index pattern changes
// when loading starts to ensure the correct pre fetch state is available
// before data fetching is triggered
if (next.fetchStatus === FetchStatus.LOADING) {
// We have to grab the current query from appState
// here since nextQuery has not been updated yet
const appStateQuery = appStateContainer.getState().query;
if (isOfAggregateQueryType(appStateQuery)) {
if (prevEsqlData.initialFetch) {
prevEsqlData.query = appStateQuery.esql;
}
const indexPatternChanged =
getIndexPatternFromESQLQuery(appStateQuery.esql) !==
getIndexPatternFromESQLQuery(prevEsqlData.query);
// Reset all default profile state when index pattern changes
if (indexPatternChanged) {
internalState.dispatch(
injectCurrentTab(internalStateActions.setResetDefaultProfileState)({
resetDefaultProfileState: {
columns: true,
rowHeight: true,
breakdownField: true,
},
})
);
}
}
return;
}
if (next.fetchStatus === FetchStatus.ERROR) {
// An error occurred, but it's still considered an initial fetch
prevEsqlData.initialFetch = false;
return;
}
if (next.fetchStatus !== FetchStatus.PARTIAL) {
return;
}
let nextAllColumns = prevEsqlData.allColumns;
let nextDefaultColumns = prevEsqlData.defaultColumns;
if (next.result?.length) {
nextAllColumns = Object.keys(next.result[0].raw);
if (hasTransformationalCommand(nextQuery.esql)) {
nextDefaultColumns = nextAllColumns.slice(0, ESQL_MAX_NUM_OF_COLUMNS);
} else {
nextDefaultColumns = [];
}
}
if (prevEsqlData.initialFetch) {
prevEsqlData.initialFetch = false;
prevEsqlData.query = nextQuery.esql;
prevEsqlData.allColumns = nextAllColumns;
prevEsqlData.defaultColumns = nextDefaultColumns;
}
const indexPatternChanged =
getIndexPatternFromESQLQuery(nextQuery.esql) !==
getIndexPatternFromESQLQuery(prevEsqlData.query);
const allColumnsChanged = !isEqual(nextAllColumns, prevEsqlData.allColumns);
const changeDefaultColumns =
indexPatternChanged || !isEqual(nextDefaultColumns, prevEsqlData.defaultColumns);
const { viewMode } = appStateContainer.getState();
const changeViewMode = viewMode !== getValidViewMode({ viewMode, isEsqlMode: true });
// If the index pattern hasn't changed, but the available columns have changed
// due to transformational commands, reset the associated default profile state
if (!indexPatternChanged && allColumnsChanged) {
internalState.dispatch(
injectCurrentTab(internalStateActions.setResetDefaultProfileState)({
resetDefaultProfileState: {
columns: true,
rowHeight: false,
breakdownField: false,
},
})
);
}
prevEsqlData.allColumns = nextAllColumns;
if (indexPatternChanged || changeDefaultColumns || changeViewMode) {
prevEsqlData.query = nextQuery.esql;
prevEsqlData.defaultColumns = nextDefaultColumns;
// just change URL state if necessary
if (changeDefaultColumns || changeViewMode) {
const nextState = {
...(changeDefaultColumns && { columns: nextDefaultColumns }),
...(changeViewMode && { viewMode: undefined }),
};
await appStateContainer.replaceUrlState(nextState);
}
}
dataSubjects.documents$.next({
...next,
fetchStatus: FetchStatus.COMPLETE,
});
};
return { esqlFetchSubscribe, cleanupEsql };
};

View file

@ -10,3 +10,6 @@
export const ADHOC_DATA_VIEW_RENDER_EVENT = 'ad_hoc_data_view';
export const SEARCH_SESSION_ID_QUERY_PARAM = 'searchSessionId';
// TEMPORARY: This is a temporary flag to enable/disable tabs in Discover until the feature is fully implemented.
export const TABS_ENABLED = false;