[Discover] Add a default "All logs" temporary data view in the Observability Solution view (#205991)

## Summary

This PR adds an "All logs" ad hoc (temporary) data view to the Discover
Observability root profile based on the central log sources setting,
allowing quick access to logs (with the most up to date log sources)
without needing to first manually create a data view:
![CleanShot 2025-01-22 at 17 47
19@2x](https://github.com/user-attachments/assets/2c03ec79-0cf9-414e-8883-130599989c25)

To support this, a new `getDefaultAdHocDataViews` extension point has
been added to Discover, allowing profiles to specify an array of ad hoc
data view specs would should be created by default when the profile is
resolved, and automatically cleaned up when the profile changes or the
user leaves Discover.

Resolves #201669.
Resolves #189166.

### Notes

- The "All logs" ad hoc data view should only appear when using the
Observability Solution view (in any deployment type).
- Data view specs returned from `getDefaultAdHocDataViews` must include
consistent IDs across resolutions in order for Discover to manage them
correctly (e.g. to find and reload the data view after a page refresh).
Situations where we'd expect a change in ID (e.g. when saving to a
Discover session) are handled internally by Discover.
- To avoid a breaking change, the returned ad hoc data views have no
impact on the default data view shown when navigating to Discover. If
any persisted data views exist, one of them will be used as the default.
If no persisted data views exist, the first entry of the array returned
by `getDefaultAdHocDataViews` will be used as the default.
- We still want to notify users in Discover when they have no ES data at
all, and prompt them to install integrations. For this reason, the "no
data" page is still shown in Discover even if there are default profile
ad hoc data views (unlike if there are persisted data views, in which
case we use the default and hide the "no data" page).
- When saving a Discover session that uses a default profile ad hoc data
view, the data view will be copied on save as `{DATA_VIEW_NAME} (copy)`.
This allows us to assign a unique ID to the version that gets saved with
the Discover session, and avoids having to choose between the profile
data view or the embedded data view when reopening the session, which
has drawbacks:
- If choosing the profile data view, the Discover session may display
incorrectly if the log sources setting changed since it was saved, and
the user would no longer be able to view the session as it was intended
without first modifying the setting to the expected value.
- If choosing the embedded data view, the replacement shown after
opening the Discover session may not reflect the latest log sources
setting until a new session is started, and there would be no way for
the user to migrate the session to use the latest version of the profile
data view.

### Checklist

- [x] 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.
- [x] [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-01-29 11:04:32 -04:00 committed by GitHub
parent e758f32cec
commit bf9d34465e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 1394 additions and 306 deletions

View file

@ -10,5 +10,8 @@
import { DEFAULT_ALLOWED_LOGS_BASE_PATTERNS_REGEXP, getLogsContextService } from '../data_types';
export const createLogsContextServiceMock = () => {
return getLogsContextService([DEFAULT_ALLOWED_LOGS_BASE_PATTERNS_REGEXP]);
return getLogsContextService({
allLogsIndexPattern: 'logs-*',
allowedDataSources: [DEFAULT_ALLOWED_LOGS_BASE_PATTERNS_REGEXP],
});
};

View file

@ -11,6 +11,7 @@ import { createRegExpPatternFrom, testPatternAgainstAllowedList } from '@kbn/dat
import type { LogsDataAccessPluginStart } from '@kbn/logs-data-access-plugin/public';
export interface LogsContextService {
getAllLogsIndexPattern(): string | undefined;
isLogsIndexPattern(indexPattern: unknown): boolean;
}
@ -32,15 +33,18 @@ export const DEFAULT_ALLOWED_LOGS_BASE_PATTERNS_REGEXP = createRegExpPatternFrom
'data'
);
export const createLogsContextService = async ({ logsDataAccess }: LogsContextServiceDeps) => {
export const createLogsContextService = async ({
logsDataAccess,
}: LogsContextServiceDeps): Promise<LogsContextService> => {
let allLogsIndexPattern: string | undefined;
let logSources: string[] | undefined;
if (logsDataAccess) {
const logSourcesService = logsDataAccess.services.logSourcesService;
logSources = (await logSourcesService.getLogSources())
allLogsIndexPattern = (await logSourcesService.getLogSources())
.map((logSource) => logSource.indexPattern)
.join(',') // TODO: Will be replaced by helper in: https://github.com/elastic/kibana/pull/192003
.split(',');
.join(','); // TODO: Will be replaced by helper in: https://github.com/elastic/kibana/pull/192003
logSources = allLogsIndexPattern.split(',');
}
const ALLOWED_LOGS_DATA_SOURCES = [
@ -48,10 +52,20 @@ export const createLogsContextService = async ({ logsDataAccess }: LogsContextSe
...(logSources ? logSources : []),
];
return getLogsContextService(ALLOWED_LOGS_DATA_SOURCES);
return getLogsContextService({
allLogsIndexPattern,
allowedDataSources: ALLOWED_LOGS_DATA_SOURCES,
});
};
export const getLogsContextService = (allowedDataSources: Array<string | RegExp>) => {
export const getLogsContextService = ({
allLogsIndexPattern,
allowedDataSources,
}: {
allLogsIndexPattern: string | undefined;
allowedDataSources: Array<string | RegExp>;
}): LogsContextService => {
const getAllLogsIndexPattern = () => allLogsIndexPattern;
const isLogsIndexPattern = (indexPattern: unknown) => {
return (
typeof indexPattern === 'string' &&
@ -59,7 +73,5 @@ export const getLogsContextService = (allowedDataSources: Array<string | RegExp>
);
};
return {
isLogsIndexPattern,
};
return { getAllLogsIndexPattern, isLogsIndexPattern };
};

View file

@ -104,6 +104,7 @@ export {
uniqFilters,
unpinFilter,
updateFilter,
updateFilterReferences,
extractTimeFilter,
extractTimeRange,
convertRangeFilterToTimeRange,

View file

@ -15,4 +15,5 @@ export * from './meta_filter';
export * from './only_disabled';
export * from './extract_time_filter';
export * from './convert_range_filter';
export * from './update_filter_references';
export * from './types';

View file

@ -0,0 +1,30 @@
/*
* 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 { Filter } from '..';
export function updateFilterReferences(
filters: Filter[],
fromDataView: string,
toDataView: string | undefined
) {
return (filters || []).map((filter) => {
if (filter.meta.index === fromDataView) {
return {
...filter,
meta: {
...filter.meta,
index: toDataView,
},
};
} else {
return filter;
}
});
}

View file

@ -31,6 +31,7 @@ export {
extractTimeFilter,
extractTimeRange,
convertRangeFilterToTimeRange,
updateFilterReferences,
} from './helpers';
export {

View file

@ -14,13 +14,16 @@ import { dataViewWithTimefieldMock } from './data_view_with_timefield';
import { dataViewAdHoc } from './data_view_complex';
import { dataViewEsql } from './data_view_esql';
export const savedSearchMock = {
id: 'the-saved-search-id',
title: 'A saved search',
searchSource: createSearchSourceMock({ index: dataViewMock }),
columns: ['default_column'],
sort: [],
} as unknown as SavedSearch;
export const createSavedSearchMock = () =>
({
id: 'the-saved-search-id',
title: 'A saved search',
searchSource: createSearchSourceMock({ index: dataViewMock }),
columns: ['default_column'],
sort: [],
} as unknown as SavedSearch);
export const savedSearchMock = createSavedSearchMock();
export const savedSearchMockWithTimeField = {
id: 'the-saved-search-id-with-timefield',
@ -40,7 +43,10 @@ export const savedSearchMockWithESQL = {
isTextBasedQuery: true,
} as unknown as SavedSearch;
export const savedSearchAdHoc = {
id: 'the-saved-search-with-ad-hoc',
searchSource: createSearchSourceMock({ index: dataViewAdHoc }),
} as unknown as SavedSearch;
export const createSavedSearchAdHocMock = () =>
({
id: 'the-saved-search-with-ad-hoc',
searchSource: createSearchSourceMock({ index: dataViewAdHoc }),
} as unknown as SavedSearch);
export const savedSearchAdHoc = createSavedSearchAdHocMock();

View file

@ -43,11 +43,11 @@ import { LocalStorageMock } from './local_storage_mock';
import { createDiscoverDataViewsMock } from './data_views';
import { SearchSourceDependencies } from '@kbn/data-plugin/common';
import { SearchResponse } from '@elastic/elasticsearch/lib/api/types';
import { urlTrackerMock } from './url_tracker.mock';
import { createElement } from 'react';
import { createContextAwarenessMocks } from '../context_awareness/__mocks__';
import { DiscoverEBTManager } from '../services/discover_ebt_manager';
import { discoverSharedPluginMock } from '@kbn/discover-shared-plugin/public/mocks';
import { createUrlTrackerMock } from './url_tracker.mock';
export function createDiscoverServicesMock(): DiscoverServices {
const dataPlugin = dataPluginMock.createStartContract();
@ -247,7 +247,7 @@ export function createDiscoverServicesMock(): DiscoverServices {
},
contextLocator: { getRedirectUrl: jest.fn(() => '') },
singleDocLocator: { getRedirectUrl: jest.fn(() => '') },
urlTracker: urlTrackerMock,
urlTracker: createUrlTrackerMock(),
profilesManager: profilesManagerMock,
ebtManager: new DiscoverEBTManager(),
setHeaderActionMenu: jest.fn(),

View file

@ -9,8 +9,9 @@
import { UrlTracker } from '../build_services';
export const urlTrackerMock = {
setTrackedUrl: jest.fn(),
restorePreviousUrl: jest.fn(),
setTrackingEnabled: jest.fn(),
} as UrlTracker;
export const createUrlTrackerMock = () =>
({
setTrackedUrl: jest.fn(),
restorePreviousUrl: jest.fn(),
setTrackingEnabled: jest.fn(),
} as UrlTracker);

View file

@ -34,6 +34,7 @@ import { buildDataTableRecord } from '@kbn/discover-utils';
import type { DataTableRecord } from '@kbn/discover-utils/types';
import type { DiscoverCustomizationId } from '../../../../customizations/customization_service';
import { FieldListCustomization, SearchBarCustomization } from '../../../../customizations';
import { InternalStateProvider } from '../../state_management/discover_internal_state_container';
const mockSearchBarCustomization: SearchBarCustomization = {
id: 'search_bar',
@ -177,13 +178,13 @@ function getCompProps(options?: { hits?: DataTableRecord[] }): DiscoverSidebarRe
};
}
function getAppStateContainer({ query }: { query?: Query | AggregateQuery }) {
const appStateContainer = getDiscoverStateMock({ isTimeBased: true }).appState;
appStateContainer.set({
function getStateContainer({ query }: { query?: Query | AggregateQuery }) {
const stateContainer = getDiscoverStateMock({ isTimeBased: true });
stateContainer.appState.set({
query: query ?? { query: '', language: 'lucene' },
filters: [],
});
return appStateContainer;
return stateContainer;
}
async function mountComponent(
@ -192,7 +193,7 @@ async function mountComponent(
services?: DiscoverServices
): Promise<ReactWrapper<DiscoverSidebarResponsiveProps>> {
let comp: ReactWrapper<DiscoverSidebarResponsiveProps>;
const appState = getAppStateContainer(appStateParams);
const { appState, internalState } = getStateContainer(appStateParams);
const mockedServices = services ?? createMockServices();
mockedServices.data.dataViews.getIdsWithTitle = jest.fn(async () =>
props.selectedDataView
@ -208,7 +209,9 @@ async function mountComponent(
comp = mountWithIntl(
<KibanaContextProvider services={mockedServices}>
<DiscoverAppStateProvider value={appState}>
<DiscoverSidebarResponsive {...props} />
<InternalStateProvider value={internalState}>
<DiscoverSidebarResponsive {...props} />
</InternalStateProvider>
</DiscoverAppStateProvider>
</KibanaContextProvider>
);

View file

@ -37,6 +37,10 @@ import {
import { useDiscoverCustomization } from '../../../../customizations';
import { useAdditionalFieldGroups } from '../../hooks/sidebar/use_additional_field_groups';
import { useIsEsqlMode } from '../../hooks/use_is_esql_mode';
import {
selectDataViewsForPicker,
useInternalStateSelector,
} from '../../state_management/discover_internal_state_container';
const EMPTY_FIELD_COUNTS = {};
@ -172,6 +176,8 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
);
const selectedDataViewRef = useRef<DataView | null | undefined>(selectedDataView);
const showFieldList = sidebarState.status !== DiscoverSidebarReducerStatus.INITIAL;
const { savedDataViews, managedDataViews, adHocDataViews } =
useInternalStateSelector(selectDataViewsForPicker);
useEffect(() => {
const subscription = props.documents$.subscribe((documentState) => {
@ -326,6 +332,9 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
) : (
<DataViewPicker
currentDataViewId={selectedDataView.id}
adHocDataViews={adHocDataViews}
managedDataViews={managedDataViews}
savedDataViews={savedDataViews}
onChangeDataView={onChangeDataView}
onAddField={createField}
onDataViewCreated={createNewDataView}
@ -338,7 +347,16 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
/>
)
) : null;
}, [selectedDataView, createNewDataView, onChangeDataView, createField, CustomDataViewPicker]);
}, [
selectedDataView,
CustomDataViewPicker,
adHocDataViews,
managedDataViews,
savedDataViews,
onChangeDataView,
createField,
createNewDataView,
]);
const onAddFieldToWorkspace = useCallback(
(field: DataViewField) => {

View file

@ -15,7 +15,10 @@ import { TextBasedLanguages } from '@kbn/esql-utils';
import { DiscoverFlyouts, dismissAllFlyoutsExceptFor } from '@kbn/discover-utils';
import { useSavedSearchInitial } from '../../state_management/discover_state_provider';
import { ESQL_TRANSITION_MODAL_KEY } from '../../../../../common/constants';
import { useInternalStateSelector } from '../../state_management/discover_internal_state_container';
import {
selectDataViewsForPicker,
useInternalStateSelector,
} from '../../state_management/discover_internal_state_container';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
import type { DiscoverStateContainer } from '../../state_management/discover_state';
import { onSaveSearch } from './on_save_search';
@ -49,9 +52,9 @@ export const DiscoverTopNav = ({
const { dataViewEditor, navigation, dataViewFieldEditor, data, uiSettings, setHeaderActionMenu } =
services;
const query = useAppStateSelector((state) => state.query);
const adHocDataViews = useInternalStateSelector((state) => state.adHocDataViews);
const { savedDataViews, managedDataViews, adHocDataViews } =
useInternalStateSelector(selectDataViewsForPicker);
const dataView = useInternalStateSelector((state) => state.dataView!);
const savedDataViews = useInternalStateSelector((state) => state.savedDataViews);
const isESQLToDataViewTransitionModalVisible = useInternalStateSelector(
(state) => state.isESQLToDataViewTransitionModalVisible
);
@ -172,9 +175,7 @@ export const DiscoverTopNav = ({
const dataViewPickerProps: DataViewPickerProps = useMemo(() => {
const isESQLModeEnabled = uiSettings.get(ENABLE_ESQL);
const supportedTextBasedLanguages: DataViewPickerProps['textBasedLanguages'] = isESQLModeEnabled
? [TextBasedLanguages.ESQL]
: [];
const supportedTextBasedLanguages = isESQLModeEnabled ? [TextBasedLanguages.ESQL] : [];
return {
trigger: {
@ -189,6 +190,7 @@ export const DiscoverTopNav = ({
onChangeDataView: stateContainer.actions.onChangeDataView,
textBasedLanguages: supportedTextBasedLanguages,
adHocDataViews,
managedDataViews,
savedDataViews,
onEditDataView: stateContainer.actions.onDataViewEdited,
};
@ -197,6 +199,7 @@ export const DiscoverTopNav = ({
addField,
createNewDataView,
dataView,
managedDataViews,
savedDataViews,
stateContainer,
uiSettings,

View file

@ -72,6 +72,7 @@ describe('test fetchAll', () => {
isDataViewLoading: false,
savedDataViews: [],
adHocDataViews: [],
defaultProfileAdHocDataViewIds: [],
expandedDoc: undefined,
customFilters: [],
overriddenVisContextAfterInvalidation: undefined,
@ -265,6 +266,7 @@ describe('test fetchAll', () => {
isDataViewLoading: false,
savedDataViews: [],
adHocDataViews: [],
defaultProfileAdHocDataViewIds: [],
expandedDoc: undefined,
customFilters: [],
overriddenVisContextAfterInvalidation: undefined,
@ -389,6 +391,7 @@ describe('test fetchAll', () => {
isDataViewLoading: false,
savedDataViews: [],
adHocDataViews: [],
defaultProfileAdHocDataViewIds: [],
expandedDoc: undefined,
customFilters: [],
overriddenVisContextAfterInvalidation: undefined,

View file

@ -36,7 +36,7 @@ export function DiscoverMainApp(props: DiscoverMainProps) {
const services = useDiscoverServices();
const { chrome, docLinks, data, spaces, history } = services;
useUrlTracking(stateContainer.savedSearchState);
useUrlTracking(stateContainer);
/**
* Adhoc data views functionality

View file

@ -21,6 +21,9 @@ import {
DiscoverCustomizationService,
} from '../../customizations/customization_service';
import { mockCustomizationContext } from '../../customizations/__mocks__/customization_context';
import { DataViewSpec } from '@kbn/data-views-plugin/common';
import { MainHistoryLocationState } from '../../../common';
import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
let mockCustomizationService: DiscoverCustomizationService | undefined;
@ -42,6 +45,7 @@ jest.mock('./discover_main_app', () => {
});
let mockRootProfileLoading = false;
let mockDefaultAdHocDataViews: DataViewSpec[] = [];
jest.mock('../../context_awareness', () => {
const originalModule = jest.requireActual('../../context_awareness');
@ -50,6 +54,7 @@ jest.mock('../../context_awareness', () => {
useRootProfile: () => ({
rootProfileLoading: mockRootProfileLoading,
AppWrapper: ({ children }: { children: ReactNode }) => <>{children}</>,
getDefaultAdHocDataViews: () => mockDefaultAdHocDataViews,
}),
};
});
@ -58,6 +63,7 @@ describe('DiscoverMainRoute', () => {
beforeEach(() => {
mockCustomizationService = createCustomizationService();
mockRootProfileLoading = false;
mockDefaultAdHocDataViews = [];
});
test('renders the main app when hasESData=true & hasUserDataView=true ', async () => {
@ -69,6 +75,25 @@ describe('DiscoverMainRoute', () => {
});
});
test('renders the main app when ad hoc data views exist', async () => {
mockDefaultAdHocDataViews = [{ id: 'test', title: 'test' }];
const component = mountComponent(true, false);
await waitFor(() => {
component.update();
expect(component.find(DiscoverMainApp).exists()).toBe(true);
});
});
test('renders the main app when a data view spec is passed through location state', async () => {
const component = mountComponent(true, false, { dataViewSpec: { id: 'test', title: 'test' } });
await waitFor(() => {
component.update();
expect(component.find(DiscoverMainApp).exists()).toBe(true);
});
});
test('renders no data page when hasESData=false & hasUserDataView=false', async () => {
const component = mountComponent(false, false);
@ -127,7 +152,11 @@ describe('DiscoverMainRoute', () => {
});
});
const mountComponent = (hasESData = true, hasUserDataView = true) => {
const mountComponent = (
hasESData = true,
hasUserDataView = true,
locationState?: MainHistoryLocationState
) => {
const props: MainRouteProps = {
customizationCallbacks: [],
customizationContext: mockCustomizationContext,
@ -135,20 +164,30 @@ const mountComponent = (hasESData = true, hasUserDataView = true) => {
return mountWithIntl(
<MemoryRouter>
<KibanaContextProvider services={getServicesMock(hasESData, hasUserDataView)}>
<KibanaContextProvider services={getServicesMock(hasESData, hasUserDataView, locationState)}>
<DiscoverMainRoute {...props} />
</KibanaContextProvider>
</MemoryRouter>
);
};
function getServicesMock(hasESData = true, hasUserDataView = true) {
function getServicesMock(
hasESData = true,
hasUserDataView = true,
locationState?: MainHistoryLocationState
) {
const dataViewsMock = discoverServiceMock.data.dataViews;
dataViewsMock.hasData = {
hasESData: jest.fn(() => Promise.resolve(hasESData)),
hasUserDataView: jest.fn(() => Promise.resolve(hasUserDataView)),
hasDataView: jest.fn(() => Promise.resolve(true)),
};
dataViewsMock.create = jest.fn().mockResolvedValue(dataViewMock);
discoverServiceMock.core.http.get = jest.fn().mockResolvedValue({});
discoverServiceMock.getScopedHistory = jest.fn().mockReturnValue({
location: {
state: locationState,
},
});
return discoverServiceMock;
}

View file

@ -40,7 +40,7 @@ import {
} from '../../customizations';
import { DiscoverStateContainer, LoadParams } from './state_management/discover_state';
import { DataSourceType, isDataSourceType } from '../../../common/data_sources';
import { useRootProfile } from '../../context_awareness';
import { useDefaultAdHocDataViews, useRootProfile } from '../../context_awareness';
const DiscoverMainAppMemoized = memo(DiscoverMainApp);
@ -133,21 +133,30 @@ export function DiscoverMainRoute({
stateContainer.actions.loadDataViewList(),
]);
if (!hasUserDataViewValue || !defaultDataViewExists) {
setNoDataState({
showNoDataPage: true,
hasESData: hasESDataValue,
hasUserDataView: hasUserDataViewValue,
});
return false;
const persistedDataViewsExist = hasUserDataViewValue && defaultDataViewExists;
const adHocDataViewsExist =
stateContainer.internalState.getState().adHocDataViews.length > 0;
const locationStateHasDataViewSpec = Boolean(historyLocationState?.dataViewSpec);
const canAccessWithAdHocDataViews =
hasESDataValue && (adHocDataViewsExist || locationStateHasDataViewSpec);
if (persistedDataViewsExist || canAccessWithAdHocDataViews) {
return true;
}
return true;
setNoDataState({
showNoDataPage: true,
hasESData: hasESDataValue,
hasUserDataView: hasUserDataViewValue,
});
return false;
} catch (e) {
setError(e);
return false;
}
},
[data.dataViews, savedSearchId, stateContainer]
[data.dataViews, historyLocationState?.dataViewSpec, savedSearchId, stateContainer]
);
const loadSavedSearch = useCallback(
@ -227,22 +236,45 @@ export function DiscoverMainRoute({
]
);
const rootProfileState = useRootProfile();
const { initializeProfileDataViews } = useDefaultAdHocDataViews({
stateContainer,
rootProfileState,
});
useEffect(() => {
if (!isCustomizationServiceInitialized) return;
setLoading(true);
setNoDataState({
hasESData: false,
hasUserDataView: false,
showNoDataPage: false,
});
setError(undefined);
if (savedSearchId) {
loadSavedSearch();
} else {
// restore the previously selected data view for a new state (when a saved search was open)
loadSavedSearch(getLoadParamsForNewSearch(stateContainer));
if (!isCustomizationServiceInitialized || rootProfileState.rootProfileLoading) {
return;
}
}, [isCustomizationServiceInitialized, loadSavedSearch, savedSearchId, stateContainer]);
const load = async () => {
setLoading(true);
setNoDataState({
hasESData: false,
hasUserDataView: false,
showNoDataPage: false,
});
setError(undefined);
await initializeProfileDataViews();
if (savedSearchId) {
await loadSavedSearch();
} else {
// restore the previously selected data view for a new state (when a saved search was open)
await loadSavedSearch(getLoadParamsForNewSearch(stateContainer));
}
};
load();
}, [
initializeProfileDataViews,
isCustomizationServiceInitialized,
loadSavedSearch,
rootProfileState.rootProfileLoading,
savedSearchId,
stateContainer,
]);
// secondary fetch: in case URL is set to `/`, used to reset to 'new' state, keeping the current data view
useUrl({
@ -339,8 +371,6 @@ export function DiscoverMainRoute({
stateContainer,
]);
const rootProfileState = useRootProfile();
if (error) {
return <DiscoverError error={error} />;
}

View file

@ -0,0 +1,84 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { renderHook } from '@testing-library/react';
import { useUrlTracking } from './use_url_tracking';
import React from 'react';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { createDiscoverServicesMock } from '../../../__mocks__/services';
import { getDiscoverStateMock } from '../../../__mocks__/discover_state.mock';
import { DiscoverServices } from '../../../build_services';
import { DiscoverStateContainer } from '../state_management/discover_state';
import { omit } from 'lodash';
import { createSavedSearchAdHocMock, createSavedSearchMock } from '../../../__mocks__/saved_search';
const renderUrlTracking = ({
services,
stateContainer,
}: {
services: DiscoverServices;
stateContainer: DiscoverStateContainer;
}) =>
renderHook(useUrlTracking, {
initialProps: stateContainer,
wrapper: ({ children }) => (
<KibanaContextProvider services={services}>{children}</KibanaContextProvider>
),
});
describe('useUrlTracking', () => {
it('should enable URL tracking for a persisted data view', () => {
const services = createDiscoverServicesMock();
const savedSearch = omit(createSavedSearchMock(), 'id');
const stateContainer = getDiscoverStateMock({ savedSearch });
expect(services.urlTracker.setTrackingEnabled).not.toHaveBeenCalled();
renderUrlTracking({ services, stateContainer });
expect(services.urlTracker.setTrackingEnabled).toHaveBeenCalledWith(true);
});
it('should disable URL tracking for an ad hoc data view', () => {
const services = createDiscoverServicesMock();
const savedSearch = omit(createSavedSearchAdHocMock(), 'id');
const stateContainer = getDiscoverStateMock({ savedSearch });
expect(services.urlTracker.setTrackingEnabled).not.toHaveBeenCalled();
renderUrlTracking({ services, stateContainer });
expect(services.urlTracker.setTrackingEnabled).toHaveBeenCalledWith(false);
});
it('should enable URL tracking if the ad hoc data view is a default profile data view', () => {
const services = createDiscoverServicesMock();
const savedSearch = omit(createSavedSearchAdHocMock(), 'id');
const stateContainer = getDiscoverStateMock({ savedSearch });
stateContainer.internalState.transitions.setDefaultProfileAdHocDataViews([
savedSearch.searchSource.getField('index')!,
]);
expect(services.urlTracker.setTrackingEnabled).not.toHaveBeenCalled();
renderUrlTracking({ services, stateContainer });
expect(services.urlTracker.setTrackingEnabled).toHaveBeenCalledWith(true);
});
it('should enable URL tracking with an ad hoc data view if in ES|QL mode', () => {
const services = createDiscoverServicesMock();
const savedSearch = omit(createSavedSearchAdHocMock(), 'id');
savedSearch.searchSource.setField('query', { esql: 'FROM test' });
const stateContainer = getDiscoverStateMock({ savedSearch });
expect(services.urlTracker.setTrackingEnabled).not.toHaveBeenCalled();
renderUrlTracking({ services, stateContainer });
expect(services.urlTracker.setTrackingEnabled).toHaveBeenCalledWith(true);
});
it('should enable URL tracking with an ad hoc data view if the saved search has an ID (persisted)', () => {
const services = createDiscoverServicesMock();
const savedSearch = createSavedSearchAdHocMock();
const stateContainer = getDiscoverStateMock({ savedSearch });
expect(services.urlTracker.setTrackingEnabled).not.toHaveBeenCalled();
renderUrlTracking({ services, stateContainer });
expect(services.urlTracker.setTrackingEnabled).toHaveBeenCalledWith(true);
});
});

View file

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

View file

@ -46,6 +46,7 @@ describe('Test discover app state container', () => {
savedSearchState = getSavedSearchContainer({
services: discoverServiceMock,
globalStateContainer: getDiscoverGlobalStateContainer(stateStorage),
internalStateContainer: internalState,
});
});

View file

@ -17,6 +17,7 @@ import type { DataView, DataViewListItem } from '@kbn/data-views-plugin/common';
import type { Filter, TimeRange } from '@kbn/es-query';
import type { DataTableRecord } from '@kbn/discover-utils/types';
import type { UnifiedHistogramVisContext } from '@kbn/unified-histogram-plugin/public';
import { differenceBy } from 'lodash';
interface InternalStateDataRequestParams {
timeRangeAbsolute?: TimeRange;
@ -28,6 +29,7 @@ export interface InternalState {
isDataViewLoading: boolean;
savedDataViews: DataViewListItem[];
adHocDataViews: DataView[];
defaultProfileAdHocDataViewIds: string[];
expandedDoc: DataTableRecord | undefined;
customFilters: Filter[];
overriddenVisContextAfterInvalidation: UnifiedHistogramVisContext | {} | undefined; // it will be used during saved search saving
@ -46,10 +48,12 @@ export interface InternalStateTransitions {
setIsDataViewLoading: (state: InternalState) => (isLoading: boolean) => InternalState;
setSavedDataViews: (state: InternalState) => (dataView: DataViewListItem[]) => InternalState;
setAdHocDataViews: (state: InternalState) => (dataViews: DataView[]) => InternalState;
setDefaultProfileAdHocDataViews: (
state: InternalState
) => (dataViews: DataView[]) => InternalState;
appendAdHocDataViews: (
state: InternalState
) => (dataViews: DataView | DataView[]) => InternalState;
removeAdHocDataViewById: (state: InternalState) => (id: string) => InternalState;
replaceAdHocDataViewWithId: (
state: InternalState
) => (id: string, dataView: DataView) => InternalState;
@ -90,6 +94,7 @@ export function getInternalStateContainer() {
dataView: undefined,
isDataViewLoading: false,
adHocDataViews: [],
defaultProfileAdHocDataViewIds: [],
savedDataViews: [],
expandedDoc: undefined,
customFilters: [],
@ -126,33 +131,50 @@ export function getInternalStateContainer() {
...prevState,
adHocDataViews: newAdHocDataViewList,
}),
appendAdHocDataViews:
(prevState: InternalState) => (dataViewsAdHoc: DataView | DataView[]) => {
// check for already existing data views
const concatList = (
Array.isArray(dataViewsAdHoc) ? dataViewsAdHoc : [dataViewsAdHoc]
).filter((dataView) => {
return !prevState.adHocDataViews.find((el: DataView) => el.id === dataView.id);
});
if (!concatList.length) {
return prevState;
}
setDefaultProfileAdHocDataViews:
(prevState: InternalState) => (defaultProfileAdHocDataViews: DataView[]) => {
const adHocDataViews = prevState.adHocDataViews
.filter((dataView) => !prevState.defaultProfileAdHocDataViewIds.includes(dataView.id!))
.concat(defaultProfileAdHocDataViews);
const defaultProfileAdHocDataViewIds = defaultProfileAdHocDataViews.map(
(dataView) => dataView.id!
);
return {
...prevState,
adHocDataViews: prevState.adHocDataViews.concat(dataViewsAdHoc),
adHocDataViews,
defaultProfileAdHocDataViewIds,
};
},
appendAdHocDataViews:
(prevState: InternalState) => (dataViewsAdHoc: DataView | DataView[]) => {
const newDataViews = Array.isArray(dataViewsAdHoc) ? dataViewsAdHoc : [dataViewsAdHoc];
const existingDataViews = differenceBy(prevState.adHocDataViews, newDataViews, 'id');
return {
...prevState,
adHocDataViews: existingDataViews.concat(newDataViews),
};
},
removeAdHocDataViewById: (prevState: InternalState) => (id: string) => ({
...prevState,
adHocDataViews: prevState.adHocDataViews.filter((dataView) => dataView.id !== id),
}),
replaceAdHocDataViewWithId:
(prevState: InternalState) => (prevId: string, newDataView: DataView) => ({
...prevState,
adHocDataViews: prevState.adHocDataViews.map((dataView) =>
dataView.id === prevId ? newDataView : dataView
),
}),
(prevState: InternalState) => (prevId: string, newDataView: DataView) => {
let defaultProfileAdHocDataViewIds = prevState.defaultProfileAdHocDataViewIds;
if (defaultProfileAdHocDataViewIds.includes(prevId)) {
defaultProfileAdHocDataViewIds = defaultProfileAdHocDataViewIds.map((id) =>
id === prevId ? newDataView.id! : id
);
}
return {
...prevState,
adHocDataViews: prevState.adHocDataViews.map((dataView) =>
dataView.id === prevId ? newDataView : dataView
),
defaultProfileAdHocDataViewIds,
};
},
setExpandedDoc: (prevState: InternalState) => (expandedDoc: DataTableRecord | undefined) => ({
...prevState,
expandedDoc,
@ -191,3 +213,16 @@ export function getInternalStateContainer() {
{ freeze: (state) => state }
);
}
export const selectDataViewsForPicker = ({
savedDataViews,
adHocDataViews: originalAdHocDataViews,
defaultProfileAdHocDataViewIds,
}: InternalState) => {
const managedDataViews = originalAdHocDataViews.filter(
({ id }) => id && defaultProfileAdHocDataViewIds.includes(id)
);
const adHocDataViews = differenceBy(originalAdHocDataViews, managedDataViews, 'id');
return { savedDataViews, managedDataViews, adHocDataViews };
};

View file

@ -17,20 +17,30 @@ import { getDiscoverGlobalStateContainer } from './discover_global_state_contain
import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import { VIEW_MODE } from '../../../../common/constants';
import { createSearchSourceMock } from '@kbn/data-plugin/common/search/search_source/mocks';
import { getInternalStateContainer } from './discover_internal_state_container';
describe('DiscoverSavedSearchContainer', () => {
const savedSearch = savedSearchMock;
const services = discoverServiceMock;
const globalStateContainer = getDiscoverGlobalStateContainer(createKbnUrlStateStorage());
const internalStateContainer = getInternalStateContainer();
describe('getTitle', () => {
it('returns undefined for new saved searches', () => {
const container = getSavedSearchContainer({ services, globalStateContainer });
const container = getSavedSearchContainer({
services,
globalStateContainer,
internalStateContainer,
});
expect(container.getTitle()).toBe(undefined);
});
it('returns the title of a persisted saved searches', () => {
const container = getSavedSearchContainer({ services, globalStateContainer });
const container = getSavedSearchContainer({
services,
globalStateContainer,
internalStateContainer,
});
container.set(savedSearch);
expect(container.getTitle()).toBe(savedSearch.title);
});
@ -38,7 +48,11 @@ describe('DiscoverSavedSearchContainer', () => {
describe('set', () => {
it('should update the current and initial state of the saved search', () => {
const container = getSavedSearchContainer({ services, globalStateContainer });
const container = getSavedSearchContainer({
services,
globalStateContainer,
internalStateContainer,
});
const newSavedSearch: SavedSearch = { ...savedSearch, title: 'New title' };
const result = container.set(newSavedSearch);
@ -51,7 +65,11 @@ describe('DiscoverSavedSearchContainer', () => {
});
it('should reset hasChanged$ to false', () => {
const container = getSavedSearchContainer({ services, globalStateContainer });
const container = getSavedSearchContainer({
services,
globalStateContainer,
internalStateContainer,
});
const newSavedSearch: SavedSearch = { ...savedSearch, title: 'New title' };
container.set(newSavedSearch);
@ -61,7 +79,11 @@ describe('DiscoverSavedSearchContainer', () => {
describe('new', () => {
it('should create a new saved search', async () => {
const container = getSavedSearchContainer({ services, globalStateContainer });
const container = getSavedSearchContainer({
services,
globalStateContainer,
internalStateContainer,
});
const result = await container.new(dataViewMock);
expect(result.title).toBeUndefined();
@ -74,7 +96,11 @@ describe('DiscoverSavedSearchContainer', () => {
});
it('should create a new saved search with provided DataView', async () => {
const container = getSavedSearchContainer({ services, globalStateContainer });
const container = getSavedSearchContainer({
services,
globalStateContainer,
internalStateContainer,
});
const result = await container.new(dataViewMock);
expect(result.title).toBeUndefined();
expect(result.id).toBeUndefined();
@ -93,6 +119,7 @@ describe('DiscoverSavedSearchContainer', () => {
const savedSearchContainer = getSavedSearchContainer({
services: discoverServiceMock,
globalStateContainer,
internalStateContainer,
});
await savedSearchContainer.load('the-saved-search-id');
expect(savedSearchContainer.getInitial$().getValue().id).toEqual('the-saved-search-id');
@ -108,6 +135,7 @@ describe('DiscoverSavedSearchContainer', () => {
const savedSearchContainer = getSavedSearchContainer({
services: discoverServiceMock,
globalStateContainer,
internalStateContainer,
});
const savedSearchToPersist = {
...savedSearchMockWithTimeField,
@ -133,6 +161,7 @@ describe('DiscoverSavedSearchContainer', () => {
const savedSearchContainer = getSavedSearchContainer({
services: discoverServiceMock,
globalStateContainer,
internalStateContainer,
});
const result = await savedSearchContainer.persist(persistedSavedSearch, saveOptions);
@ -145,6 +174,7 @@ describe('DiscoverSavedSearchContainer', () => {
const savedSearchContainer = getSavedSearchContainer({
services: discoverServiceMock,
globalStateContainer,
internalStateContainer,
});
const savedSearchToPersist = {
...savedSearchMockWithTimeField,
@ -164,6 +194,7 @@ describe('DiscoverSavedSearchContainer', () => {
const savedSearchContainer = getSavedSearchContainer({
services: discoverServiceMock,
globalStateContainer,
internalStateContainer,
});
const savedSearchToPersist = {
...savedSearchMockWithTimeField,
@ -188,6 +219,7 @@ describe('DiscoverSavedSearchContainer', () => {
const savedSearchContainer = getSavedSearchContainer({
services: discoverServiceMock,
globalStateContainer,
internalStateContainer,
});
savedSearchContainer.set(savedSearch);
savedSearchContainer.update({ nextState: { hideChart: true } });
@ -209,6 +241,7 @@ describe('DiscoverSavedSearchContainer', () => {
const savedSearchContainer = getSavedSearchContainer({
services: discoverServiceMock,
globalStateContainer,
internalStateContainer,
});
savedSearchContainer.set(savedSearch);
const updated = savedSearchContainer.update({ nextState: { hideChart: true } });
@ -224,6 +257,7 @@ describe('DiscoverSavedSearchContainer', () => {
const savedSearchContainer = getSavedSearchContainer({
services: discoverServiceMock,
globalStateContainer,
internalStateContainer,
});
const updated = savedSearchContainer.update({ nextDataView: dataViewMock });
expect(savedSearchContainer.getHasChanged$().getValue()).toBe(true);

View file

@ -7,18 +7,20 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { v4 as uuidv4 } from 'uuid';
import { SavedSearch } from '@kbn/saved-search-plugin/public';
import { BehaviorSubject } from 'rxjs';
import { cloneDeep } from 'lodash';
import { COMPARE_ALL_OPTIONS, FilterCompareOptions } from '@kbn/es-query';
import { COMPARE_ALL_OPTIONS, FilterCompareOptions, updateFilterReferences } from '@kbn/es-query';
import type { SearchSourceFields } from '@kbn/data-plugin/common';
import type { DataView } from '@kbn/data-views-plugin/common';
import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/common';
import {
canImportVisContext,
UnifiedHistogramVisContext,
} from '@kbn/unified-histogram-plugin/public';
import { SavedObjectSaveOpts } from '@kbn/saved-objects-plugin/public';
import { isEqual, isFunction } from 'lodash';
import { i18n } from '@kbn/i18n';
import { VIEW_MODE } from '../../../../common/constants';
import { restoreStateFromSavedSearch } from '../../../services/saved_searches/restore_from_saved_search';
import { updateSavedSearch } from './utils/update_saved_search';
@ -28,6 +30,7 @@ import { DiscoverAppState, isEqualFilters } from './discover_app_state_container
import { DiscoverServices } from '../../../build_services';
import { getStateDefaults } from './utils/get_state_defaults';
import type { DiscoverGlobalStateContainer } from './discover_global_state_container';
import type { DiscoverInternalStateContainer } from './discover_internal_state_container';
const FILTERS_COMPARE_OPTIONS: FilterCompareOptions = {
...COMPARE_ALL_OPTIONS,
@ -91,7 +94,7 @@ export interface DiscoverSavedSearchContainer {
* @param id
* @param dataView
*/
load: (id: string, dataView?: DataView) => Promise<SavedSearch>;
load: (id: string) => Promise<SavedSearch>;
/**
* Initialize a new saved search
* Resets the initial and current state of the saved search
@ -136,9 +139,11 @@ export interface DiscoverSavedSearchContainer {
export function getSavedSearchContainer({
services,
globalStateContainer,
internalStateContainer,
}: {
services: DiscoverServices;
globalStateContainer: DiscoverGlobalStateContainer;
internalStateContainer: DiscoverInternalStateContainer;
}): DiscoverSavedSearchContainer {
const initialSavedSearch = services.savedSearch.getNew();
const savedSearchInitial$ = new BehaviorSubject(initialSavedSearch);
@ -176,18 +181,54 @@ export function getSavedSearchContainer({
const persist = async (nextSavedSearch: SavedSearch, saveOptions?: SavedObjectSaveOpts) => {
addLog('[savedSearch] persist', { nextSavedSearch, saveOptions });
const dataView = nextSavedSearch.searchSource.getField('index');
const profileDataViewIds = internalStateContainer.getState().defaultProfileAdHocDataViewIds;
let replacementDataView: DataView | undefined;
// If the Discover session is using a default profile ad hoc data view,
// we copy it with a new ID to avoid conflicts with the profile defaults
if (dataView?.id && profileDataViewIds.includes(dataView.id)) {
const replacementSpec: DataViewSpec = {
...dataView.toSpec(),
id: uuidv4(),
name: i18n.translate('discover.savedSearch.defaultProfileDataViewCopyName', {
defaultMessage: '{dataViewName} ({discoverSessionTitle})',
values: {
dataViewName: dataView.name ?? dataView.getIndexPattern(),
discoverSessionTitle: nextSavedSearch.title,
},
}),
};
// Skip field list fetching since the existing data view already has the fields
replacementDataView = await services.dataViews.create(replacementSpec, true);
}
updateSavedSearch({
savedSearch: nextSavedSearch,
globalStateContainer,
services,
useFilterAndQueryServices: true,
dataView: replacementDataView,
});
const currentFilters = nextSavedSearch.searchSource.getField('filter');
// If the data view was replaced, we need to update the filter references
if (dataView?.id && replacementDataView?.id && Array.isArray(currentFilters)) {
nextSavedSearch.searchSource.setField(
'filter',
updateFilterReferences(currentFilters, dataView.id, replacementDataView.id)
);
}
const id = await services.savedSearch.save(nextSavedSearch, saveOptions || {});
if (id) {
set(nextSavedSearch);
}
return { id };
};
@ -266,18 +307,16 @@ export function getSavedSearchContainer({
addLog('[savedSearch] updateVisContext done', nextSavedSearch);
};
const load = async (id: string, dataView: DataView | undefined): Promise<SavedSearch> => {
addLog('[savedSearch] load', { id, dataView });
const load = async (id: string): Promise<SavedSearch> => {
addLog('[savedSearch] load', { id });
const loadedSavedSearch = await services.savedSearch.get(id);
if (!loadedSavedSearch.searchSource.getField('index') && dataView) {
loadedSavedSearch.searchSource.setField('index', dataView);
}
restoreStateFromSavedSearch({
savedSearch: loadedSavedSearch,
timefilter: services.timefilter,
});
return set(loadedSavedSearch);
};

View file

@ -271,19 +271,20 @@ export function getDiscoverStateContainer({
*/
const globalStateContainer = getDiscoverGlobalStateContainer(stateStorage);
/**
* Internal State Container, state that's not persisted and not part of the URL
*/
const internalStateContainer = getInternalStateContainer();
/**
* Saved Search State Container, the persisted saved object of Discover
*/
const savedSearchContainer = getSavedSearchContainer({
services,
globalStateContainer,
internalStateContainer,
});
/**
* Internal State Container, state that's not persisted and not part of the URL
*/
const internalStateContainer = getInternalStateContainer();
/**
* App State Container, synced with the _a part URL
*/

View file

@ -102,10 +102,13 @@ export const buildStateSubscribe =
? nextState.dataSource.dataViewId
: undefined;
const { dataView: nextDataView, fallback } = await loadAndResolveDataView(
{ id: dataViewId, savedSearch, isEsqlMode },
{ internalStateContainer: internalState, services }
);
const { dataView: nextDataView, fallback } = await loadAndResolveDataView({
dataViewId,
savedSearch,
isEsqlMode,
internalStateContainer: internalState,
services,
});
// If the requested data view is not found, don't try to load it,
// and instead reset the app state to the fallback data view

View file

@ -46,8 +46,15 @@ export async function changeDataView(
try {
nextDataView = typeof id === 'string' ? await dataViews.get(id, false) : id;
// If nextDataView is an ad hoc data view with no fields, refresh its field list.
// This can happen when default profile data views are created without fields
// to avoid unnecessary requests on startup.
if (!nextDataView.isPersisted() && !nextDataView.fields.length) {
await dataViews.refreshFields(nextDataView);
}
} catch (e) {
//
// Swallow the error and keep the current data view
}
if (nextDataView && dataView) {

View file

@ -216,15 +216,14 @@ const getStateDataView = async (
return await getEsqlDataView(query, dataView, services);
}
const result = await loadAndResolveDataView(
{
id: dataViewId,
dataViewSpec,
savedSearch,
isEsqlMode: isEsqlQuery,
},
{ services, internalStateContainer }
);
const result = await loadAndResolveDataView({
dataViewId,
dataViewSpec,
savedSearch,
isEsqlMode: isEsqlQuery,
services,
internalStateContainer,
});
return result.dataView;
};

View file

@ -13,25 +13,27 @@ import { discoverServiceMock as services } from '../../../../__mocks__/services'
describe('Resolve data view tests', () => {
test('returns valid data for an existing data view', async () => {
const id = 'the-data-view-id';
const dataViewId = 'the-data-view-id';
const result = await loadDataView({
id,
dataViewId,
services,
dataViewList: [],
savedDataViews: [],
adHocDataViews: [],
});
expect(result.loaded).toEqual(dataViewMock);
expect(result.stateVal).toEqual(id);
expect(result.stateValFound).toEqual(true);
expect(result.loadedDataView).toEqual(dataViewMock);
expect(result.requestedDataViewId).toEqual(dataViewId);
expect(result.requestedDataViewFound).toEqual(true);
});
test('returns fallback data for an invalid data view', async () => {
const id = 'invalid-id';
const dataViewId = 'invalid-id';
const result = await loadDataView({
id,
dataViewId,
services,
dataViewList: [],
savedDataViews: [],
adHocDataViews: [],
});
expect(result.loaded).toEqual(dataViewMock);
expect(result.stateValFound).toBe(false);
expect(result.stateVal).toBe(id);
expect(result.loadedDataView).toEqual(dataViewMock);
expect(result.requestedDataViewFound).toBe(false);
expect(result.requestedDataViewId).toBe(dataViewId);
});
});

View file

@ -13,79 +13,70 @@ import type { ToastsStart } from '@kbn/core/public';
import { SavedSearch } from '@kbn/saved-search-plugin/public';
import { DiscoverInternalStateContainer } from '../discover_internal_state_container';
import { DiscoverServices } from '../../../../build_services';
interface DataViewData {
/**
* List of existing data views
*/
list: DataViewListItem[];
/**
* Loaded data view (might be default data view if requested was not found)
*/
loaded: DataView;
loadedDataView: DataView;
/**
* Id of the requested data view
*/
stateVal?: string;
requestedDataViewId?: string;
/**
* Determines if requested data view was found
*/
stateValFound: boolean;
requestedDataViewFound: boolean;
}
/**
* Function to load the given data view by id, providing a fallback if it doesn't exist
*/
export async function loadDataView({
id,
dataViewId,
dataViewSpec,
services,
dataViewList,
services: { dataViews },
savedDataViews,
adHocDataViews,
}: {
id?: string;
dataViewId?: string;
dataViewSpec?: DataViewSpec;
services: DiscoverServices;
dataViewList: DataViewListItem[];
savedDataViews: DataViewListItem[];
adHocDataViews: DataView[];
}): Promise<DataViewData> {
const { dataViews } = services;
let fetchId: string | undefined = dataViewId;
let fetchId: string | undefined = id;
/**
* Handle redirect with data view spec provided via history location state
*/
// Handle redirect with data view spec provided via history location state
if (dataViewSpec) {
const isPersisted = dataViewList.find(({ id: currentId }) => currentId === dataViewSpec.id);
if (!isPersisted) {
const isPersisted = savedDataViews.find(({ id: currentId }) => currentId === dataViewSpec.id);
if (isPersisted) {
// If passed a spec for a persisted data view, reassign the fetchId
fetchId = dataViewSpec.id!;
} else {
// If passed an ad hoc data view spec, clear the instance cache
// to avoid conflicts, then create and return the data view
if (dataViewSpec.id) {
dataViews.clearInstanceCache(dataViewSpec.id);
}
const createdAdHocDataView = await dataViews.create(dataViewSpec);
return {
list: dataViewList || [],
loaded: createdAdHocDataView,
stateVal: createdAdHocDataView.id,
stateValFound: true,
loadedDataView: createdAdHocDataView,
requestedDataViewId: createdAdHocDataView.id,
requestedDataViewFound: true,
};
}
// reassign fetchId in case of persisted data view spec provided
fetchId = dataViewSpec.id!;
}
// First try to fetch the data view by ID
let fetchedDataView: DataView | null = null;
// try to fetch adhoc data view first
try {
fetchedDataView = fetchId ? await dataViews.get(fetchId) : null;
if (fetchedDataView && !fetchedDataView.isPersisted()) {
return {
list: dataViewList || [],
loaded: fetchedDataView,
stateVal: id,
stateValFound: true,
};
}
// Skipping error handling, since 'get' call trying to fetch
// adhoc data view which only created using Promise.resolve(dataView),
// Any other error will be handled by the next 'get' call below.
// eslint-disable-next-line no-empty
} catch (e) {}
} catch (e) {
// Swallow the error and fall back to the default data view
}
// If there is no fetched data view, try to fetch the default data view
let defaultDataView: DataView | null = null;
if (!fetchedDataView) {
try {
@ -94,17 +85,21 @@ export async function loadDataView({
refreshFields: true,
});
} catch (e) {
//
// Swallow the error and fall back to the first ad hoc data view
}
}
// fetch persisted data view
// If nothing else is available, use the first ad hoc data view as a fallback
let defaultAdHocDataView: DataView | null = null;
if (!fetchedDataView && !defaultDataView && adHocDataViews.length) {
defaultAdHocDataView = adHocDataViews[0];
}
return {
list: dataViewList || [],
// we can be certain that the data view exists due to an earlier hasData check
loaded: fetchedDataView || defaultDataView!,
stateVal: fetchId,
stateValFound: Boolean(fetchId) && Boolean(fetchedDataView),
// We can be certain that a data view exists due to an earlier hasData check
loadedDataView: (fetchedDataView || defaultDataView || defaultAdHocDataView)!,
requestedDataViewId: fetchId,
requestedDataViewFound: Boolean(fetchId) && Boolean(fetchedDataView),
};
}
@ -112,27 +107,31 @@ export async function loadDataView({
* Check if the given data view is valid, provide a fallback if it doesn't exist
* And message the user in this case with toast notifications
*/
export function resolveDataView(
ip: DataViewData,
savedSearch: SavedSearch | undefined,
toastNotifications: ToastsStart,
isEsqlMode?: boolean
) {
const { loaded: loadedDataView, stateVal, stateValFound } = ip;
function resolveDataView({
dataViewData,
savedSearch,
toastNotifications,
isEsqlMode,
}: {
dataViewData: DataViewData;
savedSearch: SavedSearch | undefined;
toastNotifications: ToastsStart;
isEsqlMode?: boolean;
}) {
const { loadedDataView, requestedDataViewId, requestedDataViewFound } = dataViewData;
const ownDataView = savedSearch?.searchSource.getField('index');
if (ownDataView && !stateVal) {
if (ownDataView && !requestedDataViewId) {
// the given saved search has its own data view, and no data view was specified in the URL
return ownDataView;
}
// no warnings for ES|QL mode
if (stateVal && !stateValFound && !Boolean(isEsqlMode)) {
if (requestedDataViewId && !requestedDataViewFound && !Boolean(isEsqlMode)) {
const warningTitle = i18n.translate('discover.valueIsNotConfiguredDataViewIDWarningTitle', {
defaultMessage: '{stateVal} is not a configured data view ID',
values: {
stateVal: `"${stateVal}"`,
stateVal: `"${requestedDataViewId}"`,
},
});
@ -149,8 +148,10 @@ export function resolveDataView(
}),
'data-test-subj': 'dscDataViewNotFoundShowSavedWarning',
});
return ownDataView;
}
toastNotifications.addWarning({
title: warningTitle,
text: i18n.translate('discover.showingDefaultDataViewWarningDescription', {
@ -168,38 +169,53 @@ export function resolveDataView(
return loadedDataView;
}
export const loadAndResolveDataView = async (
{
id,
dataViewSpec,
savedSearch,
isEsqlMode,
}: {
id?: string;
dataViewSpec?: DataViewSpec;
savedSearch?: SavedSearch;
isEsqlMode?: boolean;
},
{
internalStateContainer,
services,
}: { internalStateContainer: DiscoverInternalStateContainer; services: DiscoverServices }
) => {
export const loadAndResolveDataView = async ({
dataViewId,
dataViewSpec,
savedSearch,
isEsqlMode,
internalStateContainer,
services,
}: {
dataViewId?: string;
dataViewSpec?: DataViewSpec;
savedSearch?: SavedSearch;
isEsqlMode?: boolean;
internalStateContainer: DiscoverInternalStateContainer;
services: DiscoverServices;
}) => {
const { dataViews, toastNotifications } = services;
const { adHocDataViews, savedDataViews } = internalStateContainer.getState();
const adHocDataView = adHocDataViews.find((dataView) => dataView.id === id);
if (adHocDataView) return { fallback: false, dataView: adHocDataView };
const nextDataViewData = await loadDataView({
services,
id,
dataViewSpec,
dataViewList: savedDataViews,
});
const nextDataView = resolveDataView(
nextDataViewData,
savedSearch,
services.toastNotifications,
isEsqlMode
);
return { fallback: !nextDataViewData.stateValFound, dataView: nextDataView };
// Check ad hoc data views first, unless a data view spec is supplied,
// then attempt to load one if none is found
let fallback = false;
let dataView = dataViewSpec ? undefined : adHocDataViews.find((dv) => dv.id === dataViewId);
if (!dataView) {
const dataViewData = await loadDataView({
dataViewId,
services,
dataViewSpec,
savedDataViews,
adHocDataViews,
});
fallback = !dataViewData.requestedDataViewFound;
dataView = resolveDataView({
dataViewData,
savedSearch,
toastNotifications,
isEsqlMode,
});
}
// If dataView is an ad hoc data view with no fields, refresh its field list.
// This can happen when default profile data views are created without fields
// to avoid unnecessary requests on startup.
if (!dataView.isPersisted() && !dataView.fields.length) {
await dataViews.refreshFields(dataView);
}
return { fallback, dataView };
};

View file

@ -10,3 +10,4 @@
export { useProfileAccessor } from './use_profile_accessor';
export { useRootProfile, BaseAppWrapper } from './use_root_profile';
export { useAdditionalCellActions } from './use_additional_cell_actions';
export { useDefaultAdHocDataViews } from './use_default_ad_hoc_data_views';

View file

@ -0,0 +1,148 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { renderHook } from '@testing-library/react';
import { useDefaultAdHocDataViews } from './use_default_ad_hoc_data_views';
import { getDiscoverStateMock } from '../../__mocks__/discover_state.mock';
import { discoverServiceMock } from '../../__mocks__/services';
import { DataView } from '@kbn/data-views-plugin/common';
import React from 'react';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
const renderDefaultAdHocDataViewsHook = ({
rootProfileLoading,
}: {
rootProfileLoading: boolean;
}) => {
const clearInstanceCache = jest.spyOn(discoverServiceMock.dataViews, 'clearInstanceCache');
const createDataView = jest
.spyOn(discoverServiceMock.dataViews, 'create')
.mockImplementation((spec) => Promise.resolve(spec as unknown as DataView));
const existingAdHocDataVew = { id: '1', title: 'test' } as unknown as DataView;
const previousSpecs = [
{ id: '2', title: 'tes2' },
{ id: '3', title: 'test3' },
];
const newSpecs = [
{ id: '4', title: 'test4' },
{ id: '5', title: 'test5' },
];
const stateContainer = getDiscoverStateMock({});
stateContainer.internalState.transitions.appendAdHocDataViews(existingAdHocDataVew);
stateContainer.internalState.transitions.setDefaultProfileAdHocDataViews(
previousSpecs as unknown as DataView[]
);
const { result, unmount } = renderHook(useDefaultAdHocDataViews, {
initialProps: {
stateContainer,
rootProfileState: {
rootProfileLoading,
AppWrapper: () => null,
getDefaultAdHocDataViews: () => newSpecs,
},
},
wrapper: ({ children }) => (
<KibanaContextProvider services={discoverServiceMock}>{children}</KibanaContextProvider>
),
});
return {
result,
unmount,
clearInstanceCache,
createDataView,
stateContainer,
existingAdHocDataVew,
previousSpecs,
newSpecs,
};
};
describe('useDefaultAdHocDataViews', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should set default profile ad hoc data views', async () => {
const {
result,
clearInstanceCache,
createDataView,
stateContainer,
existingAdHocDataVew,
previousSpecs,
newSpecs,
} = renderDefaultAdHocDataViewsHook({ rootProfileLoading: false });
expect(clearInstanceCache).not.toHaveBeenCalled();
expect(createDataView).not.toHaveBeenCalled();
expect(stateContainer.internalState.get().adHocDataViews).toEqual([
existingAdHocDataVew,
...previousSpecs,
]);
expect(stateContainer.internalState.get().defaultProfileAdHocDataViewIds).toEqual(
previousSpecs.map((s) => s.id)
);
await result.current.initializeProfileDataViews();
expect(clearInstanceCache.mock.calls).toEqual(previousSpecs.map((s) => [s.id]));
expect(createDataView.mock.calls).toEqual(newSpecs.map((s) => [s, true]));
expect(stateContainer.internalState.get().adHocDataViews).toEqual([
existingAdHocDataVew,
...newSpecs,
]);
expect(stateContainer.internalState.get().defaultProfileAdHocDataViewIds).toEqual(
newSpecs.map((s) => s.id)
);
});
it('should not set default profile ad hoc data views when root profile is loading', async () => {
const {
result,
clearInstanceCache,
createDataView,
stateContainer,
existingAdHocDataVew,
previousSpecs,
} = renderDefaultAdHocDataViewsHook({ rootProfileLoading: true });
expect(clearInstanceCache).not.toHaveBeenCalled();
expect(createDataView).not.toHaveBeenCalled();
expect(stateContainer.internalState.get().adHocDataViews).toEqual([
existingAdHocDataVew,
...previousSpecs,
]);
expect(stateContainer.internalState.get().defaultProfileAdHocDataViewIds).toEqual(
previousSpecs.map((s) => s.id)
);
await result.current.initializeProfileDataViews();
expect(clearInstanceCache).not.toHaveBeenCalled();
expect(createDataView).not.toHaveBeenCalled();
expect(stateContainer.internalState.get().adHocDataViews).toEqual([
existingAdHocDataVew,
...previousSpecs,
]);
expect(stateContainer.internalState.get().defaultProfileAdHocDataViewIds).toEqual(
previousSpecs.map((s) => s.id)
);
});
it('should clear instance cache on unmount', async () => {
const { unmount, clearInstanceCache, stateContainer, existingAdHocDataVew, previousSpecs } =
renderDefaultAdHocDataViewsHook({ rootProfileLoading: false });
expect(clearInstanceCache).not.toHaveBeenCalled();
expect(stateContainer.internalState.get().adHocDataViews).toEqual([
existingAdHocDataVew,
...previousSpecs,
]);
expect(stateContainer.internalState.get().defaultProfileAdHocDataViewIds).toEqual(
previousSpecs.map((s) => s.id)
);
unmount();
expect(clearInstanceCache.mock.calls).toEqual(previousSpecs.map((s) => [s.id]));
expect(stateContainer.internalState.get().adHocDataViews).toEqual([existingAdHocDataVew]);
expect(stateContainer.internalState.get().defaultProfileAdHocDataViewIds).toEqual([]);
});
});

View file

@ -0,0 +1,64 @@
/*
* 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 { useState } from 'react';
import useLatest from 'react-use/lib/useLatest';
import useUnmount from 'react-use/lib/useUnmount';
import type { RootProfileState } from './use_root_profile';
import { useDiscoverServices } from '../../hooks/use_discover_services';
import type { DiscoverStateContainer } from '../../application/main/state_management/discover_state';
/**
* Hook to retrieve and initialize the default profile ad hoc data views
* @param Options The options object
* @returns An object containing the initialization function
*/
export const useDefaultAdHocDataViews = ({
stateContainer,
rootProfileState,
}: {
stateContainer: DiscoverStateContainer;
rootProfileState: RootProfileState;
}) => {
const { dataViews } = useDiscoverServices();
const { internalState } = stateContainer;
const initializeDataViews = useLatest(async () => {
if (rootProfileState.rootProfileLoading) {
return;
}
// Clear the cache of old data views before creating
// the new ones to avoid cache hits on duplicate IDs
for (const prevId of internalState.get().defaultProfileAdHocDataViewIds) {
dataViews.clearInstanceCache(prevId);
}
const profileDataViewSpecs = rootProfileState.getDefaultAdHocDataViews();
const profileDataViews = await Promise.all(
profileDataViewSpecs.map((spec) => dataViews.create(spec, true))
);
internalState.transitions.setDefaultProfileAdHocDataViews(profileDataViews);
});
// This approach allows us to return a callback with a stable reference
const [initializeProfileDataViews] = useState(() => () => initializeDataViews.current());
// Make sure to clean up on unmount
useUnmount(() => {
for (const prevId of internalState.get().defaultProfileAdHocDataViewIds) {
dataViews.clearInstanceCache(prevId);
}
internalState.transitions.setDefaultProfileAdHocDataViews([]);
});
return { initializeProfileDataViews };
};

View file

@ -22,9 +22,6 @@ jest
const render = () => {
return renderHook(() => useRootProfile(), {
initialProps: { solutionNavId: 'solutionNavId' } as React.PropsWithChildren<{
solutionNavId: string;
}>,
wrapper: ({ children }) => (
<KibanaContextProvider services={discoverServiceMock}>{children}</KibanaContextProvider>
),
@ -40,6 +37,7 @@ describe('useRootProfile', () => {
const { result } = render();
expect(result.current.rootProfileLoading).toBe(true);
expect((result.current as Record<string, unknown>).AppWrapper).toBeUndefined();
expect((result.current as Record<string, unknown>).getDefaultAdHocDataViews).toBeUndefined();
// avoid act warning
await waitFor(() => new Promise((resolve) => resolve(null)));
});
@ -49,6 +47,7 @@ describe('useRootProfile', () => {
await waitFor(() => {
expect(result.current.rootProfileLoading).toBe(false);
expect((result.current as Record<string, unknown>).AppWrapper).toBeDefined();
expect((result.current as Record<string, unknown>).getDefaultAdHocDataViews).toBeDefined();
});
});
@ -57,14 +56,17 @@ describe('useRootProfile', () => {
await waitFor(() => {
expect(result.current.rootProfileLoading).toBe(false);
expect((result.current as Record<string, unknown>).AppWrapper).toBeDefined();
expect((result.current as Record<string, unknown>).getDefaultAdHocDataViews).toBeDefined();
});
act(() => mockSolutionNavId$.next('newSolutionNavId'));
rerender();
expect(result.current.rootProfileLoading).toBe(true);
expect((result.current as Record<string, unknown>).AppWrapper).toBeUndefined();
expect((result.current as Record<string, unknown>).getDefaultAdHocDataViews).toBeUndefined();
await waitFor(() => {
expect(result.current.rootProfileLoading).toBe(false);
expect((result.current as Record<string, unknown>).AppWrapper).toBeDefined();
expect((result.current as Record<string, unknown>).getDefaultAdHocDataViews).toBeDefined();
});
});
});

View file

@ -13,17 +13,27 @@ import React from 'react';
import { useDiscoverServices } from '../../hooks/use_discover_services';
import type { Profile } from '../types';
/**
* The root profile state
*/
export type RootProfileState =
| { rootProfileLoading: true }
| {
rootProfileLoading: false;
AppWrapper: Profile['getRenderAppWrapper'];
getDefaultAdHocDataViews: Profile['getDefaultAdHocDataViews'];
};
/**
* Hook to trigger and wait for root profile resolution
* @param options Options object
* @returns If the root profile is loading
* @returns The root profile state
*/
export const useRootProfile = () => {
const { profilesManager, core } = useDiscoverServices();
const [rootProfileState, setRootProfileState] = useState<
| { rootProfileLoading: true }
| { rootProfileLoading: false; AppWrapper: Profile['getRenderAppWrapper'] }
>({ rootProfileLoading: true });
const [rootProfileState, setRootProfileState] = useState<RootProfileState>({
rootProfileLoading: true,
});
useEffect(() => {
const subscription = core.chrome
@ -32,11 +42,14 @@ export const useRootProfile = () => {
distinctUntilChanged(),
filter((id) => id !== undefined),
tap(() => setRootProfileState({ rootProfileLoading: true })),
switchMap((id) => profilesManager.resolveRootProfile({ solutionNavId: id })),
tap(({ getRenderAppWrapper }) =>
switchMap((solutionNavId) => profilesManager.resolveRootProfile({ solutionNavId })),
tap(({ getRenderAppWrapper, getDefaultAdHocDataViews }) =>
setRootProfileState({
rootProfileLoading: false,
AppWrapper: getRenderAppWrapper?.(BaseAppWrapper) ?? BaseAppWrapper,
getDefaultAdHocDataViews:
getDefaultAdHocDataViews?.(baseGetDefaultAdHocDataViews) ??
baseGetDefaultAdHocDataViews,
})
)
)
@ -51,3 +64,5 @@ export const useRootProfile = () => {
};
export const BaseAppWrapper: Profile['getRenderAppWrapper'] = ({ children }) => <>{children}</>;
const baseGetDefaultAdHocDataViews: Profile['getDefaultAdHocDataViews'] = () => [];

View file

@ -15,5 +15,6 @@ export {
useProfileAccessor,
useRootProfile,
useAdditionalCellActions,
useDefaultAdHocDataViews,
BaseAppWrapper,
} from './hooks';

View file

@ -25,6 +25,7 @@ export const createExampleRootProfileProvider = (): RootProfileProvider => ({
isExperimental: true,
profile: {
getRenderAppWrapper,
getDefaultAdHocDataViews,
getCellRenderers: (prev) => (params) => ({
...prev(params),
'@timestamp': (props) => {
@ -112,7 +113,7 @@ export const createExampleRootProfileProvider = (): RootProfileProvider => ({
export const createExampleSolutionViewRootProfileProvider = (): RootProfileProvider => ({
profileId: 'example-solution-view-root-profile',
isExperimental: true,
profile: { getRenderAppWrapper },
profile: { getRenderAppWrapper, getDefaultAdHocDataViews },
resolve: (params) => ({
isMatch: true,
context: { solutionType: params.solutionNavId as SolutionType },
@ -151,3 +152,15 @@ const getRenderAppWrapper: RootProfileProvider['profile']['getRenderAppWrapper']
</PrevWrapper>
);
};
const getDefaultAdHocDataViews: RootProfileProvider['profile']['getDefaultAdHocDataViews'] =
(prev) => () =>
[
...prev(),
{
id: 'example-root-profile-ad-hoc-data-view',
name: 'Example profile data view',
title: 'my-example-*',
timeFieldName: '@timestamp',
},
];

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 { SolutionType } from '../../../../profiles';
import { getDefaultAdHocDataViews } from './get_default_ad_hoc_data_views';
describe('getDefaultAdHocDataViews', () => {
it('must return "discover-observability-root-profile-all-logs" for the "All logs" data view ID or bookmarks will break', () => {
const dataViews = getDefaultAdHocDataViews!(() => [], {
context: { solutionType: SolutionType.Observability, allLogsIndexPattern: 'logs-*' },
})();
expect(dataViews).toEqual([
expect.objectContaining({
id: 'discover-observability-root-profile-all-logs',
name: 'All logs',
}),
]);
});
});

View file

@ -0,0 +1,33 @@
/*
* 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 { i18n } from '@kbn/i18n';
import type { ObservabilityRootProfileProvider } from '../types';
const ALL_LOGS_DATA_VIEW_ID = 'discover-observability-root-profile-all-logs';
export const getDefaultAdHocDataViews: ObservabilityRootProfileProvider['profile']['getDefaultAdHocDataViews'] =
(prev, { context }) =>
() => {
const prevDataViews = prev();
if (!context.allLogsIndexPattern) {
return prevDataViews;
}
return prevDataViews.concat({
id: ALL_LOGS_DATA_VIEW_ID,
name: i18n.translate('discover.observabilitySolution.allLogsDataViewName', {
defaultMessage: 'All logs',
}),
title: context.allLogsIndexPattern,
timeFieldName: '@timestamp',
});
};

View file

@ -8,3 +8,4 @@
*/
export { createGetAppMenu } from './get_app_menu';
export { getDefaultAdHocDataViews } from './get_default_ad_hoc_data_views';

View file

@ -17,35 +17,77 @@ describe('observabilityRootProfileProvider', () => {
const observabilityRootProfileProvider = createObservabilityRootProfileProvider(mockServices);
const RESOLUTION_MATCH = {
isMatch: true,
context: { solutionType: SolutionType.Observability },
context: expect.objectContaining({ solutionType: SolutionType.Observability }),
};
const RESOLUTION_MISMATCH = {
isMatch: false,
};
it('should match when the solution project is observability', () => {
it('should match when the solution project is observability', async () => {
expect(
observabilityRootProfileProvider.resolve({
await observabilityRootProfileProvider.resolve({
solutionNavId: SolutionType.Observability,
})
).toEqual(RESOLUTION_MATCH);
});
it('should NOT match when the solution project anything but observability', () => {
it('should NOT match when the solution project anything but observability', async () => {
expect(
observabilityRootProfileProvider.resolve({
await observabilityRootProfileProvider.resolve({
solutionNavId: SolutionType.Default,
})
).toEqual(RESOLUTION_MISMATCH);
expect(
observabilityRootProfileProvider.resolve({
await observabilityRootProfileProvider.resolve({
solutionNavId: SolutionType.Search,
})
).toEqual(RESOLUTION_MISMATCH);
expect(
observabilityRootProfileProvider.resolve({
await observabilityRootProfileProvider.resolve({
solutionNavId: SolutionType.Security,
})
).toEqual(RESOLUTION_MISMATCH);
});
describe('getDefaultAdHocDataViews', () => {
it('should return an "All logs" default data view', async () => {
const result = await observabilityRootProfileProvider.resolve({
solutionNavId: SolutionType.Observability,
});
if (!result.isMatch) {
throw new Error('Expected result to match');
}
expect(result.context.allLogsIndexPattern).toEqual('logs-*');
const defaultDataViews = observabilityRootProfileProvider.profile.getDefaultAdHocDataViews?.(
() => [],
{ context: result.context }
)();
expect(defaultDataViews).toEqual([
{
id: 'discover-observability-root-profile-all-logs',
name: 'All logs',
timeFieldName: '@timestamp',
title: 'logs-*',
},
]);
});
it('should return no default data views', async () => {
jest
.spyOn(mockServices.logsContextService, 'getAllLogsIndexPattern')
.mockReturnValueOnce(undefined);
const result = await observabilityRootProfileProvider.resolve({
solutionNavId: SolutionType.Observability,
});
if (!result.isMatch) {
throw new Error('Expected result to match');
}
expect(result.context.allLogsIndexPattern).toEqual(undefined);
const defaultDataViews = observabilityRootProfileProvider.profile.getDefaultAdHocDataViews?.(
() => [],
{ context: result.context }
)();
expect(defaultDataViews).toEqual([]);
});
});
});

View file

@ -7,23 +7,31 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { RootProfileProvider, SolutionType } from '../../../profiles';
import { ProfileProviderServices } from '../../profile_provider_services';
import { SolutionType } from '../../../profiles';
import type { ProfileProviderServices } from '../../profile_provider_services';
import { OBSERVABILITY_ROOT_PROFILE_ID } from '../consts';
import { createGetAppMenu } from './accessors';
import { createGetAppMenu, getDefaultAdHocDataViews } from './accessors';
import type { ObservabilityRootProfileProvider } from './types';
export const createObservabilityRootProfileProvider = (
services: ProfileProviderServices
): RootProfileProvider => ({
): ObservabilityRootProfileProvider => ({
profileId: OBSERVABILITY_ROOT_PROFILE_ID,
profile: {
getAppMenu: createGetAppMenu(services),
getDefaultAdHocDataViews,
},
resolve: (params) => {
if (params.solutionNavId === SolutionType.Observability) {
return { isMatch: true, context: { solutionType: SolutionType.Observability } };
if (params.solutionNavId !== SolutionType.Observability) {
return { isMatch: false };
}
return { isMatch: false };
return {
isMatch: true,
context: {
solutionType: SolutionType.Observability,
allLogsIndexPattern: services.logsContextService.getAllLogsIndexPattern(),
},
};
},
});

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 { RootProfileProvider } from '../../..';
export interface ObservabilityRootProfileContext {
allLogsIndexPattern: string | undefined;
}
export type ObservabilityRootProfileProvider = RootProfileProvider<ObservabilityRootProfileContext>;

View file

@ -51,6 +51,20 @@ export interface GetProfilesOptions {
record?: DataTableRecord;
}
/**
* Result of resolving the root profile
*/
export interface ResolveRootProfileResult {
/**
* Render app wrapper accessor
*/
getRenderAppWrapper: AppliedProfile['getRenderAppWrapper'];
/**
* Default ad hoc data views accessor
*/
getDefaultAdHocDataViews: AppliedProfile['getDefaultAdHocDataViews'];
}
export enum ContextualProfileLevel {
rootLevel = 'rootLevel',
dataSourceLevel = 'dataSourceLevel',
@ -94,11 +108,16 @@ export class ProfilesManager {
* Resolves the root context profile
* @param params The root profile provider parameters
*/
public async resolveRootProfile(params: RootProfileProviderParams) {
public async resolveRootProfile(
params: RootProfileProviderParams
): Promise<ResolveRootProfileResult> {
const serializedParams = serializeRootProfileParams(params);
if (isEqual(this.prevRootProfileParams, serializedParams)) {
return { getRenderAppWrapper: this.rootProfile.getRenderAppWrapper };
return {
getRenderAppWrapper: this.rootProfile.getRenderAppWrapper,
getDefaultAdHocDataViews: this.rootProfile.getDefaultAdHocDataViews,
};
}
const abortController = new AbortController();
@ -114,13 +133,19 @@ export class ProfilesManager {
}
if (abortController.signal.aborted) {
return { getRenderAppWrapper: this.rootProfile.getRenderAppWrapper };
return {
getRenderAppWrapper: this.rootProfile.getRenderAppWrapper,
getDefaultAdHocDataViews: this.rootProfile.getDefaultAdHocDataViews,
};
}
this.rootContext$.next(context);
this.prevRootProfileParams = serializedParams;
return { getRenderAppWrapper: this.rootProfile.getRenderAppWrapper };
return {
getRenderAppWrapper: this.rootProfile.getRenderAppWrapper,
getDefaultAdHocDataViews: this.rootProfile.getDefaultAdHocDataViews,
};
}
/**

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { DataView } from '@kbn/data-views-plugin/common';
import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/common';
import type {
CustomCellRenderer,
DataGridDensity,
@ -277,6 +277,15 @@ export interface Profile {
*/
getDefaultAppState: (params: DefaultAppStateExtensionParams) => DefaultAppStateExtension;
/**
* Gets an array of default ad hoc data views to display in the data view picker (e.g. "All logs").
* The returned specs must include consistent IDs across resolutions for Discover to manage them correctly.
* @returns The default data views to display in the data view picker
*/
getDefaultAdHocDataViews: () => Array<
Omit<DataViewSpec, 'id'> & { id: NonNullable<DataViewSpec['id']> }
>;
/**
* Data grid
*/

View file

@ -197,4 +197,37 @@ describe('DataView component', () => {
},
]);
});
it('should properly handle managed data views', async () => {
const component = mount(
wrapDataViewComponentInContext(
{
...props,
onDataViewCreated: jest.fn(),
savedDataViews: [
{
id: 'dataview-1',
title: 'dataview-1',
},
],
managedDataViews: [dataViewMock],
},
false
)
);
findTestSubject(component, 'dataview-trigger').simulate('click');
expect(component.find(DataViewSelector).prop('dataViewsList')).toStrictEqual([
{
id: 'dataview-1',
title: 'dataview-1',
},
{
id: 'the-data-view-id',
title: 'the-data-view-title',
name: 'the-data-view',
type: 'default',
isManaged: true,
},
]);
});
});

View file

@ -34,15 +34,22 @@ import adhoc from './assets/adhoc.svg';
import { changeDataViewStyles } from './change_dataview.styles';
import { DataViewSelector } from './data_view_selector';
const mapAdHocDataView = (adHocDataView: DataView): DataViewListItemEnhanced => {
return {
title: adHocDataView.title,
name: adHocDataView.name,
id: adHocDataView.id!,
type: adHocDataView.type,
isAdhoc: true,
};
};
const mapDataViewListItem = (
dataView: DataView,
partial: Partial<DataViewListItemEnhanced>
): DataViewListItemEnhanced => ({
title: dataView.title,
name: dataView.name,
id: dataView.id!,
type: dataView.type,
...partial,
});
const mapAdHocDataView = (adHocDataView: DataView) =>
mapDataViewListItem(adHocDataView, { isAdhoc: true });
const mapManagedDataView = (managedDataView: DataView) =>
mapDataViewListItem(managedDataView, { isManaged: true });
const shrinkableContainerCss = css`
min-width: 0;
@ -52,6 +59,7 @@ export function ChangeDataView({
isMissingCurrent,
currentDataViewId,
adHocDataViews,
managedDataViews,
savedDataViews,
onChangeDataView,
onAddField,
@ -83,16 +91,16 @@ export function ChangeDataView({
useEffect(() => {
const fetchDataViews = async () => {
const savedDataViewRefs: DataViewListItemEnhanced[] = savedDataViews
const savedDataViewRefs = savedDataViews
? savedDataViews
: (await data.dataViews.getIdsWithTitle()) ?? [];
const adHocDataViewRefs: DataViewListItemEnhanced[] =
adHocDataViews?.map(mapAdHocDataView) ?? [];
const adHocDataViewRefs = adHocDataViews?.map(mapAdHocDataView) ?? [];
const managedDataViewRefs = managedDataViews?.map(mapManagedDataView) ?? [];
setDataViewsList(savedDataViewRefs.concat(adHocDataViewRefs));
setDataViewsList([...savedDataViewRefs, ...adHocDataViewRefs, ...managedDataViewRefs]);
};
fetchDataViews();
}, [data, currentDataViewId, adHocDataViews, savedDataViews]);
}, [data, currentDataViewId, adHocDataViews, savedDataViews, managedDataViews]);
const isAdHocSelected = useMemo(() => {
return adHocDataViews?.some((dataView) => dataView.id === currentDataViewId);

View file

@ -45,6 +45,10 @@ export interface DataViewPickerProps {
* The adHocDataviews.
*/
adHocDataViews?: DataView[];
/**
* Data views managed by the application
*/
managedDataViews?: DataView[];
/**
* Saved data views
*/
@ -81,6 +85,7 @@ export const DataViewPicker = ({
isMissingCurrent,
currentDataViewId,
adHocDataViews,
managedDataViews,
savedDataViews,
onChangeDataView,
onEditDataView,
@ -103,6 +108,7 @@ export const DataViewPicker = ({
onCreateDefaultAdHocDataView={onCreateDefaultAdHocDataView}
trigger={trigger}
adHocDataViews={adHocDataViews}
managedDataViews={managedDataViews}
savedDataViews={savedDataViews}
selectableProps={selectableProps}
textBasedLanguages={textBasedLanguages}

View file

@ -52,6 +52,12 @@ const strings = {
defaultMessage: 'Temporary',
}),
},
managed: {
getManagedDataviewLabel: () =>
i18n.translate('unifiedSearch.query.queryBar.indexPattern.managedDataviewLabel', {
defaultMessage: 'Managed',
}),
},
search: {
getSearchPlaceholder: () =>
i18n.translate('unifiedSearch.query.queryBar.indexPattern.findDataView', {
@ -63,6 +69,7 @@ const strings = {
export interface DataViewListItemEnhanced extends DataViewListItem {
isAdhoc?: boolean;
isManaged?: boolean;
}
export interface DataViewsListProps {
@ -131,7 +138,7 @@ export function DataViewsList({
data-test-subj="indexPattern-switcher"
searchable
singleSelection="always"
options={sortedDataViewsList?.map(({ title, id, name, isAdhoc }) => ({
options={sortedDataViewsList?.map(({ title, id, name, isAdhoc, isManaged }) => ({
key: id,
label: name ? name : title,
value: id,
@ -140,6 +147,10 @@ export function DataViewsList({
<EuiBadge color="hollow" data-test-subj={`dataViewItemTempBadge-${name}`}>
{strings.editorAndPopover.adhoc.getTemporaryDataviewLabel()}
</EuiBadge>
) : isManaged ? (
<EuiBadge color="hollow" data-test-subj={`dataViewItemManagedBadge-${name}`}>
{strings.editorAndPopover.managed.getManagedDataviewLabel()}
</EuiBadge>
) : null,
}))}
onChange={(choices) => {

View file

@ -0,0 +1,119 @@
/*
* 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 expect from '@kbn/expect';
import type { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const { common, discover, unifiedFieldList } = getPageObjects([
'common',
'discover',
'unifiedFieldList',
]);
const testSubjects = getService('testSubjects');
const dataViews = getService('dataViews');
const dataGrid = getService('dataGrid');
const toasts = getService('toasts');
const browser = getService('browser');
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
describe('extension getDefaultAdHocDataViews', () => {
after(async () => {
await kibanaServer.savedObjects.clean({ types: ['search'] });
});
it('should show the profile data view', async () => {
await common.navigateToActualUrl('discover', undefined, {
ensureCurrentUrl: false,
});
expect(await dataViews.getSelectedName()).not.to.be('Example profile data view');
await dataViews.switchTo('Example profile data view');
await discover.waitUntilSearchingHasFinished();
expect(await dataViews.isManaged()).to.be(true);
expect(await unifiedFieldList.getSidebarSectionFieldNames('available')).to.have.length(7);
expect(
await (await dataGrid.getCellElementByColumnName(0, '@timestamp')).getVisibleText()
).to.be('2024-06-10T16:30:00.000Z');
expect(
await (await dataGrid.getCellElementByColumnName(5, '@timestamp')).getVisibleText()
).to.be('2024-06-10T14:00:00.000Z');
});
it('should reload the profile data view on page refresh, and not show an error toast', async () => {
await browser.refresh();
await discover.waitUntilSearchingHasFinished();
expect(await toasts.getCount({ timeout: 2000 })).to.be(0);
expect(await dataViews.getSelectedName()).to.be('Example profile data view');
expect(await dataViews.isManaged()).to.be(true);
});
it('should create a copy of the profile data view when saving the Discover session', async () => {
await discover.saveSearch('Default profile data view session');
await discover.waitUntilSearchingHasFinished();
expect(await dataViews.getSelectedName()).to.be(
'Example profile data view (Default profile data view session)'
);
expect(await dataViews.isManaged()).to.be(false);
expect(await unifiedFieldList.getSidebarSectionFieldNames('available')).to.have.length(7);
expect(
await (await dataGrid.getCellElementByColumnName(0, '@timestamp')).getVisibleText()
).to.be('2024-06-10T16:30:00.000Z');
expect(
await (await dataGrid.getCellElementByColumnName(5, '@timestamp')).getVisibleText()
).to.be('2024-06-10T14:00:00.000Z');
await dataViews.switchTo('Example profile data view');
await discover.waitUntilSearchingHasFinished();
expect(await dataViews.isManaged()).to.be(true);
expect(await unifiedFieldList.getSidebarSectionFieldNames('available')).to.have.length(7);
expect(
await (await dataGrid.getCellElementByColumnName(0, '@timestamp')).getVisibleText()
).to.be('2024-06-10T16:30:00.000Z');
expect(
await (await dataGrid.getCellElementByColumnName(5, '@timestamp')).getVisibleText()
).to.be('2024-06-10T14:00:00.000Z');
});
describe('fallback behaviour', function () {
after(async () => {
await esArchiver.load('test/functional/fixtures/es_archiver/discover/context_awareness');
await kibanaServer.importExport.load(
'test/functional/fixtures/kbn_archiver/discover/context_awareness'
);
});
it('should fall back to the profile data view when no other data views are available', async () => {
await kibanaServer.importExport.unload(
'test/functional/fixtures/kbn_archiver/discover/context_awareness'
);
await common.navigateToActualUrl('discover', undefined, {
ensureCurrentUrl: false,
});
expect(await dataViews.getSelectedName()).to.be('Example profile data view');
await discover.waitUntilSearchingHasFinished();
expect(await dataViews.isManaged()).to.be(true);
expect(await unifiedFieldList.getSidebarSectionFieldNames('available')).to.have.length(7);
expect(
await (await dataGrid.getCellElementByColumnName(0, '@timestamp')).getVisibleText()
).to.be('2024-06-10T16:30:00.000Z');
expect(
await (await dataGrid.getCellElementByColumnName(5, '@timestamp')).getVisibleText()
).to.be('2024-06-10T14:00:00.000Z');
});
it('should show the no data page when no ES data is available', async () => {
await esArchiver.unload('test/functional/fixtures/es_archiver/discover/context_awareness');
await common.navigateToActualUrl('discover', undefined, {
ensureCurrentUrl: false,
});
expect(await testSubjects.exists('kbnNoDataPage')).to.be(true);
});
});
});
}

View file

@ -46,5 +46,6 @@ export default function ({ getService, getPageObjects, loadTestFile }: FtrProvid
loadTestFile(require.resolve('./extensions/_get_additional_cell_actions'));
loadTestFile(require.resolve('./extensions/_get_app_menu'));
loadTestFile(require.resolve('./extensions/_get_render_app_wrapper'));
loadTestFile(require.resolve('./extensions/_get_default_ad_hoc_data_views'));
});
}

View file

@ -93,6 +93,17 @@ export class DataViewsService extends FtrService {
return hasBadge;
}
/**
* Checks if currently selected Data View has managed badge
*/
async isManaged() {
const dataView = await this.testSubjects.getAttribute('*dataView-switch-link', 'title');
await this.testSubjects.click('*dataView-switch-link');
const hasBadge = await this.testSubjects.exists(`dataViewItemManagedBadge-${dataView}`);
await this.testSubjects.click('*dataView-switch-link');
return hasBadge;
}
/**
* Opens Create field flayout for the selected Data View
*/

View file

@ -15,7 +15,6 @@ import { loggerMock } from '@kbn/logging-mocks';
import {
updateSearchSource,
generateLink,
updateFilterReferences,
getSmallerDataViewSpec,
fetchSearchSourceQuery,
} from './fetch_search_source_query';
@ -35,6 +34,7 @@ import {
getErrorSource,
TaskErrorSource,
} from '@kbn/task-manager-plugin/server/task_running/errors';
import { updateFilterReferences } from '@kbn/es-query';
const createDataView = () => {
const id = 'test-id';

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { buildRangeFilter, Filter } from '@kbn/es-query';
import { buildRangeFilter, Filter, updateFilterReferences } from '@kbn/es-query';
import {
DataView,
DataViewsContract,
@ -252,26 +252,6 @@ export async function generateLink(
return start + spacePrefix + '/app' + end;
}
export function updateFilterReferences(
filters: Filter[],
fromDataView: string,
toDataView: string | undefined
) {
return (filters || []).map((filter) => {
if (filter.meta.index === fromDataView) {
return {
...filter,
meta: {
...filter.meta,
index: toDataView,
},
};
} else {
return filter;
}
});
}
export function getSmallerDataViewSpec(
dataView: DataView
): DiscoverAppLocatorParams['dataViewSpec'] {

View file

@ -0,0 +1,125 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import type { FtrProviderContext } from '../../../../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const { common, discover, unifiedFieldList, svlCommonPage } = getPageObjects([
'common',
'discover',
'unifiedFieldList',
'svlCommonPage',
]);
const testSubjects = getService('testSubjects');
const dataViews = getService('dataViews');
const dataGrid = getService('dataGrid');
const toasts = getService('toasts');
const browser = getService('browser');
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
describe('extension getDefaultAdHocDataViews', () => {
before(async () => {
await svlCommonPage.loginAsAdmin();
});
after(async () => {
await kibanaServer.savedObjects.clean({ types: ['search'] });
});
it('should show the profile data view', async () => {
await common.navigateToActualUrl('discover', undefined, {
ensureCurrentUrl: false,
});
expect(await dataViews.getSelectedName()).not.to.be('Example profile data view');
await dataViews.switchTo('Example profile data view');
await discover.waitUntilSearchingHasFinished();
expect(await dataViews.isManaged()).to.be(true);
expect(await unifiedFieldList.getSidebarSectionFieldNames('available')).to.have.length(7);
expect(
await (await dataGrid.getCellElementByColumnName(0, '@timestamp')).getVisibleText()
).to.be('Jun 10, 2024 @ 16:30:00.000');
expect(
await (await dataGrid.getCellElementByColumnName(5, '@timestamp')).getVisibleText()
).to.be('Jun 10, 2024 @ 14:00:00.000');
});
it('should reload the profile data view on page refresh, and not show an error toast', async () => {
await browser.refresh();
await discover.waitUntilSearchingHasFinished();
expect(await toasts.getCount({ timeout: 2000 })).to.be(0);
expect(await dataViews.getSelectedName()).to.be('Example profile data view');
expect(await dataViews.isManaged()).to.be(true);
});
it('should create a copy of the profile data view when saving the Discover session', async () => {
await discover.saveSearch('Default profile data view session');
await discover.waitUntilSearchingHasFinished();
expect(await dataViews.getSelectedName()).to.be(
'Example profile data view (Default profile data view session)'
);
expect(await dataViews.isManaged()).to.be(false);
expect(await unifiedFieldList.getSidebarSectionFieldNames('available')).to.have.length(7);
expect(
await (await dataGrid.getCellElementByColumnName(0, '@timestamp')).getVisibleText()
).to.be('Jun 10, 2024 @ 16:30:00.000');
expect(
await (await dataGrid.getCellElementByColumnName(5, '@timestamp')).getVisibleText()
).to.be('Jun 10, 2024 @ 14:00:00.000');
await dataViews.switchTo('Example profile data view');
await discover.waitUntilSearchingHasFinished();
expect(await dataViews.isManaged()).to.be(true);
expect(await unifiedFieldList.getSidebarSectionFieldNames('available')).to.have.length(7);
expect(
await (await dataGrid.getCellElementByColumnName(0, '@timestamp')).getVisibleText()
).to.be('Jun 10, 2024 @ 16:30:00.000');
expect(
await (await dataGrid.getCellElementByColumnName(5, '@timestamp')).getVisibleText()
).to.be('Jun 10, 2024 @ 14:00:00.000');
});
describe('fallback behaviour', function () {
// Search and Security projects have a default data view, so these don't apply
this.tags(['skipSvlSearch', 'skipSvlSec']);
after(async () => {
await esArchiver.load('test/functional/fixtures/es_archiver/discover/context_awareness');
await kibanaServer.importExport.load(
'test/functional/fixtures/kbn_archiver/discover/context_awareness'
);
});
it('should fall back to the profile data view when no other data views are available', async () => {
await kibanaServer.importExport.unload(
'test/functional/fixtures/kbn_archiver/discover/context_awareness'
);
await common.navigateToActualUrl('discover', undefined, {
ensureCurrentUrl: false,
});
expect(await dataViews.getSelectedName()).to.be('Example profile data view');
await discover.waitUntilSearchingHasFinished();
expect(await dataViews.isManaged()).to.be(true);
expect(await unifiedFieldList.getSidebarSectionFieldNames('available')).to.have.length(7);
expect(
await (await dataGrid.getCellElementByColumnName(0, '@timestamp')).getVisibleText()
).to.be('Jun 10, 2024 @ 16:30:00.000');
expect(
await (await dataGrid.getCellElementByColumnName(5, '@timestamp')).getVisibleText()
).to.be('Jun 10, 2024 @ 14:00:00.000');
});
it('should show the no data page when no ES data is available', async () => {
await esArchiver.unload('test/functional/fixtures/es_archiver/discover/context_awareness');
await common.navigateToActualUrl('discover', undefined, {
ensureCurrentUrl: false,
});
expect(await testSubjects.exists('kbnNoDataPage')).to.be(true);
});
});
});
}

View file

@ -44,5 +44,6 @@ export default function ({ getService, getPageObjects, loadTestFile }: FtrProvid
loadTestFile(require.resolve('./extensions/_get_additional_cell_actions'));
loadTestFile(require.resolve('./extensions/_get_app_menu'));
loadTestFile(require.resolve('./extensions/_get_render_app_wrapper'));
loadTestFile(require.resolve('./extensions/_get_default_ad_hoc_data_views'));
});
}

View file

@ -332,7 +332,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const availableDataViews = await PageObjects.unifiedSearch.getDataViewList(
'discover-dataView-switch-link'
);
expect(availableDataViews).to.eql(['kibana_sample_data_flights', 'logstash-*']);
if (await testSubjects.exists('~nav-item-observability_project_nav')) {
expect(availableDataViews).to.eql([
'All logs',
'kibana_sample_data_flights',
'logstash-*',
]);
} else {
expect(availableDataViews).to.eql(['kibana_sample_data_flights', 'logstash-*']);
}
await dataViews.switchToAndValidate('kibana_sample_data_flights');
});
});

View file

@ -13,6 +13,7 @@ export default createTestConfig({
junit: {
reportName: 'Serverless Observability Discover Context Awareness Functional Tests',
},
suiteTags: { exclude: ['skipSvlOblt'] },
kbnServerArgs: [
`--discover.experimental.enabledProfiles=${JSON.stringify([
'example-root-profile',

View file

@ -13,6 +13,7 @@ export default createTestConfig({
junit: {
reportName: 'Serverless Search Discover Context Awareness Functional Tests',
},
suiteTags: { exclude: ['skipSvlSearch'] },
kbnServerArgs: [
`--discover.experimental.enabledProfiles=${JSON.stringify([
'example-root-profile',

View file

@ -14,6 +14,7 @@ export default createTestConfig({
reportName:
'Serverless Security Discover Context Awareness Functional Tests - Example Profiles',
},
suiteTags: { exclude: ['skipSvlSec'] },
kbnServerArgs: [
`--discover.experimental.enabledProfiles=${JSON.stringify([
'example-root-profile',